Slide 1

Slide 1 text

Remote notification tricks with Notification Service Extension 2024/07/23(Mon) Nobuhiro Ito GO Inc.

Slide 2

Slide 2 text

© GO Inc. 2 自己紹介 GO株式会社 フルスタックエンジニア / 伊藤 伸裕 Webシステム、モバイルアプリの受託開発、環境系ベンチャーでのエンジ ニアリングマネージャー経験を経て、2023年9月にGO株式会社にフルス タックエンジニアとして入社。 アプリ・バックエンドを問わずGOのシステム全体を渡り歩きながら開発を しています。

Slide 3

Slide 3 text

© GO Inc. 3 Contents 0. 『GOドライバー』と配車依頼が届く仕組み 1. iOSアプリがバックグラウンドでも 通知ペイロードからメッセージを出し分けたい! 〜Notification Service Extensionでの書き換えによる通知メッセージの出しわけ 2. 通知を開かずにアプリを起動しても同じ動作をしたい! 〜バックグラウンドで通知を保存して起動時に読み込む 3. バックグラウンドでも通知起点でリアルタイムに処理したい 〜バックグラウンド動作中のアプリ本体でリアルタイム処理

Slide 4

Slide 4 text

© GO Inc. 4 0 『GOドライバー』と配車依頼が届く仕組み

Slide 5

Slide 5 text

© GO Inc. 5 ドライバー向けアプリ『GOドライバー』 タクシーアプリ『GO』から 日本型ライドシェアの車両を呼べるように作られた ライドシェアドライバー向けのアプリ 制度開始に合わせて、Flutterを用いて3ヶ月ほどの短期間で開発 『GO』アプリ タクシー車両向け端末 ※専用のAndroidで動く ドライバー向けアプリ ※ライドシェアドライバーの個人スマホで動く

Slide 6

Slide 6 text

© GO Inc. 『GOドライバー』専用に変わっていることもあるが、 システムの基本概念はタクシーに置かれている専用端末と同じ →配車の依頼をFirebase Cloud Messaging(FCM)で端末に通知する 6 『GOドライバー』に配車依頼が届く仕組み 『GO』アプリ バックエンド ドライバー向けアプリ FCM

Slide 7

Slide 7 text

© GO Inc. 7 1 iOSアプリがバックグラウンドでも 通知ペイロードからメッセージを出し分けたい! 〜Notification Service Extensionでの書き換えによる通知メッセージの出しわけ

Slide 8

Slide 8 text

© GO Inc. タクシー車両向け端末のアプリ(Android)では、 常時フォアグラウンドで動作している前提のため通知文言を含まず、 通知の種類のみが含まれる「サイレント通知」を送信していた 『GOドライバー』でもこれを踏襲してFlutterで実装したが、 iOSでは期待した動作ができなかった 8 『GOドライバー』に届く通知と当初の設計 サイレント通知 アプリの 受信ハンドラ 通知を起点とした処理 ローカル通知を出す フォアグラウンド バックグラウンド タクシー車両向けのアプリでは「通知の文言をアプリ側で制御したい」という思想もあったらしい ❌

Slide 9

Slide 9 text

© GO Inc. Androidは状態問わずアプリのServiceが受け、アプリで通知を登録するが、 iOSはバックグラウンドでの通知はシステムが処理する iOSでバックグラウンドで受信した通知を通知センターに出すには、 サイレントでない通知を送らなければならない 9 なぜサイレント通知が処理されないか サーバーが通知を送る FirebaseMessagingService ※Androidでnotificationペイロードに文章を入れた際、バックグラウンドでもコード記述なしで 通知が登録されるが、これはFirebaseMessagingServiceがやってくれている アプリが通知表示 OSが通知表示 ユーザーコードの範囲 OS/ライブラリ OSの処理範囲 Android iOS

Slide 10

Slide 10 text

© GO Inc. 品管テストでこの状況を検出しており対策を検討していたが、 サービス開始時期がせまっていたため、制限事項としてリリースを優先。 →「業務中・運転中だからアプリはフォアグラウンドにしているだろう」  という予想があった リリース後数日で「iOSの配車承諾率が低い」という相談がくる →予想に反し、配車待ち時はアプリが  バックグラウンドにされていることが多いことが原因だった →アプリがバックグラウンドにあると通知がきても何も起こらず、 配車を受けることができない 10 通知が届かない状態でリリースしたが…

Slide 11

Slide 11 text

© GO Inc. 11 できるだけ簡単になんとかしたい… iOSでバックグラウンドで通知が届くようにするには サイレント通知ではない、メッセージのついた通知を送る必要がある そのためにはバックエンドが文言リソース持ってもらって、 状態によって送り分ける必要があるが、その調整には時間がかかりそう… 現場で大きな問題になってて、短時間ですぐ直したい! 時間が足りないから、アプリだけでなんとかできないか? そうだ、Notification Service Extensionでいけるのでは? 「サーバーサイドから適切な文章が入っている通知を送る」というのが推奨される実装

