notif ios: Handle opening of conversation on tap; take 2

Fixes #1147.

2nd attempt, first attempt was #1261. This one uses pigeon to move most of the notification payload handling to dart side (and doesn't use/rely on `zulip://notification` URL).

ios notifs: Support tapping a notification to open the conversation
Like #123, but for iOS.

When you tap a new-message notification, we should open the message's conversation.

flutter test test/model/store_test.dart test/notifications/display_test.dart test/notifications/open_test.dart test/widgets/store_test.dart --reporter expanded 2>&1

diff --git a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt
similarity index 94%
rename from android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt
rename to android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt
index f4862e2b0b..39207e3470 100644
--- a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt
+++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt
@@ -13,7 +13,7 @@ import io.flutter.plugin.common.StandardMethodCodec
 import io.flutter.plugin.common.StandardMessageCodec
 import java.io.ByteArrayOutputStream
 import java.nio.ByteBuffer
-private object NotificationsPigeonUtils {
+private object AndroidNotificationsPigeonUtils {

   fun wrapResult(result: Any?): List<Any?> {
     return listOf(result)
@@ -128,7 +128,7 @@ data class NotificationChannel (
     if (this === other) {
       return true
     }
-    return NotificationsPigeonUtils.deepEquals(toList(), other.toList())  }
+    return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList())  }

   override fun hashCode(): Int = toList().hashCode()
 }
@@ -171,7 +171,7 @@ data class AndroidIntent (
     if (this === other) {
       return true
     }
-    return NotificationsPigeonUtils.deepEquals(toList(), other.toList())  }
+    return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList())  }

   override fun hashCode(): Int = toList().hashCode()
 }
@@ -215,7 +215,7 @@ data class PendingIntent (
     if (this === other) {
       return true
     }
-    return NotificationsPigeonUtils.deepEquals(toList(), other.toList())  }
+    return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList())  }

   override fun hashCode(): Int = toList().hashCode()
 }
@@ -249,7 +249,7 @@ data class InboxStyle (
     if (this === other) {
       return true
     }
-    return NotificationsPigeonUtils.deepEquals(toList(), other.toList())  }
+    return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList())  }

   override fun hashCode(): Int = toList().hashCode()
 }
@@ -299,7 +299,7 @@ data class Person (
     if (this === other) {
       return true
     }
-    return NotificationsPigeonUtils.deepEquals(toList(), other.toList())  }
+    return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList())  }

   override fun hashCode(): Int = toList().hashCode()
 }
@@ -339,7 +339,7 @@ data class MessagingStyleMessage (
     if (this === other) {
       return true
     }
-    return NotificationsPigeonUtils.deepEquals(toList(), other.toList())  }
+    return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList())  }

   override fun hashCode(): Int = toList().hashCode()
 }
@@ -382,7 +382,7 @@ data class MessagingStyle (
     if (this === other) {
       return true
     }
-    return NotificationsPigeonUtils.deepEquals(toList(), other.toList())  }
+    return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList())  }

   override fun hashCode(): Int = toList().hashCode()
 }
@@ -419,7 +419,7 @@ data class Notification (
     if (this === other) {
       return true
     }
-    return NotificationsPigeonUtils.deepEquals(toList(), other.toList())  }
+    return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList())  }

   override fun hashCode(): Int = toList().hashCode()
 }
@@ -459,7 +459,7 @@ data class StatusBarNotification (
     if (this === other) {
       return true
     }
-    return NotificationsPigeonUtils.deepEquals(toList(), other.toList())  }
+    return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList())  }

   override fun hashCode(): Int = toList().hashCode()
 }
@@ -509,11 +509,11 @@ data class StoredNotificationSound (
     if (this === other) {
       return true
     }
-    return NotificationsPigeonUtils.deepEquals(toList(), other.toList())  }
+    return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList())  }

   override fun hashCode(): Int = toList().hashCode()
 }
-private open class NotificationsPigeonCodec : StandardMessageCodec() {
+private open class AndroidNotificationsPigeonCodec : StandardMessageCodec() {
   override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
     return when (type) {
       129.toByte() -> {
@@ -721,7 +721,7 @@ interface AndroidNotificationHostApi {
   companion object {
     /** The codec used by AndroidNotificationHostApi. */
     val codec: MessageCodec<Any?> by lazy {
-      NotificationsPigeonCodec()
+      AndroidNotificationsPigeonCodec()
     }
     /** Sets up an instance of `AndroidNotificationHostApi` to handle messages through the `binaryMessenger`. */
     @JvmOverloads
@@ -737,7 +737,7 @@ interface AndroidNotificationHostApi {
               api.createNotificationChannel(channelArg)
               listOf(null)
             } catch (exception: Throwable) {
-              NotificationsPigeonUtils.wrapError(exception)
+              AndroidNotificationsPigeonUtils.wrapError(exception)
             }
             reply.reply(wrapped)
           }
@@ -752,7 +752,7 @@ interface AndroidNotificationHostApi {
             val wrapped: List<Any?> = try {
               listOf(api.getNotificationChannels())
             } catch (exception: Throwable) {
-              NotificationsPigeonUtils.wrapError(exception)
+              AndroidNotificationsPigeonUtils.wrapError(exception)
             }
             reply.reply(wrapped)
           }
@@ -770,7 +770,7 @@ interface AndroidNotificationHostApi {
               api.deleteNotificationChannel(channelIdArg)
               listOf(null)
             } catch (exception: Throwable) {
-              NotificationsPigeonUtils.wrapError(exception)
+              AndroidNotificationsPigeonUtils.wrapError(exception)
             }
             reply.reply(wrapped)
           }
@@ -785,7 +785,7 @@ interface AndroidNotificationHostApi {
             val wrapped: List<Any?> = try {
               listOf(api.listStoredSoundsInNotificationsDirectory())
             } catch (exception: Throwable) {
-              NotificationsPigeonUtils.wrapError(exception)
+              AndroidNotificationsPigeonUtils.wrapError(exception)
             }
             reply.reply(wrapped)
           }
@@ -803,7 +803,7 @@ interface AndroidNotificationHostApi {
             val wrapped: List<Any?> = try {
               listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg))
             } catch (exception: Throwable) {
-              NotificationsPigeonUtils.wrapError(exception)
+              AndroidNotificationsPigeonUtils.wrapError(exception)
             }
             reply.reply(wrapped)
           }
@@ -835,7 +835,7 @@ interface AndroidNotificationHostApi {
               api.notify(tagArg, idArg, autoCancelArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, groupKeyArg, inboxStyleArg, isGroupSummaryArg, messagingStyleArg, numberArg, smallIconResourceNameArg)
               listOf(null)
             } catch (exception: Throwable) {
-              NotificationsPigeonUtils.wrapError(exception)
+              AndroidNotificationsPigeonUtils.wrapError(exception)
             }
             reply.reply(wrapped)
           }
@@ -852,7 +852,7 @@ interface AndroidNotificationHostApi {
             val wrapped: List<Any?> = try {
               listOf(api.getActiveNotificationMessagingStyleByTag(tagArg))
             } catch (exception: Throwable) {
-              NotificationsPigeonUtils.wrapError(exception)
+              AndroidNotificationsPigeonUtils.wrapError(exception)
             }
             reply.reply(wrapped)
           }
@@ -869,7 +869,7 @@ interface AndroidNotificationHostApi {
             val wrapped: List<Any?> = try {
               listOf(api.getActiveNotifications(desiredExtrasArg))
             } catch (exception: Throwable) {
-              NotificationsPigeonUtils.wrapError(exception)
+              AndroidNotificationsPigeonUtils.wrapError(exception)
             }
             reply.reply(wrapped)
           }
@@ -888,7 +888,7 @@ interface AndroidNotificationHostApi {
               api.cancel(tagArg, idArg)
               listOf(null)
             } catch (exception: Throwable) {
-              NotificationsPigeonUtils.wrapError(exception)
+              AndroidNotificationsPigeonUtils.wrapError(exception)
             }
             reply.reply(wrapped)
           }
