【2024/10時点】iOSアプリでFCMを使った画像付き通知を受信する方法

## 何をするのか

iOSにて、FCM（Firebase Cloud Messaging）を使って以下のような画像付き通知を受信します。

<img width = 500 src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/764557/0ce15723-7db1-b879-fe70-92155bc94bbd.jpeg">



## 落とし穴

いくつか落とし穴があります。気をつけてください。
- **Firebase（GUI）から通知を送ると、受信するまでに1〜2分ほどラグが発生します**
    - PythonやGoなどでアプリサーバーをたて、Firebaseのプッシュ通知サーバー（`https://fcm.googleapis.com/v1/projects/プロジェクト名//messages:send`）へリクエストを送りプッシュ通知を送信してください。これでラグ発生することなく通知を受信できます。
- **画像付き通知を受信するには、iOSアプリ側でNotification Service app extension を使用してリモート通知のペイロードを編集する必要があります。**
    - 対処は2つ後のセクション（iOSアプリ側でNotification Service app extension を使用してリモート通知のペイロードを編集する）に書いておきます


## アプリサーバー経由でFCM通知サーバーへアクセスする

今回は、Python(FastAPI)×[Render](https://render.com/) でアプリサーバーを構築し、通知サーバーへアクセスしています。
以下、Pythonのコードです。プロジェクトに依存している箇所は変更していますので、適宜自身のプロジェクトと照らし合わせて変更してください mm

実装を見ると分かりますが、アプリサーバー経由で通知サーバーへアクセスする際、アクセストークンが必要だったり、リクエストパラメータを間違えないよう設定する必要があります。（パラメータに関するリファレンスはコードの下に示しておきます）

```main.py
# API
from fastapi import FastAPI   # pip install fastapi
from pydantic import BaseModel  # リクエストbodyを定義するために必要
import uvicorn   # pip install uvicorn

# FCMを用いたPush通知
import requests   # pip install requests
from oauth2client.service_account import ServiceAccountCredentials   # pip install oauth2client
import json

# パラメータ
PROJECT_ID = ''   # 任意
JSON_PATH = '〜.json'  # 任意
BASE_URL = 'https://fcm.googleapis.com'
FCM_ENDPOINT = 'v1/projects/' + PROJECT_ID + '/messages:send'
FCM_URL = BASE_URL + '/' + FCM_ENDPOINT
print(FCM_URL)
SCOPES = ['https://www.googleapis.com/auth/firebase.messaging']

def _get_access_token():
  """Retrieve a valid access token that can be used to authorize requests.

  :return: Access token.
  """
  credentials = ServiceAccountCredentials.from_json_keyfile_name(
      JSON_PATH , SCOPES)
  access_token_info = credentials.get_access_token()
  return access_token_info.access_token

def _send_fcm_message(fcm_message):

    """Send HTTP request to FCM with given message.
    Args:
        fcm_message: JSON object that will make up the body of the request.
    """
    # [START use_access_token]
    headers = {
        'Authorization': 'Bearer ' + _get_access_token(),
        'Content-Type': 'application/json; UTF-8',
    }
    # [END use_access_token]
    resp = requests.post(FCM_URL, data=json.dumps(fcm_message), headers=headers)

    if resp.status_code == 200:
        print('Message sent to Firebase for delivery, response:')
        print(resp.text)
        return True
    else:
        print('Unable to send message to Firebase')
        print(resp.text)
        return False


# main
app = FastAPI()

# POSTでiOSアプリから受け取るデータ
class ToUser(BaseModel):
    user_fcm_token: str
    title: str
    body: str
    imageURL: str

# POST通信
@app.post("/pushNotification/")
def create_pushNotification(toUser: ToUser):
    common_message = {
        "message":{ 
            "token": toUser.user_fcm_token, 
            "notification":{ 
                "body": toUser.body, 
                "title": toUser.title,
                "image": toUser.imageURL
            },
            "apns":{
                "payload":{
                    "aps":{
                        "mutable-content":1
                    }
                },
                "fcm_options": {
                    "image": toUser.imageURL
                }
            }
        }
    }   # POSTでデータを送るためにJSON形式に変換
    print(json.dumps(common_message, indent=2))
    
    # アクセストークンの取得も行なっている
    isPushNotification = _send_fcm_message(common_message)

    if isPushNotification:
        return {"id": 0, "result": "success"}
    else:
        return {"id": 0, "result": "failure"}
```

<br>

**FCM通知サーバーへのリクエストパラメータに関するリファレンス**

https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?hl=ja#ApnsConfig

https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?hl=ja#notification




## iOSアプリ側でNotification Service app extension を使用してリモート通知のペイロードを編集する

画像付き通知の **画像** を受信しバナーへ表示するためには、Notification Service app extension を用いて画像ダウンロードするプログラムを実装する必要があります。


以下2つのサイトを見ながら（漏れなく）実装追加してみてください。

**1. プロジェクトに Notification Service app extension を追加する**

https://dev.classmethod.jp/articles/user-notifications-framework-13/

<img width = 700 src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/764557/783a8c57-3bfc-5921-04e2-2a12c4d90da5.png">

**2. NotificationService.swift を編集し画像ダウンロードを実装**

https://dev.classmethod.jp/articles/ios-push-notification-attach-file/

```NotificationService.swift
// 1 にて自動生成されるファイル 手動で追加するものではないので注意

class NotificationService: UNNotificationServiceExtension {
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        guard let content = (request.content.mutableCopy() as? UNMutableNotificationContent) else {
            contentHandler(request.content)
            return
        }

        let userInfo = request.content.userInfo as NSDictionary
        let fcmOptions = userInfo["fcm_options"] as? [String: String]

        guard let fcmOptions = fcmOptions,
            let url = URL(string: fcmOptions["image"] ?? "")
        else {
            contentHandler(content)
            return
        }

        print(url)

        let task = URLSession.shared.dataTask(
            with: url,
            completionHandler: { data, _, error in
                let fileName = url.lastPathComponent
                let writePath = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(fileName)

                do {
                    try data?.write(to: writePath)
                    let attachment = try UNNotificationAttachment(identifier: fileName, url: writePath, options: nil)
                    content.attachments = [attachment]
                    contentHandler(content)
                } catch {
                    print("error: \(error)")
                    contentHandler(content)
                }
            })
        task.resume()
    }

    override func serviceExtensionTimeWillExpire() {
        // Called just before the extension will be terminated by the system.
        // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
        if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }
}
```

`アプリサーバーの構築` と `Notification Service app extension を用いた実装` を済ませれば、本記事冒頭に示したように画像付き通知が受信可能なはずです。

**それでは、良いFCMライフを！**