SupabaseとFCM(Firebase Cloud Messaging)を使ってPush通知機能構築！

# 通知機能を構築したい
Flutterでアプリケーション作成をしています。
今回やりたいことは、通知機能の構築です。

モバイルオーダーを注文して、
店舗側で準備ができたタイミングで通知が来ますよね！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/c79cf5b7-bb4d-43b1-af48-e5c0ebc1a256.png)

Push通知は様々なアプリで利用されていますが、
あれを実際に構築してみました。勉強になったことをまとめてみました。



## SupabaseとFirebaseを用いた構築
今回の通知機能を構築する上で利用するのはSupabaseとFirebaseです。
私の端末がAndroidというのが主な理由ですが、今回、iPhoneの話はしません。（できません）

https://supabase.com/

公式サイトで、Push通知のページがあります。

https://supabase.com/docs/guides/functions/examples/push-notifications?queryGroups=platform&platform=fcm

このページには動画も用意されています。主にこの動画を参照して作業を進めました。

https://www.youtube.com/watch?v=CiSv9E6ZKVc

#### 構築内容詳細

> Firebase Cloud Messaging (FCM) is a push notification service offered by Google that allows you to send push notifications to your users' devices on iOS, Android, and Web.

> This guide will show you how to send push notifications to your app when a new row is inserted into a table using FCM, Supabase Edge Functions, and database web hooks.

上記文章は、公式サイトの冒頭に記載されていた内容です。

上記文章をふまえると、通知機能構築で必要なのは以下の4つです。
- アプリケーション（動作確認のための端末またはエミュレーター）
- DBテーブル（アプリで使用するデータを格納）
- Edge Functions(実際に通知を行う関数)
- WebHook(DBテーブルとEdge Functionsの紐づけ)

動画内で登場したフローをお借りしますが、以下の流れで通知まで行われます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/23cb4e53-0672-422c-917b-e54891201323.png)

#### 作業を行う前提
初学者向けでありません。
個人規模で問題ありませんが、以下の経験がない場合、非常に大変かと思います。
- Flutterによる開発経験(端末を使った動作確認ができる程度に)
- クラウドサービスを用いた開発経験（AWSやAzureやCloudflareなど）
- ログイン認証機能の実装経験（メールアドレス・パスワードでOK）
- データベースを用いた開発（Postgresqlが最善）

またSupabaseについて、以下機能の利用経験があると、スムーズに進められると思います。
（クラウドサーバレス開発のご経験があれば、イメージしやすいと思います。）
- Database
- Edge Functions
- WebHook

## Supabaseプロジェクトのセットアップ
早速ですが、作業を進めていきます。
通知機能を行う上で、プロジェクト作成が必要です。

アカウント作成を行い、ダッシュボードの`New Project`をクリックして、作成を進めてください。（詳細は省きます。）

必要なテーブルの作成作業は、
[テーブル作成](#テーブル作成)で記載しています。そのタイミングに作成いただければOKです。

## Firebaseプロジェクトのセットアップ
続いて、Firebaseの設定を進めます。

#### プロジェクト作成
こちらも、プロジェクト作成は済んでいる前提で進めます。
プロジェクト未作成の場合は、以下リンクを参照して作成を行ってください。

https://firebase.google.com/docs/projects/learn-more?hl=ja

#### Flutterアプリの連携方法
Flutterのアプリケーションと、Firebaseプロジェクトを連携させます。

https://firebase.google.com/docs/flutter/setup?hl=ja&_gl=1*118gmm*_up*MQ..*_ga*MjA1NDIyNDUzOS4xNzQzNzQ1OTY2*_ga_CW55HF8NVT*MTc0Mzc0NTk2Ni4xLjAuMTc0Mzc0NzM0OC4wLjAuMA..&platform=ios

上記リンクの手順通りですが、以下コマンドを実行すればOKです。
#### パッケージのインストール
```powershell
flutter pub add firebase_core firebase_messaging
```
https://pub.dev/packages/firebase_core

https://pub.dev/packages/firebase_messaging

#### flutterfire_cliの設定
```powershell
dart pub global activate flutterfire_cli
flutterfire configure
```

> ? You have an existing `firebase.json` file and possibly already configured your project for Firebase. Would you prefer to reuse the values in your existing `firebase.json` file to configure your project? (y/n) › yes

既にプロジェクトのルートパスに`firebase.json`がある場合には、既存の設定を利用するか質問されます。
各々の環境によって異なるので、適切な方を選択します。

:::note info
**Git Bashによる作業**
以下のようにエラーとなる可能性もあるので、powershellで進めるのが確実です！
```bash
dart pub global activate flutterfire_cli
flutterfire configure
```
> bash: flutterfire: command not found
:::

#### 秘密鍵の生成
動画でもダウンロードする場面が流れていますが、通知するためのAPIを実行する際に、Firebaseのどのプロジェクトを用いた通知か識別が必要です。
そのための識別の役割も担うファイルをダウンロードします。

プロジェクトの設定画面から、タブ「サービスアカウント」を選択します。
「新しい秘密鍵を生成」をクリックして、秘密鍵を入手します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/025aa5e1-fa90-45e3-b909-3ec8dc88f5fe.png)

