Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

rajveermalviya
Copy link
Member

@rajveermalviya rajveermalviya commented Feb 26, 2025

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).

@rajveermalviya rajveermalviya force-pushed the dev-ios-notif-2 branch 5 times, most recently from 463b9ee to 8478226 Compare March 6, 2025 14:53
@rajveermalviya rajveermalviya marked this pull request as ready for review March 6, 2025 14:54
@rajveermalviya rajveermalviya force-pushed the dev-ios-notif-2 branch 2 times, most recently from 0142dbd to 89df63b Compare March 6, 2025 17:29
@rajveermalviya rajveermalviya added the maintainer review PR ready for review by Zulip maintainers label Mar 10, 2025
@rajveermalviya rajveermalviya force-pushed the dev-ios-notif-2 branch 2 times, most recently from 80a34eb to 9c07740 Compare March 10, 2025 18:37
@chrisbobbe
Copy link
Collaborator

Ah this has gathered a conflict in lib/widgets/app.dart; could you resolve it please? (I see you did a few days ago, but looks like it's happened again; thanks. 🙂)

@rajveermalviya rajveermalviya force-pushed the dev-ios-notif-2 branch 3 times, most recently from 3b50218 to 2178fe6 Compare March 13, 2025 22:07
@rajveermalviya
Copy link
Member Author

(Rebased to main, Thanks!)

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! There's a lot here, and I haven't gotten around to it all today. But here are some comments from an initial review.

GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context);
return child!;
},
return DeferrredBuilderWidget(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: check spelling (here and in commit message)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(also spelling of _ZulipAppState.initState in the commit message)

Comment on lines 8 to 16
/// Provides access to the app's data.
///
/// There should be one of this widget, near the root of the tree.
///
/// See also:
/// * [GlobalStoreWidget.of], to get access to the data.
/// * [PerAccountStoreWidget], for the user's data associated with a
/// particular Zulip account.
class GlobalStoreWidget extends StatefulWidget {
// This is separate from [GlobalStoreWidget] only because we need
// a [StatefulWidget] to get hold of the store, and an [InheritedWidget] to
// provide it to descendants, and one widget can't be both of those.
class GlobalStoreWidget extends InheritedNotifier<GlobalStore> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you mean to move this implementation comment here and delete the dartdoc? The implementation comment doesn't make sense in this context—saying GlobalStoreWidget is "separate from" GlobalStoreWidget.

child: PerAccountStoreWidget(accountId: eg.selfAccount.id,
child: RealmContentNetworkImage(src))));
await tester.pumpWidget(DeferrredBuilderWidget(
future: ZulipBinding.instance.getGlobalStoreUniquely(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We normally do this as testBinding.getGlobalStoreUniquely, right?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, for these tests that aren't specifically about GlobalStoreWidget, it would be simpler to use TestZulipApp instead, I think.

store: store,
child: PerAccountStoreWidget(
accountId: accountId,
child: MyWidgetWithMixin(key: widgetWithMixinKey)));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can use TestZulipApp for this test?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used TestZulipApp in tests where possible, but kept this unchanged because I was getting the same extraneous dep changes mentioned in the above TODO.

Comment on lines 41 to 63
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
// Do nothing; we don't offer notifications on these platforms.
break;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment is wrong about Android; we do offer notifications on Android.

A lot of the code added in this commit is implemented just for iOS, but named/documented as though it's cross-platform, because it doesn't say it's just for iOS.

I don't know if we plan to align the implementation with the names/docs or vice versa. The answer might be in the later commits (I haven't read them yet), but it would be helpful to comment on this in the commit message, I think.

List<Route<dynamic>> _handleGenerateInitialRoutesIos(_) {
// 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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "its"

await tester.pump();
takeStartingRoutes();
matchesNavigation(check(pushedRoutes).single, account, message);
debugDefaultTargetPlatformOverride = null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, awkward to have this teardown line so far away from its corresponding setup line.

How about instead passing variant: const TargetPlatformVariant({TargetPlatform.iOS})) to testWidgets?

final route = _routeForNotification(context, payload);
if (route == null) return; // TODO(log)

// TODO(nav): Better interact with existing nav stack on notif open
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should have an iOS counterpart to

for this? Or make that a both-platforms issue? That issue says:

(The iOS counterpart is covered by #1147, for navigating at all when a notification is tapped.)

but it seems reasonable to postpone this part of it; we'd just want to keep track of it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After this PR is merged the potential fix for that issue would work on both platforms, because this PR consolidates the notification routing implementation on both iOS and Android.

@EventChannelApi()
abstract class NotificationHostEvents {
/// An event stream that emits a notification payload when
/// app encounters a notification tap, while the app is runnning.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "the app encounters a notification tap, while the app is running."

await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
await prepare(tester);
await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage());
debugDefaultTargetPlatformOverride = null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(same comment about maybe using variant: const TargetPlatformVariant({TargetPlatform.iOS}))

@rajveermalviya
Copy link
Member Author

Thanks for the review @chrisbobbe! Pushed a new revision, PTAL.

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Here's a more thorough review of the first four commits:

d1f648c app: Use DeferredBuilderWidget while loading GlobalStore
a5a0fff pigeon [nfc]: Rename pigeon file to notification -> android_notifications
ab3ff84 notif ios: Navigate when app launched from notification
4b2ade0 notif ios: Navigate when app running but in background

That leaves the last two commits:

28ea77f notif android: Migrate to cross-platform Pigeon API for navigation
616defe docs: Document testing push notifications on iOS Simulator

Actually for your next revision, could you send a new PR with everything except the "Migrate to cross-platform" commit? That one's pretty large, so makes sense to review separately.

@@ -0,0 +1,167 @@
// Autogenerated from Pigeon (v24.2.1), do not edit directly.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs an update for the Pigeon 25 upgrade e2aac35.

Comment on lines 279 to 315
/// The widget to build when [future] completes, with it's result
/// passed as `result`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "its"

@@ -381,7 +381,7 @@ data class StoredNotificationSound (
)
}
}
private open class NotificationsPigeonCodec : StandardMessageCodec() {
private open class AndroidNotificationsPigeonCodec : StandardMessageCodec() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit-message nit:

pigeon [nfc]: Rename pigeon file to `notification` -> `android_notifications`

I think the "to" should be deleted? Or moved to replace the "->"?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pigeon [nfc]: Rename pigeon file `notifications.dart` to `android_notifications.dart`

commit-message nit: limit summary line length to 76 (this is 85).

Comment on lines 21 to 26
/// On iOS, this checks and returns value for the `remoteNotification` key
/// in the `launchOptions` map. The value could be either the raw APNs data
/// dictionary, if the launch of the app was triggered by a notification tap,
/// otherwise it will be null.
///
/// See: https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be a bit more concise I think:

