SNSアプリのプッシュ通知にアバター表示：Siriインテント活用（iMessage類似, INSendMessageIntent）

この機能はSNSアプリ向けです。この機能を実装することで

- アプリのアイコンだけでなく、チャットのアバターが通知に表示されます。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/a6ba630f-ed55-9810-251c-60e7d5f1888c.jpeg">

（アプリアイコンは右下に表示されます。このデモではアプリアイコンは空白です。）

- システムの連絡先アプリや共有シートを通して、アプリ内の連絡先をシステムが提案できるようになります。

- ユーザーがフォーカスモードをオンにして、選択した相手だけからの通信を許可すると、これらの人たちがあなたのアプリを通じて送信したメッセージが通知されます。

- 通知サービス拡張機能を使って、あなたのリモート通知を画面に表示される前に編集することができます。

## プロジェクトの設定を行う

まず、あなたのXcodeプロジェクトの `Communication Notifications` の機能を有効にする必要があります。

その後、メインアプリケーションターゲットの `Info.plist` ファイルに以下のキーと値を追加します。

```
NSUserActivityTypes (Array)
    - INStartCallIntent
    - INSendMessageIntent
```

![スクリーンショット 2021-09-27 14.53.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/7d135bfa-5246-b755-9533-b2c0fe64fdc7.png)

## ローカル通知に向けた実装

この機能を試すには、まずローカル通知用に実装してみましょう。

事前に、ユーザーがアプリに通知を送信する許可を与えていることを確認してください。

### まず、チャットの参加者についての情報をご提供ください

システムは、お使いのアプリ内の連絡先とiOSの「連絡先」アプリ内の連絡先を照合しようとしますので、チャットの参加者についての情報をシステムに提供いただく必要があります。

そのためには `Intents` フレームワークをインポートしていただく必要があります。その後関連する情報をご提供ください：

```swift
let demoParticipant: INPerson = INPerson(
    personHandle: INPersonHandle(value: "John-Appleseed@mac.com", type: .emailAddress),
    nameComponents: try? PersonNameComponents("John Appleseed"),
    displayName: "@john",
    image: INImage(imageData: UIImage(systemName: "applelogo")!.pngData()!),
    contactIdentifier: nil,
    customIdentifier: "john",
    isMe: false,
    suggestionType: .instantMessageAddress
)
```

|  パラメータ名  | 説明  |
| ----------- | ----------- |
|  `personHandle`  |  連絡先のEメールアドレスまたは電話番号。これは、iOSの連絡先アプリの連絡先情報と照合するために使用されます。  |
| `nameComponents`  | 参加者の本名（提供可能な場合） |
|  `displayName`  | 参加者のニックネームまたは表示名 |
|  `image `  | 参加者の画像またはアバター |
|  `contactIdentifier `  | iOSの連絡先アプリから取得した識別子。これにより、システムが連絡先情報を直接照合することができます。 |
|  `customIdentifier `  | カスタム識別子（参加者のユーザーID） |
|  `isMe `  | `INPerson` オブジェクトは、送信者と受信者の両方を表すことができます。この変数の設定が `True` の場合、このオブジェクトが現デバイスのユーザーを表すことを意味します。 |

### それから、`Intent`オブジェクトを定義します。

```swift
let intent = INSendMessageIntent(
    recipients: [currentUser],
    outgoingMessageType: .outgoingMessageText,
    content: chatMessage,
    speakableGroupName: INSpeakableString(spokenPhrase: sender.displayName),
    conversationIdentifier: "chat001",
    serviceName: nil,
    sender: sender,
    attachments: nil
)

intent.setImage(sender.image, forParameterNamed: \.sender)
```

ここであなたはメッセージの受信者および送信者を表す `INPerson` オブジェクトを規定します。メッセージのコンテンツ、およびグループIDを規定します。

アプリがメッセージを受信するとき、メッセージを送信した人を送信者 `sender` に設定し、受信者 `recipients` を現在のユーザーに設定します。
アプリがメッセージを送信するとき、送信者 `sender` を現在のユーザーに設定し、受信者 `recipients` についてはメッセージを受信する人に設定します。

当社ではローカル通知を送信するために `UNMutableNotificationContent` オブジェクトを作成しているため、
`INSendMessageIntent` に`UNMutableNotificationContent` を追加するためにアップデート `updating` 機能を使用していただきます。

