Firebase (2) - Cloud Messaging -


FirebaseのCloud Messagingを使ってみる

環境の設定

Firebase Consoleからプロジェクトを作成。その後に以下の手順をふむ

  1. 設定・全般からマイアプリの登録を行いapiKeyなどの必要な情報の取得(WebとAndroid両方)
  2. 設定・クラウドメッセージングからウェブプッシュ証明の生成(※Androidアプリ側は後述するgoogle-services.jsonを使用するので不要)
  3. 設定・サービスアカウントから秘密鍵を生成(※クライアントとかからWeb Pushを実行する側で必要)

マイアプリの登録

Firebase Consoleから設定・全般タブからマイアプリを登録。今回はWeb PushとAndroidの両方を使うのでこの2つを登録する

image

  • Webフロントエンド版側は表示されてるfirebaseConfigの変数(apiKeyなどが含まれてるHash Object)の情報をコピーする
  • Androidアプリ側はgoogle-services.jsonをダウンロードしてプロジェクトのappディレクトリの直下に置く

Webプッシュ証明書を設定

Firebase Configから設定・クラウドメッセージングタブからウェブプッシュ証明書を生成する。

image

そこに表示されてる鍵ペア値をコピーする

Webフロントエンド側で使うだけなのでAndroid側では使用しない。おそらくスマホアプリ向けに使うのではあればこれは不要

送信側クライアントで使用する秘密鍵を取得

image

Cloud Messagingを送信する側でFirebaseを使用する為にこれが必要なのだが、これを利用するときの手法が2つあって

  1. "firebase-admin/app"パッケージのcertを使ってこのJSONをコード中で読み込んで実行する方法
  2. "firebase-admin/app"パッケージのapplicationDefaultを使って環境変数(GOOGLE_APPLICATION_CREDENTIALS)からロードする方法

また、Google CloudのIAM上でWorkload Identityを設定することでこの秘密鍵のファイルをダウンロードして本番環境なのに配置したりしなくてもapplicationDefaultを使えばできるらしい(Gemini曰く)。AWSなどを使って行う場合にはこの環境変数をAWS Secret Managerを使ってセットしておいてそれをクライアントのプログラム上から使用するような感じでもできるらしい。だけどAWS使うなら素直にWorkload Identityを設定した方がきっと楽な気がする

これで設定関係は終わり

権限の確認

Google CloudのIAM管理からCloud Messagingの権限管理で

image

っていう感じで上記の秘密鍵のclient_emailで指定されてあるメールアドレスとプリンシパルが一致してるか確認。まぁ大体ちゃんとそうなってるはず

さてここからコーディングを

Web側(index.html)

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>FCMテスト</title>
    <script src="https://www.gstatic.com/firebasejs/12.15.0/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/12.15.0/firebase-messaging-compat.js"></script>
  </head>
  <body>
    <div id="token-area"></div>
    <script>
      const firebaseConfig = {
        // 省略
      };
      firebase.initializeApp(firebaseConfig);

      const messaging = firebase.messaging();

      async function initFCM() {
        if (!('serviceWorker' in navigator)) {
          console.error('このブラウザは Service Worker をサポートしていません。');
          return;
        }

        try {
          await navigator.serviceWorker.register('./firebase-messaging-sw.js');
          const swRegistration = await navigator.serviceWorker.ready;

          const permission = await Notification.requestPermission();

          if (permission === 'granted') {
            const token = await messaging.getToken({
              vapidKey: 'ウェブプッシュ証明書で取得した鍵ペアの値',
              serviceWorkerRegistration: swRegistration
            });

            if (token) {
              const tokenArea = document.getElementById('token-area');
              tokenArea.innerText = token;
            } else {
              console.warn('トークンが取得できませんでした');
            }
          } else {
            console.warn('通知がブロックされています。');
          }
        } catch (error) {
          console.error('エラーが発生しました:', error);
        }
      }

      window.addEventListener('load', initFCM);

      messaging.onMessage((payload) => {
        alert(`【通知受信】\nタイトル: ${payload.notification.title}\n本文: ${payload.notification.body}`);
      });
    </script>
  </body>