:::note warn
**生成した秘密鍵の扱い**

このダウンロードした秘密鍵は、git管理対象外としてください。
動画内でも説明がある通りです。
:::

#### 休憩するなら1

作業を中断される場合は、このタイミングがいいかと思います！

## FCMトークンのテーブル登録とデバイストークン取得
動画内のセクション「[DartコードでFCMを設定する](https://youtu.be/CiSv9E6ZKVc?si=Yh7OlnmTuNnMV67x&t=375)」
でそのあたりの話をしています。

デバイストークンですが、
**特定のデバイスとモバイルアプリを識別するためのID**です。

#### テーブル作成
動画内でもそうですが、
FCMトークンの取得処理と合わせてSupabaseを用いたログイン処理が一緒に出てきます。

Supabaseでは`auth.users`テーブルという認証ユーザ情報管理用のテーブルがあり、
ログイン認証が完了時にでメールアドレスとユーザーIDの情報が追加されます。

取得したトークンを認証ユーザーIDとセットで、テーブルに登録します。

公式の記事でいえば、以下テーブルがそれに該当します。
```sql
create table public.profiles (
  id uuid references auth.users(id) not null primary key,
  fcm_token text
);
```
また、データ登録や更新をするための作業用テーブル（※）が必要です。
私が作業したときと全く同じではないですが、以下のようなテーブルがあればいいかと思います。
後述していますが、`provided_status`の更新時に通知ができるように構築を行います。
```sql
create table "public"."orders" (
    "id" uuid not null default gen_random_uuid(),
    "user_id" references auth.users(id) not null,
    "provided_status" smallint not null default 0,
    "created_at" timestamp with time zone not null default now(),
    "updated_at" timestamp with time zone not null default now()
);
```
※RLS(Row Level Security)は、必要に応じて設定してください。（設定なしでも通知機能は構築可能です。）

#### Dartのコードでデバイストークン取得
Dartのコード上でデバイストークンを取得する処理を実装します。

動画内でコーディングされている通りのコードを実装しますが、以下パッケージが必要になるので、追加します。

https://pub.dev/packages/supabase_flutter

私は以下のような形で登録する処理を用意しています。
**ログイン処理**
```dart
  // ログイン済か判定するためのユーザ情報取得
  await widget.authService.signOutWithEmailAndPassword(); 
  if (userData != null) {
    await widget.authService.signOutWithEmailAndPassword();
    widget.showSnackBar('サインアウトしました。');
  } else {
   // サインイン処理(以下リンクの処理とほぼ同じ)
   // https://supabase.com/docs/reference/dart/auth-signinwithpassword
    await widget.authService.signInWithEmailAndPassword(
      _emailController.text,
      _passwordController.text,
    );
    try {
      // FCMトークン取得
      await FirebaseMessaging.instance.requestPermission();
      final fcmToken = await FirebaseMessaging.instance.getToken();
      if (fcmToken != null) {
         await authService.setFcmToken(fcmToken);
      }
      // 画面遷移処理
    } catch (e) {
      print('FCMトークン取得エラー: $e');
    }
  }  
```
**リフレッシュ処理**
トークンが更新された場合にも通知を受けられるように必要な処理です。
この処理は、ログイン処理以外の箇所で実装します。（動画もそのようにしている）
細かい実装箇所は、各々のアプリで変わります。
```dart
FirebaseMessaging.instance.onTokenRefresh.listen((fcmToken) async {
    await authService.setFcmToken(fcmToken);
});
```
**トークン登録処理**
私は認証系の処理を以下ファイルでまとめているので、そこにメソッドを追加しています。
（テーブル名は違いますが、他は動画内と同じ）
```dart:auth_service.dart
  Future<void> setFcmToken(String fcmToken) async {
    final userId = supabase.auth.currentUser!.id;
    await supabase.from('user_profiles').upsert({'id': userId, 'fcm_token': fcmToken});
  }
```

## テスト送信
動画内のセクション「[テストFCMメッセージを手動で送信する](https://youtu.be/CiSv9E6ZKVc?si=MsNmxIykiQLQaAcY&t=558)」
でそのあたりの話をしています。

この後Edge Functionsを作成しますが、
一旦アプリケーションを動かして、FCMトークンがテーブルに登録されることを確認してみます。

またテーブルに登録されたトークン値を使って、テストメッセージを送信します。
登録されたトークン値のあたりで右クリックをして、値をコピーします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/4ec08254-f2ca-42c3-9fd9-3a12b7943ea3.png)

#### Messagingキャンペーンの作成
Firebaseのマネジメントコンソールに移動し、Messagingを選択します。

https://firebase.google.com/docs/cloud-messaging?hl=ja

以下の順に操作します。
1. 「最初のキャンペーンを作成する」をクリック
1. 「Firebase Notification メッセージ」を選択
1. 「作成」をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/88c9582c-949f-4d45-86c3-787e51077a8e.png)