```swift
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming

interaction.donate(completion: nil)

do {
    content = try content.updating(from: intent) as! UNMutableNotificationContent
} catch {
    // Handle error
}
```

これにより、連絡先画像が通知に組み込まれます。

通常の通知

![regular-chat-message.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/409054fc-8169-5af4-0d98-63b8b4be2d90.jpeg)

`INSendMessageIntent`に関する通知です

![new-chat-message.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/5a2ac63c-2373-9971-a906-0870415b45a0.jpeg)
（アプリアイコンは右下に表示されます。このデモではアプリアイコンは空白です。）

これは完成したコードです。

```swift
Button("Schedule a notification (5 seconds later)") {
    
    let sender = [DemoParticipants.demoParticipant, DemoParticipants.demoParticipant2].randomElement()!
    let currentUser = DemoParticipants.currentUser
    let chatMessage = "テスト、テスト、テスト"
    
    var content = UNMutableNotificationContent()
    content.title = "New chat message"
    content.body = chatMessage

    let intent = INSendMessageIntent(
        recipients: [currentUser],
        outgoingMessageType: .outgoingMessageText,
        content: chatMessage,
        speakableGroupName: INSpeakableString(spokenPhrase: sender.displayName),
        conversationIdentifier: "chat001",
        serviceName: nil,
        sender: sender,
        attachments: nil
    )

    intent.setImage(sender.image, forParameterNamed: \.sender)

    let interaction = INInteraction(intent: intent, response: nil)
    interaction.direction = .incoming

    interaction.donate(completion: nil)

    do {
        content = try content.updating(from: intent) as! UNMutableNotificationContent
    } catch {
        // Handle error
    }

    // Show the message after 5 seconds
    let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
    let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
    UNUserNotificationCenter.current().add(request)

}
```

あなたはこれからコンタクトイメージの通知を見ることになります。このシステムは最終的に、あなたのアプリについて、システム共有シートおよびコンタクトアプリにおけるオプションであることを示唆します。

（これはシミュレーター上のデモのアプリでは見られません。しかしこれは、私が単に限られた回数でのみ、それを試したからかもしれません）。

## リモート通知の実装

SNSの通知の大半はサーバーからAppleのAPNサーバーに送られてからデバイスに送信されます。

通知サービスの拡張機能をご利用いただく必要があります。その拡張機能は、通知を処理してから画面に表示するために使用されるものです。

AppleのAPNサーバーからアプリの通知サービス拡張機能に流れ、次にスマホの通知センターに流れます。

### まず、`Notification Service Extension`拡張機能をプロジェクトに追加してください。

![スクリーンショット 2021-09-25 18.05.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/1526d57f-00dc-8343-fc7f-4609e395a2e2.png)

### 次に、新しく追加した `Notification Service Extension` 拡張機能の `Info.plist` に以下のキーと値を追加してください。

![スクリーンショット 2021-09-25 18.06.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/f9bf97d1-6cf6-38ff-61cf-45a530f64f0c.png)

### この機能 `didReceive` にコードを書き、通知オブジェクトに追加情報を加えます。

この機能はApple APNサーバーから通知が送られるたび、それがユーザーの画面に表示される前に呼び出されます。

```swift
import UserNotifications
import Intents
import UIKit

class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        if let bestAttemptContent = bestAttemptContent {
            // ...
        }
    }
}
```

`INPerson`を初期化するには、送信者の情報が必要となります。この情報はAPNプッシュ通知に含めることができます。

たとえば、以下がAPNプッシュ通知の一例です：

```json
{
    "Simulator Target Bundle": "com.demo.SNS-Intent-Demo",
    "aps": {
        "alert": {
            "body": "Hello world! This is a test message.",
            "title": "@Neko"
        },
    },
    "sender_id": "1",
    "sender_name": "NekoNeko",
    "sender_image_url": "hhttps://placekitten.com/200/287",
    "sender_nickname": "@Neko",
    "sender_email": "Neko@Neko.Neko",
    "chat-session_id": "chat-1"
}
```

まず、プッシュ通知から追加情報を入手することができます。

