Firebase で iOS アプリにプッシュ通知し、アプリ内で受信設定を切り替える（2025年2月）

## はじめに

Firebase の Cloud Messaging を利用して、iPhone アプリにプッシュ通知を実装し、アプリ内で受信設定を切り替えられるようにしたい。

ネットを検索すると、Firebase によるプッシュ通知に関しては親切な記事が出てくるが、コピペでも動かない…とか、どうやってテストするの…とか、バックグラウンド・フォアグラウンドで受信した時に、どういう動きになるの？など、ワンストップでサクっと解決しなかった。

Firebase のドキュメントにあるサンプルコードで、SwiftUI ベースのものが良いのか UIKit ベースのものが良いのか分からない。アプリ内での受信設定の切り替えについては、調べた限り具体的なコード付きの解説は見つからなかった。

なので、必要そうなことを備忘録としてまとめる。
Firebase で iOS にプッシュ通知は初めましてなので、おかしいところはあるかも知れない。シミュレータと実機テストで期待通りの動きにはなったので、そこまでの備忘録。

## 実証環境

- Xcode: 16.2
- Simulator: iPhone 16 Pro Max (18.2)
- iPhone（実機）: iPhone 14 Pro / iOS 18.3

そろそろ新しい iPhone 欲しいなぁ。でも、iPad が割れた…

## 方針

- Push 通知は `AppDelegate` に実装する
- アプリ内受信設定は、`UserDefaults` に保存する


## セットアップ

まずは、XCode でまっさらな iOS アプリを作る。

### Firebase SDK の追加

