-
Notifications
You must be signed in to change notification settings - Fork 309
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
base: main
Are you sure you want to change the base?
Conversation
463b9ee
to
8478226
Compare
0142dbd
to
89df63b
Compare
89df63b
to
4f3224a
Compare
80a34eb
to
9c07740
Compare
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. 🙂) |
3b50218
to
2178fe6
Compare
(Rebased to main, Thanks!) |
There was a problem hiding this 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.
lib/widgets/app.dart
Outdated
GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); | ||
return child!; | ||
}, | ||
return DeferrredBuilderWidget( |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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)
lib/widgets/store.dart
Outdated
/// 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> { |
There was a problem hiding this comment.
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
.
test/widgets/content_test.dart
Outdated
child: PerAccountStoreWidget(accountId: eg.selfAccount.id, | ||
child: RealmContentNetworkImage(src)))); | ||
await tester.pumpWidget(DeferrredBuilderWidget( | ||
future: ZulipBinding.instance.getGlobalStoreUniquely(), |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
test/widgets/store_test.dart
Outdated
store: store, | ||
child: PerAccountStoreWidget( | ||
accountId: accountId, | ||
child: MyWidgetWithMixin(key: widgetWithMixinKey))); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
lib/notifications/open.dart
Outdated
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; |
There was a problem hiding this comment.
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.
lib/widgets/app.dart
Outdated
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "its"
test/notifications/open_test.dart
Outdated
await tester.pump(); | ||
takeStartingRoutes(); | ||
matchesNavigation(check(pushedRoutes).single, account, message); | ||
debugDefaultTargetPlatformOverride = null; |
There was a problem hiding this comment.
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
?
lib/notifications/open.dart
Outdated
final route = _routeForNotification(context, payload); | ||
if (route == null) return; // TODO(log) | ||
|
||
// TODO(nav): Better interact with existing nav stack on notif open |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
pigeon/notifications.dart
Outdated
@EventChannelApi() | ||
abstract class NotificationHostEvents { | ||
/// An event stream that emits a notification payload when | ||
/// app encounters a notification tap, while the app is runnning. |
There was a problem hiding this comment.
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."
test/notifications/open_test.dart
Outdated
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); | ||
await prepare(tester); | ||
await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); | ||
debugDefaultTargetPlatformOverride = null; |
There was a problem hiding this comment.
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})
)
2178fe6
to
616defe
Compare
Thanks for the review @chrisbobbe! Pushed a new revision, PTAL. |
There was a problem hiding this 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.
ios/Runner/Notifications.g.swift
Outdated
@@ -0,0 +1,167 @@ | |||
// Autogenerated from Pigeon (v24.2.1), do not edit directly. |
There was a problem hiding this comment.
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.
lib/widgets/app.dart
Outdated
/// The widget to build when [future] completes, with it's result | ||
/// passed as `result`. |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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 "->"?
There was a problem hiding this comment.
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).
pigeon/notifications.dart
Outdated
/// 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 |
There was a problem hiding this comment.
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.
test/model/binding.dart
Outdated
FakeNotificationPigeonApi? _notificationPigeonApi; | ||
|
||
@override | ||
FakeNotificationPigeonApi get notificationPigeonApi { | ||
return (_notificationPigeonApi ??= FakeNotificationPigeonApi()); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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());
ios/Runner/AppDelegate.swift
Outdated
func onNotificationTapEvent(data: NotificationPayloadForOpen) { | ||
if let eventSink = eventSink { | ||
eventSink.success(data) | ||
} | ||
} |
There was a problem hiding this comment.
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)
}
}
pigeon/notifications.dart
Outdated
@EventChannelApi() | ||
abstract class NotificationHostEvents { |
There was a problem hiding this comment.
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?
lib/model/binding.dart
Outdated
// 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(); |
There was a problem hiding this comment.
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.
lib/notifications/open.dart
Outdated
/// 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 { |
There was a problem hiding this comment.
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.
test/notifications/open_test.dart
Outdated
|
||
testWidgets('(iOS) stream message', (tester) async { | ||
addTearDown(testBinding.reset); | ||
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); |
There was a problem hiding this comment.
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
?
173f11f
to
9813a4d
Compare
07290de
to
c4cad8f
Compare
(Rebased to fix conflicts.) |
c4cad8f
to
7ba7cc4
Compare
There was a problem hiding this 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)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+ 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
## 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
## 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.
There was a problem hiding this 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.
ios/Runner/AppDelegate.swift
Outdated
let listener = notificationTapEventListener! | ||
let userInfo = response.notification.request.content.userInfo | ||
listener.onNotificationTapEvent(payload: userInfo) |
There was a problem hiding this comment.
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
ios/Runner/AppDelegate.swift
Outdated
// Setup handler for notification tap while the app is running. | ||
UNUserNotificationCenter.current().delegate = self |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this 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).
lib/widgets/dialog.dart
Outdated
/// The context argument is used to look up [ZulipLocalizations], | ||
/// the [Navigator] and [Theme] for the dialog. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
lib/notifications/open.dart
Outdated
/// A [Future] that completes to signal that the initialization of | ||
/// [NotificationOpenManager] has completed or errored. | ||
Future<void>? get initializationFuture => _initializedSignal?.future; |
There was a problem hiding this comment.
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?
lib/widgets/app.dart
Outdated
onGenerateInitialRoutes: _handleGenerateInitialRoutes); | ||
// TODO migrate Android's handling to the new Pigeon API. | ||
onGenerateInitialRoutes: defaultTargetPlatform == TargetPlatform.iOS | ||
? _handleGenerateInitialRoutesIos | ||
: _handleGenerateInitialRoutes); |
There was a problem hiding this comment.
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.
lib/widgets/app.dart
Outdated
@@ -202,6 +203,31 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver { | |||
]; | |||
} | |||
|
|||
List<Route<dynamic>> _handleGenerateInitialRoutesIos(_) { |
There was a problem hiding this comment.
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,
];
}
lib/notifications/open.dart
Outdated
/// Service for handling notification navigation. | ||
class NotificationOpenManager { |
There was a problem hiding this comment.
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
.
lib/notifications/open.dart
Outdated
/// 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// 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.
lib/notifications/open.dart
Outdated
unawaited(navigator.push(route)); | ||
} | ||
|
||
NotificationNavigationData? _tryParsePayload( |
There was a problem hiding this comment.
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
lib/notifications/open.dart
Outdated
required this.realmUrl, | ||
required this.userId, | ||
required this.narrow, | ||
}) : assert(narrow is TopicNarrow || narrow is DmNarrow); |
There was a problem hiding this comment.
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?
lib/notifications/open.dart
Outdated
final String realmUrl; | ||
switch (zulipData) { | ||
case {'realm_url': final String value}: | ||
realmUrl = value; |
There was a problem hiding this comment.
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.
lib/notifications/open.dart
Outdated
allRecipientIds: pmUsers | ||
.split(',') | ||
.map((e) => int.parse(e, radix: 10)) | ||
.toList(growable: false)..sort(), |
There was a problem hiding this comment.
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:
.toList(growable: false)..sort(), | |
.toList(growable: false) | |
..sort(), |
lib/notifications/open.dart
Outdated
/// 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) { |
There was a problem hiding this comment.
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.
f7c0a46
to
865341e
Compare
Thanks for the review @gnprice! Pushed an update, PTAL. |
Thanks for the revision! High-level comment on the main code changes in this revision: this rename commit looks good: and the structure of the refactors (along the lines of #1379 (comment)) within the next commit looks good too: But that latter commit is harder to read than I'd like it to be, because those refactors (moving a lot of code around) are combined in the same commit with substantial other changes (introducing new logic for iOS). Let's split that into two:
This is an example of clear and coherent commits. I think each of those new commits will be easier to read once split up than the same changes are as part of the combined commit. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and here's a re-review on the changes related to my last three review groups above: the Swift changes #1379 (review); the small commits from early in the branch #1379 (review); and the Dart changes in the first of the two main commits #1379 (review) . Just a handful of small comments from those, below.
Next I'll look at the docs commit, following up on #1379 (review) , and that will complete my re-review.
After you split up that first main commit as described in
#1379 (comment) , I'll be able to read that more completely, followed by the final commit.
lib/widgets/dialog.dart
Outdated
@@ -49,10 +49,12 @@ class DialogStatus<T> { | |||
/// | |||
/// The [DialogStatus.result] field of the return value can be used | |||
/// for waiting for the dialog to be closed. | |||
/// | |||
/// The context argument is used to look up the [Navigator] for the dialog. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a bit misleading: the context might indeed be used to look that up, but it's also used to look up other things. The reader can see one of those things a few lines below, which casts doubt on the trustworthiness of the whole function's doc.
My suggestion above at #1379 (comment) was a bit different; here it is concretely:
/// The context argument is used to look up the [Navigator] for the dialog. | |
/// The context argument should be a descendant of the app's main [Navigator]. |
By being less specific about concrete details (that the caller of this function doesn't need to care about), this version is actually more complete: it covers all the ways the context ends up getting used, because all the ancestors it needs are either the main Navigator or one of its ancestors.
lib/widgets/app.dart
Outdated
? _initialRouteIos(context) | ||
: _initialRouteAndroid(context, initialRoute); | ||
if (route != null) { | ||
return [HomePage.buildRoute(accountId: route.accountId), route]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I think keeping this list vertically expanded (like it is in main) is helpful for keeping both of the routes visually salient. Similar to how the children of Row
and Column
widgets almost always get listed on separate lines.
} | ||
} | ||
|
||
class NotificationNavigationData { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's give this class a bit of dartdoc to say what it's intended to mean, like the old NotificationOpenPayload
had. Like this (cf #1379 (comment)):
class NotificationNavigationData { | |
/// The data from a notification that describes what to do | |
/// when the user opens the notification. | |
class NotificationNavigationData { |
/// Service for handling notification navigation. | ||
class NotificationNavigationService { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's your thinking behind naming this class and this file with "navigate", rather than "open" as in the previous revision?
(Either name seems plausible to me; but I'm curious to hear what shifted your thinking to cause you to rename it.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just thought that this class should match the naming with NotificationNavigationData
, so changed both to "navigate".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, thanks. Let's name them NotificationOpenService
and open.dart
, like in that previous revision.
That makes the scope a bit more general: they're not only about navigating in a way related to a notification, but more generally about whatever we might do on opening a notification.
Those concepts might coincide for now, but I expect they won't always: for example, we might want to take you directly to the relevant message in history, rather than to the first unread or the newest message. (Maybe it was an @-mention in the middle of a long-running conversation that you weren't previously reading.) That's a further detail of opening the notification that isn't about navigation, because it doesn't change what page you're on compared to just going to the default start point in the conversation.
And then I think we'd want that logic to live together with the logic that's here in this version. In particular this logic is really more about how to interpret the data we get from the notification than it is about how to navigate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another angle on that is that the dartdoc in this version is kind of tautological — cf https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#avoid-useless-documentation or https://dart.dev/effective-dart/documentation#avoid-redundancy-with-the-surrounding-context . I tried thinking of a better summary line to write, and didn't come up with anything great — in particular, how does the navigation relate to the notification?
The answer to that is basically that the navigation is for opening the notification. So we can make that the core concept of what the class is. And then the doc can say:
/// Responds to the user opening a notification.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Re NotificationNavigationData
, we'll probably want that to end up covering the more general "open" concept too: in particular we'll need the message ID from the notification, and that will naturally become another field on that class. That more general concept is reflected in the name of the existing NotificationOpenPayload
, and in the dartdoc this version has which I supplied at #1379 (comment) above.)
OK, and the docs changes generally all look good. I think my remaining comments on the doc are all best expressed by just making the edits — so I've pushed an additional commit to the branch: commit 5df6033
Let's leave that unsquashed, but reorder it to just after the commit that introduces the doc. Then the remaining steps are as described in my review just above: #1379 (review) |
…art` This makes it clear that these bindings are for Android only.
And fix a typo.
Also make use of a handy shorthand within `jq`.
…Data And rename it's methods to be clear that they are Android only.
And move the notification navigation data parsing utilities to the new class.
5df6033
to
920ee7a
Compare
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.
920ee7a
to
1c03950
Compare
Thanks for the review @gnprice! Pushed a new revision, PTAL. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for splitting up that commit! That was very helpful for my reading those changes. Here's a full review of the resulting two commits:
80a89e3 notif [nfc]: Introduce NotificationNavigationServer
b8cfff7 notif ios: Navigate when app launched from notification
except the tests.
I'll take a look at those tests next. Then the remaining to-do list for review will be the final commit:
1c03950 notif ios: Navigate when app running but in background
/// The context argument is used to look up the [Navigator], which is used | ||
/// to show an error dialog if there is a failure. | ||
AccountRoute<void>? routeForNotificationFromLaunch({required BuildContext context}) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
similar to #1379 (comment)
} else { | ||
// The account didn't match any existing accounts, | ||
// fall through to show the default route below. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment doesn't seem accurate. This condition will indeed occur if the data for the initial route specified an account which didn't match any existing accounts… but it will also occur if the data for the initial route didn't specify any account at all.
In particular I believe this condition will occur every time the app is launched other than by opening a notification.
onGenerateRoute: (_) => null, | ||
|
||
onGenerateInitialRoutes: _handleGenerateInitialRoutes); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: stray diff hunk
try { | ||
return NotificationNavigationData.parseAndroidNotificationUrl(url); | ||
} on FormatException catch (e, st) { | ||
assert(debugLog('$e\n$st')); | ||
final zulipLocalizations = ZulipLocalizations.of(context); | ||
showErrorDialog(context: context, | ||
title: zulipLocalizations.errorNotificationOpenTitle); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
notif [nfc]: Introduce NotificationNavigationServer
And move the notification navigation data parsing utilities to
the new class.
This change doesn't look like it's NFC (see definition). If the URL doesn't parse, the old code would throw (because it called parseAndroidNotificationUrl
directly); this version will instead pop up an error dialog.
The dialog seems like a fine change. That change should go in a different commit, though (either before or after). The great value of the NFC concept is that knowing a given commit is supposed to be NFC can help make it a lot simpler to read: all the logic in the red -
parts of the diff will match up, not necessarily textually but semantically, with logic in the green +
parts of the same diff. The reader can therefore mentally cross off all the bits that match up, aiming to get to zero.
lib/widgets/app.dart
Outdated
final route = _initialRouteAndroid(context, initialRoute); | ||
final route = defaultTargetPlatform == TargetPlatform.iOS | ||
? _initialRouteIos(context) | ||
: _initialRouteAndroid(context, initialRoute); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this diff hunk — it expresses pretty crisply the fact that before this change, we didn't respond to an initial route on iOS (only on Android), and after this change, we do.
/// A [Future] that completes to signal that the initialization of | ||
/// [NotificationNavigationService] has completed or errored. | ||
/// | ||
/// Returns null if [start] wasn't called yet. | ||
Future<void>? get initializationFuture => _initializedSignal?.future; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Future terminology, "complete" includes both success and failure. See e.g. [Future.whenComplete].
/// A [Future] that completes to signal that the initialization of | |
/// [NotificationNavigationService] has completed or errored. | |
/// | |
/// Returns null if [start] wasn't called yet. | |
Future<void>? get initializationFuture => _initializedSignal?.future; | |
/// 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 initializationFuture => _initializedSignal?.future; |
(also includes nit: omit "returns" for describing getter; a getter impersonates a field, so it just "is" its value; and "wasn't" doesn't sound right to me tense-wise)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the name, how about just initialized
? It's common for things of type Future
to have names that describe the future's result, letting the type speak for the fact that it's a future.
/// Service for handling notification navigation. | ||
class NotificationNavigationService { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Re NotificationNavigationData
, we'll probably want that to end up covering the more general "open" concept too: in particular we'll need the message ID from the notification, and that will naturally become another field on that class. That more general concept is reflected in the name of the existing NotificationOpenPayload
, and in the dartdoc this version has which I supplied at #1379 (comment) above.)
unawaited(navigator.push(route)); | ||
} | ||
|
||
NotificationNavigationData? _tryParseIosApnsPayload( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this be static? Seems similar to tryParseAndroidNotificationUrl
just below it.
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this _navigateForNotification
method should get an assert that the platform is iOS, to clarify why this line makes sense.
/// Navigates to the [MessageListPage] of the specific conversation | ||
/// for the provided payload that was attached while creating the | ||
/// notification. | ||
Future<void> _navigateForNotification(NotificationTapEvent event) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this method be static? Doesn't look like it's using any instance data.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and here's a review on the tests in that next-to-last commit:
b8cfff7 notif ios: Navigate when app launched from notification
I think some of the changes requested here will help make those tests easier to read and understand, so for this round I haven't yet read every line.
addTearDown(NotificationService.debugReset); | ||
testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter'); | ||
addTearDown(NotificationService.debugReset); | ||
addTearDown(NotificationNavigationService.debugReset); | ||
await NotificationService.instance.start(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm yeah, good thought reordering these while you're here editing them.
addTearDown(NotificationService.debugReset); | ||
addTearDown(NotificationNavigationService.debugReset); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be good if we can avoid going and adding another detail like this that tests have to know about.
How about having NotificationService.debugReset
call the new service's debugReset
? Seems appropriate for the main service to take responsibility for resetting the new one, given that the main service is also the one place responsible for starting up the new service.
|
||
Future<void> init() async { | ||
addTearDown(testBinding.reset); | ||
testBinding.firebaseMessagingInitialToken = '012abc'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this line needed? I'd think this data wouldn't be involved in any of these tests.
group('NotificationOpenPayload', () { | ||
group('NotificationNavigationData', () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the code this is testing is getting moved between files, the tests should move to remain in a corresponding file.
|
||
/// Parses the iOS APNs payload and retrieves the information | ||
/// required for navigation. | ||
factory NotificationNavigationData.fromIosApnsPayload(Map<Object?, Object?> payload) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, does this function have tests? I don't see them. (I do see a variety of test cases for the logic downstream of here, which is good.)
This logic is fairly densely packed with fiddly details, like parsing logic tends to be, so it's logic that's valuable to have good tests for. And, again typically for parsing logic, it has a pretty crisp interface for both input and output, which makes it nicely amenable to writing plenty of unit tests. So let's go ahead and do that.
For a point of comparison, see the existing tests of NotificationOpenPayload
in display_test.dart
.
matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); | ||
}, variant: const TargetPlatformVariant({TargetPlatform.iOS})); | ||
|
||
testWidgets('(iOS) uses associated account as initial account; if initial route', (tester) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand what a ";" means here.
// Set up a value for `PlatformDispatcher.defaultRouteName` to return, | ||
// for determining the initial route. | ||
final message = eg.streamMessage(); | ||
final payload = messageApnsPayload(message, account: eg.selfAccount); | ||
testBinding.notificationPigeonApi.setNotificationDataFromLaunch( | ||
NotificationDataFromLaunch(payload: payload)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this comment accurate? I don't see anything about PlatformDispatcher.defaultRouteName
here.
It looks like this test case is copied from an existing one in display_test.dart
. But that one says:
// Set up a value for `PlatformDispatcher.defaultRouteName` to return,
// for determining the initial route.
final account = eg.selfAccount;
final message = eg.streamMessage();
final data = messageFcmMessage(message, account: account);
final intentDataUrl = NotificationNavigationData(
/* … */).buildAndroidNotificationUrl();
addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue);
tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString();
so PlatformDispatcher.defaultRouteName
really is what it's about.
final account = eg.selfAccount; | ||
final message = eg.streamMessage(); | ||
final data = messageFcmMessage(message, account: account); | ||
final intentDataUrl = NotificationOpenPayload( | ||
final intentDataUrl = NotificationNavigationData( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Speaking of this test, it looks like it (and several of its neighbors) should also move to navigate_test.dart
/ open_test.dart
as part of the code-moving commit — the code they're testing is part of what's getting moved.
(In particular the way these tests exercise the code they're testing is via ZulipApp — and the relevant call sites in ZulipApp switch from NotificationDisplayManager methods to NotificationNavigationService methods.)
As a bonus, those moves may allow you to outright move some of the helpers from this test file, rather than copy them as the current version does.
final route = routeForNotification(context: context, url: url); | ||
assert(url.scheme == 'zulip' && url.host == 'notification'); | ||
final payload = | ||
NotificationNavigationService.tryParseAndroidNotificationUrl(context, url); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This navigateForNotification
method seems like it should move too — it's logically closely related to the other methods like routeForNotification
which are moving.
It's also quite similar to the _navigateForNotification
method which the last commit introduces on the other class. It might be fine to have some code duplication between these two methods — but if we do that, it's definitely better if they're right next to each other, so that the parallelism is more visible.
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).