Flutter × Firebase Cloud Messaging × AWS Lambdaでリモートプッシュ通知を送りたい（2022/08）

# はじめに
初投稿です。

Flutterでアプリ制作を行っていた際にプッシュ通知機能を実装しようとして色々調べた際、様々な情報が錯綜しており、どの記事が正しいのかが分からなくなって色んなことを試した結果、大口の沼にはまり込んでしまったため、自分用の備忘録として残します。

同じ問題に直面している人の助けとなれば幸いです。

# 前提条件
リモートプッシュ通知を送る方法は様々あると思いますが、今回はプロジェクトの要件として利用していたAWS Lambda（以下Lambda）からFirebase Cloud Messaging（以下FCM）を利用してAndroid端末にプッシュ通知を送っていきます。今回は、LambdaはNode.js（TypeScript）で実装します。

iOS端末にプッシュ通知を送る際は、Appleの開発者サイトやXCodeで追加設定が必要になるのですが、今回は割愛します。

この記事では以下のことを行っている前提で説明していきます。
- AWSアカウント・Firebaseアカウント作成済み
- Flutterプロジェクト作成済み


# 事前準備
### Firebase
Firebaseの準備から行います。Firebaseのプロジェクトをまだ作成していない場合は、新たなプロジェクトを作成しましょう。やり方は名前を付けて、アナリティクスの構成を決定するだけなので割愛します。

作成が完了すると、このような画面が表示されます。
![firebase_project_top.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565209/05e89f0c-1f38-9450-daad-a7d52161c7ff.png)

#### プロジェクトにアプリを追加
作成したFirebaseのプロジェクトに、Androidアプリを追加します。次の画像の赤枠で囲まれたアイコンをクリックします。
![firebase_project_add_android.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565209/683871cd-6e28-5b6e-08ea-682f378a1fea.png)

FlutterプロジェクトでAndroidのパッケージ名を確認・入力し、「アプリを登録」をクリックします。
![firebase_project_add_android_app_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565209/f79b5c05-d35c-4cd3-f4f2-f1a7a7fb95e8.png)

すると構成ファイルをダウンロードできるようになるため、ダウンロードしてFlutterのプロジェクトに追加します。構成ファイルは、/[project_root]/android/appの中に保存します。

③のステップはスキップしてこれで追加完了です。

iOSアプリも同時に行う場合は、プロジェクトのトップからiOSアプリの追加をクリックし、同じ手順を踏みます。構成ファイルは、/[project_root]/ios/Runnerの中に保存します。

#### サービスアカウント
続いてFirebaseをLambdaで使えるように設定を行います。Firebaseのプロジェクトトップページの左側のメニュー上部から、「プロジェクトの概要」の右にある歯車アイコンをクリックし、「プロジェクトの設定」をクリックします。
![firebase_project_service_account.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565209/7ff60fc5-11f0-e2f3-bb7a-ea216ce6614b.png)

「サービスアカウント」のタブをクリックし、「新しい秘密鍵の生成」をクリックします。
![firebase_project_config.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565209/009e1ede-1406-0a51-6388-f1ca9981b919.png)

よく読んで、「キーを生成」をクリックします。
![firebase_project_config_caution.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/565209/3751a51f-e13f-0d14-f8fe-1b717c848a5f.png)

するとjsonファイルがダウンロードされますので、これをNode.jsプロジェクトのルートに保存しましょう。


# Flutterの設定
Flutter側でプッシュ通知を有効にするためにFlutter側でも設定を行います。「FlutterFire」を利用すると必要な設定を自動で行ってくれるため、今回はこちらで設定を進めます。実装時は以下の公式ドキュメントを参考にしながら実装しました。
https://firebase.flutter.dev/docs/overview/

### FlutterFire
FlutterFireとは、その名の通りFlutterとFirebaseを繋ぎ合わせるためのFlutter用のプラグインです。

#### FlutterFireの導入
FlutterFireを利用するには`firebase_core`プラグインを導入する必要があるため、コマンドプロンプトやターミナルでFlutterプロジェクトフォルダに移動してから、以下のコマンドを実行します。

```
flutter pub add firebase_core
```