diff --git a/docs/howto/push-notifications-ios-simulator.md b/docs/howto/push-notifications-ios-simulator.md
new file mode 100644
index 0000000000..d54bb20491
--- /dev/null
+++ b/docs/howto/push-notifications-ios-simulator.md
@@ -0,0 +1,300 @@
+# Testing Push Notifications on iOS Simulator
+
+For documentation on testing push notifications on Android or a real
+iOS device, see https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md
+
+This doc describes how to test client-side changes on iOS Simulator.
+It will demonstrate how to use APNs payloads the server sends to
+the Apple Push Notification service to show notifications on iOS
+Simulator.
+
+
+### Contents
+
+* [Trigger a notification on the iOS Simulator](#trigger-notification)
+* [Canned APNs payloads](#canned-payloads)
+* [Produce sample APNs payloads](#produce-payload)
+
+
+

+
+## Trigger a notification on the iOS Simulator
+
+The iOS Simulator permits delivering a notification payload
+artificially, as if APNs had delivered it to the device,
+but without actually involving APNs or any other server.
+
+As input for this operation, you'll need an APNs payload,
+i.e. a JSON blob representing what APNs might deliver to the app
+for a notification.
+
+To get an APNs payload, you can generate one from a Zulip dev server
+by following the [instructions in a section below](#produce-payload),
+or you can use one of the payloads included
+in this document [below](#canned-payloads).
+
+
+### 1. Determine the device ID of the iOS Simulator
+
+To receive a notification on the iOS Simulator, we need to first
+determine the device ID of the iOS Simulator, to specify which
+Simulator instance we want to push the payload to.
+
+```shell-session
+$ xcrun simctl list devices booted
+```
+
+
+Example output:
+
+```shell-session
+$ xcrun simctl list devices booted
+== Devices ==
+-- iOS 18.3 --
+    iPhone 16 Pro (90CC33B2-679B-4053-B380-7B986A29F28C) (Booted)
+```
+
+
+
+
+### 2. Trigger a notification by pushing the payload to the iOS Simulator
+
+By running the following command with a valid APNs payload, you should
+receive a notification on the iOS Simulator for the zulip-flutter app.
+Tapping on the notification should route to the respective conversation.
+
+```shell-session
+$ xcrun simctl push [device-id] com.zulip.flutter [payload json path]
+```
+
+
+Example output:
+
+```shell-session
+$ xcrun simctl push 90CC33B2-679B-4053-B380-7B986A29F28C com.zulip.flutter ./dm.json
+Notification sent to 'com.zulip.flutter'
+```
+
+
+
+
+

+
+## Canned APNs payloads
+
+The following pre-canned APNs payloads can be used in case you don't
+have one.
+
+These canned payloads were generated from
+Zulip Server 11.0-dev+git 8fd04b0f0, API Feature Level 377,
+in April 2025.
+The `user_id` is that of `iago@zulip.com` in the Zulip dev environment.
+
+These canned payloads assume that EXTERNAL_HOST has its default value
+for the dev server. If you've
+[set EXTERNAL_HOST to use an IP address](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md#4-set-external_host)
+in order to enable your device to connect to the dev server, you'll
+need to adjust the `realm_url` fields. You can do this by a
+find-and-replace for `localhost`; for example,
+`perl -i -0pe s/localhost/10.0.2.2/g tmp/*.json` after saving the
+canned payloads to files `tmp/*.json`.
+
+
+Payload: dm.json
+
+```json
+{
+    "aps": {
+        "alert": {
+            "title": "Zoe",
+            "subtitle": "",
+            "body": "But wouldn't that show you contextually who is in the audience before you have to open the compose box?"
+        },
+        "sound": "default",
+        "badge": 0,
+    },
+    "zulip": {
+        "server": "zulipdev.com:9991",
+        "realm_id": 2,
+        "realm_uri": "http://localhost:9991",
+        "realm_url": "http://localhost:9991",
+        "realm_name": "Zulip Dev",
+        "user_id": 11,
+        "sender_id": 7,
+        "sender_email": "user7@zulipdev.com",
+        "time": 1740890583,
+        "recipient_type": "private",
+        "message_ids": [
+            87
+        ]
+    }
+}
+```
+
+
+
+
+Payload: group_dm.json
+
+```json
+{
+    "aps": {
+        "alert": {
+            "title": "Othello, the Moor of Venice, Polonius (guest), Iago",
+            "subtitle": "Othello, the Moor of Venice:",
+            "body": "Sit down awhile; And let us once again assail your ears, That are so fortified against our story What we have two nights seen."
+        },
+        "sound": "default",
+        "badge": 0,
+    },
+    "zulip": {
+        "server": "zulipdev.com:9991",
+        "realm_id": 2,
+        "realm_uri": "http://localhost:9991",
+        "realm_url": "http://localhost:9991",
+        "realm_name": "Zulip Dev",
+        "user_id": 11,
+        "sender_id": 12,
+        "sender_email": "user12@zulipdev.com",
+        "time": 1740533641,
+        "recipient_type": "private",
+        "pm_users": "11,12,13",
+        "message_ids": [
+            17
+        ]
+    }
+}
+```
+
+
+
+
+Payload: stream.json
+
+```json
+{
+    "aps": {
+        "alert": {
+            "title": "#devel > plotter",
+            "subtitle": "Desdemona:",
+            "body": "Despite the fact that such a claim at first glance seems counterintuitive, it is derived from known results. Electrical engineering follows a cycle of four phases: location, refinement, visualization, and evaluation."
+        },
+        "sound": "default",
+        "badge": 0,
+    },
+    "zulip": {
+        "server": "zulipdev.com:9991",
+        "realm_id": 2,
+        "realm_uri": "http://localhost:9991",
+        "realm_url": "http://localhost:9991",
+        "realm_name": "Zulip Dev",
+        "user_id": 11,
+        "sender_id": 9,
+        "sender_email": "user9@zulipdev.com",
+        "time": 1740558997,
+        "recipient_type": "stream",
+        "stream": "devel",
+        "stream_id": 11,
+        "topic": "plotter",
+        "message_ids": [
+            40
+        ]
+    }
+}
+```
+
+
+
+
+

+
+## Produce sample APNs payloads
+
+### 1. Set up dev server
+
+To set up and run the dev server on the same Mac machine that hosts
+the iOS Simulator, follow Zulip's
+[standard instructions](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html)
+for setting up a dev server.
+
+If you want to run the dev server on a different machine than the Mac
+host, you'll need to follow extra steps
+[documented here](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md)
+to make it possible for the app running on the iOS Simulator to
+connect to the dev server.
+
+
+### 2. Set up the dev user to receive mobile notifications.
+
+We'll use the devlogin user `iago@zulip.com` to test notifications.
+Log in to that user by going to `/devlogin` on that server on Web.
+
+Then follow the steps [here](https://zulip.com/help/mobile-notifications)
+to enable Mobile Notifications for "Channels".
+
+
+### 3. Log in as the dev user on zulip-flutter.
+
+
+
+To log in as this user in the Flutter app, you'll need the password
+that was generated by the development server. You can print the
+password by running this command inside your `vagrant ssh` shell:
+```
+$ ./manage.py print_initial_password iago@zulip.com
+```
+
+Then run the app on the iOS Simulator, accept the permission to
+receive push notifications, and then log in as the dev user
+(`iago@zulip.com`).
+
+
+### 4. Edit the server code to log the notification payload.
+
+We need to retrieve the APNs payload the server generates and sends
+to the bouncer. To do that we can add a log statement after the
+server completes generating the payload in `zerver/lib/push_notifications.py`:
+
+```diff
+     apns_payload = get_message_payload_apns(
+         user_profile,
+         message,
+         trigger,
+         mentioned_user_group_id,
+         mentioned_user_group_name,
+         can_access_sender,
+     )
+     gcm_payload, gcm_options = get_message_payload_gcm(
+         user_profile, message, mentioned_user_group_id, mentioned_user_group_name, can_access_sender
+     )
+     logger.info("Sending push notifications to mobile clients for user %s", user_profile_id)
++    logger.info("APNS payload %s", orjson.dumps(apns_payload).decode())
+
+     android_devices = list(
+         PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.FCM).order_by("id")
+```
+
+
+### 5. Send messages to the dev user
+
+To generate notifications to the dev user `iago@zulip.com` we need to
+send messages from another user. For a variety of different types of
+payloads try sending a message in a topic, a message in a group DM,
+and one in one-one DM. Then look for the payloads in the server logs
+by searching for "APNS payload".
+
+
+### 6. Transform and save the payload to a file
+
+The payload JSON recorded in the steps above is in the form the
+Zulip server sends to the bouncer.  The bouncer restructures this
+slightly to produce the actual payload which it sends to APNs,
+and which APNs delivers to the app on the device.
+To apply the same restructuring, run the payload through
+the following `jq` command:
+
+```shell-session
+$ echo '{"alert":{"title": ...' \
+    | jq '{aps: {alert, sound, badge}, zulip: .custom.zulip}' \
+    > payload.json
+```
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index b4928e2220..7df051a142 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -13,6 +13,7 @@
 		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
 		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
 		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+		B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34E9F082D776BEB0009AED2 /* Notifications.g.swift */; };
 		F311C174AF9C005CE4AADD72 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EAE3F3F518B95B7BFEB4FE7 /* Pods_Runner.framework */; };
 /* End PBXBuildFile section */

@@ -48,6 +49,7 @@
 		97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 		97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+		B34E9F082D776BEB0009AED2 /* Notifications.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.g.swift; sourceTree = "<group>"; };
 		B3AF53A72CA20BD10039801D /* Zulip.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Zulip.xcconfig; path = Flutter/Zulip.xcconfig; sourceTree = "<group>"; };
 /* End PBXFileReference section */

@@ -115,6 +117,7 @@
 				1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
 				1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
 				74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+				B34E9F082D776BEB0009AED2 /* Notifications.g.swift */,
 				74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
 			);
 			path = Runner;
@@ -297,6 +300,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+				B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */,
 				1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index b636303481..eefed07cd6 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -3,11 +3,64 @@ import Flutter

 @main
 @objc class AppDelegate: FlutterAppDelegate {
+  private var notificationTapEventListener: NotificationTapEventListener?
+
   override func application(
     _ application: UIApplication,
     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
   ) -> Bool {
     GeneratedPluginRegistrant.register(with: self)
+    let controller = window?.rootViewController as! FlutterViewController
+
+    // Retrieve the remote notification payload from launch options;
+    // this will be null if the launch wasn't triggered by a notification.
+    let notificationPayload = launchOptions?[.remoteNotification] as? [AnyHashable : Any]
+    let api = NotificationHostApiImpl(notificationPayload.map { NotificationDataFromLaunch(payload: $0) })
+    NotificationHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: api)
+
+    notificationTapEventListener = NotificationTapEventListener()
+    NotificationTapEventsStreamHandler.register(with: controller.binaryMessenger, streamHandler: notificationTapEventListener!)
+
+    UNUserNotificationCenter.current().delegate = self
+
     return super.application(application, didFinishLaunchingWithOptions: launchOptions)
   }
+
+  override func userNotificationCenter(
+    _ center: UNUserNotificationCenter,
+    didReceive response: UNNotificationResponse,
+    withCompletionHandler completionHandler: @escaping () -> Void
+  ) {
+    if response.actionIdentifier == UNNotificationDefaultActionIdentifier {
+      let userInfo = response.notification.request.content.userInfo
+      notificationTapEventListener!.onNotificationTapEvent(payload: userInfo)
+    }
+    completionHandler()
+  }
+}
+
+private class NotificationHostApiImpl: NotificationHostApi {
+  private let maybeDataFromLaunch: NotificationDataFromLaunch?
+
+  init(_ maybeDataFromLaunch: NotificationDataFromLaunch?) {
+    self.maybeDataFromLaunch = maybeDataFromLaunch
+  }
+
+  func getNotificationDataFromLaunch() -> NotificationDataFromLaunch? {
+    maybeDataFromLaunch
+  }
+}
+
+// Adapted from Pigeon's Swift example for @EventChannelApi:
+//   https://github.com/flutter/packages/blob/2dff6213a/packages/pigeon/example/app/ios/Runner/AppDelegate.swift#L49-L74
+class NotificationTapEventListener: NotificationTapEventsStreamHandler {
+  var eventSink: PigeonEventSink<NotificationTapEvent>?
+
+  override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<NotificationTapEvent>) {
+    eventSink = sink
+  }
+
+  func onNotificationTapEvent(payload: [AnyHashable : Any]) {
+    eventSink?.success(NotificationTapEvent(payload: payload))
+  }
 }
diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift
new file mode 100644
index 0000000000..40db818d33
--- /dev/null
+++ b/ios/Runner/Notifications.g.swift
@@ -0,0 +1,335 @@
+// Autogenerated from Pigeon (v25.3.1), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+
+import Foundation
+
+#if os(iOS)
+  import Flutter
+#elseif os(macOS)
+  import FlutterMacOS
+#else
+  #error("Unsupported platform.")
+#endif
+
+/// Error class for passing custom error details to Dart side.
+final class PigeonError: Error {
+  let code: String
+  let message: String?
+  let details: Sendable?
+
+  init(code: String, message: String?, details: Sendable?) {
+    self.code = code
+    self.message = message
+    self.details = details
+  }
+
+  var localizedDescription: String {
+    return
+      "PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
+  }
+}
+
+private func wrapResult(_ result: Any?) -> [Any?] {
+  return [result]
+}
+
+private func wrapError(_ error: Any) -> [Any?] {
+  if let pigeonError = error as? PigeonError {
+    return [
+      pigeonError.code,
+      pigeonError.message,
+      pigeonError.details,
+    ]
+  }
+  if let flutterError = error as? FlutterError {
+    return [
+      flutterError.code,
+      flutterError.message,
+      flutterError.details,
+    ]
+  }
+  return [
+    "\(error)",
+    "\(type(of: error))",
+    "Stacktrace: \(Thread.callStackSymbols)",
+  ]
+}
+
+private func isNullish(_ value: Any?) -> Bool {
+  return value is NSNull || value == nil
+}
+
+private func nilOrValue<T>(_ value: Any?) -> T? {
+  if value is NSNull { return nil }
+  return value as! T?
+}
+
+func deepEqualsNotifications(_ lhs: Any?, _ rhs: Any?) -> Bool {
+  let cleanLhs = nilOrValue(lhs) as Any?
+  let cleanRhs = nilOrValue(rhs) as Any?
+  switch (cleanLhs, cleanRhs) {
+  case (nil, nil):
+    return true
+
+  case (nil, _), (_, nil):
+    return false
+
+  case is (Void, Void):
+    return true
+
+  case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
+    return cleanLhsHashable == cleanRhsHashable
+
+  case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
+    guard cleanLhsArray.count == cleanRhsArray.count else { return false }
+    for (index, element) in cleanLhsArray.enumerated() {
+      if !deepEqualsNotifications(element, cleanRhsArray[index]) {
+        return false
+      }
+    }
+    return true
+
+  case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
+    guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
+    for (key, cleanLhsValue) in cleanLhsDictionary {
+      guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
+      if !deepEqualsNotifications(cleanLhsValue, cleanRhsDictionary[key]!) {
+        return false
+      }
+    }
+    return true
+
+  default:
+    // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
+    return false
+  }
+}
+
+func deepHashNotifications(value: Any?, hasher: inout Hasher) {
+  if let valueList = value as? [AnyHashable] {
+     for item in valueList { deepHashNotifications(value: item, hasher: &hasher) }
+     return
+  }
+
+  if let valueDict = value as? [AnyHashable: AnyHashable] {
+    for key in valueDict.keys {
+      hasher.combine(key)
+      deepHashNotifications(value: valueDict[key]!, hasher: &hasher)
+    }
+    return
+  }
+
+  if let hashableValue = value as? AnyHashable {
+    hasher.combine(hashableValue.hashValue)
+  }
+
+  return hasher.combine(String(describing: value))
+}
+
+
+
+/// Generated class from Pigeon that represents data sent in messages.
+struct NotificationDataFromLaunch: Hashable {
+  /// The raw payload that is attached to the notification,
+  /// holding the information required to carry out the navigation.
+  ///
+  /// See [NotificationHostApi.getNotificationDataFromLaunch].
+  var payload: [AnyHashable?: Any?]
+
+
+  // swift-format-ignore: AlwaysUseLowerCamelCase
+  static func fromList(_ pigeonVar_list: [Any?]) -> NotificationDataFromLaunch? {
+    let payload = pigeonVar_list[0] as! [AnyHashable?: Any?]
+
+    return NotificationDataFromLaunch(
+      payload: payload
+    )
+  }
+  func toList() -> [Any?] {
+    return [
+      payload
+    ]
+  }
+  static func == (lhs: NotificationDataFromLaunch, rhs: NotificationDataFromLaunch) -> Bool {
+    return deepEqualsNotifications(lhs.toList(), rhs.toList())  }
+  func hash(into hasher: inout Hasher) {
+    deepHashNotifications(value: toList(), hasher: &hasher)
+  }
+}
+
+/// Generated class from Pigeon that represents data sent in messages.
+struct NotificationTapEvent: Hashable {
+  /// The raw payload that is attached to the notification,
+  /// holding the information required to carry out the navigation.
+  ///
+  /// See [notificationTapEvents].
+  var payload: [AnyHashable?: Any?]
+
+
+  // swift-format-ignore: AlwaysUseLowerCamelCase
+  static func fromList(_ pigeonVar_list: [Any?]) -> NotificationTapEvent? {
+    let payload = pigeonVar_list[0] as! [AnyHashable?: Any?]
+
+    return NotificationTapEvent(
+      payload: payload
+    )
+  }
+  func toList() -> [Any?] {
+    return [
+      payload
+    ]
+  }
+  static func == (lhs: NotificationTapEvent, rhs: NotificationTapEvent) -> Bool {
+    return deepEqualsNotifications(lhs.toList(), rhs.toList())  }
+  func hash(into hasher: inout Hasher) {
+    deepHashNotifications(value: toList(), hasher: &hasher)
+  }
+}
+
+private class NotificationsPigeonCodecReader: FlutterStandardReader {
+  override func readValue(ofType type: UInt8) -> Any? {
+    switch type {
+    case 129:
+      return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?])
+    case 130:
+      return NotificationTapEvent.fromList(self.readValue() as! [Any?])
+    default:
+      return super.readValue(ofType: type)
+    }
+  }
+}
+
+private class NotificationsPigeonCodecWriter: FlutterStandardWriter {
+  override func writeValue(_ value: Any) {
+    if let value = value as? NotificationDataFromLaunch {
+      super.writeByte(129)
+      super.writeValue(value.toList())
+    } else if let value = value as? NotificationTapEvent {
+      super.writeByte(130)
+      super.writeValue(value.toList())
+    } else {
+      super.writeValue(value)
+    }
+  }
+}
+
+private class NotificationsPigeonCodecReaderWriter: FlutterStandardReaderWriter {
+  override func reader(with data: Data) -> FlutterStandardReader {
+    return NotificationsPigeonCodecReader(data: data)
+  }
+
+  override func writer(with data: NSMutableData) -> FlutterStandardWriter {
+    return NotificationsPigeonCodecWriter(data: data)
+  }
+}
+
+class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
+  static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter())
+}
+
+var notificationsPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: NotificationsPigeonCodecReaderWriter());
+
+/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
+protocol NotificationHostApi {
+  /// Retrieves notification data if the app was launched by tapping on a notification.
+  ///
+  /// Returns `launchOptions.remoteNotification`,
+  /// which is the raw APNs data dictionary
+  /// if the app launch was opened by a notification tap,
+  /// else null. See Apple doc:
+  ///   https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification
+  func getNotificationDataFromLaunch() throws -> NotificationDataFromLaunch?
+}
+
+/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
+class NotificationHostApiSetup {
+  static var codec: FlutterStandardMessageCodec { NotificationsPigeonCodec.shared }
+  /// Sets up an instance of `NotificationHostApi` to handle messages through the `binaryMessenger`.
+  static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NotificationHostApi?, messageChannelSuffix: String = "") {
+    let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
+    /// Retrieves notification data if the app was launched by tapping on a notification.
+    ///
+    /// Returns `launchOptions.remoteNotification`,
+    /// which is the raw APNs data dictionary
+    /// if the app launch was opened by a notification tap,
+    /// else null. See Apple doc:
+    ///   https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification
+    let getNotificationDataFromLaunchChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+    if let api = api {
+      getNotificationDataFromLaunchChannel.setMessageHandler { _, reply in
+        do {
+          let result = try api.getNotificationDataFromLaunch()
+          reply(wrapResult(result))
+        } catch {
+          reply(wrapError(error))
+        }
+      }
+    } else {
+      getNotificationDataFromLaunchChannel.setMessageHandler(nil)
+    }
+  }
+}
+
+private class PigeonStreamHandler<ReturnType>: NSObject, FlutterStreamHandler {
+  private let wrapper: PigeonEventChannelWrapper<ReturnType>
+  private var pigeonSink: PigeonEventSink<ReturnType>? = nil
+
+  init(wrapper: PigeonEventChannelWrapper<ReturnType>) {
+    self.wrapper = wrapper
+  }
+
+  func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
+    -> FlutterError?
+  {
+    pigeonSink = PigeonEventSink<ReturnType>(events)
+    wrapper.onListen(withArguments: arguments, sink: pigeonSink!)
+    return nil
+  }
+
+  func onCancel(withArguments arguments: Any?) -> FlutterError? {
+    pigeonSink = nil
+    wrapper.onCancel(withArguments: arguments)
+    return nil
+  }
+}
+
+class PigeonEventChannelWrapper<ReturnType> {
+  func onListen(withArguments arguments: Any?, sink: PigeonEventSink<ReturnType>) {}
+  func onCancel(withArguments arguments: Any?) {}
+}
+
+class PigeonEventSink<ReturnType> {
+  private let sink: FlutterEventSink
+
+  init(_ sink: @escaping FlutterEventSink) {
+    self.sink = sink
+  }
+
+  func success(_ value: ReturnType) {
+    sink(value)
+  }
+
+  func error(code: String, message: String?, details: Any?) {
+    sink(FlutterError(code: code, message: message, details: details))
+  }
+
+  func endOfStream() {
+    sink(FlutterEndOfEventStream)
+  }
+
+}
+
+class NotificationTapEventsStreamHandler: PigeonEventChannelWrapper<NotificationTapEvent> {
+  static func register(with messenger: FlutterBinaryMessenger,
+                      instanceName: String = "",
+                      streamHandler: NotificationTapEventsStreamHandler) {
+    var channelName = "dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents"
+    if !instanceName.isEmpty {
+      channelName += ".\(instanceName)"
+    }
+    let internalStreamHandler = PigeonStreamHandler<NotificationTapEvent>(wrapper: streamHandler)
+    let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: notificationsPigeonMethodCodec)
+    channel.setStreamHandler(internalStreamHandler)
+  }
+}
+
diff --git a/lib/host/notifications.dart b/lib/host/notifications.dart
new file mode 100644
index 0000000000..6c3e593e2c
--- /dev/null
+++ b/lib/host/notifications.dart
@@ -0,0 +1,1 @@
+export './notifications.g.dart';
diff --git a/lib/host/notifications.g.dart b/lib/host/notifications.g.dart
new file mode 100644
index 0000000000..a83b67c804
--- /dev/null
+++ b/lib/host/notifications.g.dart
@@ -0,0 +1,210 @@
+// Autogenerated from Pigeon (v25.3.1), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
+
+import 'dart:async';
+import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
+
+import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
+import 'package:flutter/services.dart';
+
+PlatformException _createConnectionError(String channelName) {
+  return PlatformException(
+    code: 'channel-error',
+    message: 'Unable to establish connection on channel: "$channelName".',
+  );
+}
+bool _deepEquals(Object? a, Object? b) {
+  if (a is List && b is List) {
+    return a.length == b.length &&
+        a.indexed
+        .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
+  }
+  if (a is Map && b is Map) {
+    return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
+        (b as Map<Object?, Object?>).containsKey(entry.key) &&
+        _deepEquals(entry.value, b[entry.key]));
+  }
+  return a == b;
+}
+
+
+class NotificationDataFromLaunch {
+  NotificationDataFromLaunch({
+    required this.payload,
+  });
+
+  /// The raw payload that is attached to the notification,
+  /// holding the information required to carry out the navigation.
+  ///
+  /// See [NotificationHostApi.getNotificationDataFromLaunch].
+  Map<Object?, Object?> payload;
+
+  List<Object?> _toList() {
+    return <Object?>[
+      payload,
+    ];
+  }
+
+  Object encode() {
+    return _toList();  }
+
+  static NotificationDataFromLaunch decode(Object result) {
+    result as List<Object?>;
+    return NotificationDataFromLaunch(
+      payload: (result[0] as Map<Object?, Object?>?)!.cast<Object?, Object?>(),
+    );
+  }
+
+  @override
+  // ignore: avoid_equals_and_hash_code_on_mutable_classes
+  bool operator ==(Object other) {
+    if (other is! NotificationDataFromLaunch || other.runtimeType != runtimeType) {
+      return false;
+    }
+    if (identical(this, other)) {
+      return true;
+    }
+    return _deepEquals(encode(), other.encode());
+  }
+
+  @override
+  // ignore: avoid_equals_and_hash_code_on_mutable_classes
+  int get hashCode => Object.hashAll(_toList())
+;
+}
+
+class NotificationTapEvent {
+  NotificationTapEvent({
+    required this.payload,
+  });
+
+  /// The raw payload that is attached to the notification,
+  /// holding the information required to carry out the navigation.
+  ///
+  /// See [notificationTapEvents].
+  Map<Object?, Object?> payload;
+
+  List<Object?> _toList() {
+    return <Object?>[
+      payload,
+    ];
+  }
+
+  Object encode() {
+    return _toList();  }
+
+  static NotificationTapEvent decode(Object result) {
+    result as List<Object?>;
+    return NotificationTapEvent(
+      payload: (result[0] as Map<Object?, Object?>?)!.cast<Object?, Object?>(),
+    );
+  }
+
+  @override
+  // ignore: avoid_equals_and_hash_code_on_mutable_classes
+  bool operator ==(Object other) {
+    if (other is! NotificationTapEvent || other.runtimeType != runtimeType) {
+      return false;
+    }
+    if (identical(this, other)) {
+      return true;
+    }
+    return _deepEquals(encode(), other.encode());
+  }
+
+  @override
+  // ignore: avoid_equals_and_hash_code_on_mutable_classes
+  int get hashCode => Object.hashAll(_toList())
+;
+}
+
+
+class _PigeonCodec extends StandardMessageCodec {
+  const _PigeonCodec();
+  @override
+  void writeValue(WriteBuffer buffer, Object? value) {
+    if (value is int) {
+      buffer.putUint8(4);
+      buffer.putInt64(value);
+    }    else if (value is NotificationDataFromLaunch) {
+      buffer.putUint8(129);
+      writeValue(buffer, value.encode());
+    }    else if (value is NotificationTapEvent) {
+      buffer.putUint8(130);
+      writeValue(buffer, value.encode());
+    } else {
+      super.writeValue(buffer, value);
+    }
+  }
+
+  @override
+  Object? readValueOfType(int type, ReadBuffer buffer) {
+    switch (type) {
+      case 129:
+        return NotificationDataFromLaunch.decode(readValue(buffer)!);
+      case 130:
+        return NotificationTapEvent.decode(readValue(buffer)!);
+      default:
+        return super.readValueOfType(type, buffer);
+    }
+  }
+}
+
+const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec());
+
+class NotificationHostApi {
+  /// Constructor for [NotificationHostApi].  The [binaryMessenger] named argument is
+  /// available for dependency injection.  If it is left null, the default
+  /// BinaryMessenger will be used which routes to the host platform.
+  NotificationHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
+      : pigeonVar_binaryMessenger = binaryMessenger,
+        pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
+  final BinaryMessenger? pigeonVar_binaryMessenger;
+
+  static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
+
+  final String pigeonVar_messageChannelSuffix;
+
+  /// Retrieves notification data if the app was launched by tapping on a notification.
+  ///
+  /// Returns `launchOptions.remoteNotification`,
+  /// which is the raw APNs data dictionary
+  /// if the app launch was opened by a notification tap,
+  /// else null. See Apple doc:
+  ///   https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification
+  Future<NotificationDataFromLaunch?> getNotificationDataFromLaunch() async {
+    final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch$pigeonVar_messageChannelSuffix';
+    final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
+      pigeonVar_channelName,
+      pigeonChannelCodec,
+      binaryMessenger: pigeonVar_binaryMessenger,
+    );
+    final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
+    final List<Object?>? pigeonVar_replyList =
+        await pigeonVar_sendFuture as List<Object?>?;
+    if (pigeonVar_replyList == null) {
+      throw _createConnectionError(pigeonVar_channelName);
+    } else if (pigeonVar_replyList.length > 1) {
+      throw PlatformException(
+        code: pigeonVar_replyList[0]! as String,
+        message: pigeonVar_replyList[1] as String?,
+        details: pigeonVar_replyList[2],
+      );
+    } else {
+      return (pigeonVar_replyList[0] as NotificationDataFromLaunch?);
+    }
+  }
+}
+
+Stream<NotificationTapEvent> notificationTapEvents( {String instanceName = ''}) {
+  if (instanceName.isNotEmpty) {
+    instanceName = '.$instanceName';
+  }
+  final EventChannel notificationTapEventsChannel =
+      EventChannel('dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents$instanceName', pigeonMethodCodec);
+  return notificationTapEventsChannel.receiveBroadcastStream().map((dynamic event) {
+    return event as NotificationTapEvent;
+  });
+}
+
diff --git a/lib/model/binding.dart b/lib/model/binding.dart
index fb3add46da..4d1a0adaac 100644
--- a/lib/model/binding.dart
+++ b/lib/model/binding.dart
@@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart' as url_launcher;
 import 'package:wakelock_plus/wakelock_plus.dart' as wakelock_plus;

 import '../host/android_notifications.dart';