</html>

firebaseConfigに登録したマイアプリのWebタイプのapiKeyなどが含まれるのをここにコピーする。もちろんServiceWorker側も必要なので作っていく

Web側(firebase-messaging-sw.js)

importScripts('https://www.gstatic.com/firebasejs/12.15.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/12.15.0/firebase-messaging-compat.js')

const firebaseConfig = {
  // 省略
};
firebase.initializeApp(firebaseConfig)
firebase.messaging();

本当はWeb側のJSとServiceWorkerでfirebaseConfigを共通化できるようにするべきなんだろうけど

Webサーバーにデプロイしてからこれにアクセスすると画面上にアクセストークンが出るのでそれをコピーしておく

とりあえずちゃんと受信できるかクライアント側を作って実行して送ってみる

クライアント側(client.mjs)

"fireabase-admin"のライブラリが必要。普通にnpmでインストールする
import { initializeApp, applicationDefault } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';

initializeApp({ credential: applicationDefault() });

const message = {
  notification: { title: '送信', body: 'Hello' },
  token: '取得したアクセストークン'
};

getMessaging().send(message).then((response) => {
  console.log('送信成功しました!メッセージID:', response);
}).catch((error) => {
  console.error('送信エラー:', error);
});

applicationDefaultを使用するため、GOOGLE_APPLICATION_CREDENTIALSの環境変数にダウンロードした秘密鍵.jsonのパスを指定する

GOOGLE_APPLICATION_CREDENTIALS=secret-key.json node client.mjs

これを実行するとブラウザでアクセスしてる場合はブラウザの通知でメッセージが表示される(今回の場合はalertが起きる)。ブラウザがforegroundではない(最小化してるなど)の場合にはOSの通知機能でメッセージが表示されるようになる

ついでにapplicationDefaultを使用しないでファイルを読み込んで使う場合には

import { initializeApp, cert } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
import { readFileSync } from 'fs';

const serviceAccount = JSON.parse(readFileSync(new URL('./secret-key.json', import.meta.url)));
initializeApp({ credential: cert(serviceAccount) });

const message = {
  notification: { title: '送信', body: 'Hello' },
  token: '取得したアクセストークン'
};

getMessaging().send(message).then((response) => {
  console.log('送信成功しました!メッセージID:', response);
}).catch((error) => {
  console.error('送信エラー:', error);
});

という感じでCloud Messagingで通知を送ったりできる。ちなみに後述するAndroidアプリの場合にはtopicSubscripbeっていうこのトピックに接続してる側一括で送信する機能があるのでわざわざアクセストークンを取得してごちゃごちゃする必要はない。しかしWeb版のAPIにはこのtopicSubscribeに該当するAPIが無いので、ユーザーのアクセストークンをDBなどに保存するなどで保管して通知を行う場合にはこのトークンをDBなどから取得して送信処理をするっていうのが必要になる(余談参照)

アクセストークンは一定で失効する場合がある。その場合にはgetMessaging().sendを呼ぶときにエラーが起きるようになるらしいので、それが起きた場合にはDBなどからアクセストークンを削除する処理が必要になるかと思う多分...

まぁWebアプリ版のはこんくらいでAndroidアプリでも受信してみる

Firebaseを使うAndroidアプリの環境を設定

まずAndroidアプリにFirebase関係のライブラリやらGoogle Serviceに関わるプラグインの設定やらが必要になるので以下をやっていく

/build.gradleの設定

plugins {
    id 'com.android.application' version '8.2.2' apply false
    id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
    id 'com.google.gms.google-services' version '4.4.1' apply false
}

んでお次はapp/build.gradleを

/app/build.gradle

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'com.google.gms.google-services'
}

