Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Remote notification tricks with Notification Se...

Remote notification tricks with Notification Service Extension

GO TechTalk #28 iOSの潜在能力を引き出せ!リッチプッシュとApple Payの実践活用術 で発表した資料です。

■ YouTube
https://www.youtube.com/live/_DvNB-ZlFTA?si=EY7roS61_b3pwGUC&t=1059

■ connpass
https://jtx.connpass.com/event/321462/

GO Inc. dev

July 23, 2024
Tweet

More Decks by GO Inc. dev

Other Decks in Programming

Transcript

  1. © GO Inc. 2 自己紹介 GO株式会社 フルスタックエンジニア / 伊藤 伸裕

    Webシステム、モバイルアプリの受託開発、環境系ベンチャーでのエンジ ニアリングマネージャー経験を経て、2023年9月にGO株式会社にフルス タックエンジニアとして入社。 アプリ・バックエンドを問わずGOのシステム全体を渡り歩きながら開発を しています。
  2. © GO Inc. 3 Contents 0. 『GOドライバー』と配車依頼が届く仕組み 1. iOSアプリがバックグラウンドでも 通知ペイロードからメッセージを出し分けたい!

    〜Notification Service Extensionでの書き換えによる通知メッセージの出しわけ 2. 通知を開かずにアプリを起動しても同じ動作をしたい! 〜バックグラウンドで通知を保存して起動時に読み込む 3. バックグラウンドでも通知起点でリアルタイムに処理したい 〜バックグラウンド動作中のアプリ本体でリアルタイム処理
  3. © GO Inc. タクシー車両向け端末のアプリ(Android)では、 常時フォアグラウンドで動作している前提のため通知文言を含まず、 通知の種類のみが含まれる「サイレント通知」を送信していた 『GOドライバー』でもこれを踏襲してFlutterで実装したが、 iOSでは期待した動作ができなかった 8 『GOドライバー』に届く通知と当初の設計

    サイレント通知 アプリの 受信ハンドラ 通知を起点とした処理 ローカル通知を出す フォアグラウンド バックグラウンド タクシー車両向けのアプリでは「通知の文言をアプリ側で制御したい」という思想もあったらしい ❌
  4. © GO Inc. Androidは状態問わずアプリのServiceが受け、アプリで通知を登録するが、 iOSはバックグラウンドでの通知はシステムが処理する iOSでバックグラウンドで受信した通知を通知センターに出すには、 サイレントでない通知を送らなければならない 9 なぜサイレント通知が処理されないか サーバーが通知を送る

    FirebaseMessagingService ※Androidでnotificationペイロードに文章を入れた際、バックグラウンドでもコード記述なしで 通知が登録されるが、これはFirebaseMessagingServiceがやってくれている アプリが通知表示 OSが通知表示 ユーザーコードの範囲 OS/ライブラリ OSの処理範囲 Android iOS
  5. © 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) } } }
  6. © GO Inc. 14 XcodeでNSEを追加すると、 UNNotificationServiceExtensionのテンプレが追加される 届いた通知はdidReceiveのハンドラに届く 通知の中でrequestの中身に入っているcontentを書き換え、 contentHandlerで書き換え後の通知を返す 処理に使える時間には制限があり、

    時間切れするとserviceExtensionTimeWillExpireが呼ばれるので、 その時点での通知をcontentHandlerに返す必要がある NSEの実装に必要な処理 override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
  7. © 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
  8. © 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 }
  9. © GO Inc. 17 バックエンド側に依頼したこと NSEが起動するには、mutable-contentの指定と 「表示される通知であること」が必要 FCMのペイロードに、固定のalertとmutable-contentを含めてもらった { //

    notification: { ... }, // 元々指定なし data: { type: "...", ... }, apns: { payload: { aps: { alert: "通知があります", // 当たり障りのない文章を指定 mutable-content: 1 // フラグON } } } } { // notification: { ... }, // 元々指定なし data: { type: "...", ... } }
  10. © GO Inc. 22 Shared Containerを使うには… Shared Containerを使うには、Developer PortalでApp IDに設定を加え、

    Xcodeで設定する アプリ本体とExtension両方に紐付けが必要なことと、 設定後にプロビジョニングプロファイルを再発行する必要があることに注意 App Groupの作成 App IDにGroupの紐付け Xcodeで Capability追加
  11. © 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() }
  12. © 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
  13. © GO Inc. 27 アプリ本体でリアルタイムなバックグラウンド動作 iOSでのバックグラウンド動作はBackground Modeで決まった動作であれば可能だが この中にバックグラウンド中もアプリ本体のプロセスが継続的に動作するものがある →代表的なものとしてバックグラウンド位置情報がある この場合、通知で起動したNSEからShared

    Containerを経由して バックグラウンド動作中のアプリ本体にシグナルを送りリアルタイム処理が可能 →Shared Container内のファイルを変更監視することなどで実現できる Apple WatchのWatchKit Extensionなどではよく使われる手法で、 MMWormholeというライブラリを使うことで簡単に実現できる App Extension Shared Container 書き込み 受信して動作 (『GOドライバー』でも使用)
  14. © 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")") }
  15. © GO Inc. 30 まとめ Notification Service Extensionを、 リッチプッシュ以外に活用する方法をご紹介しました 「通知内容のクライアントサイドでの単なる書き換え」

    「通知を起点にプログラムを動作させる基盤としての活用」 「リアルタイムに動作させる仕組み」 他のExtensionもアイデア次第で他の用途もあるかも! 今後もいろいろ試してサービスに応用していきます! APNsやFCMのプッシュ通知は送達保証がありません。届かないことを考慮することを忘れないようにしましょう。
  16. © 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