FlutterでiOS Web Pushを実装する

# FlutterでiOS Web Pushを実装する
今回は、FlutterでiOS Web Pushを受け取れる簡単なWebアプリを実装する方法を紹介したいと思います。（記事作成に生成系AIを一部使用してます）

## Web Pushについて
Web Pushとは、Webアプリケーションからユーザーに通知を送る仕組みのことです。Web Pushを使えば、ユーザーがWebアプリケーションを開いていなくても、ブラウザやOSの通知センターにメッセージを表示することができます。

Web Pushは、Service Workerというバックグラウンドで動作するスクリプトと、Push APIというブラウザが提供するAPIを使って実現されます。Service Workerは、Webアプリケーションがインストールされた時に登録され、Push APIは、ユーザーが通知の許可を与えた時に呼び出されます。Push APIは、Webアプリケーションに一意に紐づくPush Subscriptionというオブジェクトを生成し、その中に含まれるendpointというURLをサーバー側に送ります。サーバー側は、endpointに対してHTTP POSTリクエストを送ることで、通知を配信することができます。

Web Pushは、AndroidやWindowsなどの多くのプラットフォームでサポートされていますが、iOSでは長らくサポートされていませんでした。しかし、2023年3月にリリースされたiOS16.4から、SafariでWeb Pushがサポートされるようになりました。iOSアプリでPush通知を実装するには、年額$99（約11000〜14000円）のDeveloper Programに加入必要だったり、その後も証明書の発行などが面倒だったり何かと大変なわけです。。これは、FlutterでクロスプラットフォームなWebアプリケーションを開発する上でも大きな出来事です。

しかし、iOSでWeb Pushを実装するには、いくつかの注意点があります。例えば、iOSではFirebase Cloud Messaging (FCM)というGoogleが提供するサービスを使って通知を送ることができません。また、iOSではService Workerの登録やPush Subscriptionの生成などの処理が非同期に行われるため、タイミングによってはエラーが発生する可能性があります。

そこで、この記事では、iOSでも安定してWeb Pushを受け取れるようにするための工夫を紹介します。具体的には、以下のような手順で実装します。

1. FlutterでWebアプリケーションを作成し、Service WorkerとPush Notificationの設定を行う
2. firestore上に通知のToken（endpoint）を保存する。
3. あるFirestoreのデータが追加されたら、firestore上に保存されたToken宛にFCMを使ってCloud Functionsから通知を送信

## FlutterでWebアプリケーションを作成し、Service WorkerとPush Notificationの設定を行う
まずは、FlutterでWebアプリケーションを作成します。Flutterの環境構築後（手順は割愛）、`flutter create`コマンドでプロジェクトを作成する際に`--platforms web`オプションを指定することで、Webアプリケーション用のテンプレートが生成されます。

```bash
flutter create --platforms web web_push_demo
```

プロジェクトを作成したら、`web`フォルダの中にある`manifest.json`ファイルを開きます。このファイルは、Webアプリケーションのメタデータを定義するファイルです。ここで、`gcm_sender_id`というキーを追加し、値に`103953800507`という数字を入力します。これは、FCMを使うために必要な設定です。今回アプリ名は"are_doko_web"としました。

```json
{
  "name": "are_doko_web",
  "short_name": "are_doko_web",
  "start_url": ".",
  "display": "standalone",
  "background_color": "#0175C2",
  "theme_color": "#0175C2",
  "description": "A new Flutter project.",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "icons/Icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "icons/Icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
}
```

次に、`web`フォルダの中にある`index.html`ファイルを開きます。このファイルは、WebアプリケーションのエントリーポイントとなるHTMLファイルです。ここで、`<head>`タグの中に以下のコードを追加します。これは、Service Workerを登録するためのコードです。

```html
<script>
  window.addEventListener('load', function (ev) {
  _flutter.loader.loadEntrypoint({
      serviceWorker: {
        serviceWorkerVersion: serviceWorkerVersion,
      },
      onEntrypointLoaded: function (engineInitializer) {
        engineInitializer.initializeEngine().then(function (appRunner) {
          appRunner.runApp();
        });
      }
    });
  });
</script>
<script>
  if ("serviceWorker" in navigator) {
    window.addEventListener("load", function () {
      navigator.serviceWorker.register("/firebase-messaging-sw.js");
    });
  }
</script>
```