android {
    namespace 'sample.app'
    compileSdk 34

    defaultConfig {
        applicationId "sample.app"
        minSdk 26
        targetSdk 34
        versionCode 1
        versionName "1.0"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {
    implementation platform('com.google.firebase:firebase-bom:32.7.0')
    implementation 'com.google.firebase:firebase-messaging-ktx'

    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'com.google.android.material:material:1.11.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
}

applicationIdなどはFirebase ConsoleでAndroidアプリを登録する際に指定したアプリケーションIDのパッケージ名をちゃんと指定する

んで設定のときにダウンロードした"google-services.json"をappディレクトリの直下に入れておく

Service側で受け取って処理するためAndroidManifest.xmlも設定する

/app/src/main/AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/Theme.AppCompat.Light.DarkActionBar">

        <activity  android:name=".MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <service android:name=".MyFirebaseMessagingService" android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
    </application>
</manifest>

あとはFirebase Cloud Messagingに送信されたデータを受信するためのServiceを作る

/app/src/main/java/.../MyFirebaseMessagingService.kt

package sample.app

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage

class MyFirebaseMessagingService : FirebaseMessagingService() {

    companion object {
        private const val CHANNEL_ID = "fcm_topic_channel"
        private const val CHANNEL_NAME = "FCMトピック通知"
        private const val NOTIFICATION_ID = 1001
    }

    override fun onNewToken(token: String) {
        super.onNewToken(token)
    }

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)
        val title = remoteMessage.notification?.title ?: remoteMessage.data["title"] ?: "新しいメッセージ"
        val body = remoteMessage.notification?.body ?: remoteMessage.data["body"] ?: ""
        showNotification(title, body)
    }

    private fun showNotification(title: String, body: String) {
        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        createNotificationChannel(notificationManager)

        val intent = Intent(this, MainActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
        }
        val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE)

        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(android.R.drawable.ic_dialog_info)
            .setContentTitle(title)
            .setContentText(body)
            .setStyle(NotificationCompat.BigTextStyle().bigText(body))
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .build()

        notificationManager.notify(NOTIFICATION_ID, notification)
    }

    private fun createNotificationChannel(manager: NotificationManager) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH).apply {
                description = "FCMトピックから受信した通知チャンネル"
            }
            manager.createNotificationChannel(channel)
        }
    }
}

まぁonMessageReceivedで受け取ってそれをNotificationで通知として出してるだけ。ただしここはあくまで受け取るだけ。トピックへの接続などはActivity側でやる

/app/src/main/java/.../MainActivity.kt

package sample.app

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging

class MainActivity : AppCompatActivity() {

    companion object {
        const val TOPIC = "test-topic"
    }

    private val requestPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        val msg = if (isGranted) {
          "通知の許可が付与されました"
        } else {
          "通知の許可が拒否されました"
        }

        Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
        updateStatus()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        askNotificationPermission()
        subscribeToTopic()
        updateStatus()
    }

    private fun askNotificationPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
                != PackageManager.PERMISSION_GRANTED
            ) {
                requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
            }
        }
    }

    private fun subscribeToTopic() {
        FirebaseMessaging.getInstance().subscribeToTopic(TOPIC)
            .addOnCompleteListener { task ->
                val msg = if (task.isSuccessful) {
                    "トピック「$TOPIC」にサブスクライブしました"
                } else {
                    "サブスクライブ失敗: ${task.exception?.message}"
                }
                Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
                updateStatus()
            }
    }

    private fun updateStatus() {
        val permissionStatus = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
                == PackageManager.PERMISSION_GRANTED
            ) "✓ 付与済み" else "✗ 未許可"
        } else {
            "✓ 不要 (Android 12以下)"
        }

        findViewById<TextView>(R.id.tv_status).text = """
            通知権限: $permissionStatus
        """.trimIndent()
    }
}

"test-topic"っていうFirebase Cloud Messagingのチャンネル(クライアント側とも合わせる)を指定して、クライアントから送られたメーセージを受信できるようにする。ちなみにAndroid13以降からはこういう通知を受け取る許可をアプリ側で承認するようにしないといけないっぽい

まぁこれでAndroidアプリ側で受信できるはずなのでさっきのclient.mjsを以下のように修正して再実行

import { initializeApp, applicationDefault } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';

initializeApp({ credential: applicationDefault() });