#### 通知作成と実行
「作成」をクリック後、画面が変わります。
**この段階で、Flutterのアプリケーションは起動した状態にしておきます。**

通知のタイトル、通知テキストの2項目を入力すると、
「テストメッセージを送信」がクリック可能な状態になるので、クリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/2727327e-eb76-4ac1-bc7a-0a9ac17ddd5d.png)

FCMトークンを入力する箇所があります。
Supabaseのテーブルでコピーした値をここに貼り付けます。
入力後、`＋`ボタンが表示されるので、クリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/9584fb41-0f93-44be-99a1-98ef745e3707.png)

「テスト」をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/89c111a3-f604-433c-bce1-b364337965c9.png)

以下のように通知が表示されれば、利用端末のFCMトークンが正しく設定されていることが確認できました。（画像内で隠している箇所は、`pubspec.yaml`の`name`の値が表示されています。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/2e212cc9-3d84-4e4a-b681-038bb88dff47.png)

残りは
Edge Functionsの作成と、WebHookの設定です。

#### 休憩するなら2

ここで一区切りです。作業を中断される場合は、ここちょうどいいかと思います！

## Edge Functionsの処理を実装

https://supabase.com/docs/guides/functions/quickstart

`Edge Functions`ですが、Lambda(AWS)やAzure Functionと同じようなものです。
言語がTypescriptですが、ゴリゴリ実装するほど量はないので、
何らかの言語の経験があれば、作業に支障はありません。

https://supabase.com/docs/guides/local-development

#### 機能アップデート
関数作成からデプロイまでの作業に際し、
これまでは、Dockerとsupabase-cliが必要でした。

先日のLaunch Weekにて、ブラウザ上でのコード編集ができるようになりました。
このアップデートによって、ブラウザ上で直接編集したり、必要なファイルをアップロードしたり、ということができるようになりました。

https://supabase.com/blog/supabase-edge-functions-deploy-dashboard-deno-2-1

お好きな方で作業を進めてきましょう！