#### FlutterFireの初期化
FlutterFireを初期化するためには、FlutterFire CLIを使います。FlutterFire CLIはFirebase CLIに依存しているため、Firebase CLIをインストールする必要があります。Firebase CLIをまだ導入していない場合は以下のドキュメントに従って導入してください。
https://firebase.google.com/docs/cli

FlutterFireを導入します。コマンドプロンプトやターミナルで以下のコマンドを実行します。
```
dart pub global activate flutterfire_cli
```

インストールが完了したら、プロジェクトフォルダで以下のコマンドを実行します。
```
flutterfire configure
```
実行すると、先程設定したFirebaseのプロジェクトが表示されますので選択します。選択後、どのプラットフォームで設定を行うかを問われますので、必要なプラットフォームを選択して、続行します。

設定が完了すると`firebase_options.dart`というファイルが作成されているはずです。これを/[project_root]/lib/main.dartに追記します。

まずは`firebase_core`と`firebase_options.dart`をインポートして
```dart
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
```

メイン関数を修正します。
```dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 中略..

  runApp(MyApp());
}
```

#### FlutterFireを初期化出来ない場合
`flutterfire configure`のコマンドが失敗してしまう場合などは、Firebase CLIでしっかりログインが出来ているかどうかを確認してみてください。

# プッシュ通知の実装（Flutter）
こちらを参考に実装して行きます。
https://firebase.flutter.dev/docs/messaging/usage

まずは、`firebase_messaging`のプラグインを追加します。
```
flutter pub add firebase_messaging
```

/[project_root]/lib/main.dartにそのまま通知の処理を書いていきます。

```dart
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; // 追加
import 'firebase_options.dart';

// 中略..

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 中略..

  // FCMから通知を受け取るための処理
  FirebaseMessaging messaging = FirebaseMessaging.instance; // 追加
  // 通知の許可をもらう
  NotificationSettings settings = await messaging.requestPermission(
    alert: true,
    announcement: false,
    badge: true,
    carPlay: false,
    criticalAlert: false,
    provisional: false,
    sound: true,
  ); // 追加
  print('User granted permission: ${settings.authorizationStatus}'); // 追加
  　
  runApp(MyApp());
}
```

### Foreground, Background, Terminated
メッセージを受け取る際、アプリが最前面に表示されている時（Foreground）、アプリが裏で待機している時（Background）、アプリが終了している時（Terminated）でそれぞれの処理を書かなければいけないようです。


#### Foregroundメッセージ
アプリがForeground状態のときは、`onMessage`ストリームを使ってメッセージを受け取ります。通知の許可をもらう処理の後に以下を追記します。
```dart
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
  print('Got a message whilst in the foreground!');
  print('Message data: ${message.data}');

  if(message.notification != null) {
    print('Message also contained a notification: ${message.notification}');
  }
  // 中略..
  // メッセージを受け取った際に行いたい処理等
});
```
デフォルトでは __Foreground時にメッセージを受け取っても通知は表示されません。__ Foreground時にメッセージを受け取った際にもプッシュ通知を表示したい場合は、`flutter_local_notifications`プラグイン等を使ってローカルプッシュ通知を表示させるように実装を行います。実装する場合は、以下を参考に実装してみてください。
https://firebase.flutter.dev/docs/messaging/notifications/#foreground-notifications

#### Background, Terminatedメッセージ
アプリがBackgroundまたはTerminatedの状態のときは`onBackgroundMessage`ハンドラを利用します。ドキュメントによると、ハンドラは以下の条件を満たしていなければならないようです。
- アノニマス関数でない
- top-level関数である（初期化が必要なクラス関数等ではない）

main関数の外とForegroundメッセージの処理の後に処理を追記します。
```dart
// 追加
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // Firestore等の他のFirebaseのサービスを使う場合は、initializeApp()で初期化を行う。
  await Firebase.initializeApp();
  print("Handling a background message: ${message.messageId}");

  // 中略..
  // メッセージを受け取った際に行いたい処理等
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 中略..

  // FCMから通知を受け取るための処理
  FirebaseMessaging messaging = FirebaseMessaging.instance;
  // 通知の許可をもらう
  NotificationSettings settings = await messaging.requestPermission(
    alert: true,
    announcement: false,
    badge: true,
    carPlay: false,
    criticalAlert: false,
    provisional: false,
    sound: true,
  );
  print('User granted permission: ${settings.authorizationStatus}');

  FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    print('Got a message whilst in the foreground!');
    print('Message data: ${message.data}');

    if(message.notification != null) {
      print('Message also contained a notification: ${message.notification}');
    }
    // 中略..
    // メッセージを受け取った際に行いたい処理等
  });

  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); // 追加

  runApp(MyApp());
}
```
Foregroundメッセージとは違い、Background, Terminatedメッセージはメッセージを受け取ったタイミングでプッシュ通知が端末に表示されます。