[Apple アプリに Firebase をインストールするオプション](https://firebase.google.com/docs/ios/installation-methods?hl=ja) を参考に Firebase iOS SDK をインストールする。

今回は、Xcode のメニューの `File > Add Package Dependencies...` から `Firebase-ios-sdk` を追加した。

GitHub からダウンロードするので、Xcode で GitHub とまだ連携していない場合は、メニューの `Xcode > Settings...` で設定を開き、Accounts から GitHub を追加して連携する。

（Firebase の設定ではないが、同じ画面なので、必要なので Apple ID は追加しておく。）

SDK はダウンロードしただけでは使えないので、設定する。
アプリ設定の `TARGETS > General > Frameworks, Libraries, and Embedded Content` で、`FirebaseMessaging` を追加する。

![Screenshot 2025-02-09 at 13.40.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/18cda599-0338-f096-8cc8-c38389714789.png)

### Signing & Capabilities の設定

設定は、`TARGETS > Signing & Capabilities` で行う。

Firebase で、APNs（Apple Push Notification Service）の証明書を使うので、`Team`, `Bundle Identifier` を設定する。後述の APNs は、選択した Team で発行する必要がある。
今回は、Bundle Identifier は `dev.takt.Push` とした。

`+ Capability` をクリックし、`Background Modes` を追加。`Remote notifications` にチェックを入れる。
`+ Capability` をクリックし、`Push Notifications` を追加。


![Screenshot 2025-02-09 at 13.50.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/a2708c76-a2ad-524d-8835-03dc883d5c3a.png)

これらの操作を行うと、プロジェクトのルートに、`Info.plist` と `Push.entitlements` というファイルが作られる。


### APNs の登録とダウンロード

まず、[App Store Connect](https://appstoreconnect.apple.com/apps) でアプリを追加する(必要があるはず)。

（嘘かも知れないけど）登録しないと APNs が発行できないはず。
（嘘かも知れないけど）登録した App の数より多くの APNs はダウンロードできない（ダウンロード済みと言われる）はず。

`Bundle ID` を選択するところでは、先程 `Bundle Identifier` で設定したものが `XC dev takt Push - dev.takt.Push` などと選択肢に出てくるのでそれを選ぶ。

次に、Developer サイトの [Certificates, Identifiers & Profiles > Keys](https://developer.apple.com/account/resources/authkeys/list) で APNs を追加する。

![Screenshot 2025-02-09 at 14.10.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/88317b34-36c6-4651-9265-2398a91adec9.png)

キャプチャがイマイチだが、Key Name を適当に入れて（Push とか）、APNs にチェックを入れ、`AuthKey_XXXXX.p8` をダウンロードする。作った時にしかダウンロードできないので、確実にダウンロードする。

この後、Firebase で APNs をアップロードし、Key ID, Team ID を登録するが、各 ID は、

![Screenshot 2025-02-09 at 14.12.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/fd34fe76-fb06-f7ab-abd4-76162d184791.png)

↑の赤いところに書いてあるものを入れる。

### Firebase に登録する

[Firebase Console](https://console.firebase.google.com/?hl=ja) でプロジェクトを作成する。適当に作る。

まずは、プロジェクト画面で、アプリを追加で `iOS+` というボタンがあるので、追加する。

![Screenshot 2025-02-09 at 14.22.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/1cefbbbc-38f5-89e8-6c1d-d9fddfe2b98a.png)

`プロジェクトの設定 > Cloud Messaging` で追加した Apple アプリを選択して、APNs 認証キー を追加する。先程ダウンロードした p8 ファイルと Key ID, Team ID は先述の通りにいれる。

どこかで自然に出てきた気がするが、`プロジェクトの設定 > 全般` 画面から、`GoogleService-info.plist` をダウンロードし、Xcode のプロジェクトのルート（`info.plist` と同じところ）に追加する。

![Screenshot 2025-02-09 at 14.32.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/cbbe9437-c695-5b44-654d-b04ca4c76e77.png)

`GoogleService-info.plist` は、秘匿情報を含んでいる（気がする・積極的には公開したくない）ので、Git で管理する場合は、`.gitignore` に追加し、README.md にダウンロードして build してねと注意書きを加えておこう。

以上でセットアップは完了。

## AppDelegate を実装する

まずは、アプリ内での受信設定を考慮せずに実装する。Firebase のドキュメントにサンプルがあって、SwiftUI, Swift, Objective-c などの例があるが、`AppDelegate.swift` が UI じゃないので、Swift（UIKit ベース）で実装する。

まずは、[firebase/quickstart-ios](https://github.com/firebase/quickstart-ios/blob/main/messaging/MessagingExampleSwift/AppDelegate.swift) の AppDelegate のサンプルをザッと見ておく。

それを元に、以下のように実装する。

```AppDelegate.swift
import UIKit
import UserNotifications
import FirebaseCore
import FirebaseMessaging

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication
            .LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        FirebaseApp.configure()

        if #available(iOS 10.0, *) {
            UNUserNotificationCenter.current().requestAuthorization(options: [
                .alert, .sound, .badge,
            ]) {
                granted, error in
                print("DEBUG: Permission granted: \(granted)")
            }
            UNUserNotificationCenter.current().delegate = self
            Messaging.messaging().delegate = self
        }

        application.registerForRemoteNotifications()

        return true
    }

    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        Messaging.messaging().apnsToken = deviceToken
    }
}

extension AppDelegate: UNUserNotificationCenterDelegate {
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (
            UNNotificationPresentationOptions
        ) -> Void
    ) {
        completionHandler([[.banner, .list, .sound]])
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let userInfo = response.notification.request.content.userInfo
        NotificationCenter.default.post(
            name:
                Notification.Name("didReceiveRemoteNotification"), object: nil,
            userInfo: userInfo
        )
    }
}

extension AppDelegate: MessagingDelegate {
    @objc func messaging(
        _ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?
    ) {
        #if DEBUG
        print("DEBUG: Firebase token: \(String(describing: fcmToken))")
        #endif
    }
}

```

`if #available(iOS 10.0, *) {}` は、現在 iOS 10 より古いアプリは申請できないので、必要ない。

```swift
    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        Messaging.messaging().apnsToken = deviceToken
    }
```

に関しては、`Info.plist` で `FirebaseAppDelegateProxyEnabled` を設定した場合に `Messaging.messaging().apnsToken = deviceToke` と書く必要があるが、設定しない場合、もしくは YES を設定する場合は書く必要がある。`FirebaseAppDelegateProxyEnabled` は設定しなくても動くが、毎度 Warning がうるさいので、設定しておいた。これだけしかやらないのであれば、`Info.plist` に設定しなくても良いと思われる。が、うるさいので設定した。設定しない場合は、

```swift
    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {}
```

こうしておけば良い。削除してしまうと、後々動かなくなるので（理由は深堀りしてない）、どちらかで。

なお、Firebase からの Warning は、

```
11.8.1 - [FirebaseMessaging][I-FCM001000] FIRMessaging Remote Notifications proxy enabled, will swizzle remote notification receiver handlers. If you'd prefer to manually integrate Firebase Messaging, add "FirebaseAppDelegateProxyEnabled" to your Info.plist, and set it to NO. Follow the instructions at:
https://firebase.google.com/docs/cloud-messaging/ios/client#method_swizzling_in_firebase_messaging
to ensure proper integration.
```

こんなものが出る。非常にうるさい。

つぎに、
```swift
extension AppDelegate: MessagingDelegate {
    @objc func messaging(
        _ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?
    ) {
        #if DEBUG
        print("DEBUG: Firebase token: \(String(describing: fcmToken))")
        #endif
    }
}
```

この部分だが、後で Firebase からテストメッセージを送る時に、FCM Token が必要になるため、受け取った Token を print した。リリースバージョンでは、余計なものは出さないようにしておいた方が良いので、`#if DEBUG` を付けた。
正しく FCM Token を受信すると、以下のようなものが出力される。

```
DEBUG: Firebase token: Optional("f9jkYmSMwksR....")
```


作成した `AppDelegate` を、App に登録する。

```swift
import SwiftUI

@main
struct PushApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
```

これで Firebase からの Push 通知を受けられるようになった。シミュレーターや実機に build して立ち上げると、`requestAuthorization` が最初に通信許可を求めてくれるので、その辺りは個別に実装しなくて良い。今回の実装では、`.badge` は使わないが、後で使いたくなった時に再度 Request するのも面倒なので、入れておいた。

## テストメッセージを送る

Firebase Console でプロジェクトを選択し、`実行 > Messaging` から、`最初のキャンペーンを作成` ボタンを押して通知を作成する。

![Screenshot 2025-02-09 at 15.31.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/f215520e-7498-6298-8a48-75d9c06ed9c4.png)

Firebase Notification メッセージを選択して作成
![Screenshot 2025-02-09 at 15.32.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/5072bae3-26ec-41e0-3de9-4c002362e257.png)

タイトルとテキストを適当に入力して、テストメッセージを送信ボタンを押す
![Screenshot 2025-02-09 at 15.33.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/9c2cfba0-2a9b-2f9b-e108-ba7f98cc6a87.png)

FCM 登録トークンを追加というところに、先程 print した FCM Token をコピペして、＋ボタンを押す。
![Screenshot 2025-02-09 at 15.33.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/a307376b-b64a-3ba6-c132-e2ae38525b10.png)

```
DEBUG: Firebase token: Optional("f9jkYmSMwksR....")
```
こういうヤツ。これが出ていなければ、何かがおかしい。戻ってひとつずつ確認。

![Screenshot 2025-02-09 at 15.34.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/f8dd762a-f7d5-b9e5-a421-1bfbc366ebc9.png)

これで、テストボタンを押すと、数秒後に Push 通知が届く。

![Screenshot 2025-02-09 at 15.34.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/a154019d-e57b-36b9-ef9d-b15cfa768c73.png)


通知をタップすると、ロック画面・バックグラウンド（アプリを立ち上げてない時）では、アプリが開かれて通知がクリアされ（通知のリストから消える）、フォアグラウンドでは通知がクリアされる。

個別の View を開いたりなどもできるようなので、やりたいことに応じて実装を。

プッシュ通知の Request を拒否した場合は、メッセージは届かない。実機の場合は、設定から許可に変更すれば届くようになる。テストはヌケモレなく。

## アプリ内での受信設定を行う

Push 通知が送れるのは、便利だが、ユーザが通知をオフにする方法が見つけづらい（iOS の設定から探すのみ）のは、ユーザフレンドリーとは言えないので、アプリ内で受信設定を実装した方が良い。

なお、以下で紹介する内容は、簡易的？な実装例で、本当に完全な意味で通知を拒否する場合は、iOS の設定でないとできないので、iOS の設定画面へ誘導するようにする必要がある。

さて、View の方は、適当なトグルを作って、設定するようにする。

```swift
import SwiftUI

struct ContentView: View {
    @State private var receiveNotification: Bool = true

    var body: some View {
        NavigationStack {
            VStack {
                Toggle("Enable Notification", isOn: $receiveNotification)
                    .padding()

                Spacer()
            }
            .navigationTitle("Push Notification")
        }
    }
}

#Preview {
    ContentView()
}
```

`UserDefaults` の操作は、別途 `Settings.swift` ファイルにする。

```swift:Settings.swift
import Foundation

class Settings {
    static var receiveNotification: Bool {
        get {
            return UserDefaults.standard.bool(forKey: "receiveNotification")
        }
        set {
            UserDefaults.standard.set(newValue, forKey: "receiveNotification")
        }
    }
}
```

UserDefaults の操作は、`@AppStorage`, `@Published + didSet`, `get/set` など色々オプションがあるが、後々 AppDelegate 内で使いたいので、`get/set` にした。

この `Settings.swift` の `receiveNotification` を ContentView で直接バインドする。

```swift
import SwiftUI

struct ContentView: View {
    @State private var receiveNotification: Bool = Settings.receiveNotification

    var body: some View {
        NavigationStack {
            VStack {
                Toggle("Enable Notification", isOn: $receiveNotification)
                    .padding()
                    .onChange(of: receiveNotification) {
                        Settings.receiveNotification = receiveNotification
                    }

                Spacer()
            }
            .navigationTitle("Push Notification")
        }
    }
}

#Preview {
    ContentView()
}
```

以上で、受信設定を `UserDefaults` に永続的に保存することができるようになったので、拒否された時に受信しないように、また、拒否から許可に復帰した時に受信するように処理を加える。

`AppDelegate` 内に全て書ければそれが良いのだが、`AppDelegate` は、`UserDefaults` の値が変更されるたびに呼び出されたりはしないので、`Settings.swift` 内で `set` する際にその処理を行う。

```swift
import Foundation
import UIKit

class Settings {
    static var receiveNotification: Bool {
        get {
            if UserDefaults.standard.object(forKey: "receiveNotification") == nil {
                return true
            }
            return UserDefaults.standard.bool(forKey: "receiveNotification")
        }
        set {
            updateNotificationStatus(isEnabled: newValue)
            UserDefaults.standard.set(newValue, forKey: "receiveNotification")
        }
    }

    private static func updateNotificationStatus(isEnabled: Bool) {
        DispatchQueue.main.async {
            if isEnabled {
                UIApplication.shared.registerForRemoteNotifications()
                print("DEBUG: プッシュ通知を登録しました")
            } else {
                UIApplication.shared.unregisterForRemoteNotifications()
                print("DEBUG: プッシュ通知を解除しました")
            }
        }
    }
}
```

`set` 時に `registerForRemoteNotifications`, `unregisterForRemoteNotifications` を切り替えれば良い。

先出しになるが、`UserDefaults` の値は、ユーザが設定しない限り nil になるが、オプショナルで設定しない場合は、nil は Bool では false になる。`AppDelegate` の処理で使う際に、それだと都合が悪いので、初期値的に true を返すようにしてある。今回の場合、弊害はなさそうだが、問題がある場合は、`Bool?` にして、`nil` の場合の処理を `AppDelegate` で書けば良いと思う。

ここで、View に戻り、

```swift
                Toggle("Enable Notification", isOn: $receiveNotification)
                    .padding()
                    .onChange(of: receiveNotification) {
                        Settings.receiveNotification = receiveNotification
                    }
```

の `onChange` は `Settings.receiveNotification` を直接バインドしているのに必要なのか？という疑問が残る。なくても `receiveNotification` の値は正しく変更されるが、その際、`set` メソッドは通らず、そこで追加した `updateNotificationStatus` は実行されない模様。同様に `@Published + disSet`, `AppStorage` もうまく行かない。ダサい書き方になっているが、どうせ一箇所だけと目を瞑ることにする。

Refactoring で削除されちゃいそうなので、`// DO NOT REMOVE ME!!` などとコメントしておいた方が良いかも知れない。

最後に、`AppDelegate` で、アプリ起動時の念の為の処理を加える。

```swift
        application.registerForRemoteNotifications()
```

の部分を

```swift
        if Settings.receiveNotification {
            application.registerForRemoteNotifications()
        } else {
            application.unregisterForRemoteNotifications()
        }
```

と書き換える。

ここで、`Messaging.messaging().isAutoInitEnabled` という、FCM Token を自動で生成するかフラグを、受信拒否の場合は false にして禁止した方がより良いとも思うが、`UserDefaults` の `set` 内で `true/false` の切り替えをやったところ、うまく反映されなかったので、やらないことにした。プッシュ通知の Request は許可している状態なので、特に問題にはならないと思われる。拒否している場合は、そもそも Firebase まで行かないし。

シミュレーター、実機等で過不足なくテストしてみよう。

## Privacy Manifests の設置

実装は完了だが、現在 iOS 17 以降のアプリを App Store に提出する場合、Private Manifests が必要で、今回のように、`UserDefaults` を使ったり、Firebase を使う場合書かないといけない。これは実装者の仕事なので、ちゃんとやろう。

何を書くべきかというと、

- アクセスする API
    - `NSPrivacyAccessedAPINotificaitons` （プッシュ通知を利用する）
        - 通常のプッシュ通知（トラッキングなし）なら不要
        - 広告やターゲティング通知を Firebase で行うなら記載
- 収集するデータ
    - `NSPrivacyCollectedDataDeviceID` （Firebase の FCM Token を取得する）
    - `NSPrivacyCollectedDataUserPreferences` （UserDefaults に設定を保存する）
- トラッキング関連
    - `NSPrivacyTrackingDomains` （Firebase の通知送信サーバーのドメイン）
- 3rd Party SDK
    - `NSPrivacyThirdPartySDKs` （Firebase Cloud Messaging の利用）
        - Firebase Analytics を併用する場合は、それも必要（今回はなし）


```json:PrivacyInfo.xcprivacy
{
  "NSPrivacyAccessedAPITypes": {
    "NSPrivacyAccessedAPINotifications": true
  },
  "NSPrivacyCollectedDataTypes": {
    "NSPrivacyCollectedDataDeviceID": {
      "NSPrivacyCollectedDataTypeSensitive": false,
      "NSPrivacyCollectedDataPurposes": ["AppFunctionality"]
    },
    "NSPrivacyCollectedDataUserPreferences": {
      "NSPrivacyCollectedDataTypeSensitive": false,
      "NSPrivacyCollectedDataPurposes": ["AppFunctionality"]
    }
  },
  "NSPrivacyTrackingDomains": [
    "firebaseinstallations.googleapis.com",
    "fcm.googleapis.com"
  ],
  "NSPrivacyThirdPartySDKs": [
    {
      "NSPrivacyThirdPartySDKName": "Firebase Cloud Messaging",
      "NSPrivacyThirdPartySDKVersion": "11.18.1",
      "NSPrivacyThirdPartySDKUsage": ["Push Notifications"]
    }
  ]
}
```

たぶん、こんな内容になる。分かりやすいように？ + 内容が「たぶん」でコピペしないで欲しいので json で書いているが、実際は plist な xml だ。それぞれちゃんとやってください。

`PrivacyInfo.xcprivacy` は、`plist` のひとつなので、アプリのルート (`Info.plist` と同じ階層) に置く。

![Screenshot 2025-02-09 at 16.41.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/61f87db6-063c-04b5-ff90-9e2b931f3233.png)

## (2025年2月11日追記) FCM Token を 2 回受信する件
View をレンダリングすると、FCM Token を 2回受けとることがある。無駄に複数回リクエストしてしまっているのではないか？と調べてみたが、2回リクエストするのが正しい動きのようだ。

Firebase iOS SDK の Issue によれば、

> his is expected. We recently discover that sometimes, the APNS token is collected from Apple with a slight delay after first token request was issued, so client has to issue a second token request with the apns token mapping in it. So you are getting that scenario which is why you are getting the token callback twice.
>
> It wasn't happening before because there was a bug that the second token request is not triggered and was recently fixed in #6669.
> 
> This should not affect your work as long as you always keep your token updated with the Messaging didReceiveRegistrationToken: callback.
> 
> via. [Messaging didReceiveRegistrationToken called twice on startup after deleting and reinstalling app](https://github.com/firebase/firebase-ios-sdk/issues/6813)

APNs トークンがちょっと遅れて受信されるため、APNs トークンのマッピングを含む 2 回目のトークンリクエストを発行する必要がある。以前は 2 回目がトリガーされないというバグがあり、修正済み。`didReceiveRegistrationToken` コールバックで FCM Token を監視している限り問題ない。

とのこと。ちょっと鬱陶しいけど、まずい実装で Firebase に無駄な負荷をかけているわけではないようなので、気にしなくて良い。中の人が、そう言って Issue を閉じてるので、大丈夫。

## おわり

以上で、Firebase Cloud Messaging を使って iOS アプリにプッシュ通知を送り、かつアプリ内でも受信設定を切り替えることができるようになった。テストも。

こんなもんでプッシュ通知できるなんて素晴らしい！と思いつつ、もうちょっと簡単でも良いのでは？とも思ったりする。

もっとスマートなやり方をご存知の方は、優しく教えて下さい。