また、画面に表示するFlutter Widgetの中に以下のコードを追加します。これは、Push Notificationの許可を求めるボタンを表示するためのコードです。

```dart:home_screen_page.dart
        return Scaffold(
          body: (省略),
          floatingActionButtonLocation: FloatingActionButtonLocation.endTop,
          floatingActionButton: FloatingActionButton(
            mini: true,
            onPressed: _registerNotification,
            child: const Icon(Icons.notification_add),
          ), 
        );
```

さらに、そこから呼ばれる`_registerNotification`メソッドの中に以下のコードを追加します。これは、ボタンがクリックされた時にPush Subscriptionを生成し、firestoreに保存するためのコードです。

```dart:home_screen_page.dart
  Future<void> _registerNotification() async {
    // パーミッションの設定
    final settings = await messaging.requestPermission();

    // トークン取得
    final token = (await messaging.getToken(
      vapidKey: dotenv.env['VAPID_KEY'],
    ))
        .toString();

    final firestore = FirebaseFirestore.instance;
    final data = {
      'token': token,
      'create_timestamp': FieldValue.serverTimestamp(),
    };
    await firestore.collection('notification').add(data);
  }
```

上記`vapidKey`にはFirebaseコンソールの [プロジェクト設定] > [Cloud Messaging] > [ウェブプッシュ証明書] から取得できますが、ハードコードするのはリスキーなので、GitHub Secretsを使います。
![スクリーンショット 2024-01-06 10.19.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/bb92e868-71e6-1b7b-b36f-22d3d526358a.png)

設定手順は省略しますが、以下のように設定できます。
![スクリーンショット 2024-01-06 10.21.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/4678d60b-efea-dd19-9259-b25e80ad248c.png)

アプリ側にその値を取り込むには、プロジェクトフォルダ直下に.envファイルを作成し、その値を設定しておきます。
```txt:.env
VAPID_KEY: xxxxxx(上記VAPID_KEYの値を貼り付けます)
```

そして、`pubspec.yaml`で以下のように.envファイルを指定し、
```yaml:pubspec.yaml
flutter:
  assets:
    - .env
```
レポジトリにコミットしないように`.gitignore`ファイルに１行追加しておきます。
```txt:.gitignore
.env
```

これで、FlutterでWebアプリケーションを作成し、Service WorkerとPush Notificationの設定を行うことができました。