### FCM Token
サーバーがどの端末にプッシュ通知を送信するかを特定するために、端末側でトークンを発行する必要があります。通知の許可をもらった後に以下の処理を追記します。
```dart
String? token = await messaging.getToken();
print(token);
```
実際のサービスでは、トークンはユーザーの認証を行い（必要であれば）、サーバー側に送ってデータベースへ保存しておく必要がありますが、今回はデモなので、トークンをログに出力しておきます。このトークンは後ほどLambda側の実装で使います。

# プッシュ通知の実装（Lambda）
続いてLambda側の実装に入ります。まずはFirebase Admin Node.js SDKを導入しましょう。
コマンドプロンプト・ターミナルでNode.jsのプロジェクトのルートへ移動し、コマンドを実行します。
```
npm i firebase-admin
```

/[project-root]/src/FirebaseCloudMessagingService.tsを作成します。また、[事前準備](#事前準備)の[サービスアカウント](#サービスアカウント)でダウンロードしたjsonファイルを読み込みます。[FCM Token](#fcm-token)も忘れずに記載します。

```typescript
import admin from "firebase-admin";
import firebaseAccountCredentials from "../service-account.json";

class FirebaseCloudMessagingService {
  constructor() {
    // adminが既に初期化されている場合は初期化しないようにする
    if(admin.apps.length === 0) {
      const serviceAccount = firebaseAccountCredentials as admin.ServiceAccount;
      admin.initializeApp({
        credential: admin.credential.cert(serviceAccount),
      });
    }
  }

  public async sendNotification() {
    const res = await admin.messaging().send({
      token: "{Flutterで取得したFCM Token}",
      notification: {
        title: "Hello, World!",
        body: "リモートプッシュ通知です！",
      },
      android: {
        priority: "high",
      },
      apns: {
        payload: {
          aps: {
            contentAvailable: true,
          },
        },
        headers: {
          "apns-push-type": "background",
          "apns-priority": "5", // Must be `5` when `contentAvailable` is set to true.
        },
      },
    });
    console.log(res);
  }
}

export default FirebaseCloudMessagingService;
```

`android`だの`apns`だの訳のわからないプロパティがくっついていますが、これはそれぞれAndroidとiOSで認識されるプロパティのようで、メッセージを重要性の高いメッセージとして認識させるための記述のようです。

今回は`messaging`の`send`関数を使いましたが、`sendMultiCast`という関数もあり、これは複数のトークンに対して同じメッセージを一括で送信することができます。`token`というプロパティを`tokens`に変更して、配列でトークンを指定してあげると使えます。

では、Lambdaが実行されたらメッセージが送られるように実装して行きます。/[project-root]/src/index.tsを実装して行きます。

```typescript
import { APIGatewayProxyCallbackV2, APIGatewayProxyEventV2, Context } from "aws-lambda";
import FirebaseCloudMessagingService from "./FirebaseCloudMessagingService";

export async function handler(event: APIGatewayProxyEventV2, context: Context, callback: APIGatewayProxyCallbackV2): Promise<any> {
  FirebaseCloudMessagingService fcm = new FirebaseCloudMessagingService();
  await fcm.sendNotification();

  callback(null, {
    statusCode: 200,
    body: JSON.stringify({
      message: "Message has been sent",
    }),
  });
}
```

これでLambdaが実行されれば、Flutterアプリに"Hello, World!"というタイトルでプッシュ通知が届くはずです。

# 終わりに
以上がLambdaを使ってFCMを介しFlutterにプッシュ通知を送る実装になります。実際のサービスレベルではまだまだ必要な実装が多いと思いますが、リモートプッシュ通知を送るために何が必要になるのか等、この記事で知ってもらえたら幸いです。