const message = {
  notification: { title: '送信', body: 'Hello' },
  topic: "test-topic"
};

getMessaging().send(message).then((response) => {
  console.log('送信成功しました!メッセージID:', response);
}).catch((error) => {
  console.error('送信エラー:', error);
});

ここでActivityで指定したトピック名(test-topic)を指定して送信。アプリを入れてる側で通知が表示されるようになる(はず)

というようにAndroidなどのスマホアプリ向けのはこのトピックっていう機能を使ってそれに接続してる人に通知を送信したりすることができる。ただしWeb版のAPIにはこれが無いのでそれをやるにはWeb版はアクセストークンの保持が必要になるっていう感じ(余談参照)

以上!

余談

フロントエンドにはsubscribeToTopicのようなAPIはないが、node.jsなどを利用したサーバープログラム上などではできるらしい。その手法を使って以下のようにすればWeb側でもsubscribeToTopicのようなことはできるらしい

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>FCMテスト</title>
    <script src="https://www.gstatic.com/firebasejs/12.15.0/firebase-app-compat.js"></script>
    <script src="https://www.gstatic.com/firebasejs/12.15.0/firebase-messaging-compat.js"></script>
  </head>
  <body>
    <script>
      const firebaseConfig = {
        // 省略
      };
      firebase.initializeApp(firebaseConfig);

      const messaging = firebase.messaging();

      async function initFCM() {
        if (!('serviceWorker' in navigator)) {
          console.error('このブラウザは Service Worker をサポートしていません。');
          return;
        }

        try {
          await navigator.serviceWorker.register('./firebase-messaging-sw.js');
          const swRegistration = await navigator.serviceWorker.ready;
          const permission = await Notification.requestPermission();

          if (permission === 'granted') {
            const token = await messaging.getToken({
              vapidKey: 'ウェブプッシュ証明書で取得した鍵ペアの値',
              serviceWorkerRegistration: swRegistration
            });

            if (token) {
              await fetch('/api/subscribe', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                  token: token,
                  topic: 'test-topic'
                })
              });
            } else {
              console.warn('トークンが取得できませんでした');
            }
          } else {
            console.warn('通知がブロックされています。');
          }
        } catch (error) {
          console.error('エラーが発生しました:', error);
        }
      }

      window.addEventListener('load', initFCM);

      messaging.onMessage((payload) => {
        alert(`【通知受信】\nタイトル: ${payload.notification.title}\n本文: ${payload.notification.body}`);
      });
    </script>
  </body>
</html>

これがフロントエンドでNode.jsでexpressとfirebaseを使って以下のようなサーバーを作る

import express from 'express';
import { initializeApp, applicationDefault } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';

initializeApp({ credential: applicationDefault() });

const app = express();
const PORT = 3000;

app.use(express.json());
app.use(express.static('.'));

app.post('/api/subscribe', (req, res) => {
  const { token, topic } = req.body;

  if (!token || !topic) {
    return res.status(400).json({ error: 'トークンとトピック名は必須です。' });
  }

  getMessaging().subscribeToTopic([token], topic)
    .then((response) => {
      res.json({
        success: true,
        message: `トピック「${topic}」に登録しました。`,
        details: response
      });
    })
    .catch((error) => {
      console.error('トピック登録エラー:', error);
      res.status(500).json({ error: 'トピックの登録に失敗しました。', details: error.message });
    });
});

app.listen(PORT, () => {
  console.log(`http://localhost:${PORT}`);
});

というようにすればWebフロントエンド側でもsubscribeToTopicのような感じにすることもできるよってことでw

Table of Contents
環境の設定 マイアプリの登録 Webプッシュ証明書を設定 送信側クライアントで使用する秘密鍵を取得 権限の確認 Web側(index.html) Web側(firebase-messaging-sw.js) クライアント側(client.mjs) Firebaseを使うAndroidアプリの環境を設定 /build.gradleの設定 /app/build.gradle /app/src/main/AndroidManifest.xml /app/src/main/java/.../MyFirebaseMessagingService.kt /app/src/main/java/.../MainActivity.kt 余談
関連記事