Slide 12

Slide 12 text

© GO Inc. 12 「表示される通知」をトリガーに起動し、 通知内容を書き換えて、通知ペイロードに含まれないコンテンツを補完する Extensionとして実装されるので、アプリ本体と別のプロセスで動作する プッシュ通知をトリガーに任意コードを動作させる機能としては、 バックグラウンドフェッチ(content-available)に比べて動作確率が高い Notification Service Extension (以下NSE) 通知 OSが通知表示 OSの処理範囲 通知内容の書き換え ユーザーコードの範囲 (Notification Service Extension) 画像取得 外部通信も可能 例えば・・・

Slide 13

Slide 13 text

© GO Inc. 13 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) guard let bestAttemptContent = bestAttemptContent else { contentHandler(request.content) return } // code here contentHandler(bestAttemptContent) } override func serviceExtensionTimeWillExpire() { if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { contentHandler(bestAttemptContent) } } }

Slide 14

Slide 14 text

© GO Inc. 14 XcodeでNSEを追加すると、 UNNotificationServiceExtensionのテンプレが追加される 届いた通知はdidReceiveのハンドラに届く 通知の中でrequestの中身に入っているcontentを書き換え、 contentHandlerで書き換え後の通知を返す 処理に使える時間には制限があり、 時間切れするとserviceExtensionTimeWillExpireが呼ばれるので、 その時点での通知をcontentHandlerに返す必要がある NSEの実装に必要な処理 override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {

Slide 15

Slide 15 text

© GO Inc. override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) guard let bestAttemptContent = bestAttemptContent else { contentHandler(request.content) return } guard let type = request.content.userInfo["type"] as? String else { contentHandler(bestAttemptContent) return } switch (type) { case "driver_confirm": bestAttemptContent.title = "配車依頼" bestAttemptContent.body = "配車依頼が届きました" bestAttemptContent.interruptionLevel = .timeSensitive bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "ORDER.mp3")) default: break } contentHandler(bestAttemptContent) } 15

Slide 16

Slide 16 text

© GO Inc. 16 今回は通知のペイロードにどの種類の通知かを示すデータが入っている typeの値を見て通知ペイロードを書き換える (音声や優先度も書き換えられる) NSEの実装例 guard let type = request.content.userInfo["type"] as? String else { contentHandler(bestAttemptContent) return } switch (type) { case "request": bestAttemptContent.body = "配車依頼が届きました" bestAttemptContent.interruptionLevel = .timeSensitive bestAttemptContent.sound = UNNotificationSound( named: UNNotificationSoundName(rawValue: "ORDER.mp3")) default: break }

Slide 17

Slide 17 text