  /// 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

And this is only used on iOS at this commit, right; the "On iOS" can be added in the later commit where it also starts being used on Android.

Comment on lines 293 to 298
FakeNotificationPigeonApi? _notificationPigeonApi;

@override
FakeNotificationPigeonApi get notificationPigeonApi {
return (_notificationPigeonApi ??= FakeNotificationPigeonApi());
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, can use fewer lines:

  @override
  FakeNotificationPigeonApi get notificationPigeonApi =>
    (_notificationPigeonApi ??= FakeNotificationPigeonApi());

Comment on lines 64 to 68
func onNotificationTapEvent(data: NotificationPayloadForOpen) {
if let eventSink = eventSink {
eventSink.success(data)
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of the one class NotificationPayloadForOpen, could we have two separate classes NotificationDataFromLaunch and NotificationTapEvent? Perhaps with a Payload typedef helper:

typedef Payload = Map<Object?, Object?>;

class NotificationDataFromLaunch {
  const NotificationDataFromLaunch({required this.payload});

  /// The raw payload that is attached to the notification,
  /// holding the information required to carry out the navigation.
  final Payload payload;
}

class NotificationTapEvent {
  const NotificationTapEvent({required this.payload});

  /// The raw payload that is attached to the notification,
  /// holding the information required to carry out the navigation.
  final Payload payload;
}

I think the current naming makes the event-channel code harder to read than it needs to be. When reading the Pigeon example code, I see "event" used pretty consistently in the names of things. Here, we have both "data" (onNotificationTapEvent's param) and "payload", and "payload" is used ambiguously for a payload (NotificationPayloadForOpen.payload) and something that contains a payload (NotificationPayloadForOpen itself).

That would mean, for this method:

  func onNotificationTapEvent(event: NotificationTapEvent) {
    if let eventSink = eventSink {
      eventSink.success(event)
    }
  }

Comment on lines 30 to 31
@EventChannelApi()
abstract class NotificationHostEvents {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would NotificationEventChannelApi be a better name for this, to make it clearer what kind of thing it is?

// 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 _notifInteractionHost = notif_pigeon.NotificationHostApi();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is _notifInteractionHost the best name for this? How about _hostApi? We might add more to NotificationHostApi that's not about interacting with notifications (such as a method to query the current notification permissions). Also, NotificationHostApi isn't the only code that's about interacting with notifications; notif_pigeon.notificationTapEvents is too.

Comment on lines 102 to 105
/// Navigates to the [MessageListPage] of the specific conversation
/// for the provided payload that was attached while creating the
/// notification.
Future<void> _navigateForNotification(NotificationPayloadForOpen payload) async {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another reason the NotificationPayloadForOpen rename would be helpful: currently, it's pretty unclear that this private method is only about notifications that come while the app is open. It'll be helpful for debugging if it's easier to see what code is about the launch notification vs. not.


testWidgets('(iOS) stream message', (tester) async {
addTearDown(testBinding.reset);
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
Copy link
Collaborator

@chrisbobbe chrisbobbe Mar 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This add-self-account line looks boring; can we put it in prepare?

@rajveermalviya
Copy link
Member Author

Thanks for the review @gnprice! Pushed an update, PTAL.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revision!

Here's comments on just the first commit:
60577d6 app: Move initialization of GlobalStore from GlobalStoreWidget to ZulipApp

It's felt unsatisfying how that change concentrates more functionality on the omnibus widget ZulipApp, particularly as that causes it to get duplicated on TestZulipApp (which introduces the risk of divergence between test and live code). After the comment below about comments that refer to the store getting loaded, I noticed that it seemed like some of those comments were going to need to get more complicated, too, to explain the new situation.

So I went and tried implementing the solution I think I suggested when we looked at this task in a call a few weeks ago: have GlobalStoreWidget keep responsibility for loading the store, and add a parameter to have it await an additional future. I think that works out well. Just pushed back to the PR branch a revision with that change:
9426b82 store: Add "blocking future" option on GlobalStoreWidget

The new commit is nice and simple:

 lib/widgets/store.dart       | 11 ++++++++++
 test/widgets/store_test.dart | 54 ++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 63 insertions(+), 2 deletions(-)

vs.

 6 files changed, 158 insertions(+), 168 deletions(-)

The effect on the subsequent "Navigate when app launched from notification" commit is small:

    -@@ lib/widgets/app.dart: class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
    -     WidgetsBinding.instance.addObserver(this);
    -     () async {
    -       final globalStore = await ZulipBinding.instance.getGlobalStoreUniquely();
    -+      final notifFuture = NotificationOpenManager.instance.initializationFuture;
    -+      if (notifFuture != null) await notifFuture;
    -       if (!mounted) return;
    -       setState(() {
    -         _globalStore = globalStore;
    +@@ lib/widgets/app.dart: class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
    +   @override
    +   Widget build(BuildContext context) {
    +     return GlobalStoreWidget(
    ++      blockingFuture: NotificationOpenManager.instance.initializationFuture,
    +       child: Builder(builder: (context) {

globalStore = GlobalStoreWidget.of(navigator.context);
}));

// First, shows a loading page instead of child.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// First, shows a loading page instead of child.
// First, shows a loading page.

The "instead of child" no longer makes sense in the ZulipApp context.

Directionality(
textDirection: TextDirection.ltr,
child: GlobalStoreWidget(
// no PerAccountStoreWidget
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment seems still relevant.

@@ -15,15 +13,14 @@ import 'page.dart';
/// * [GlobalStoreWidget.of], to get access to the data.
/// * [PerAccountStoreWidget], for the user's data associated with a
/// particular Zulip account.
class GlobalStoreWidget extends StatefulWidget {
const GlobalStoreWidget({
class GlobalStoreWidget extends InheritedNotifier<GlobalStore> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few places where comments mention GlobalStoreWidget to refer to the work it does of loading the global store. See git grep -PC2 '\[GlobalStoreWidget\]', and in particular some of the matches in lib/model/binding.dart, test/model/binding.dart, test/widgets/test_app.dart. So those will need to be updated.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here's a re-review on the docs commit.

Comment on lines 89 to 90
The canned payloads were generated from Zulip Server 11.0-dev+git
8fd04b0f, API Feature Level 377, in April 2025.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a version from this week, after you originally wrote this doc — did you confirm that it still produces the same output?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, When I had tried this server version with the fresh dev environment, I had verified that it produced the same output, modulo the message content.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks!

these intermediate steps records how to get those payloads, which may
be useful in future._

Setup and run Zulip dev server following the [setup tutorial](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this isn't enough on its own — for getting one's phone to be able to talk to the dev server you'll want the instructions at https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md (per #1379 (comment)).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had changed to that link because these instructions were specific to testing on iOS Simulator, and expectation was to do these intermediate (optional) steps on the Simulator too.

Changed back to this link, in case reader tries these steps on an actual device, or in your case where server is running on a different machine than the Mac.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah true, the doc does say it's about the simulator. And the key xcrun simctl push step at the end sure sounds like it'll only work on the simulator.

It's probably not real common to be running the dev server on a different machine from the Mac hosting the simulator. So it'd be reasonable to add a remark here saying that if you'll be running the dev server on the Mac that's also hosting the simulator, then those steps boil down to just the normal Zulip dev server instructions (and include a direct link to the latter).

Apple's Push Notification service to show notifications on iOS
Simulator.

## 1.(Optional) Setup dev server
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
## 1.(Optional) Setup dev server
## 1. (Optional) Setup dev server

Comment on lines 13 to 14
[Follow the steps in this section to setup a development server, and
register the development server to the production bouncer](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md#server).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh interesting. That is a bit puzzling, though — looking at the dev_settings.py in that commit, it seems like it'll end up trying to talk to the bouncer at http://push.zulipdev.com:9991 (exactly like the signup command was doing when I tried above), or at an even more unlikely URL like http://push.192.168.0.101:9991 when one sets EXTERNAL_HOST to enable one's phone to get to the server.


## 3. (Optional) Login to the dev user on zulip-flutter.

<!-- TODO(405) Guide to use the new devlogin page instead -->
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<!-- TODO(405) Guide to use the new devlogin page instead -->
<!-- TODO(#405) Guide to use the new devlogin page instead -->

That's the convention we use for TODO links to issues. See existing examples:

$ git grep -P 'TODO.\d+'
$ git grep -P 'TODO.#\d+'

Keeping the convention consistent helps keep the references greppable.

Comment on lines +86 to +96
_If you skipped steps 2-5, you'll need pre-forged APNs payloads for
existing messages in a default development server messages for the
user `[email protected]`:_
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump — I'd like this information to not get lost for the next person.

Here's a version that could go in verbatim:

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 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.

@rajveermalviya rajveermalviya force-pushed the dev-ios-notif-2 branch 2 times, most recently from d7e8e77 to 40864b1 Compare April 14, 2025 16:58
@rajveermalviya
Copy link
Member Author

Thanks for the review @gnprice! Pushed a new revision, PTAL.

@rajveermalviya rajveermalviya requested a review from gnprice April 14, 2025 17:15
user `[email protected]`:_

These canned payloads were generated from Zulip Server 11.0-dev+git
8fd04b0f0, API Feature Level 377, in April 2025.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, thanks for adding the 9th digit here :-)

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, completed re-review of the things covered in the last pair of reviews. All looks good; one comment here above:
#1379 (comment)

Still on my plate in reviewing this are:

@rajveermalviya
Copy link
Member Author

(Rebased to fix conflicts.)

rajveermalviya and others added 8 commits April 22, 2025 17:26
…art`

This makes it clear that these bindings are for Android only.
Introduces a new Pigeon API file, and adds the corresponding
bindings in Swift. Unlike the `pigeon/android_notifications.dart`
API this doesn't use the ZulipPlugin hack, as that is
only needed when we want the Pigeon functions to be available
inside a background isolate (see doc in `zulip_plugin/pubspec.yaml`).

Since the notification tap will trigger an app launch first
(if not running already) anyway, we can be sure that these new
functions won't be running on a Dart background isolate, thus not
needing the ZulipPlugin hack.
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I finally came back and tested out these instructions after the debugging described in that chat thread (from #1379 (comment) ).

This version works great, with one correction below.

Then I also have one high-level comment on the structure (doesn't call for much new text, mostly just moving things around plus a bit of new section-intro text), and a couple of small comments.

My remaining review to-do list for this PR is the other three points in #1379 (review) .

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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
+ logger.info("APNS payload %s", orjson.dumps(apns_payload))
+ logger.info("APNS payload %s", orjson.dumps(apns_payload).decode())

That way it gets printed in the log as verbatim JSON. Otherwise the byte-string gets encoded the way it would be in Python source code, like b'…' — which means that if you try to copy-paste it to use directly, it can get mangled depending on the contents. For example if you send a message whose content is ", you get:
APNS payload: b'{"alert":{"title":"Desdemona","subtitle":"","body":"\\""},"sound":"default","badge":0,"custom":{"zulip":{"server":"192.168.0.113:9991","realm_id":2,"realm_uri":"http://192.168.0.113:9991","realm_url":"http://192.168.0.113:9991","realm_name":"Zulip Dev","user_id":11,"sender_id":9,"sender_email":"[email protected]","time":1746576414,"recipient_type":"private","message_ids":[104]}}}'

with "\\"" , which fails to parse as JSON.


</details>

To receive a notification on the iOS Simulator, we need to push
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I've gotten the full instructions working end to end for me and then scanned back through them, I think the structure of what the reader is intended to do here can be expressed more clearly with a bit of reordering:

  • First, a ## section consisting of the steps from here to the end.

    This is the main thing the document is saying to do — everything else is optional, and/or only needs to be done occasionally before then doing this portion many times.

  • Then, a ## section with the three pre-canned payloads, i.e. the portion of Step 6 above this point.

  • Then, a third ## section describing how to produce new canned payloads. That consists of Steps 1–5, with each one adjusted to a ###-level section instead of ##-level so that it's a sub-section of this overall section.

Then the structure is: you do what's in the first section. To get input to use in that, you can use either the second section or the third section.

And then in particular the individual steps of the third section don't get labeled as optional; they're all required if you want to produce payloads. So this structure makes that relationship clearer, whereas the individual "optional" labels look as if they're saying one might pick and choose.

Apple's Push Notification service to show notifications on iOS
Simulator.

## 1. (Optional) Setup dev server
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## 1. (Optional) Setup dev server
## 1. (Optional) Set up dev server

"setup" is a noun, "set up" the verb

And then follow the steps [here](https://zulip.com/help/mobile-notifications)
to enable Mobile Notifications for "Channels".

## 3. (Optional) Login to the dev user on zulip-flutter.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## 3. (Optional) Login to the dev user on zulip-flutter.
## 3. (Optional) Log in as the dev user on zulip-flutter.

Similarly "login" is a noun, "log in" the verb.

And as a smaller point: one logs in "to" a server, a realm, or even an account, but logs in "as" a user. The user isn't a space one is entering, but an identity one is taking on.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's re-review on the Swift changes. The revisions look good; small comments below.

Comment on lines +36 to +38
let listener = notificationTapEventListener!
let userInfo = response.notification.request.content.userInfo
listener.onNotificationTapEvent(payload: userInfo)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: bit simpler with listener inlined

Comment on lines +24 to +25
// Setup handler for notification tap while the app is running.
UNUserNotificationCenter.current().delegate = self
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "setup" verb/noun

But actually I think this is a comment that can be omitted. In Xcode if you put your cursor on the delegate property that's getting set here, you get this bit of docs:
https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/delegate

Use the delegate object to respond to user-selected actions and to process incoming notifications when your app is in the foreground.

and I think that makes the point just as well as the comment does.

}
}

class NotificationTapEventListener: NotificationTapEventsStreamHandler {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's include comments somewhere in here with links to the Flutter examples that you found helpful, from the thread #1379 (comment) , since those examples were nontrivial to find.

Here at the top of this class is probably a fine place.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, and re-reviewed the small commits from early in the branch. Just a couple of small comments below.

With this and the reviews just above, the remaining items for me to review in this PR are, after #1379 (review), down to just:

  • review the Dart changes in those two main commits at the end of the branch (having covered the Swift changes above).

Comment on lines +53 to +54
/// The context argument is used to look up [ZulipLocalizations],
/// the [Navigator] and [Theme] for the dialog.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how to use this information, when writing or editing a call site of this function. What sort of context does this mean is acceptable or not acceptable?

I believe the main upshot is: this context should be a descendant of the app's main [Navigator]. (That's enough, because the [ZulipLocalizations] and [Theme] are provided by [MaterialApp] and go above the [Navigator].)

Does that cover it? Or are there situations where further guidance beyond that would be helpful?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also the same information should probably go on showSuggestedActionDialog too, since that's so similar.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, and started on those two main commits at the end.

I think in this round I've read the first of them:
37683db notif ios: Navigate when app launched from notification

except its tests.

Comment on lines +37 to +39
/// A [Future] that completes to signal that the initialization of
/// [NotificationOpenManager] has completed or errored.
Future<void>? get initializationFuture => _initializedSignal?.future;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the meaning when this is null?

Comment on lines -256 to +285
onGenerateInitialRoutes: _handleGenerateInitialRoutes);
// TODO migrate Android's handling to the new Pigeon API.
onGenerateInitialRoutes: defaultTargetPlatform == TargetPlatform.iOS
? _handleGenerateInitialRoutesIos
: _handleGenerateInitialRoutes);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be somewhat cleaner to push this conditional down into the _handleGenerateInitialRoutes method. The method could consist of just checking this condition and calling one of two methods like _handleGenerateInitialRoutesAndroid and _handleGenerateInitialRoutesIos; pushing the conditional there would mean that this MaterialApp constructor call, which has lots of other things it's dealing with, doesn't get into that detail.

@@ -202,6 +203,31 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
];
}

List<Route<dynamic>> _handleGenerateInitialRoutesIos(_) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re my comment just previous, it actually looks like the old and new methods would share most of their code — the start and end are the same, and only the middle differs. So I'd try making them one method with the conditional just in the middle.

The two sides of that conditional could still be in two separate methods if that seems cleanest — like:

  final route = defaultTargetPlatform == TargetPlatform.iOS
    ? _initialRouteIos() : _initialRouteAndroid(initialRouteStr);
  if (route != null) {
    return [
      HomePage.buildRoute(accountId: route.accountId),
      route,
    ];
  }

Comment on lines +22 to +23
/// Service for handling notification navigation.
class NotificationOpenManager {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this doc comment is right — the term "service" fits for this, similar to NotificationService, and unlike NotificationDisplayManager. This is especially so with that listener for notification-tap events, which has this effectively standing ready to act.

So let's name this with "service" instead of "manager", and accordingly rename the init method to start.

Comment on lines +73 to +75
/// Returns null if app launch wasn't triggered by a notification, or if
/// an error occurs while determining the route for the notification, in
/// which case an error dialog is also shown.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Returns null if app launch wasn't triggered by a notification, or if
/// an error occurs while determining the route for the notification, in
/// which case an error dialog is also shown.
/// 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.

Otherwise it's ambiguous whether the error dialog applies to both of these cases.

unawaited(navigator.push(route));
}

NotificationNavigationData? _tryParsePayload(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: may as well have this as its own method when it's first introduced, instead of in the next commit

required this.realmUrl,
required this.userId,
required this.narrow,
}) : assert(narrow is TopicNarrow || narrow is DmNarrow);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps make the type itself be SendableNarrow?

Comment on lines +185 to +188
final String realmUrl;
switch (zulipData) {
case {'realm_url': final String value}:
realmUrl = value;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be more compact as a switch-expression.

allRecipientIds: pmUsers
.split(',')
.map((e) => int.parse(e, radix: 10))
.toList(growable: false)..sort(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the sort is a significant step, so best to keep it visible and not tuck it in:

Suggested change
.toList(growable: false)..sort(),
.toList(growable: false)
..sort(),

Comment on lines +90 to +94
/// 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.
AccountRoute<void>? _routeForNotification(BuildContext context, NotificationNavigationData data) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the logic in this method all duplicates most of what's in NotificationDisplayManager.routeForNotification.

It'd be good to avoid duplicating that. Instead, can have a method with this logic (could live on either class), and have NotificationDisplayManager.routeForNotification call that.

This does take its input in a different form, as NotificationNavigationData instead of NotificationOpenPayload. The simplest way to share the logic, starting from this version, would be to have the method that has this logic just take the three fields as separate arguments — since those two classes have the exact same three fields.

It also feels like these two classes are really two ways of describing the same thing, though. How about making fromIosApnsPayload be a new constructor on the NotificationOpenPayload class? The doc comment on that class would change a bit:

/// The information contained in 'zulip://notification/…' internal
/// Android intent data URL, used for notification-open flow.
class NotificationOpenPayload {

but I think the real point remains the same: it's the data about a notification that describes what to do on opening it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration
Projects
None yet
Development

Successfully merging this pull request may close these issues.

ios notifs: Support tapping a notification to open the conversation
3 participants