🔔

2024年版 SwiftUIでFirebaseCloudMessagingを使いデバイス間でPush通知を送り合う

2024/09/03に公開

はじめに

Firebase Cloud Messagingを使用して、デバイス間でPush通知を送り合うコードを書いてみました

大雑把な一連の流れとしては、

1.Firebaseからサービスアカウントキーを取得

2.アプリ起動時に端末からFirebase Cloud Messaging Token(以下FCM Token)を取得
取得したFCM Tokenをデータベース等に保存(ここではFirebase Databaseを使用しています)

3.アプリ内のユーザーが他の特定ユーザーに向かってPush通知を送るアクションを起こす(Buttonタップ等)

4.データベースに保存してある送信先ユーザーのFCMTokenとサービスアカウントキーのプライベートキーからJWTキーを生成しPush通知を送信

前提

OS: macOS Sonoma 14.3.1
Xcode: Version 15.4
Interface: SwiftUI
Life Cycle: SwiftUI App
ライブラリ管理ツール: Swift Package Manager
・アプリにFirebaseを登録済み
・FirebaseライブラリとJWTKitからインポート済み
・データベースはFirebaseDatabaseを使用

0.CloudMessagingの設定

アプリでPush通知を利用できるようにするために、APNs証明書を作成したり、作成した証明書をFirebase Consoleに登録したり、色々準備をする必要があります。
ここではすでに設定済みであることを前提にしていますが、詳細な設定方法は以下のリンクが大変参考になりました。

ExampleApp.swift
import SwiftUI
import UserNotifications
import Firebase
import FirebaseMessaging

@main
struct ExampleApp: App {
   @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
   var body: some Scene {
      WindowGroup {
         HomeView()
      }
   }
}

// MARK: - AppDelegate Main
class AppDelegate: NSObject, UIApplicationDelegate, MessagingDelegate {
   func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
      FirebaseApp.configure()
      Messaging.messaging().delegate = self
      UNUserNotificationCenter.current().delegate = self
      
      // Push通知許可のポップアップを表示
      let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
      UNUserNotificationCenter.current().requestAuthorization(options: authOptions) { granted, _ in
         guard granted else { return }
         DispatchQueue.main.async {
            application.registerForRemoteNotifications()
         }
      }
      return true
   }
}

// MARK: - AppDelegate Push Notification
extension AppDelegate: UNUserNotificationCenterDelegate {
   func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
       if let messageID = userInfo["gcm.message_id"] {
          print("MessageID: \(messageID)")
       }
       print(userInfo)
       completionHandler(.newData)
   }
   
   // アプリがForeground時にPush通知を受信する処理
   func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
      completionHandler([.banner, .sound])
   }
}

1.アプリ起動時に端末からFCMTokenを取得

CloudMessagingはアプリの起動時に、インスタンスの登録トークンというものを生成します。
このトークンをユーザーIDなど一意なものに紐付けておけば、ユーザーから別の特定ユーザーに向けてPush通知を送ることができます。

FirebaseStoreだと Users > uid > の枝に以下画像のような感じで各々のユーザーのFCM Tokenを登録しておきます。
そのために、各々のユーザーがアプリ起動時に自分のFCM TokenをFirebaseDatabaseに保存しなければなりません。

ExampleApp.swift
extension AppDelegate: UNUserNotificationCenterDelegate {
    // テスト通知に必要なFCMトークンを出力する
    func application(_ application: UIApplication,
                      didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Messaging.messaging().apnsToken = deviceToken
        Messaging.messaging().token { token, error in
          if let error = error {
            print("Error fetching FCM registration token: \(error)")
          } else if let token = token {
            print("FCM registration token: \(token)")
          }
        }
    }

    func setFCMToken(fcmToken: String) {
        let db = Firestore.firestore()

        db.collection("コレクション名").document("ドキュメント名").setData([
            "fcmToken": fcmToken
        ]) { error in
            if let error = error {
                print("Error updating document: \(error)")
            } else {
                print("Document successfully updated")
            }
        }
    }
}

2.Push通知送信ボタンを作る

アプリ毎にプッシュ通知を送るトリガーはそれぞれ違うと思うのですが、今回はボタンを押したら
プッシュ通知が届くようにします。

ContentView
import SwiftUI

struct FugaView: View {

   var body: some View {
      Spacer()
      Button(action: {
         // ここにPush通知送信メソッドを書く
      }) {
         Text("Push通知送信 📣")
      }
      Spacer()
   }
}

3.HTTPSリクエスト送信処理を実装

https://fcm.googleapis.com/v1/projects/プロジェクト名/messages:send のエンドポイントに、送信先のFCM Tokenを付与してPOSTリクエストを送ることで、Push通知を送ることができます。
また、ここでPush通知のタイトルと本文を決めたり、ペイロードといってPush通知に含める情報を定義したりします。
詳細は公式ドキュメントを参考にしていただければと思います。