```swift
if let senderAccountID = bestAttemptContent.userInfo["sender_id"] as? String,
               let senderName = bestAttemptContent.userInfo["sender_name"] as? String,
               let senderImageURLString = bestAttemptContent.userInfo["sender_image_url"] as? String,
               let senderImageURL = URL(string: senderImageURLString),
               let senderDisplayName = bestAttemptContent.userInfo["sender_nickname"] as? String,
               let senderEmailAddr = bestAttemptContent.userInfo["sender_email"] as? String,
               let chatSessionID = bestAttemptContent.userInfo["chat-session_id"] as? String
{
    // ...
}
```

そして、こうした情報を使用して `INPerson` または `INSendMessageIntent` を作成することで、プッシュ通知のメッセージにこれを付加します。

```swift
class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
        
        if let bestAttemptContent = bestAttemptContent {
            // Modify the notification content here...
            bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
            
            /*
             Use the custom information included in this notification from the chat server to retrive the chat participant's information.
             - This is the information of the sender of the message.
             - Providing a user's accruate name helps the iOS system match this user with a contact in the system contact app.
             */
            if let senderAccountID = bestAttemptContent.userInfo["sender_id"] as? String,
               let senderName = bestAttemptContent.userInfo["sender_name"] as? String,
               let senderImageURLString = bestAttemptContent.userInfo["sender_image_url"] as? String,
               let senderImageURL = URL(string: senderImageURLString),
               let senderDisplayName = bestAttemptContent.userInfo["sender_nickname"] as? String,
               let senderEmailAddr = bestAttemptContent.userInfo["sender_email"] as? String,
               let chatSessionID = bestAttemptContent.userInfo["chat-session_id"] as? String
            {
                
                // You can also use the sender's phone number to initialize the `INPersonHandle` object. This will help the iOS system to match this sender with a contact.
                // TODO: - Here you need to download the image data from the URL. In this demo, we are using a system image instead.
                let messageSender = INPerson(
                    personHandle: INPersonHandle(value: senderEmailAddr, type: .emailAddress),
                    nameComponents: try? PersonNameComponents(senderName),
                    displayName: senderDisplayName,
                    image: INImage(imageData: UIImage(systemName: "applelogo")!.pngData()!),
                    contactIdentifier: nil,
                    customIdentifier: senderAccountID,
                    isMe: false,
                    suggestionType: .instantMessageAddress
                )
                
                let intent = INSendMessageIntent(recipients: nil,
                                                 outgoingMessageType: .outgoingMessageText,
                                                 content: bestAttemptContent.body,
                                                 speakableGroupName: INSpeakableString(spokenPhrase: senderDisplayName),
                                                 conversationIdentifier: chatSessionID,
                                                 serviceName: nil,
                                                 sender: messageSender,
                                                 attachments: nil)
                
                let interaction = INInteraction(intent: intent, response: nil)
                interaction.direction = .incoming
                interaction.donate(completion: nil)
                do {
                    let messageContent = try request.content.updating(from: intent)
                    contentHandler(messageContent)
                } catch {
                    print(error.localizedDescription)
                }
                
            }
            
            contentHandler(bestAttemptContent)
        }
    }
    
    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)
        }
    }

}
```

### 追加資料：通知を変更する

`UNNotificationServiceExtension`拡張機能を使うと、リモート通知がユーザー画面に表示される前に、その内容を読むように変更できます。

```swift
import UserNotifications

class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
        
        if let bestAttemptContent = bestAttemptContent {
            // Modify the notification content here...
            bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
            
            contentHandler(bestAttemptContent)
        }
    }
    
    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)
        }
    }

}
```

例えば、以下のコードでは画面に表示される通知のタイトルを変更することができます。

```swift
bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
```

シミュレーターでは`.apns`ファイルを使用してこの機能をテストできないことにご注意ください。

## 完成したプロジェクト

完成したデモプロジェクトはこちらからダウンロードできます。

https://github.com/mszpro/iOS15-Intents-SNS

---

:relaxed: [Twitter ツイッター @MszPro](https://twitter.com/MszPro)

[iOS Dev Letter ( •̀∀︎•́ )✧︎⋆︎*](https://ios-dev-letter.com/)

[私の公開されているQiita記事をカテゴリー別にご覧いただけます。](http://mszpro.com/ioskiji/)
<img width="150" src="https://cdn.mszmagic.com/static-web-content/AppClipImage_small.png">

---

:sunny: 今後ともよろしくお願い致します。