実際にPWAを起動してみます。私の場合は、mainブランチにマージされた時に実行されるGitHub ActionsからFirebase Hostingに自動でデプロイするようにしていますが、手順は[この私の記事](https://qiita.com/u_ki0630/private/b9d94bd1f92c04a34130)を参照ください。
ちなみに、GitHub ActionsからFirebase Hostingにデプロイする時は、以下のようにローカル環境と同等のenvファイルになるように設定する必要があります。
```yaml
name: Deploy to Firebase Hosting on merge

on:
  push:
    branches:
      - main

jobs:
  build_and_deploy:
    runs-on: ubuntu-latest
    env: 
      VAPID_KEY: '${{ secrets.VAPID_KEY }}'
    steps:
      - uses: actions/checkout@v3
      - run: sudo snap install flutter --classic
      - run: flutter config --enable-web
      - name: 'Create env file'
        run: |
          touch .env
          echo VAPID_KEY=${{ secrets.VAPID_KEY }} >> .env
      - run: flutter build web -t lib/main.dart --release
      - uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: '${{ secrets.GITHUB_TOKEN }}'
          firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_XXX }}'
          channelId: live
          projectId: are-doko-web
```

PWAはSafariで該当のURLを開き、以下の手順で起動できます。
<img width="300" alt="IMG_0192.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/9d43ccbc-7ac4-b7f5-47f7-70793bc5e13e.png">

起動すると、以下のような画面になります。
（Safariから同じことをしても、Service Workerがエラーを吐きます。この画面の他の実装については割愛で...）

|起動後  |通知ボタンタップ後  |
|---|---|
|![IMG_4981.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/2cfe7a3c-229e-2495-553f-e44589407ca4.png) |![IMG_4982.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/6d4ecd38-3ebd-ef1d-8829-3c9008282554.png) |

Firestoreには以下のように登録されていました。

![スクリーンショット 2024-01-06 12.51.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/317e3fee-6fef-a32b-e147-3fe1b5f372b2.png)

次に、あるFirestoreのデータが追加されたら、firestore上に保存されたToken宛にFCMを使ってCloud Functionsから通知を送信していきます。

## FCMから通知を送信する
前の手順で、Push Subscriptionのendpoint（通知のToken）をfirestoreに保存するコードを書きました。

Cloud Functionsを使うためには、Firebase CLIをインストールし、プロジェクトフォルダで`firebase init functions`コマンドを実行する必要があります。Firebase CLIのインストール方法や`firebase init functions`コマンドの実行方法は、[公式ドキュメント]を参照してください。使用言語はPython,TypeScript,JavaScriptから選べますが、今回はTypesSriptを使いました。

`firebase init functions`コマンドを実行したら、`functions`フォルダの中にある`index.ts`ファイルを開きます。このファイルは、Cloud Functionsのコードを記述するファイルです。ここで、以下のコードを追加します。

```typescript:index.ts
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import {Notification} from "firebase-admin/messaging";;

admin.initializeApp();

export const onAddItem = functions.firestore
  .document("items/{itemId}")
  .onCreate(async (snapshot: admin.firestore.DocumentSnapshot) => {
    const data = snapshot.data();
    if (data) {
      const document = {
        name: data.name,
        category: data.category,
        location_category: data.location_category,
      };
      const serializedDocument = JSON.stringify(document);
      const notification: Notification = {
        title: "ドキュメントが追加されました。",
        body: serializedDocument,
      };

      // 通知するユーザーのトークン一覧を取得します。
      const notificationSnapshot = await admin.firestore()
        .collection("notification").get();
      const tokens = notificationSnapshot.docs.map((doc) => doc.id);
      for (const token of tokens) {
        // 通知を送信します。
        await sendNotification(token, notification);
      }
    }
  });

// 通知を送信する関数を定義します。
export const sendNotification = async function(
  token: string,
  notification: Notification,
): Promise<void> {
  // FCMのクライアントを生成します。
  const fcm = admin.messaging();

  // 通知を送信します。
  await fcm.send({
    token: token,
    notification: notification,
  }).then((response) => {
    // Response is a message ID string.
    console.log("Successfully sent message:", response);
  })
    .catch((error) => {
      console.log("Error sending message:", error);
    });
};

```

これで、`items`のCollectionにドキュメントが追加されたら、firestore上に保存されたToken宛にFCMを使ってCloud Functionsから通知を送信する実装ができました。

実際にブラウザから該当URLを開き、itemを追加してみます。（レイアウト崩れてますが...）
![スクリーンショット 2024-01-06 13.02.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/9d8fa1d3-5b82-25d2-cf26-73c2189f0a8c.png)

数秒後に、PWAとしてTokenを登録していたiPhoneに以下の通知が届きました。
![IMG_4986.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/b7183a3b-b29d-d81b-4f05-2faafd83c0fc.png)

## まとめ
この記事では、FlutterでiOS Web Pushを受け取れる簡単なWebアプリケーションを実装する方法を紹介しました。具体的には、以下のような手順で実装しました。

これで、FlutterでクロスプラットフォームなWebアプリケーションにWeb Push機能を追加することができました。iOSでもWeb Pushがサポートされたことで、FlutterでWebアプリケーション開発する魅力がさらに高まりましたね。

もちろん、この記事で紹介した方法はあくまでも簡単な例です。実際には、通知の内容やデザイン、エラー処理やセキュリティ対策など、もっと細かい部分にも注意しなければなりません。また、iOS以外のプラットフォームでもWeb Pushが正常に動作するかどうかも確認する必要があります。

FlutterでWeb Pushを使って、ユーザーに素敵な体験を提供しましょう！

[公式ドキュメント]: https://firebase.google.com/docs
[参考記事]: https://medium.com/flutter-community/flutter-web-push-notification-with-firebase-cloud-messaging-9c6a7f5d2c5a