#### Edge Functionsでやること
この後に作成するWebHookと関係しますが、以下の処理を行います。
公式ドキュメントのコードと照らし合わせてみるのが、分かりやすいと思います。
- データベースの更新内容を取得（WebHook作成時に設定した内容次第）
- FCM(デバイス)トークン情報をテーブルから取得（既に前作業で登録済）
- データベースの更新内容に応じて、通知有り無しを制御（各々でカスタマイズ）
- 通知を行う場合に、API呼び出し

実装は、[実装詳細](#実装詳細)（記事の最後）に記載しています。

#### サービスアカウントファイルの配置
[秘密鍵の生成](#秘密鍵の生成)の際にダウンロードしたファイルをここで使います。

配置場所は以下の通りです。
`pushNotify`は関数名なので、各自で作成された関数名になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/ed4822a3-d21b-48b8-abfa-8f3a03be6855.png)

:::note info
**マネジメントコンソールの赤波線**

SupabaseのEdge Function上で関数を確認すると、赤波線が引かれています。
何かimport関係の作業が必要なのかと思いましたが、このままでも問題なく通知は受信できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/fe63b541-d776-4ff6-99fc-b5fe28212964.png)

画像内にもある`deno.json`の詳細が気になる方は、以下をご覧ください。

https://docs.deno.com/runtime/fundamentals/configuration/

:::

## WebHookの構築
テーブルデータ更新をトリガーにして、Edge Functionsを実行します。
このトリガーを作成します。

#### WebHookを作成
以下はSupabaseのダッシュボードです。以下画像の右端にある「Create a new hook」をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/b8eca0a7-94a4-4b2a-b3ef-9991c009d744.png)

「Create a new hook」をクリックすると、以下の画面が表示されます。

作成には、以下情報が必要です。
- どのテーブルに対して適用する？（私の場合は、`orders`テーブル）
- どういったイベントで機能させる？（私の場合は、`UPDATE`のみ）
- 対象のイベントでどういったどのFunction(またhttpリクエスト)を実行する？（私の場合は、`pushNotify`）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/c326e1c8-d0bd-4dcd-9d52-6d283de15064.png)

## 動作確認
`orders`テーブルの`UPDATE`イベント時にEdge Functionsが実行されるように設定をしました。データ更新時、どういった内容を受け取っているか、確認します。
```typescript:index.ts
Deno.serve(async (req) => {
  const payload: WebhookPayload = await req.json() // ←この内容を確認
  // ...略
}
```
※`WebhookPayload`：このinterfaceは、各自のテーブルに合わせて修正