実装するにあたり、CloudMessagingのサービスアカウントキーが必要なので、メモしておきます。
Firebase Console -> プロジェクト概要横の歯車ボタン -> サービスアカウントキータブ -> 新しい秘密鍵を生成してください。

NotificationManager
import Foundation
import FirebaseMessaging
import FirebaseFirestore
import JWTKit

final class NotificationManager {
   static let instance: NotificationManager = NotificationManager()

    func sendPushNotification(fcmToken: String, Title: String, Body: String) {
        guard let url = URL(string: "https://fcm.googleapis.com/v1/projects/プロジェクト名/messages:send") else {
            print("Invalid URL")
            return
        }
        
        let payload: [String: Any] = [
            "message": [
                "token": fcmToken,
                "notification": [
                    "title": Title,
                    "body": Body
                ]
            ]
        ]
        
        guard let jsonData = try? JSONSerialization.data(withJSONObject: payload) else {
            print("Failed to create JSON data")
            return
        }
        
        // トークンを生成
        var token = ""
        do {
            token = try generateAccessToken()
            print("Access token: \(token)")
        } catch {
            print("Failed to generate access token: \(error)")
        }
        
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        request.httpBody = jsonData
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                DispatchQueue.main.async {
                    print("Error: \(error.localizedDescription)")
                }
                return
            }
            
            if let httpResponse = response as? HTTPURLResponse {
                DispatchQueue.main.async {
                    print("Response: \(httpResponse.statusCode)")
                }
            }
        }.resume()
    }

    func generateAccessToken() throws -> String {
        // Replace with your actual service account private key and client email
        let privateKey = """
        -----BEGIN PRIVATE KEY-----\n
        \n-----END PRIVATE KEY-----\n
        """
        let clientEmail = "CLIENTEMAIL"

        // Initialize JWTSigner for RS256 (RSA SHA-256)
        let signers = JWTSigner.rs256(key: try .private(pem: privateKey))

        let payload = PayloadData(
            iss: clientEmail,
            scope: "https://www.googleapis.com/auth/firebase.messaging",
            aud: "https://oauth2.googleapis.com/token",
            exp: Date().addingTimeInterval(3600), // 1 hour expiration
            iat: Date()
        )

        // Generate JWT token
        let jwt: String
        do {
            jwt = try signers.sign(payload)
        } catch {
            throw error
        }

        // Exchange the JWT for an access token
        let url = URL(string: "https://oauth2.googleapis.com/token")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

        let body = "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=\(jwt)"
        request.httpBody = body.data(using: .utf8)

        let semaphore = DispatchSemaphore(value: 0)
        var accessToken: String?
        var requestError: Error?

        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                requestError = error
                print("Error requesting access token: \(error)")
            } else if let data = data {
                do {
                    if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
                       let token = json["access_token"] as? String {
                        accessToken = token
                    } else {
                        print("Invalid response data: \(String(data: data, encoding: .utf8) ?? "nil")")
                    }
                } catch {
                    requestError = error
                    print("Error parsing response data: \(error)")
                }
            }
            semaphore.signal()
        }

        task.resume()
        semaphore.wait()

        if let error = requestError {
            throw error
        }

        // Return the access token or handle error if nil
        guard let token = accessToken else {
            fatalError("Failed to retrieve access token")
        }

        return token
    }
}

struct PayloadData: JWTPayload {
    let iss: String
    let scope: String
    let aud: String
    let exp: Date
    let iat: Date
    
    // Conformance to JWTPayload requires this function
    func verify(using signer: JWTSigner) throws {
        // Add custom verification logic if needed
    }
}

ペイロード(上のコードではparamString)の"data"キーに辞書型で登録することによって、値をPush通知に含めることができます。
アクセストークンを生成するときのprivateKeyは先ほど取得したアクセストークンキー内にある
プライベートキーを貼り付けてください。
URLの"https://fcm.googleapis.com/v1/projects/プロジェクト名/messages:send"
projects以降のプロジェクト名は自分のプロジェクト名に変更してください

これでAPIを叩けるようになったので、先程のContentView内のボタンに処理を追加します。
送信先のFCM Tokenはあらかじめ取得した後に使用するようお願いします。

ContentView
import SwiftUI

struct ContentView: View {

   var body: some View {
      Spacer()
      Button(action: {
        PushNotificationManager().sendPushNotification(fcmToken: String, Title: String, Body: String)
      }
      }) {
         Text("Push通知送信 📣")
      }
      Spacer()
   }
}

届きました!

ぜひ試してみてください。

参考記事

https://zenn.dev/aphananthe42/articles/132a2d823bd0a3

Discussion