© GO Inc. 17 バックエンド側に依頼したこと NSEが起動するには、mutable-contentの指定と 「表示される通知であること」が必要 FCMのペイロードに、固定のalertとmutable-contentを含めてもらった { // notification: { ... }, // 元々指定なし data: { type: "...", ... }, apns: { payload: { aps: { alert: "通知があります", // 当たり障りのない文章を指定 mutable-content: 1 // フラグON } } } } { // notification: { ... }, // 元々指定なし data: { type: "...", ... } }

Slide 18

Slide 18 text

© GO Inc. 18 固定の通知文言をExtensionで書き換えに成功 バックエンドから送られてきている固定の通知文言を、 アプリ側で変更して表示することができた

Slide 19

Slide 19 text

© GO Inc. 19 2 通知を開かずにアプリを起動しても同じ動作をしたい! 〜バックグラウンドで通知を保存して起動時に読み込む

Slide 20

Slide 20 text

© GO Inc. 通知が届いた後、アプリを起動したら配車依頼の画面が出てほしいけど、 通知をタップせずに起動した場合は画面が出てくれない →バックグラウンド時に届いた通知を  開封なしでそのままでは取得することができないのが原因 (UNUserNotificationCenter.getDeliveredNotifications(completionHandler:)で通知センターにある通知を列挙する手段もあるが、 通知センターから消されてしまうと対応できない) 20 通知を開かずともアプリ起動時に同じ動きをしたい 通知が届く 通知をタップ アプリ起動 配車依頼画面 何も起こらない… ※通知を起点とした処理 この動きをしてほしい

Slide 21

Slide 21 text

© GO Inc. 受信した通知のデータをNSEで保存しておいて、 フォアグラウンドに戻った時に参照し、通知開封時と同じ動作をさせたい Extensionとアプリの間でデータを共有するには、 Shared Containerに保存すればよい(共有フォルダのような概念) 事前にApp IDにApp Groupの設定を入れておく必要がある 21 NSEで通知を保存 App Extension Shared Container Appのストレージ Extensionのストレージ

Slide 22

Slide 22 text

© GO Inc. 22 Shared Containerを使うには… Shared Containerを使うには、Developer PortalでApp IDに設定を加え、 Xcodeで設定する アプリ本体とExtension両方に紐付けが必要なことと、 設定後にプロビジョニングプロファイルを再発行する必要があることに注意 App Groupの作成 App IDにGroupの紐付け Xcodeで Capability追加

Slide 23

Slide 23 text

© GO Inc. 23 Shared Containerを使ったUserDefaultsの共有 Shared Container経由のUserDefaultsを使うには、 UserDefaults(suiteName: "AppGroupName") から取得できる UserDefaultsのインスタンスを使用する 今回は Notification Service Extensionで通知をJSONにして保存した if let appGroupDefaults = UserDefaults(suiteName: appGroupId), let encodedBytes = try? JSONSerialization.data( withJSONObject: bestAttemptContent.userInfo, options: []), let encoded = String(data: encodedBytes, encoding: .utf8) { appGroupDefaults.set(encoded, forKey: "lastBackgroundCarRequestRelatedNotificationPayload") appGroupDefaults.synchronize() }

Slide 24

Slide 24 text

© GO Inc. 24 FlutterでのShared Container事情 『GOドライバー』のアプリ本体はFlutterで、NSEはSwiftという構成になっています FlutterからShared Containerの読み書きには shared_preference_app_group プラグインを導入しました →shared_preference プラグインとインターフェイスが同じ ただしAndroidには実装がないので、 Androidではflutter_secure_storageプラグインを使うラッパーを用意しました。 →shared_preferenceプラグインは内部にキャッシュ機構があり、 複数プロセスから操作すると不整合を起こすため、毎回取得を行うものを使用 App Extension Shared Container

Slide 25

Slide 25 text

© GO Inc. 25 実装後 配車依頼の通知を受信したあと、 通知をタップしても、アプリをタップしても配車画面が出るようになった

Slide 26

Slide 26 text

© GO Inc. 26 3 バックグラウンドでも通知起点でリアルタイムに処理したい 〜バックグラウンド動作中のアプリ本体でリアルタイム処理

Slide 27

Slide 27 text

© GO Inc. 27 アプリ本体でリアルタイムなバックグラウンド動作 iOSでのバックグラウンド動作はBackground Modeで決まった動作であれば可能だが この中にバックグラウンド中もアプリ本体のプロセスが継続的に動作するものがある →代表的なものとしてバックグラウンド位置情報がある この場合、通知で起動したNSEからShared Containerを経由して バックグラウンド動作中のアプリ本体にシグナルを送りリアルタイム処理が可能 →Shared Container内のファイルを変更監視することなどで実現できる Apple WatchのWatchKit Extensionなどではよく使われる手法で、 MMWormholeというライブラリを使うことで簡単に実現できる App Extension Shared Container 書き込み 受信して動作 (『GOドライバー』でも使用)

Slide 28

Slide 28 text

© GO Inc. 28 let wormhole = MMWormhole(applicationGroupIdentifier: appGroupId, optionalDirectory: "wormhole") // send wormhole.passMessageObject(payload, identifier: "receivedNotification") // receive wormhole.listenForMessage(withIdentifier: "receivedNotification") { message in print("Wormhole: Received message: \(message ?? "nil")") }

Slide 29

Slide 29 text

© GO Inc. 通知受信をトリガーにバックグラウンドでも 配車依頼画面への遷移をさせることができるのではないかと検討した 実際にMMWormholeを組み込んで AppDelegateにイベントがくることを確認したものの、 Flutter側にイベント通知するにはPluginの開発が必要となるため 工数的な観点で見送った 29 『GOドライバー』でも検討したが… 通知が届く バックグラウンドでUI更新 Androidと処理を合わせられるメリットがある

Slide 30

Slide 30 text

© GO Inc. 30 まとめ Notification Service Extensionを、 リッチプッシュ以外に活用する方法をご紹介しました 「通知内容のクライアントサイドでの単なる書き換え」 「通知を起点にプログラムを動作させる基盤としての活用」 「リアルタイムに動作させる仕組み」 他のExtensionもアイデア次第で他の用途もあるかも! 今後もいろいろ試してサービスに応用していきます! APNsやFCMのプッシュ通知は送達保証がありません。届かないことを考慮することを忘れないようにしましょう。

Slide 31

Slide 31 text

© GO Inc. 文章・画像等の内容の無断転載及び複製等の行為はご遠慮ください

Slide 32

Slide 32 text

© GO Inc. 32 参考資料 Modifying content in newly delivered notifications https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-deli vered-notifications Configuring App Groups https://developer.apple.com/documentation/xcode/configuring-app-groups shared_preference_app_group (Flutter Plugin) https://pub.dev/packages/shared_preference_app_group flutter_secure_storage (Flutter Plugin) https://pub.dev/packages/flutter_secure_storage MMWormhole https://github.com/mutualmobile/MMWormhole