以下のように表示されました。(カラムは一部省略）

私の場合は、`provided_status`が"0"から"1"へ更新されたとき通知したかったので、**old_record（更新前）**の情報も使っています。

```json
{
  "payload": {
    "type": "UPDATE",
    "table": "orders",
    "record": {
      "id": "e7ebdbb1-f2e4-1a11-a790-0ae9e82169a7",
      "user_id": "11111111-d866-4d86-81b4-af11acdc1111",
      "order_id": "order_75y0BioS12Q5wUOo_001",
      "provided_status": "1",
    },
    "schema": "public",
    "old_record": {
      "id": "e7ebdbb1-f2e4-1a11-a790-0ae9e82169a7",
      "user_id": "11111111-d866-4d86-81b4-af11acdc1111",
      "order_id": "order_75y0BioS12Q5wUOo_001",
      "provided_status": "0",
    }
  }
}
```
## フォアグラウンド実行とバックグラウンド実行
通知には大きく2種類あります。
- 対象のアプリが起動中でも、通知を届けたい場合：フォアグラウンド実行
- 対象のアプリが起動中でないときに、通知を届けたい場合：バックグラウンド実行

両方やってみました。

実装はClaudeの結果を利用しており、詳細確認はこれから、という部分もあります。
通知チャンネル(実装内のidとtitle)は特に修正しませんでしたが、このままでも機能しました。

またフォアグラウンド通知のためには、以下パッケージが必要です。

https://pub.dev/packages/flutter_local_notifications

<details><summary><b>main.dartの実装例</b></summary>

```dart:main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:testingapp/constants/id_keys.dart';
import 'package:testingapp/firebase_options.dart';
import 'package:testingapp/main/app.dart';

@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // バックグラウンドでFirebaseを初期化
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // 通知設定を初期化
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
  // FCM初期化関数を呼び出し（フォアグラウンド通知対応）
  initFCM();

  // 画面の向きを縦方向に固定
  // 画面の向きを縦方向に固定
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp, //通常の縦向き
    DeviceOrientation.portraitDown, //上下逆さまの縦向き
  ]).then((_) {
    runApp(
      kDebugMode
          ? ProviderScope(child: const TestingApp())
          : ProviderScope(child: const TestingApp()),
    );
  });
}

// ローカル通知プラグインのインスタンスをグローバルで定義
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
    FlutterLocalNotificationsPlugin();

// フォアグラウンド通知
void initFCM() {
  // ローカル通知の初期化
  const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings(
    '@mipmap/ic_launcher',
  );

  const InitializationSettings initializationSettings = InitializationSettings(
    android: initializationSettingsAndroid,
  );

  flutterLocalNotificationsPlugin.initialize(initializationSettings);

  // Android用の通知チャンネル設定
  const AndroidNotificationChannel channel = AndroidNotificationChannel(
    'high_importance_channel', // id
    'High Importance Notifications', // title
    description: 'This channel is used for important notifications.', // description
    importance: Importance.high,
  );

  flutterLocalNotificationsPlugin
      .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
      ?.createNotificationChannel(channel);

  // フォアグラウンドメッセージのリスナー
  FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    print('Foreground message received: ${message.notification?.title}');

    // フォアグラウンドで通知を表示するためのコード
    if (message.notification != null) {
      flutterLocalNotificationsPlugin.show(
        message.hashCode,
        message.notification!.title,
        message.notification!.body,
        NotificationDetails(
          android: AndroidNotificationDetails(
            channel.id,
            channel.name,
            channelDescription: channel.description,
            importance: Importance.high,
            priority: Priority.high,
          ),
        ),
      );
    }
  });
}

```

</details>

## 動作確認

データベースのテーブルの値を、ダッシュボード上から直接更新してみます。

`provided_status`を0から1に修正します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/cf9c2176-d57c-4778-b768-d8f68b532428.png)

スマートフォンのスクリーンショットですが、
以下画像のように通知が届いてたので成功です！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/c266f3da-facf-40d6-a2c2-9097a7d89725.png)

#### エラーが発生した場合
Edge FunctionsでLogを確認して、エラーの原因を探します。
以下画像のように、`ERROR`と表示されるので、その内容にそって修正をしてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/fd62c57f-1560-4dfc-829c-ff415dc6ba73.png)

## まとめ・この後
通知機能の構築って、難しいんだろうな～～と思って始めました。

動画と公式ドキュメントが分かりやすかったから、だと思いますが、
思った以上に短い時間で進めることができました。
（作業時間としては5～10時間）

今後別のアプリで通知機能を構築する際は、スムーズに進められそうです。

#### この後
また今回はデバイストークンを用いた基本的な通知機能の構築を行いましたが、
トピックを用いた通知も実現ができる、と作業終盤に知りました。
このあたりもやってみたいと思います。

https://techgrowup.net/firebase-cloud-messaging-token-deprecated/

> FCMの新機能により、トピックベースまたはユーザーセグメントベースでの通知送信が可能になり、個別のデバイスに対してトークンを管理しなくても、通知が届く仕組みが導入されました。これにより、複数のユーザーに対して一斉に通知を送信する場合でも、トークンを介さずに操作ができるようになっています。

https://firebase.google.com/docs/cloud-messaging/manage-tokens?hl=ja

#### One Signal
余談ですが、色々動画を見ていたところ、
通知機能を実現するためのサービスが他にもあることが分かりました。

https://documentation.onesignal.com/

Firebaseのサービスアカウントファイルをアップロードする作業が途中であったので、
これまでしてきた話と大きく変わるところはなさそうです。

またSupabaseと組み合わせて通知機能を構築する動画がありました。

https://www.youtube.com/watch?v=DJaFai5hJn8

## 実装詳細
記事の途中で記載した通り、Edge Functionsの実装を添付しました。

https://gist.github.com/eno-conan/55f12c4d32beb1ff253db1b5aa6997b8