+import '../host/notifications.dart' as notif_pigeon;
 import '../log.dart';
 import '../widgets/store.dart';
 import 'store.dart';
@@ -180,6 +181,9 @@ abstract class ZulipBinding {
   /// Wraps the [AndroidNotificationHostApi] constructor.
   AndroidNotificationHostApi get androidNotificationHost;

+  /// Wraps the [notif_pigeon.NotificationHostApi] class.
+  NotificationPigeonApi get notificationPigeonApi;
+
   /// Pick files from the media library, via package:file_picker.
   ///
   /// This wraps [file_picker.pickFiles].
@@ -324,6 +328,19 @@ class PackageInfo {
   });
 }

+// Pigeon generates methods under `@EventChannelApi` annotated classes
+// in global scope of the generated file. This is a helper class to
+// namespace the notification related Pigeon API under a single class.
+class NotificationPigeonApi {
+  final _hostApi = notif_pigeon.NotificationHostApi();
+
+  Future<notif_pigeon.NotificationDataFromLaunch?> getNotificationDataFromLaunch() =>
+    _hostApi.getNotificationDataFromLaunch();
+
+  Stream<notif_pigeon.NotificationTapEvent> notificationTapEventsStream() =>
+    notif_pigeon.notificationTapEvents();
+}
+
 /// A concrete binding for use in the live application.
 ///
 /// The global store returned by [getGlobalStore], and consequently by
@@ -469,6 +486,9 @@ class LiveZulipBinding extends ZulipBinding {
   @override
   AndroidNotificationHostApi get androidNotificationHost => AndroidNotificationHostApi();

+  @override
+  NotificationPigeonApi get notificationPigeonApi => NotificationPigeonApi();
+
   @override
   Future<file_picker.FilePickerResult?> pickFiles({
     bool allowMultiple = false,
diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart
index 6e2585e135..0a3de1689a 100644
--- a/lib/notifications/display.dart
+++ b/lib/notifications/display.dart
@@ -3,24 +3,18 @@ import 'dart:io';

 import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
-import 'package:flutter/widgets.dart' hide Notification;
 import 'package:http/http.dart' as http;

 import '../api/model/model.dart';
 import '../api/notifications.dart';
-import '../generated/l10n/zulip_localizations.dart';
 import '../host/android_notifications.dart';
 import '../log.dart';
 import '../model/binding.dart';
 import '../model/localizations.dart';
 import '../model/narrow.dart';
-import '../widgets/app.dart';
 import '../widgets/color.dart';
-import '../widgets/dialog.dart';
-import '../widgets/message_list.dart';
-import '../widgets/page.dart';
-import '../widgets/store.dart';
 import '../widgets/theme.dart';
+import 'open.dart';

 AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost;

@@ -302,7 +296,7 @@ class NotificationDisplayManager {
           TopicNarrow(streamId, topic),
         FcmMessageDmRecipient(:var allRecipientIds) =>
           DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId),
-      }).buildUrl();
+      }).buildAndroidNotificationUrl();

     await _androidHost.notify(
       id: kNotificationId,
@@ -481,62 +475,6 @@ class NotificationDisplayManager {

   static String _personKey(Uri realmUrl, int userId) => "$realmUrl|$userId";

-  /// Provides the route and the account ID by parsing the notification URL.
-  ///
-  /// The URL must have been generated using [NotificationOpenPayload.buildUrl]
-  /// while creating the notification.
-  ///
-  /// Returns null and shows an error dialog if the associated account is not
-  /// found in the global store.
-  static AccountRoute<void>? routeForNotification({
-    required BuildContext context,
-    required Uri url,
-  }) {
-    assert(defaultTargetPlatform == TargetPlatform.android);
-
-    final globalStore = GlobalStoreWidget.of(context);
-
-    assert(debugLog('got notif: url: $url'));
-    assert(url.scheme == 'zulip' && url.host == 'notification');
-    final payload = NotificationOpenPayload.parseUrl(url);
-
-    final account = globalStore.accounts.firstWhereOrNull(
-      (account) => account.realmUrl.origin == payload.realmUrl.origin
-                && account.userId == payload.userId);
-    if (account == null) { // TODO(log)
-      final zulipLocalizations = ZulipLocalizations.of(context);
-      showErrorDialog(context: context,
-        title: zulipLocalizations.errorNotificationOpenTitle,
-        message: zulipLocalizations.errorNotificationOpenAccountNotFound);
-      return null;
-    }
-
-    return MessageListPage.buildRoute(
-      accountId: account.id,
-      // TODO(#1565): Open at specific message, not just conversation
-      narrow: payload.narrow);
-  }
-
-  /// Navigates to the [MessageListPage] of the specific conversation
-  /// given the `zulip://notification/…` Android intent data URL,
-  /// generated with [NotificationOpenPayload.buildUrl] while creating
-  /// the notification.
-  static Future<void> navigateForNotification(Uri url) async {
-    assert(defaultTargetPlatform == TargetPlatform.android);
-    assert(debugLog('opened notif: url: $url'));
-
-    NavigatorState navigator = await ZulipApp.navigator;
-    final context = navigator.context;
-    assert(context.mounted);
-    if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that
-
-    final route = routeForNotification(context: context, url: url);
-    if (route == null) return; // TODO(log)
-
-    // TODO(nav): Better interact with existing nav stack on notif open
-    unawaited(navigator.push(route));
-  }
-
   static Future<Uint8List?> _fetchBitmap(Uri url) async {
     try {
       // TODO timeout to prevent waiting indefinitely
@@ -550,86 +488,3 @@ class NotificationDisplayManager {
     return null;
   }
 }
-
-/// The information contained in 'zulip://notification/…' internal
-/// Android intent data URL, used for notification-open flow.
-class NotificationOpenPayload {
-  final Uri realmUrl;
-  final int userId;
-  final Narrow narrow;
-
-  NotificationOpenPayload({
-    required this.realmUrl,
-    required this.userId,
-    required this.narrow,
-  });
-
-  factory NotificationOpenPayload.parseUrl(Uri url) {
-    if (url case Uri(
-      scheme: 'zulip',
-      host: 'notification',
-      queryParameters: {
-        'realm_url': var realmUrlStr,
-        'user_id': var userIdStr,
-        'narrow_type': var narrowType,
-        // In case of narrowType == 'topic':
-        // 'channel_id' and 'topic' handled below.
-
-        // In case of narrowType == 'dm':
-        // 'all_recipient_ids' handled below.
-      },
-    )) {
-      final realmUrl = Uri.parse(realmUrlStr);
-      final userId = int.parse(userIdStr, radix: 10);
-
-      final Narrow narrow;
-      switch (narrowType) {
-        case 'topic':
-          final channelIdStr = url.queryParameters['channel_id']!;
-          final channelId = int.parse(channelIdStr, radix: 10);
-          final topicStr = url.queryParameters['topic']!;
-          narrow = TopicNarrow(channelId, TopicName(topicStr));
-        case 'dm':
-          final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!;
-          final allRecipientIds = allRecipientIdsStr.split(',')
-            .map((idStr) => int.parse(idStr, radix: 10))
-            .toList(growable: false);
-          narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId);
-        default:
-          throw const FormatException();
-      }
-
-      return NotificationOpenPayload(
-        realmUrl: realmUrl,
-        userId: userId,
-        narrow: narrow,
-      );
-    } else {
-      // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
-      throw const FormatException();
-    }
-  }
-
-  Uri buildUrl() {
-    return Uri(
-      scheme: 'zulip',
-      host: 'notification',
-      queryParameters: <String, String>{
-        'realm_url': realmUrl.toString(),
-        'user_id': userId.toString(),
-        ...(switch (narrow) {
-          TopicNarrow(streamId: var channelId, :var topic) => {
-            'narrow_type': 'topic',
-            'channel_id': channelId.toString(),
-            'topic': topic.apiName,
-          },
-          DmNarrow(:var allRecipientIds) => {
-            'narrow_type': 'dm',
-            'all_recipient_ids': allRecipientIds.join(','),
-          },
-          _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'),
-        })
-      },
-    );
-  }
-}
diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart
new file mode 100644
index 0000000000..2eb281473c
--- /dev/null
+++ b/lib/notifications/open.dart
@@ -0,0 +1,346 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import '../api/model/model.dart';
+import '../generated/l10n/zulip_localizations.dart';
+import '../host/notifications.dart';
+import '../log.dart';
+import '../model/binding.dart';
+import '../model/narrow.dart';
+import '../widgets/app.dart';
+import '../widgets/dialog.dart';
+import '../widgets/message_list.dart';
+import '../widgets/page.dart';
+import '../widgets/store.dart';
+
+NotificationPigeonApi get _notifPigeonApi => ZulipBinding.instance.notificationPigeonApi;
+
+/// Responds to the user opening a notification.
+class NotificationOpenService {
+  static NotificationOpenService get instance => (_instance ??= NotificationOpenService._());
+  static NotificationOpenService? _instance;
+
+  NotificationOpenService._();
+
+  /// Reset the state of the [NotificationNavigationService], for testing.
+  static void debugReset() {
+    _instance = null;
+  }
+
+  NotificationDataFromLaunch? _notifDataFromLaunch;
+
+  /// A [Future] that completes to signal that the initialization of
+  /// [NotificationNavigationService] has completed
+  /// (with either success or failure).
+  ///
+  /// Null if [start] hasn't been called.
+  Future<void>? get initialized => _initializedSignal?.future;
+
+  Completer<void>? _initializedSignal;
+
+  Future<void> start() async {
+    assert(_initializedSignal == null);
+    _initializedSignal = Completer<void>();
+    try {
+      switch (defaultTargetPlatform) {
+        case TargetPlatform.iOS:
+          _notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch();
+          _notifPigeonApi.notificationTapEventsStream()
+            .listen(_navigateForNotification);
+
+        case TargetPlatform.android:
+          // Do nothing; we do notification routing differently on Android.
+          // TODO migrate Android to use the new Pigeon API.
+          break;
+
+        case TargetPlatform.fuchsia:
+        case TargetPlatform.linux:
+        case TargetPlatform.macOS:
+        case TargetPlatform.windows:
+          // Do nothing; we don't offer notifications on these platforms.
+          break;
+      }
+    } finally {
+      _initializedSignal!.complete();
+    }
+  }
+
+  /// Provides the route to open if the app was launched through a tap on
+  /// a notification.
+  ///
+  /// Returns null if app launch wasn't triggered by a notification, or if
+  /// an error occurs while determining the route for the notification.
+  /// In the latter case an error dialog is also shown.
+  ///
+  /// The context argument should be a descendant of the app's main [Navigator].
+  AccountRoute<void>? routeForNotificationFromLaunch({required BuildContext context}) {
+    assert(defaultTargetPlatform == TargetPlatform.iOS);
+    final data = _notifDataFromLaunch;
+    if (data == null) return null;
+    assert(debugLog('opened notif: ${jsonEncode(data.payload)}'));
+
+    final notifNavData = _tryParseIosApnsPayload(context, data.payload);
+    if (notifNavData == null) return null; // TODO(log)
+
+    return routeForNotification(context: context, data: notifNavData);
+  }
+
+  /// Provides the route to open by parsing the notification payload.
+  ///
+  /// Returns null and shows an error dialog if the associated account is not
+  /// found in the global store.
+  ///
+  /// The context argument should be a descendant of the app's main [Navigator].
+  static AccountRoute<void>? routeForNotification({
+    required BuildContext context,
+    required NotificationOpenPayload data,
+  }) {
+    final globalStore = GlobalStoreWidget.of(context);
+
+    final account = globalStore.accounts.firstWhereOrNull(
+      (account) => account.realmUrl.origin == data.realmUrl.origin
+                && account.userId == data.userId);
+    if (account == null) { // TODO(log)
+      final zulipLocalizations = ZulipLocalizations.of(context);
+      showErrorDialog(context: context,
+        title: zulipLocalizations.errorNotificationOpenTitle,
+        message: zulipLocalizations.errorNotificationOpenAccountNotFound);
+      return null;
+    }
+
+    return MessageListPage.buildRoute(
+      accountId: account.id,
+      // TODO(#1565): Open at specific message, not just conversation
+      narrow: data.narrow);
+  }
+
+  /// Navigates to the [MessageListPage] of the specific conversation
+  /// for the provided payload that was attached while creating the
+  /// notification.
+  static Future<void> _navigateForNotification(NotificationTapEvent event) async {
+    assert(defaultTargetPlatform == TargetPlatform.iOS);
+    assert(debugLog('opened notif: ${jsonEncode(event.payload)}'));
+
+    NavigatorState navigator = await ZulipApp.navigator;
+    final context = navigator.context;
+    assert(context.mounted);
+    if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that
+
+    final notifNavData = _tryParseIosApnsPayload(context, event.payload);
+    if (notifNavData == null) return; // TODO(log)
+    final route = routeForNotification(context: context, data: notifNavData);
+    if (route == null) return; // TODO(log)
+
+    // TODO(nav): Better interact with existing nav stack on notif open
+    unawaited(navigator.push(route));
+  }
+
+  /// Navigates to the [MessageListPage] of the specific conversation
+  /// given the `zulip://notification/…` Android intent data URL,
+  /// generated with [NotificationOpenPayload.buildAndroidNotificationUrl]
+  /// while creating the notification.
+  static Future<void> navigateForAndroidNotificationUrl(Uri url) async {
+    assert(defaultTargetPlatform == TargetPlatform.android);
+    assert(debugLog('opened notif: url: $url'));
+
+    NavigatorState navigator = await ZulipApp.navigator;
+    final context = navigator.context;
+    assert(context.mounted);
+    if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that
+
+    assert(url.scheme == 'zulip' && url.host == 'notification');
+    final data = tryParseAndroidNotificationUrl(context: context, url: url);
+    if (data == null) return; // TODO(log)
+    final route = routeForNotification(context: context, data: data);
+    if (route == null) return; // TODO(log)
+
+    // TODO(nav): Better interact with existing nav stack on notif open
+    unawaited(navigator.push(route));
+  }
+
+  static NotificationOpenPayload? _tryParseIosApnsPayload(
+    BuildContext context,
+    Map<Object?, Object?> payload,
+  ) {
+    try {
+      return NotificationOpenPayload.parseIosApnsPayload(payload);
+    } on FormatException catch (e, st) {
+      assert(debugLog('$e\n$st'));
+      final zulipLocalizations = ZulipLocalizations.of(context);
+      showErrorDialog(context: context,
+        title: zulipLocalizations.errorNotificationOpenTitle);
+      return null;
+    }
+  }
+
+  static NotificationOpenPayload? tryParseAndroidNotificationUrl({
+    required BuildContext context,
+    required Uri url,
+  }) {
+    try {
+      return NotificationOpenPayload.parseAndroidNotificationUrl(url);
+    } on FormatException catch (e, st) {
+      assert(debugLog('$e\n$st'));
+      final zulipLocalizations = ZulipLocalizations.of(context);
+      showErrorDialog(context: context,
+        title: zulipLocalizations.errorNotificationOpenTitle);
+      return null;
+    }
+  }
+}
+
+/// The data from a notification that describes what to do
+/// when the user opens the notification.
+class NotificationOpenPayload {
+  final Uri realmUrl;
+  final int userId;
+  final Narrow narrow;
+
+  NotificationOpenPayload({
+    required this.realmUrl,
+    required this.userId,
+    required this.narrow,
+  });
+
+  /// Parses the iOS APNs payload and retrieves the information
+  /// required for navigation.
+  factory NotificationOpenPayload.parseIosApnsPayload(Map<Object?, Object?> payload) {
+    if (payload case {
+      'zulip': {
+        'user_id': final int userId,
+        'sender_id': final int senderId,
+      } && final zulipData,
+    }) {
+      final eventType = zulipData['event'];
+      if (eventType != null && eventType != 'message') {
+        // On Android, we also receive "remove" notification messages, tagged
+        // with an `event` field with value 'remove'. As of Zulip Server 10,
+        // however, these are not yet sent to iOS devices, and we don't have a
+        // way to handle them even if they were.
+        //
+        // The messages we currently do receive, and can handle, are analogous
+        // to Android notification messages of event type 'message'. On the
+        // assumption that some future version of the Zulip server will send
+        // explicit event types in APNs messages, accept messages with that
+        // `event` value, but no other.
+        throw const FormatException();
+      }
+
+      final realmUrl = switch (zulipData) {
+        {'realm_url': final String value} => value,
+        {'realm_uri': final String value} => value,
+        _ => throw const FormatException(),
+      };
+
+      final narrow = switch (zulipData) {
+        {
+          'recipient_type': 'stream',
+          // TODO(server-5) remove this comment.
+          // We require 'stream_id' here but that is new from Server 5.0,
+          // resulting in failure on pre-5.0 servers.
+          'stream_id': final int streamId,
+          'topic': final String topic,
+        } =>
+          TopicNarrow(streamId, TopicName(topic)),
+
+        {'recipient_type': 'private', 'pm_users': final String pmUsers} =>
+          DmNarrow(
+            allRecipientIds: pmUsers
+              .split(',')
+              .map((e) => int.parse(e, radix: 10))
+              .toList(growable: false)
+              ..sort(),
+            selfUserId: userId),
+
+        {'recipient_type': 'private'} =>
+          DmNarrow.withUser(senderId, selfUserId: userId),
+
+        _ => throw const FormatException(),
+      };
+
+      return NotificationOpenPayload(
+        realmUrl: Uri.parse(realmUrl),
+        userId: userId,
+        narrow: narrow);
+    } else {
+      // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
+      throw const FormatException();
+    }
+  }
+
+  /// Parses the internal Android notification url, that was created using
+  /// [buildAndroidNotificationUrl], and retrieves the information required
+  /// for navigation.
+  factory NotificationOpenPayload.parseAndroidNotificationUrl(Uri url) {
+    if (url case Uri(
+      scheme: 'zulip',
+      host: 'notification',
+      queryParameters: {
+        'realm_url': var realmUrlStr,
+        'user_id': var userIdStr,
+        'narrow_type': var narrowType,
+        // In case of narrowType == 'topic':
+        // 'channel_id' and 'topic' handled below.
+
+        // In case of narrowType == 'dm':
+        // 'all_recipient_ids' handled below.
+      },
+    )) {
+      final realmUrl = Uri.parse(realmUrlStr);
+      final userId = int.parse(userIdStr, radix: 10);
+
+      final Narrow narrow;
+      switch (narrowType) {
+        case 'topic':
+          final channelIdStr = url.queryParameters['channel_id']!;
+          final channelId = int.parse(channelIdStr, radix: 10);
+          final topicStr = url.queryParameters['topic']!;
+          narrow = TopicNarrow(channelId, TopicName(topicStr));
+        case 'dm':
+          final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!;
+          final allRecipientIds = allRecipientIdsStr.split(',')
+            .map((idStr) => int.parse(idStr, radix: 10))
+            .toList(growable: false);
+          narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId);
+        default:
+          throw const FormatException();
+      }
+
+      return NotificationOpenPayload(
+        realmUrl: realmUrl,
+        userId: userId,
+        narrow: narrow,
+      );
+    } else {
+      // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
+      throw const FormatException();
+    }
+  }
+
+  Uri buildAndroidNotificationUrl() {
+    return Uri(
+      scheme: 'zulip',
+      host: 'notification',
+      queryParameters: <String, String>{
+        'realm_url': realmUrl.toString(),
+        'user_id': userId.toString(),
+        ...(switch (narrow) {
+          TopicNarrow(streamId: var channelId, :var topic) => {
+            'narrow_type': 'topic',
+            'channel_id': channelId.toString(),
+            'topic': topic.apiName,
+          },
+          DmNarrow(:var allRecipientIds) => {
+            'narrow_type': 'dm',
+            'all_recipient_ids': allRecipientIds.join(','),
+          },
+          _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'),
+        })
+      },
+    );
+  }
+}
diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart
index d60469ff30..212b0f5f0d 100644
--- a/lib/notifications/receive.dart
+++ b/lib/notifications/receive.dart
@@ -8,6 +8,7 @@ import '../firebase_options.dart';
 import '../log.dart';
 import '../model/binding.dart';
 import 'display.dart';
+import 'open.dart';

 @pragma('vm:entry-point')
 class NotificationService {
@@ -24,6 +25,7 @@ class NotificationService {
     instance.token.dispose();
     _instance = null;
     assert(debugBackgroundIsolateIsLive = true);
+    NotificationOpenService.debugReset();
   }

   /// Whether a background isolate should initialize [LiveZulipBinding].
@@ -77,6 +79,8 @@ class NotificationService {
         await _getFcmToken();

       case TargetPlatform.iOS: // TODO(#324): defer requesting notif permission
+        await NotificationOpenService.instance.start();
+
         await ZulipBinding.instance.firebaseInitializeApp(
           options: kFirebaseOptionsIos);

diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart
index 54ba92588b..b1aa763ac8 100644
--- a/lib/widgets/app.dart
+++ b/lib/widgets/app.dart
@@ -9,7 +9,7 @@ import '../log.dart';
 import '../model/actions.dart';
 import '../model/localizations.dart';
 import '../model/store.dart';
-import '../notifications/display.dart';
+import '../notifications/open.dart';
 import 'about_zulip.dart';
 import 'dialog.dart';
 import 'home.dart';
@@ -168,27 +168,45 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
     super.dispose();
   }

-  List<Route<dynamic>> _handleGenerateInitialRoutes(String initialRoute) {
-    // The `_ZulipAppState.context` lacks the required ancestors. Instead
-    // we use the Navigator which should be available when this callback is
-    // called and it's context should have the required ancestors.
-    final context = ZulipApp.navigatorKey.currentContext!;
+  AccountRoute<void>? _initialRouteIos(BuildContext context) {
+    return NotificationOpenService.instance
+        .routeForNotificationFromLaunch(context: context);
+  }

+  // TODO migrate Android's notification navigation to use the new Pigeon API.
+  AccountRoute<void>? _initialRouteAndroid(
+    BuildContext context,
+    String initialRoute,
+  ) {
     final initialRouteUrl = Uri.tryParse(initialRoute);
     if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) {
-      final route = NotificationDisplayManager.routeForNotification(
+      assert(debugLog('got notif: url: $initialRouteUrl'));
+      final data = NotificationOpenService.tryParseAndroidNotificationUrl(
         context: context,
         url: initialRouteUrl);
+      if (data == null) return null; // TODO(log)
+      return NotificationOpenService.routeForNotification(
+        context: context,
+        data: data);
+    }
+
+    return null;
+  }
+
+  List<Route<dynamic>> _handleGenerateInitialRoutes(String initialRoute) {
+    // The `_ZulipAppState.context` lacks the required ancestors. Instead
+    // we use the Navigator which should be available when this callback is
+    // called and its context should have the required ancestors.
+    final context = ZulipApp.navigatorKey.currentContext!;

-      if (route != null) {
-        return [
-          HomePage.buildRoute(accountId: route.accountId),
-          route,
-        ];
-      } else {
-        // The account didn't match any existing accounts,
-        // fall through to show the default route below.
-      }
+    final route = defaultTargetPlatform == TargetPlatform.iOS
+        ? _initialRouteIos(context)
+        : _initialRouteAndroid(context, initialRoute);
+    if (route != null) {
+      return [
+        HomePage.buildRoute(accountId: route.accountId),
+        route,
+      ];
     }

     final globalStore = GlobalStoreWidget.of(context);
@@ -209,7 +227,7 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
         await LoginPage.handleWebAuthUrl(url);
         return true;
       case Uri(scheme: 'zulip', host: 'notification') && var url:
-        await NotificationDisplayManager.navigateForNotification(url);
+        await NotificationOpenService.navigateForAndroidNotificationUrl(url);
         return true;
     }
     return super.didPushRouteInformation(routeInformation);
@@ -218,6 +236,7 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
   @override
   Widget build(BuildContext context) {
     return GlobalStoreWidget(
+      blockingFuture: NotificationOpenService.instance.initialized,
       child: Builder(builder: (context) {
         return MaterialApp(
           onGenerateTitle: (BuildContext context) {
diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart
index 08ce8f08c7..4d269cddba 100644
--- a/lib/widgets/dialog.dart
+++ b/lib/widgets/dialog.dart
@@ -52,10 +52,12 @@ class DialogStatus<T> {
 ///
 /// Prose in [message] should have final punctuation:
 ///   https://github.com/zulip/zulip-flutter/pull/1498#issuecomment-2853578577
+///
+/// The context argument should be a descendant of the app's main [Navigator].
 // This API is inspired by [ScaffoldManager.showSnackBar].  We wrap
 // [showDialog]'s return value, a [Future], inside [DialogStatus]
 // whose documentation can be accessed.  This helps avoid confusion when
-// intepreting the meaning of the [Future].
+// interpreting the meaning of the [Future].
 DialogStatus<void> showErrorDialog({
   required BuildContext context,
   required String title,
@@ -86,6 +88,8 @@ DialogStatus<void> showErrorDialog({
 /// If the dialog was canceled,
 /// either with the cancel button or by tapping outside the dialog's area,
 /// it completes with null.
+///
+/// The context argument should be a descendant of the app's main [Navigator].
 DialogStatus<bool> showSuggestedActionDialog({
   required BuildContext context,
   required String title,
diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart
index ab287b745a..f5fe4d3cc6 100644
--- a/lib/widgets/store.dart
+++ b/lib/widgets/store.dart
@@ -18,10 +18,18 @@ import 'page.dart';
 class GlobalStoreWidget extends StatefulWidget {
   const GlobalStoreWidget({
     super.key,
+    this.blockingFuture,
     this.placeholder = const LoadingPlaceholder(),
     required this.child,
   });

+  /// An additional future to await before showing the child.
+  ///
+  /// If [blockingFuture] is non-null, then this widget will build [child]
+  /// only after the future completes.  This widget's behavior is not affected
+  /// by whether the future's completion is with a value or with an error.
+  final Future<void>? blockingFuture;
+
   final Widget placeholder;
   final Widget child;

@@ -87,6 +95,9 @@ class _GlobalStoreWidgetState extends State<GlobalStoreWidget> {
     super.initState();
     (() async {
       final store = await ZulipBinding.instance.getGlobalStoreUniquely();
+      if (widget.blockingFuture != null) {
+        await widget.blockingFuture!.catchError((_) {});
+      }
       setState(() {
         this.store = store;
       });
diff --git a/pigeon/android_notifications.dart b/pigeon/android_notifications.dart
new file mode 100644
index 0000000000..901369001c
--- /dev/null
+++ b/pigeon/android_notifications.dart
@@ -0,0 +1,306 @@
+import 'package:pigeon/pigeon.dart';
+
+// To rebuild this pigeon's output after editing this file,
+// run `tools/check pigeon --fix`.
+@ConfigurePigeon(PigeonOptions(
+  dartOut: 'lib/host/android_notifications.g.dart',
+  kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt',
+  kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'),
+))
+
+/// Corresponds to `androidx.core.app.NotificationChannelCompat`
+///
+/// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat
+class NotificationChannel {
+  /// Corresponds to `androidx.core.app.NotificationChannelCompat.Builder`
+  ///
+  /// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat.Builder
+  NotificationChannel({
+    required this.id,
+    required this.importance,
+    this.name,
+    this.lightsEnabled,
+    this.soundUrl,
+    this.vibrationPattern,
+  });
+
+  final String id;
+
+  /// Specifies the importance level of notifications
+  /// to be posted on this channel.
+  ///
+  /// Must be a valid constant from [NotificationImportance].
+  final int importance;
+
+  final String? name;
+  final bool? lightsEnabled;
+  final String? soundUrl;
+  final Int64List? vibrationPattern;
+}
+
+/// Corresponds to `android.content.Intent`
+///
+/// See:
+///   https://developer.android.com/reference/android/content/Intent
+///   https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E)
+class AndroidIntent {
+  AndroidIntent({required this.action, required this.dataUrl, this.flags = 0});
+
+  final String action;
+  final String dataUrl;
+
+  /// A combination of flags from [IntentFlag].
+  final int flags;
+}
+
+/// Corresponds to `android.app.PendingIntent`.
+///
+/// See: https://developer.android.com/reference/android/app/PendingIntent
+class PendingIntent {
+  /// Corresponds to `PendingIntent.getActivity`.
+  PendingIntent({required this.requestCode, required this.intent, required this.flags});
+
+  final int requestCode;
+  final AndroidIntent intent;
+
+  /// A combination of flags from [PendingIntent.flags], and others associated
+  /// with `Intent`; see Android docs for `PendingIntent.getActivity`.
+  final int flags;
+}
+
+/// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle`
+///
+/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle
+class InboxStyle {
+  InboxStyle({required this.summaryText});
+
+  final String summaryText;
+}
+
+/// Corresponds to `androidx.core.app.Person`
+///
+/// See: https://developer.android.com/reference/androidx/core/app/Person
+class Person {
+  Person({
+    required this.iconBitmap,
+    required this.key,
+    required this.name,
+  });
+
+  /// An icon for this person.
+  ///
+  /// This should be compressed image data, in a format to be passed
+  /// to `androidx.core.graphics.drawable.IconCompat.createWithData`.
+  /// Supported formats include JPEG, PNG, and WEBP.
+  ///
+  /// See:
+  ///  https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int)
+  final Uint8List? iconBitmap;
+
+  final String key;
+  final String name;
+}
+
+/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message`
+///
+/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message
+class MessagingStyleMessage {
+  MessagingStyleMessage({
+    required this.text,
+    required this.timestampMs,
+    required this.person,
+  });
+
+  final String text;
+  final int timestampMs;
+  final Person person;
+}
+
+/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle`
+///
+/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle
+class MessagingStyle {
+  MessagingStyle({
+    required this.user,
+    required this.conversationTitle,
+    required this.isGroupConversation,
+    required this.messages,
+  });
+
+  final Person user;
+  final String? conversationTitle;
+  final List<MessagingStyleMessage> messages;
+  final bool isGroupConversation;
+}
+
+/// Corresponds to `android.app.Notification`
+///
+/// See: https://developer.android.com/reference/kotlin/android/app/Notification
+class Notification {
+  Notification({required this.group, required this.extras});
+
+  final String group;
+  final Map<String, String> extras;
+  // Various other properties too; add them if needed.
+}
+
+/// Corresponds to `android.service.notification.StatusBarNotification`
+///
+/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification
+class StatusBarNotification {
+  StatusBarNotification({required this.id, required this.tag, required this.notification});
+
+  final int id;
+  final String tag;
+  final Notification notification;
+
+  // Ignore `groupKey` and `key`.  While the `.groupKey` contains the
+  // `.notification.group`, and the `.key` contains the `.id` and `.tag`,
+  // they also have more stuff added on (and their structure doesn't seem to
+  // be documented.)
+  // final String? groupKey;
+  // final String? key;
+
+  // Various other properties too; add them if needed.
+}
+
+/// Represents details about a notification sound stored in the
+/// shared media store.
+///
+/// Returned as a list entry by
+/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory].
+class StoredNotificationSound {
+  StoredNotificationSound({
+    required this.fileName,
+    required this.isOwned,
+    required this.contentUrl,
+  });
+
+  /// The display name of the sound file.
+  final String fileName;
+
+  /// Specifies whether this file was created by the app.
+  ///
+  /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the
+  /// metadata matches the app's package name.
+  final bool isOwned;
+
+  /// A `content://…` URL pointing to the sound file.
+  final String contentUrl;
+}
+
+@HostApi()
+abstract class AndroidNotificationHostApi {
+  /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`.
+  ///
+  /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat)
+  void createNotificationChannel(NotificationChannel channel);
+
+  /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`.
+  ///
+  /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat()
+  List<NotificationChannel> getNotificationChannels();
+
+  /// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel`
+  ///
+  /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String)
+  void deleteNotificationChannel(String channelId);
+
+  /// The list of notification sound files present under `Notifications/Zulip/`
+  /// in the device's shared media storage,
+  /// found with `android.content.ContentResolver.query`.
+  ///
+  /// This is a complex ad-hoc method.
+  /// For detailed behavior, see its implementation.
+  ///
+  /// Requires minimum of Android 10 (API 29) or higher.
+  ///
+  /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String)
+  List<StoredNotificationSound> listStoredSoundsInNotificationsDirectory();
+
+  /// Wraps `android.content.ContentResolver.insert` combined with
+  /// `android.content.ContentResolver.openOutputStream` and
+  /// `android.content.res.Resources.openRawResource`.
+  ///
+  /// Copies a raw resource audio file to `Notifications/Zulip/`
+  /// directory in device's shared media storage. Returns the URL
+  /// of the target file in media store.
+  ///
+  /// Requires minimum of Android 10 (API 29) or higher.
+  ///
+  /// See:
+  ///   https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues)
+  ///   https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri)
+  ///   https://developer.android.com/reference/android/content/res/Resources#openRawResource(int)
+  String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName});
+
+  /// Corresponds to `android.app.NotificationManager.notify`,
+  /// combined with `androidx.core.app.NotificationCompat.Builder`.
+  ///
+  /// The arguments `tag` and `id` go to the `notify` call.
+  /// The rest go to method calls on the builder.
+  ///
+  /// The `color` should be in the form 0xAARRGGBB.
+  /// See [ColorExtension.argbInt].
+  ///
+  /// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier`
+  /// to get a resource ID to pass to `Builder.setSmallIcon`.
+  /// Whatever name is passed there must appear in keep.xml too:
+  /// see https://github.com/zulip/zulip-flutter/issues/528 .
+  ///
+  /// See:
+  ///   https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify
+  ///   https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder
+  // TODO(pigeon): Try ProxyApi for Notification objects, once that exists for Kotlin.
+  //   As of 2024-03, ProxyApi is actively being implemented; the Dart side just landed.
+  //   https://github.com/flutter/flutter/issues/134777
+  void notify({
+    String? tag,
+    required int id,
+
+    // The remaining arguments go to method calls on NotificationCompat.Builder.
+    bool? autoCancel,
+    required String channelId,
+    int? color,
+    PendingIntent? contentIntent,
+    String? contentText,
+    String? contentTitle,
+    Map<String, String>? extras,
+    String? groupKey,
+    InboxStyle? inboxStyle,
+    bool? isGroupSummary,
+    MessagingStyle? messagingStyle,
+    int? number,
+    String? smallIconResourceName,
+    // NotificationCompat.Builder has lots more methods; add as needed.
+    // Keep them alphabetized, for easy comparison with that class's docs.
+  });
+
+  /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`,
+  /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`.
+  ///
+  /// Returns the messaging style, if any, of an active notification
+  /// that has tag `tag`.  If there are several such notifications,
+  /// an arbitrary one of them is used.
+  /// Returns null if there are no such notifications.
+  ///
+  /// See:
+  ///   https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications()
+  ///   https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification)
+  MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag);
+
+  /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`.
+  ///
+  /// The keys of entries to fetch from notification's extras bundle must be
+  /// specified in the [desiredExtras] list. If this list is empty, then
+  /// [Notifications.extras] will also be empty. If value of the matched entry
+  /// is not of type string or is null, then that entry will be skipped.
+  ///
+  /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications()
+  List<StatusBarNotification> getActiveNotifications({required List<String> desiredExtras});
+
+  /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`.
+  ///
+  /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int)
+  void cancel({String? tag, required int id});
+}
diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart
index 708ae4efb5..66c1bd2e71 100644
--- a/pigeon/notifications.dart
+++ b/pigeon/notifications.dart
@@ -3,304 +3,51 @@ import 'package:pigeon/pigeon.dart';
 // To rebuild this pigeon's output after editing this file,
 // run `tools/check pigeon --fix`.
 @ConfigurePigeon(PigeonOptions(
-  dartOut: 'lib/host/android_notifications.g.dart',
-  kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt',
-  kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'),
+  dartOut: 'lib/host/notifications.g.dart',
+  swiftOut: 'ios/Runner/Notifications.g.swift',
 ))

-/// Corresponds to `androidx.core.app.NotificationChannelCompat`
-///
-/// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat
-class NotificationChannel {
-  /// Corresponds to `androidx.core.app.NotificationChannelCompat.Builder`
-  ///
-  /// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat.Builder
-  NotificationChannel({
-    required this.id,
-    required this.importance,
-    this.name,
-    this.lightsEnabled,
-    this.soundUrl,
-    this.vibrationPattern,
-  });
-
-  final String id;
+class NotificationDataFromLaunch {
+  const NotificationDataFromLaunch({required this.payload});

-  /// Specifies the importance level of notifications
-  /// to be posted on this channel.
+  /// The raw payload that is attached to the notification,
+  /// holding the information required to carry out the navigation.
   ///
-  /// Must be a valid constant from [NotificationImportance].
-  final int importance;
-
-  final String? name;
-  final bool? lightsEnabled;
-  final String? soundUrl;
-  final Int64List? vibrationPattern;
-}
-
-/// Corresponds to `android.content.Intent`
-///
-/// See:
-///   https://developer.android.com/reference/android/content/Intent
-///   https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E)
-class AndroidIntent {
-  AndroidIntent({required this.action, required this.dataUrl, this.flags = 0});
-
-  final String action;
-  final String dataUrl;
-
-  /// A combination of flags from [IntentFlag].
-  final int flags;
-}
-
-/// Corresponds to `android.app.PendingIntent`.
-///
-/// See: https://developer.android.com/reference/android/app/PendingIntent
-class PendingIntent {
-  /// Corresponds to `PendingIntent.getActivity`.
-  PendingIntent({required this.requestCode, required this.intent, required this.flags});
-
-  final int requestCode;
-  final AndroidIntent intent;
-
-  /// A combination of flags from [PendingIntent.flags], and others associated
-  /// with `Intent`; see Android docs for `PendingIntent.getActivity`.
-  final int flags;
-}
-
-/// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle`
-///
-/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle
-class InboxStyle {
-  InboxStyle({required this.summaryText});
-
-  final String summaryText;
+  /// See [NotificationHostApi.getNotificationDataFromLaunch].
+  final Map<Object?, Object?> payload;
 }

-/// Corresponds to `androidx.core.app.Person`
-///
-/// See: https://developer.android.com/reference/androidx/core/app/Person
-class Person {
-  Person({
-    required this.iconBitmap,
-    required this.key,
-    required this.name,
-  });
+class NotificationTapEvent {
+  const NotificationTapEvent({required this.payload});

-  /// An icon for this person.
+  /// The raw payload that is attached to the notification,
+  /// holding the information required to carry out the navigation.
   ///
-  /// This should be compressed image data, in a format to be passed
-  /// to `androidx.core.graphics.drawable.IconCompat.createWithData`.
-  /// Supported formats include JPEG, PNG, and WEBP.
-  ///
-  /// See:
-  ///  https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int)
-  final Uint8List? iconBitmap;
-
-  final String key;
-  final String name;
-}
-
-/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message`
-///
-/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message
-class MessagingStyleMessage {
-  MessagingStyleMessage({
-    required this.text,
-    required this.timestampMs,
-    required this.person,
-  });
-
-  final String text;
-  final int timestampMs;
-  final Person person;
-}
-
-/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle`
-///
-/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle
-class MessagingStyle {
-  MessagingStyle({
-    required this.user,
-    required this.conversationTitle,
-    required this.isGroupConversation,
-    required this.messages,
-  });
-
-  final Person user;
-  final String? conversationTitle;
-  final List<MessagingStyleMessage> messages;
-  final bool isGroupConversation;
-}
-
-/// Corresponds to `android.app.Notification`
-///
-/// See: https://developer.android.com/reference/kotlin/android/app/Notification
-class Notification {
-  Notification({required this.group, required this.extras});
-
-  final String group;
-  final Map<String, String> extras;
-  // Various other properties too; add them if needed.
-}
-
-/// Corresponds to `android.service.notification.StatusBarNotification`
-///
-/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification
-class StatusBarNotification {
-  StatusBarNotification({required this.id, required this.tag, required this.notification});
-
-  final int id;
-  final String tag;
-  final Notification notification;
-
-  // Ignore `groupKey` and `key`.  While the `.groupKey` contains the
-  // `.notification.group`, and the `.key` contains the `.id` and `.tag`,
-  // they also have more stuff added on (and their structure doesn't seem to
-  // be documented.)
-  // final String? groupKey;
-  // final String? key;
-
-  // Various other properties too; add them if needed.
-}
-
-/// Represents details about a notification sound stored in the
-/// shared media store.
-///
-/// Returned as a list entry by
-/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory].
-class StoredNotificationSound {
-  StoredNotificationSound({
-    required this.fileName,
-    required this.isOwned,
-    required this.contentUrl,
-  });
-
-  /// The display name of the sound file.
-  final String fileName;
-
-  /// Specifies whether this file was created by the app.
-  ///
-  /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the
-  /// metadata matches the app's package name.
-  final bool isOwned;
-
-  /// A `content://…` URL pointing to the sound file.
-  final String contentUrl;
+  /// See [notificationTapEvents].
+  final Map<Object?, Object?> payload;
 }

 @HostApi()
-abstract class AndroidNotificationHostApi {
-  /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`.
-  ///
-  /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat)
-  void createNotificationChannel(NotificationChannel channel);
-
-  /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`.
-  ///
-  /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat()
-  List<NotificationChannel> getNotificationChannels();
-
-  /// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel`
-  ///
-  /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String)
-  void deleteNotificationChannel(String channelId);
-
-  /// The list of notification sound files present under `Notifications/Zulip/`
-  /// in the device's shared media storage,
-  /// found with `android.content.ContentResolver.query`.
-  ///
-  /// This is a complex ad-hoc method.
-  /// For detailed behavior, see its implementation.
-  ///
-  /// Requires minimum of Android 10 (API 29) or higher.
-  ///
-  /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String)
-  List<StoredNotificationSound> listStoredSoundsInNotificationsDirectory();
-
-  /// Wraps `android.content.ContentResolver.insert` combined with
-  /// `android.content.ContentResolver.openOutputStream` and
-  /// `android.content.res.Resources.openRawResource`.
-  ///
-  /// Copies a raw resource audio file to `Notifications/Zulip/`
-  /// directory in device's shared media storage. Returns the URL
-  /// of the target file in media store.
-  ///
-  /// Requires minimum of Android 10 (API 29) or higher.
-  ///
-  /// See:
-  ///   https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues)
-  ///   https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri)
-  ///   https://developer.android.com/reference/android/content/res/Resources#openRawResource(int)
-  String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName});
-
-  /// Corresponds to `android.app.NotificationManager.notify`,
-  /// combined with `androidx.core.app.NotificationCompat.Builder`.
-  ///
-  /// The arguments `tag` and `id` go to the `notify` call.
-  /// The rest go to method calls on the builder.
-  ///
-  /// The `color` should be in the form 0xAARRGGBB.
-  /// See [ColorExtension.argbInt].
-  ///
-  /// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier`
-  /// to get a resource ID to pass to `Builder.setSmallIcon`.
-  /// Whatever name is passed there must appear in keep.xml too:
-  /// see https://github.com/zulip/zulip-flutter/issues/528 .
-  ///
-  /// See:
-  ///   https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify
-  ///   https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder
-  // TODO(pigeon): Try ProxyApi for Notification objects, once that exists for Kotlin.
-  //   As of 2024-03, ProxyApi is actively being implemented; the Dart side just landed.
-  //   https://github.com/flutter/flutter/issues/134777
-  void notify({
-    String? tag,
-    required int id,
-
-    // The remaining arguments go to method calls on NotificationCompat.Builder.
-    bool? autoCancel,
-    required String channelId,
-    int? color,
-    PendingIntent? contentIntent,
-    String? contentText,
-    String? contentTitle,
-    Map<String, String>? extras,
-    String? groupKey,
-    InboxStyle? inboxStyle,
-    bool? isGroupSummary,
-    MessagingStyle? messagingStyle,
-    int? number,
-    String? smallIconResourceName,
-    // NotificationCompat.Builder has lots more methods; add as needed.
-    // Keep them alphabetized, for easy comparison with that class's docs.
-  });
-
-  /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`,
-  /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`.
-  ///
-  /// Returns the messaging style, if any, of an active notification
-  /// that has tag `tag`.  If there are several such notifications,
-  /// an arbitrary one of them is used.
-  /// Returns null if there are no such notifications.
-  ///
-  /// See:
-  ///   https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications()
-  ///   https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification)
-  MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag);
-
-  /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`.
-  ///
-  /// The keys of entries to fetch from notification's extras bundle must be
-  /// specified in the [desiredExtras] list. If this list is empty, then
-  /// [Notifications.extras] will also be empty. If value of the matched entry
-  /// is not of type string or is null, then that entry will be skipped.
-  ///
-  /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications()
-  List<StatusBarNotification> getActiveNotifications({required List<String> desiredExtras});
-
-  /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`.
-  ///
-  /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int)
-  void cancel({String? tag, required int id});
+abstract class NotificationHostApi {
+  /// Retrieves notification data if the app was launched by tapping on a notification.
+  ///
+  /// Returns `launchOptions.remoteNotification`,
+  /// which is the raw APNs data dictionary
+  /// if the app launch was opened by a notification tap,
+  /// else null. See Apple doc:
+  ///   https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification
+  NotificationDataFromLaunch? getNotificationDataFromLaunch();
+}
+
+@EventChannelApi()
+abstract class NotificationEventChannelApi {
+  /// An event stream that emits a notification payload when the app
+  /// encounters a notification tap, while the app is running.
+  ///
+  /// Emits an event when
+  /// `userNotificationCenter(_:didReceive:withCompletionHandler:)` gets
+  /// called, indicating that the user has tapped on a notification. The
+  /// emitted payload will be the raw APNs data dictionary from the
+  /// `UNNotificationResponse` passed to that method.
+  NotificationTapEvent notificationTapEvents();
 }

diff --git a/test/model/binding.dart b/test/model/binding.dart
index 2c70b68826..839242c1ce 100644
--- a/test/model/binding.dart
+++ b/test/model/binding.dart
@@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
 import 'package:test/fake.dart';
 import 'package:url_launcher/url_launcher.dart' as url_launcher;
 import 'package:zulip/host/android_notifications.dart';
+import 'package:zulip/host/notifications.dart';
 import 'package:zulip/model/binding.dart';
 import 'package:zulip/model/store.dart';
 import 'package:zulip/widgets/app.dart';
@@ -311,14 +312,18 @@ class TestZulipBinding extends ZulipBinding {

   void _resetNotifications() {
     _androidNotificationHostApi = null;
+    _notificationPigeonApi = null;
   }

+  @override
+  FakeAndroidNotificationHostApi get androidNotificationHost =>
+    (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi());
   FakeAndroidNotificationHostApi? _androidNotificationHostApi;

   @override
-  FakeAndroidNotificationHostApi get androidNotificationHost {
-    return (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi());
-  }
+  FakeNotificationPigeonApi get notificationPigeonApi =>
+    (_notificationPigeonApi ??= FakeNotificationPigeonApi());
+  FakeNotificationPigeonApi? _notificationPigeonApi;

   /// The value that `ZulipBinding.instance.pickFiles()` should return.
   ///
@@ -756,6 +761,32 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi {
   }
 }

+class FakeNotificationPigeonApi implements NotificationPigeonApi {
+  NotificationDataFromLaunch? _notificationDataFromLaunch;
+
+  /// Populates the notification data for launch to be returned
+  /// by [getNotificationDataFromLaunch].
+  void setNotificationDataFromLaunch(NotificationDataFromLaunch? data) {
+    _notificationDataFromLaunch = data;
+  }
+
+  @override
+  Future<NotificationDataFromLaunch?> getNotificationDataFromLaunch() async =>
+    _notificationDataFromLaunch;
+
+  StreamController<NotificationTapEvent>? _notificationTapEventsStreamController;
+
+  void addNotificationTapEvent(NotificationTapEvent event) {
+    _notificationTapEventsStreamController!.add(event);
+  }
+
+  @override
+  Stream<NotificationTapEvent> notificationTapEventsStream() {
+    _notificationTapEventsStreamController ??= StreamController();
+    return _notificationTapEventsStreamController!.stream;
+  }
+}
+
 typedef AndroidNotificationHostApiNotifyCall = ({
   String? tag,
   int id,
diff --git a/test/model/store_test.dart b/test/model/store_test.dart
index 0b303b53e2..c3aadb1171 100644
--- a/test/model/store_test.dart
+++ b/test/model/store_test.dart
@@ -1293,8 +1293,8 @@ void main() {
       // (This is probably the common case.)
       addTearDown(testBinding.reset);
       testBinding.firebaseMessagingInitialToken = '012abc';
-      addTearDown(NotificationService.debugReset);
       testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter');
+      addTearDown(NotificationService.debugReset);
       await NotificationService.instance.start();

       // On store startup, send the token.
@@ -1321,8 +1321,8 @@ void main() {
       // request for the token is still pending.
       addTearDown(testBinding.reset);
       testBinding.firebaseMessagingInitialToken = '012abc';
-      addTearDown(NotificationService.debugReset);
       testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter');
+      addTearDown(NotificationService.debugReset);
       final startFuture = NotificationService.instance.start();

       // TODO this test is a bit brittle in its interaction with asynchrony;
diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart
index ffb5d345d8..7fe04c73ee 100644
--- a/test/notifications/display_test.dart
+++ b/test/notifications/display_test.dart
@@ -1,4 +1,3 @@
-import 'dart:async';
 import 'dart:io';
 import 'dart:typed_data';

@@ -6,7 +5,6 @@ import 'package:checks/checks.dart';
 import 'package:collection/collection.dart';
 import 'package:fake_async/fake_async.dart';
 import 'package:firebase_messaging/firebase_messaging.dart';
-import 'package:flutter/material.dart' hide Notification;
 import 'package:flutter_test/flutter_test.dart';
 import 'package:http/http.dart' as http;
 import 'package:http/testing.dart' as http_testing;
@@ -18,24 +16,15 @@ import 'package:zulip/model/localizations.dart';
 import 'package:zulip/model/narrow.dart';
 import 'package:zulip/model/store.dart';
 import 'package:zulip/notifications/display.dart';
+import 'package:zulip/notifications/open.dart';
 import 'package:zulip/notifications/receive.dart';
-import 'package:zulip/widgets/app.dart';
 import 'package:zulip/widgets/color.dart';
-import 'package:zulip/widgets/home.dart';
-import 'package:zulip/widgets/message_list.dart';
-import 'package:zulip/widgets/page.dart';
 import 'package:zulip/widgets/theme.dart';

 import '../example_data.dart' as eg;
 import '../fake_async.dart';
 import '../model/binding.dart';
-import '../model/narrow_checks.dart';
-import '../stdlib_checks.dart';
 import '../test_images.dart';
-import '../test_navigation.dart';
-import '../widgets/dialog_checks.dart';
-import '../widgets/message_list_checks.dart';
-import '../widgets/page_checks.dart';

 MessageFcmMessage messageFcmMessage(
   Message zulipMessage, {
@@ -354,7 +343,7 @@ void main() {
           TopicNarrow(streamId, topic),
         FcmMessageDmRecipient(:var allRecipientIds) =>
           DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId),
-      }).buildUrl();
+      }).buildAndroidNotificationUrl();

       final messageStyleMessagesChecks =
         messageStyleMessages.mapIndexed((i, messageData) {
@@ -1034,423 +1023,6 @@ void main() {
       check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
     })));
   });
-
-  group('NotificationDisplayManager open', () {
-    late List<Route<void>> pushedRoutes;
-
-    void takeStartingRoutes({Account? account, bool withAccount = true}) {
-      account ??= eg.selfAccount;
-      final expected = <Condition<Object?>>[
-        if (withAccount)
-          (it) => it.isA<MaterialAccountWidgetRoute>()
-            ..accountId.equals(account!.id)
-            ..page.isA<HomePage>()
-        else
-          (it) => it.isA<WidgetRoute>().page.isA<ChooseAccountPage>(),
-      ];
-      check(pushedRoutes.take(expected.length)).deepEquals(expected);
-      pushedRoutes.removeRange(0, expected.length);
-    }
-
-    Future<void> prepare(WidgetTester tester,
-        {bool early = false, bool withAccount = true}) async {
-      await init(addSelfAccount: false);
-      pushedRoutes = [];
-      final testNavObserver = TestNavigatorObserver()
-        ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
-      // This uses [ZulipApp] instead of [TestZulipApp] because notification
-      // logic uses `await ZulipApp.navigator`.
-      await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver]));
-      if (early) {
-        check(pushedRoutes).isEmpty();
-        return;
-      }
-      await tester.pump();
-      takeStartingRoutes(withAccount: withAccount);
-      check(pushedRoutes).isEmpty();
-    }
-
-    Future<void> openNotification(WidgetTester tester, Account account, Message message) async {
-      final data = messageFcmMessage(message, account: account);
-      final intentDataUrl = NotificationOpenPayload(
-        realmUrl: data.realmUrl,
-        userId: data.userId,
-        narrow: switch (data.recipient) {
-        FcmMessageChannelRecipient(:var streamId, :var topic) =>
-          TopicNarrow(streamId, topic),
-        FcmMessageDmRecipient(:var allRecipientIds) =>
-          DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId),
-      }).buildUrl();
-      unawaited(
-        WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString()));
-      await tester.idle(); // let navigateForNotification find navigator
-    }
-
-    void matchesNavigation(Subject<Route<void>> route, Account account, Message message) {
-      route.isA<MaterialAccountWidgetRoute>()
-        ..accountId.equals(account.id)
-        ..page.isA<MessageListPage>()
-          .initNarrow.equals(SendableNarrow.ofMessage(message,
-            selfUserId: account.userId));
-    }
-
-    Future<void> checkOpenNotification(WidgetTester tester, Account account, Message message) async {
-      await openNotification(tester, account, message);
-      matchesNavigation(check(pushedRoutes).single, account, message);
-      pushedRoutes.clear();
-    }
-
-    testWidgets('stream message', (tester) async {
-      addTearDown(testBinding.reset);
-      await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
-      await prepare(tester);
-      await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage());
-    });
-
-    testWidgets('direct message', (tester) async {
-      addTearDown(testBinding.reset);
-      await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
-      await prepare(tester);
-      await checkOpenNotification(tester, eg.selfAccount,
-        eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]));
-    });
-
-    testWidgets('account queried by realmUrl origin component', (tester) async {
-      addTearDown(testBinding.reset);
-      await testBinding.globalStore.add(
-        eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')),
-        eg.initialSnapshot());
-      await prepare(tester);
-
-      await checkOpenNotification(tester,
-        eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example/')),
-        eg.streamMessage());
-      await checkOpenNotification(tester,
-        eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')),
-        eg.streamMessage());
-    });
-
-    testWidgets('no accounts', (tester) async {
-      await prepare(tester, withAccount: false);
-      await openNotification(tester, eg.selfAccount, eg.streamMessage());
-      await tester.pump();
-      check(pushedRoutes.single).isA<DialogRoute<void>>();
-      await tester.tap(find.byWidget(checkErrorDialog(tester,
-        expectedTitle: zulipLocalizations.errorNotificationOpenTitle,
-        expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound)));
-    });
-
-    testWidgets('mismatching account', (tester) async {
-      addTearDown(testBinding.reset);
-      await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
-      await prepare(tester);
-      await openNotification(tester, eg.otherAccount, eg.streamMessage());
-      await tester.pump();
-      check(pushedRoutes.single).isA<DialogRoute<void>>();
-      await tester.tap(find.byWidget(checkErrorDialog(tester,
-        expectedTitle: zulipLocalizations.errorNotificationOpenTitle,
-        expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound)));
-    });
-
-    testWidgets('find account among several', (tester) async {
-      addTearDown(testBinding.reset);
-      final realmUrlA = Uri.parse('https://a-chat.example/');
-      final realmUrlB = Uri.parse('https://chat-b.example/');
-      final user1 = eg.user();
-      final user2 = eg.user();
-      final accounts = [
-        eg.account(id: 1001, realmUrl: realmUrlA, user: user1),
-        eg.account(id: 1002, realmUrl: realmUrlA, user: user2),
-        eg.account(id: 1003, realmUrl: realmUrlB, user: user1),
-        eg.account(id: 1004, realmUrl: realmUrlB, user: user2),
-      ];
-      for (final account in accounts) {
-        await testBinding.globalStore.add(account, eg.initialSnapshot());
-      }
-      await prepare(tester);
-
-      await checkOpenNotification(tester, accounts[0], eg.streamMessage());
-      await checkOpenNotification(tester, accounts[1], eg.streamMessage());
-      await checkOpenNotification(tester, accounts[2], eg.streamMessage());
-      await checkOpenNotification(tester, accounts[3], eg.streamMessage());
-    });
-
-    testWidgets('wait for app to become ready', (tester) async {
-      addTearDown(testBinding.reset);
-      await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
-      await prepare(tester, early: true);
-      final message = eg.streamMessage();
-      await openNotification(tester, eg.selfAccount, message);
-      // The app should still not be ready (or else this test won't work right).
-      check(ZulipApp.ready.value).isFalse();
-      check(ZulipApp.navigatorKey.currentState).isNull();
-      // And the openNotification hasn't caused any navigation yet.
-      check(pushedRoutes).isEmpty();
-
-      // Now let the GlobalStore get loaded and the app's main UI get mounted.
-      await tester.pump();
-      // The navigator first pushes the starting routes…
-      takeStartingRoutes();
-      // … and then the one the notification leads to.
-      matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message);
-    });
-
-    testWidgets('at app launch', (tester) async {
-      addTearDown(testBinding.reset);
-      // Set up a value for `PlatformDispatcher.defaultRouteName` to return,
-      // for determining the intial route.
-      final account = eg.selfAccount;
-      final message = eg.streamMessage();
-      final data = messageFcmMessage(message, account: account);
-      final intentDataUrl = NotificationOpenPayload(
-        realmUrl: data.realmUrl,
-        userId: data.userId,
-        narrow: switch (data.recipient) {
-          FcmMessageChannelRecipient(:var streamId, :var topic) =>
-            TopicNarrow(streamId, topic),
-          FcmMessageDmRecipient(:var allRecipientIds) =>
-            DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId),
-        }).buildUrl();
-      addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue);
-      tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString();
-
-      // Now start the app.
-      await testBinding.globalStore.add(account, eg.initialSnapshot());
-      await prepare(tester, early: true);
-      check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet
-
-      // Once the app is ready, we navigate to the conversation.
-      await tester.pump();
-      takeStartingRoutes();
-      matchesNavigation(check(pushedRoutes).single, account, message);
-    });
-
-    testWidgets('uses associated account as initial account; if initial route', (tester) async {
-      addTearDown(testBinding.reset);
-
-      final accountA = eg.selfAccount;
-      final accountB = eg.otherAccount;
-      final message = eg.streamMessage();
-      final data = messageFcmMessage(message, account: accountB);
-      await testBinding.globalStore.add(accountA, eg.initialSnapshot());
-      await testBinding.globalStore.add(accountB, eg.initialSnapshot());
-
-      final intentDataUrl = NotificationOpenPayload(
-        realmUrl: data.realmUrl,
-        userId: data.userId,
-        narrow: switch (data.recipient) {
-          FcmMessageChannelRecipient(:var streamId, :var topic) =>
-            TopicNarrow(streamId, topic),
-          FcmMessageDmRecipient(:var allRecipientIds) =>
-            DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId),
-        }).buildUrl();
-      addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue);
-      tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString();
-
-      await prepare(tester, early: true);
-      check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet
-
-      await tester.pump();
-      takeStartingRoutes(account: accountB);
-      matchesNavigation(check(pushedRoutes).single, accountB, message);
-    });
-  });
-
-  group('NotificationOpenPayload', () {
-    test('smoke round-trip', () {
-      // DM narrow
-      var payload = NotificationOpenPayload(
-        realmUrl: Uri.parse('http://chat.example'),
-        userId: 1001,
-        narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001),
-      );
-      var url = payload.buildUrl();
-      check(NotificationOpenPayload.parseUrl(url))
-        ..realmUrl.equals(payload.realmUrl)
-        ..userId.equals(payload.userId)
-        ..narrow.equals(payload.narrow);
-
-      // Topic narrow
-      payload = NotificationOpenPayload(
-        realmUrl: Uri.parse('http://chat.example'),
-        userId: 1001,
-        narrow: eg.topicNarrow(1, 'topic A'),
-      );
-      url = payload.buildUrl();
-      check(NotificationOpenPayload.parseUrl(url))
-        ..realmUrl.equals(payload.realmUrl)
-        ..userId.equals(payload.userId)
-        ..narrow.equals(payload.narrow);
-    });
-
-    test('buildUrl: smoke DM', () {
-      final url = NotificationOpenPayload(
-        realmUrl: Uri.parse('http://chat.example'),
-        userId: 1001,
-        narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001),
-      ).buildUrl();
-      check(url)
-        ..scheme.equals('zulip')
-        ..host.equals('notification')
-        ..queryParameters.deepEquals({
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          'narrow_type': 'dm',
-          'all_recipient_ids': '1001,1002',
-        });
-    });
-
-    test('buildUrl: smoke topic', () {
-      final url = NotificationOpenPayload(
-        realmUrl: Uri.parse('http://chat.example'),
-        userId: 1001,
-        narrow: eg.topicNarrow(1, 'topic A'),
-      ).buildUrl();
-      check(url)
-        ..scheme.equals('zulip')
-        ..host.equals('notification')
-        ..queryParameters.deepEquals({
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          'narrow_type': 'topic',
-          'channel_id': '1',
-          'topic': 'topic A',
-        });
-    });
-
-    test('parse: smoke DM', () {
-      final url = Uri(
-        scheme: 'zulip',
-        host: 'notification',
-        queryParameters: <String, String>{
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          'narrow_type': 'dm',
-          'all_recipient_ids': '1001,1002',
-        });
-      check(NotificationOpenPayload.parseUrl(url))
-        ..realmUrl.equals(Uri.parse('http://chat.example'))
-        ..userId.equals(1001)
-        ..narrow.which((it) => it.isA<DmNarrow>()
-          ..allRecipientIds.deepEquals([1001, 1002])
-          ..otherRecipientIds.deepEquals([1002]));
-    });
-
-    test('parse: smoke topic', () {
-      final url = Uri(
-        scheme: 'zulip',
-        host: 'notification',
-        queryParameters: <String, String>{
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          'narrow_type': 'topic',
-          'channel_id': '1',
-          'topic': 'topic A',
-        });
-      check(NotificationOpenPayload.parseUrl(url))
-        ..realmUrl.equals(Uri.parse('http://chat.example'))
-        ..userId.equals(1001)
-        ..narrow.which((it) => it.isA<TopicNarrow>()
-          ..streamId.equals(1)
-          ..topic.equals(eg.t('topic A')));
-    });
-
-    test('parse: fails when missing any expected query parameters', () {
-      final testCases = <Map<String, String>>[
-        {
-          // 'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          'narrow_type': 'topic',
-          'channel_id': '1',
-          'topic': 'topic A',
-        },
-        {
-          'realm_url': 'http://chat.example',
-          // 'user_id': '1001',
-          'narrow_type': 'topic',
-          'channel_id': '1',
-          'topic': 'topic A',
-        },
-        {
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          // 'narrow_type': 'topic',
-          'channel_id': '1',
-          'topic': 'topic A',
-        },
-        {
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          'narrow_type': 'topic',
-          // 'channel_id': '1',
-          'topic': 'topic A',
-        },
-        {
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          'narrow_type': 'topic',
-          'channel_id': '1',
-          // 'topic': 'topic A',
-        },
-        {
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          // 'narrow_type': 'dm',
-          'all_recipient_ids': '1001,1002',
-        },
-        {
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          'narrow_type': 'dm',
-          // 'all_recipient_ids': '1001,1002',
-        },
-      ];
-      for (final params in testCases) {
-        check(() => NotificationOpenPayload.parseUrl(Uri(
-          scheme: 'zulip',
-          host: 'notification',
-          queryParameters: params,
-        )))
-          // Missing 'realm_url', 'user_id' and 'narrow_type'
-          // throws 'FormatException'.
-          // Missing 'channel_id', 'topic', when narrow_type == 'topic'
-          // throws 'TypeError'.
-          // Missing 'all_recipient_ids', when narrow_type == 'dm'
-          // throws 'TypeError'.
-          .throws<Object>();
-      }
-    });
-
-    test('parse: fails when scheme is not "zulip"', () {
-      final url = Uri(
-        scheme: 'http',
-        host: 'notification',
-        queryParameters: <String, String>{
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          'narrow_type': 'topic',
-          'channel_id': '1',
-          'topic': 'topic A',
-        });
-      check(() => NotificationOpenPayload.parseUrl(url))
-        .throws<FormatException>();
-    });
-
-    test('parse: fails when host is not "notification"', () {
-      final url = Uri(
-        scheme: 'zulip',
-        host: 'example',
-        queryParameters: <String, String>{
-          'realm_url': 'http://chat.example',
-          'user_id': '1001',
-          'narrow_type': 'topic',
-          'channel_id': '1',
-          'topic': 'topic A',
-        });
-      check(() => NotificationOpenPayload.parseUrl(url))
-        .throws<FormatException>();
-    });
-  });
 }

 extension on Subject<CopySoundResourceToMediaStoreCall> {
@@ -1530,9 +1102,3 @@ extension on Subject<StatusBarNotification> {
   Subject<Notification> get notification => has((x) => x.notification, 'notification');
   Subject<String> get tag => has((x) => x.tag, 'tag');
 }
-
-extension on Subject<NotificationOpenPayload> {
-  Subject<Uri> get realmUrl => has((x) => x.realmUrl, 'realmUrl');
-  Subject<int> get userId => has((x) => x.userId, 'userId');
-  Subject<Narrow> get narrow => has((x) => x.narrow, 'narrow');
-}
diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart
new file mode 100644
index 0000000000..a2c14ca20a
--- /dev/null
+++ b/test/notifications/open_test.dart
@@ -0,0 +1,577 @@
+import 'dart:async';
+
+import 'package:checks/checks.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/api/model/model.dart';
+import 'package:zulip/api/notifications.dart';
+import 'package:zulip/host/notifications.dart';
+import 'package:zulip/model/database.dart';
+import 'package:zulip/model/localizations.dart';
+import 'package:zulip/model/narrow.dart';
+import 'package:zulip/notifications/open.dart';
+import 'package:zulip/notifications/receive.dart';
+import 'package:zulip/widgets/app.dart';
+import 'package:zulip/widgets/home.dart';
+import 'package:zulip/widgets/message_list.dart';
+import 'package:zulip/widgets/page.dart';
+
+import '../example_data.dart' as eg;
+import '../model/binding.dart';
+import '../model/narrow_checks.dart';
+import '../stdlib_checks.dart';
+import '../test_navigation.dart';
+import '../widgets/dialog_checks.dart';
+import '../widgets/message_list_checks.dart';
+import '../widgets/page_checks.dart';
+import 'display_test.dart';
+
+Map<String, Object?> messageApnsPayload(
+  Message zulipMessage, {
+  String? streamName,
+  Account? account,
+}) {
+  account ??= eg.selfAccount;
+  return {
+    "aps": {
+      "alert": {
+        "title": "test",
+        "subtitle": "test",
+        "body": zulipMessage.content,
+      },
+      "sound": "default",
+      "badge": 0,
+    },
+    "zulip": {
+      "server": "zulip.example.cloud",
+      "realm_id": 4,
+      "realm_uri": account.realmUrl.toString(),
+      "realm_url": account.realmUrl.toString(),
+      "realm_name": "Test",
+      "user_id": account.userId,
+      "sender_id": zulipMessage.senderId,
+      "sender_email": zulipMessage.senderEmail,
+      "time": zulipMessage.timestamp,
+      "message_ids": [zulipMessage.id],
+      ...(switch (zulipMessage) {
+        StreamMessage(:var streamId, :var topic) => {
+          "recipient_type": "stream",
+          "stream_id": streamId,
+          if (streamName != null) "stream": streamName,
+          "topic": topic,
+        },
+        DmMessage(allRecipientIds: [_, _, _, ...]) => {
+          "recipient_type": "private",
+          "pm_users": zulipMessage.allRecipientIds.join(","),
+        },
+        DmMessage() => {"recipient_type": "private"},
+      }),
+    },
+  };
+}
+
+void main() {
+  TestZulipBinding.ensureInitialized();
+  final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
+
+  Future<void> init({bool addSelfAccount = true}) async {
+    if (addSelfAccount) {
+      await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+    }
+    addTearDown(testBinding.reset);
+    testBinding.firebaseMessagingInitialToken = '012abc';
+    addTearDown(NotificationService.debugReset);
+    NotificationService.debugBackgroundIsolateIsLive = false;
+    await NotificationService.instance.start();
+  }
+
+  group('NotificationOpenService', () {
+    late List<Route<void>> pushedRoutes;
+
+    void takeStartingRoutes({Account? account, bool withAccount = true}) {
+      account ??= eg.selfAccount;
+      final expected = <Condition<Object?>>[
+        if (withAccount)
+          (it) => it.isA<MaterialAccountWidgetRoute>()
+            ..accountId.equals(account!.id)
+            ..page.isA<HomePage>()
+        else
+          (it) => it.isA<WidgetRoute>().page.isA<ChooseAccountPage>(),
+      ];
+      check(pushedRoutes.take(expected.length)).deepEquals(expected);
+      pushedRoutes.removeRange(0, expected.length);
+    }
+
+    Future<void> prepare(WidgetTester tester,
+        {bool early = false, bool withAccount = true}) async {
+      await init(addSelfAccount: false);
+      pushedRoutes = [];
+      final testNavObserver = TestNavigatorObserver()
+        ..onPushed = (route, prevRoute) => pushedRoutes.add(route);
+      // This uses [ZulipApp] instead of [TestZulipApp] because notification
+      // logic uses `await ZulipApp.navigator`.
+      await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver]));
+      if (early) {
+        check(pushedRoutes).isEmpty();
+        return;
+      }
+      await tester.pump();
+      takeStartingRoutes(withAccount: withAccount);
+      check(pushedRoutes).isEmpty();
+    }
+
+    Uri androidNotificationUrlForMessage(Account account, Message message) {
+      final data = messageFcmMessage(message, account: account);
+      return NotificationOpenPayload(
+        realmUrl: data.realmUrl,
+        userId: data.userId,
+        narrow: switch (data.recipient) {
+        FcmMessageChannelRecipient(:var streamId, :var topic) =>
+          TopicNarrow(streamId, topic),
+        FcmMessageDmRecipient(:var allRecipientIds) =>
+          DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId),
+      }).buildAndroidNotificationUrl();
+    }
+
+    Future<void> openNotification(WidgetTester tester, Account account, Message message) async {
+      switch (defaultTargetPlatform) {
+        case TargetPlatform.android:
+          final intentDataUrl = androidNotificationUrlForMessage(account, message);
+          unawaited(
+            WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString()));
+          await tester.idle(); // let navigateForNotification find navigator
+
+        case TargetPlatform.iOS:
+          final payload = messageApnsPayload(message, account: account);
+          testBinding.notificationPigeonApi.addNotificationTapEvent(
+            NotificationTapEvent(payload: payload));
+          await tester.idle(); // let navigateForNotification find navigator
+
+        default:
+          throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"');
+      }
+    }
+
+    void setupNotificationDataForLaunch(WidgetTester tester, Account account, Message message) {
+      switch (defaultTargetPlatform) {
+        case TargetPlatform.android:
+          // Set up a value for `PlatformDispatcher.defaultRouteName` to return,
+          // for determining the initial route.
+          final intentDataUrl = androidNotificationUrlForMessage(account, message);
+          addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue);
+          tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString();
+
+        case TargetPlatform.iOS:
+          // Set up a value to return for
+          // `notificationPigeonApi.getNotificationDataFromLaunch`.
+          final payload = messageApnsPayload(message, account: account);
+          testBinding.notificationPigeonApi.setNotificationDataFromLaunch(
+            NotificationDataFromLaunch(payload: payload));
+
+        default:
+          throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"');
+      }
+    }
+
+    void matchesNavigation(Subject<Route<void>> route, Account account, Message message) {
+      route.isA<MaterialAccountWidgetRoute>()
+        ..accountId.equals(account.id)
+        ..page.isA<MessageListPage>()
+          .initNarrow.equals(SendableNarrow.ofMessage(message,
+            selfUserId: account.userId));
+    }
+
+    Future<void> checkOpenNotification(WidgetTester tester, Account account, Message message) async {
+      await openNotification(tester, account, message);
+      matchesNavigation(check(pushedRoutes).single, account, message);
+      pushedRoutes.clear();
+    }
+
+    testWidgets('stream message', (tester) async {
+      addTearDown(testBinding.reset);
+      await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+      await prepare(tester);
+      await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage());
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('direct message', (tester) async {
+      addTearDown(testBinding.reset);
+      await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+      await prepare(tester);
+      await checkOpenNotification(tester, eg.selfAccount,
+        eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]));
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('account queried by realmUrl origin component', (tester) async {
+      addTearDown(testBinding.reset);
+      await testBinding.globalStore.add(
+        eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')),
+        eg.initialSnapshot());
+      await prepare(tester);
+
+      await checkOpenNotification(tester,
+        eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example/')),
+        eg.streamMessage());
+      await checkOpenNotification(tester,
+        eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')),
+        eg.streamMessage());
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('no accounts', (tester) async {
+      await prepare(tester, withAccount: false);
+      await openNotification(tester, eg.selfAccount, eg.streamMessage());
+      await tester.pump();
+      check(pushedRoutes.single).isA<DialogRoute<void>>();
+      await tester.tap(find.byWidget(checkErrorDialog(tester,
+        expectedTitle: zulipLocalizations.errorNotificationOpenTitle,
+        expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound)));
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('mismatching account', (tester) async {
+      addTearDown(testBinding.reset);
+      await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+      await prepare(tester);
+      await openNotification(tester, eg.otherAccount, eg.streamMessage());
+      await tester.pump();
+      check(pushedRoutes.single).isA<DialogRoute<void>>();
+      await tester.tap(find.byWidget(checkErrorDialog(tester,
+        expectedTitle: zulipLocalizations.errorNotificationOpenTitle,
+        expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound)));
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('find account among several', (tester) async {
+      addTearDown(testBinding.reset);
+      final realmUrlA = Uri.parse('https://a-chat.example/');
+      final realmUrlB = Uri.parse('https://chat-b.example/');
+      final user1 = eg.user();
+      final user2 = eg.user();
+      final accounts = [
+        eg.account(id: 1001, realmUrl: realmUrlA, user: user1),
+        eg.account(id: 1002, realmUrl: realmUrlA, user: user2),
+        eg.account(id: 1003, realmUrl: realmUrlB, user: user1),
+        eg.account(id: 1004, realmUrl: realmUrlB, user: user2),
+      ];
+      for (final account in accounts) {
+        await testBinding.globalStore.add(account, eg.initialSnapshot());
+      }
+      await prepare(tester);
+
+      await checkOpenNotification(tester, accounts[0], eg.streamMessage());
+      await checkOpenNotification(tester, accounts[1], eg.streamMessage());
+      await checkOpenNotification(tester, accounts[2], eg.streamMessage());
+      await checkOpenNotification(tester, accounts[3], eg.streamMessage());
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('wait for app to become ready', (tester) async {
+      addTearDown(testBinding.reset);
+      await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+      await prepare(tester, early: true);
+      final message = eg.streamMessage();
+      await openNotification(tester, eg.selfAccount, message);
+      // The app should still not be ready (or else this test won't work right).
+      check(ZulipApp.ready.value).isFalse();
+      check(ZulipApp.navigatorKey.currentState).isNull();
+      // And the openNotification hasn't caused any navigation yet.
+      check(pushedRoutes).isEmpty();
+
+      // Now let the GlobalStore get loaded and the app's main UI get mounted.
+      await tester.pump();
+      // The navigator first pushes the starting routes…
+      takeStartingRoutes();
+      // … and then the one the notification leads to.
+      matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message);
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('at app launch', (tester) async {
+      addTearDown(testBinding.reset);
+      final account = eg.selfAccount;
+      final message = eg.streamMessage();
+      setupNotificationDataForLaunch(tester, account, message);
+
+      // Now start the app.
+      await testBinding.globalStore.add(account, eg.initialSnapshot());
+      await prepare(tester, early: true);
+      check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet
+
+      // Once the app is ready, we navigate to the conversation.
+      await tester.pump();
+      takeStartingRoutes();
+      matchesNavigation(check(pushedRoutes).single, account, message);
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('uses associated account as initial account; if initial route', (tester) async {
+      addTearDown(testBinding.reset);
+
+      final accountA = eg.selfAccount;
+      final accountB = eg.otherAccount;
+      final message = eg.streamMessage();
+      await testBinding.globalStore.add(accountA, eg.initialSnapshot());
+      await testBinding.globalStore.add(accountB, eg.initialSnapshot());
+      setupNotificationDataForLaunch(tester, accountB, message);
+
+      await prepare(tester, early: true);
+      check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet
+
+      await tester.pump();
+      takeStartingRoutes(account: accountB);
+      matchesNavigation(check(pushedRoutes).single, accountB, message);
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+  });
+
+  group('NotificationOpenPayload', () {
+    test('android: smoke round-trip', () {
+      // DM narrow
+      var payload = NotificationOpenPayload(
+        realmUrl: Uri.parse('http://chat.example'),
+        userId: 1001,
+        narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001),
+      );
+      var url = payload.buildAndroidNotificationUrl();
+      check(NotificationOpenPayload.parseAndroidNotificationUrl(url))
+        ..realmUrl.equals(payload.realmUrl)
+        ..userId.equals(payload.userId)
+        ..narrow.equals(payload.narrow);
+
+      // Topic narrow
+      payload = NotificationOpenPayload(
+        realmUrl: Uri.parse('http://chat.example'),
+        userId: 1001,
+        narrow: eg.topicNarrow(1, 'topic A'),
+      );
+      url = payload.buildAndroidNotificationUrl();
+      check(NotificationOpenPayload.parseAndroidNotificationUrl(url))
+        ..realmUrl.equals(payload.realmUrl)
+        ..userId.equals(payload.userId)
+        ..narrow.equals(payload.narrow);
+    });
+
+    group('parseIosApnsPayload', () {
+      test('smoke one-one DM', () {
+        final userA = eg.user(userId: 1001);
+        final userB = eg.user(userId: 1002);
+        final account = eg.account(
+          realmUrl: Uri.parse('http://chat.example'),
+          user: userA);
+        final payload = messageApnsPayload(eg.dmMessage(from: userB, to: [userA]),
+          account: account);
+        check(NotificationOpenPayload.parseIosApnsPayload(payload))
+          ..realmUrl.equals(Uri.parse('http://chat.example'))
+          ..userId.equals(1001)
+          ..narrow.which((it) => it.isA<DmNarrow>()
+            ..otherRecipientIds.deepEquals([1002]));
+      });
+
+      test('smoke group DM', () {
+        final userA = eg.user(userId: 1001);
+        final userB = eg.user(userId: 1002);
+        final userC = eg.user(userId: 1003);
+        final account = eg.account(
+          realmUrl: Uri.parse('http://chat.example'),
+          user: userA);
+        final payload = messageApnsPayload(eg.dmMessage(from: userC, to: [userA, userB]),
+          account: account);
+        check(NotificationOpenPayload.parseIosApnsPayload(payload))
+          ..realmUrl.equals(Uri.parse('http://chat.example'))
+          ..userId.equals(1001)
+          ..narrow.which((it) => it.isA<DmNarrow>()
+            ..otherRecipientIds.deepEquals([1002, 1003]));
+      });
+
+      test('smoke topic message', () {
+        final userA = eg.user(userId: 1001);
+        final account = eg.account(
+          realmUrl: Uri.parse('http://chat.example'),
+          user: userA);
+        final payload = messageApnsPayload(eg.streamMessage(
+          stream: eg.stream(streamId: 1),
+          topic: 'topic A'),
+          account: account);
+        check(NotificationOpenPayload.parseIosApnsPayload(payload))
+          ..realmUrl.equals(Uri.parse('http://chat.example'))
+          ..userId.equals(1001)
+          ..narrow.which((it) => it.isA<TopicNarrow>()
+            ..streamId.equals(1)
+            ..topic.equals(TopicName('topic A')));
+      });
+    });
+
+    group('buildAndroidNotificationUrl', () {
+      test('smoke DM', () {
+        final url = NotificationOpenPayload(
+          realmUrl: Uri.parse('http://chat.example'),
+          userId: 1001,
+          narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001),
+        ).buildAndroidNotificationUrl();
+        check(url)
+          ..scheme.equals('zulip')
+          ..host.equals('notification')
+          ..queryParameters.deepEquals({
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            'narrow_type': 'dm',
+            'all_recipient_ids': '1001,1002',
+          });
+      });
+
+      test('smoke topic', () {
+        final url = NotificationOpenPayload(
+          realmUrl: Uri.parse('http://chat.example'),
+          userId: 1001,
+          narrow: eg.topicNarrow(1, 'topic A'),
+        ).buildAndroidNotificationUrl();
+        check(url)
+          ..scheme.equals('zulip')
+          ..host.equals('notification')
+          ..queryParameters.deepEquals({
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            'narrow_type': 'topic',
+            'channel_id': '1',
+            'topic': 'topic A',
+          });
+      });
+    });
+
+    group('parseAndroidNotificationUrl', () {
+      test('smoke DM', () {
+        final url = Uri(
+          scheme: 'zulip',
+          host: 'notification',
+          queryParameters: <String, String>{
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            'narrow_type': 'dm',
+            'all_recipient_ids': '1001,1002',
+          });
+        check(NotificationOpenPayload.parseAndroidNotificationUrl(url))
+          ..realmUrl.equals(Uri.parse('http://chat.example'))
+          ..userId.equals(1001)
+          ..narrow.which((it) => it.isA<DmNarrow>()
+            ..allRecipientIds.deepEquals([1001, 1002])
+            ..otherRecipientIds.deepEquals([1002]));
+      });
+
+      test('smoke topic', () {
+        final url = Uri(
+          scheme: 'zulip',
+          host: 'notification',
+          queryParameters: <String, String>{
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            'narrow_type': 'topic',
+            'channel_id': '1',
+            'topic': 'topic A',
+          });
+        check(NotificationOpenPayload.parseAndroidNotificationUrl(url))
+          ..realmUrl.equals(Uri.parse('http://chat.example'))
+          ..userId.equals(1001)
+          ..narrow.which((it) => it.isA<TopicNarrow>()
+            ..streamId.equals(1)
+            ..topic.equals(eg.t('topic A')));
+      });
+
+      test('fails when missing any expected query parameters', () {
+        final testCases = <Map<String, String>>[
+          {
+            // 'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            'narrow_type': 'topic',
+            'channel_id': '1',
+            'topic': 'topic A',
+          },
+          {
+            'realm_url': 'http://chat.example',
+            // 'user_id': '1001',
+            'narrow_type': 'topic',
+            'channel_id': '1',
+            'topic': 'topic A',
+          },
+          {
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            // 'narrow_type': 'topic',
+            'channel_id': '1',
+            'topic': 'topic A',
+          },
+          {
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            'narrow_type': 'topic',
+            // 'channel_id': '1',
+            'topic': 'topic A',
+          },
+          {
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            'narrow_type': 'topic',
+            'channel_id': '1',
+            // 'topic': 'topic A',
+          },
+          {
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            // 'narrow_type': 'dm',
+            'all_recipient_ids': '1001,1002',
+          },
+          {
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            'narrow_type': 'dm',
+            // 'all_recipient_ids': '1001,1002',
+          },
+        ];
+        for (final params in testCases) {
+          check(() => NotificationOpenPayload.parseAndroidNotificationUrl(Uri(
+            scheme: 'zulip',
+            host: 'notification',
+            queryParameters: params,
+          )))
+            // Missing 'realm_url', 'user_id' and 'narrow_type'
+            // throws 'FormatException'.
+            // Missing 'channel_id', 'topic', when narrow_type == 'topic'
+            // throws 'TypeError'.
+            // Missing 'all_recipient_ids', when narrow_type == 'dm'
+            // throws 'TypeError'.
+            .throws<Object>();
+        }
+      });
+
+      test('fails when scheme is not "zulip"', () {
+        final url = Uri(
+          scheme: 'http',
+          host: 'notification',
+          queryParameters: <String, String>{
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            'narrow_type': 'topic',
+            'channel_id': '1',
+            'topic': 'topic A',
+          });
+        check(() => NotificationOpenPayload.parseAndroidNotificationUrl(url))
+          .throws<FormatException>();
+      });
+
+      test('fails when host is not "notification"', () {
+        final url = Uri(
+          scheme: 'zulip',
+          host: 'example',
+          queryParameters: <String, String>{
+            'realm_url': 'http://chat.example',
+            'user_id': '1001',
+            'narrow_type': 'topic',
+            'channel_id': '1',
+            'topic': 'topic A',
+          });
+        check(() => NotificationOpenPayload.parseAndroidNotificationUrl(url))
+          .throws<FormatException>();
+      });
+    });
+  });
+}
+
+extension on Subject<NotificationOpenPayload> {
+  Subject<Uri> get realmUrl => has((x) => x.realmUrl, 'realmUrl');
+  Subject<int> get userId => has((x) => x.userId, 'userId');
+  Subject<Narrow> get narrow => has((x) => x.narrow, 'narrow');
+}
diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart
index f8da5e24a0..54490ede93 100644
--- a/test/widgets/store_test.dart
+++ b/test/widgets/store_test.dart
@@ -70,12 +70,12 @@ void main() {
             return const SizedBox.shrink();
           })));
     // First, shows a loading page instead of child.
-    check(tester.any(find.byType(CircularProgressIndicator))).isTrue();
+    check(find.byType(CircularProgressIndicator)).findsOne();
     check(globalStore).isNull();

     await tester.pump();
     // Then after loading, mounts child instead, with provided store.
-    check(tester.any(find.byType(CircularProgressIndicator))).isFalse();
+    check(find.byType(CircularProgressIndicator)).findsNothing();
     check(globalStore).identicalTo(testBinding.globalStore);

     await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
@@ -84,6 +84,56 @@ void main() {
       .equals((accountId: eg.selfAccount.id, account: eg.selfAccount));
   });

+  testWidgets('GlobalStoreWidget awaits blockingFuture', (tester) async {
+    addTearDown(testBinding.reset);
+
+    final completer = Completer<void>();
+    await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr,
+      child: GlobalStoreWidget(
+        blockingFuture: completer.future,
+        child: Text('done'))));
+
+    await tester.pump();
+    await tester.pump();
+    await tester.pump();
+    // Even after the store must have loaded,
+    // still shows loading page while blockingFuture is pending.
+    check(find.byType(CircularProgressIndicator)).findsOne();
+    check(find.text('done')).findsNothing();
+
+    // Once blockingFuture completes…
+    completer.complete();
+    await tester.pump();
+    // … mounts child instead of the loading page.
+    check(find.byType(CircularProgressIndicator)).findsNothing();
+    check(find.text('done')).findsOne();
+  });
+
+  testWidgets('GlobalStoreWidget handles failed blockingFuture like success', (tester) async {
+    addTearDown(testBinding.reset);
+
+    final completer = Completer<void>();
+    await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr,
+      child: GlobalStoreWidget(
+        blockingFuture: completer.future,
+        child: Text('done'))));
+
+    await tester.pump();
+    await tester.pump();
+    await tester.pump();
+    // Even after the store must have loaded,
+    // still shows loading page while blockingFuture is pending.
+    check(find.byType(CircularProgressIndicator)).findsOne();
+    check(find.text('done')).findsNothing();
+
+    // Once blockingFuture completes, even with an error…
+    completer.completeError(Exception('oops'));
+    await tester.pump();
+    // … mounts child instead of the loading page.
+    check(find.byType(CircularProgressIndicator)).findsNothing();
+    check(find.text('done')).findsOne();
+  });
+
   testWidgets('GlobalStoreWidget.of updates dependents', (tester) async {
     addTearDown(testBinding.reset);