notif: On Android handle notification taps via Pigeon API#2043
notif: On Android handle notification taps via Pigeon API#2043gnprice merged 6 commits intozulip:mainfrom
Conversation
2e2b67b to
3521955
Compare
chrisbobbe
left a comment
There was a problem hiding this comment.
Thanks! I'm not super familiar with this code. I think the main thing I'd like to understand is why so much in the ios/ directory is touched here, when the issue and PR description are pretty explicit that this is an Android bugfix. (There's even a name with "android" that appears in a file in ios/, which I've pointed out below; I'm confused about that.)
test/model/binding.dart
Outdated
| late final _notificationTapEventsStreamController = | ||
| StreamController<NotificationTapEvent>(); |
There was a problem hiding this comment.
Is late final intended, instead of just final?
| } | ||
|
|
||
| /// Generated class from Pigeon that represents data sent in messages. | ||
| struct AndroidNotificationTapEvent: NotificationTapEvent { |
There was a problem hiding this comment.
Why is the name AndroidNotificationTapEvent appearing in a file in ios/? That looks like a smell to me; can it be avoided?
There was a problem hiding this comment.
This is because of a limitation in Pigeon, AndroidNotificationTapEvent is canonically defined in pigeon/notification.dart and every other class definitions with the same name in *.g.{dart,swift,kt} is generated by Pigeon. And there doesn't seem to be a convenient annotation to use to indicate a platform-specific type.
AIUI only way to conditionally generate these types would be to have separate files for Android an iOS in pigeon/, and @ConfigurePigeon(PigeonOptions(…)) in each of those would specify paths only for their corresponding platforms. Another complexity is that NotificationTapEvent is a sealed class, and I am not sure how well Pigeon's generator supports multi-file Dart library.
Do note that these types (AndroidNotificationTapEvent in Notification.g.swift, and IosNotificationTapEvent in Notifications.g.kt) remain unused, so they probably get tree-shaken out during the build.
There was a problem hiding this comment.
Also I've pushed 4b1198b which separates the renaming of NotificationTapEvent -> IosNotificationTapEvent in a nfc commit, for easier review of the final commit.
3521955 to
b900a61
Compare
|
Thanks for the review @chrisbobbe! Pushed an update, PTAL. Also see #2043 (comment) for the reason why there are changes being made in |
b900a61 to
c791db0
Compare
|
(Rebased to fix conflicts with #2059.) |
chrisbobbe
left a comment
There was a problem hiding this comment.
Thanks! Glad to be fixing this bug; comments below.
| ValueNotifier<String?> token = ValueNotifier(null); | ||
|
|
||
| Future<void> start() async { | ||
| await NotificationOpenService.instance.start(); |
There was a problem hiding this comment.
notif: Move NotificationOpenService init at the start of NotificationService init
In the commit-message summary line, do you mean s/at/to/?
There was a problem hiding this comment.
It sounds like this commit is NFC. Can it be marked as such?
notif: Move NotificationOpenService init to the start of NotificationService init
NotificationOpenService.instance.start already handles all the
platforms itself, by doing nothing on all except iOS, so move it's
initialization out of the platform-specific switch here.
lib/notifications/open.dart
Outdated
| } | ||
| } No newline at end of file |
There was a problem hiding this comment.
nit: keep newline at end of file
pigeon/notifications.dart
Outdated
| /// An event that is only emitted on iOS platform when a notification is | ||
| /// tapped on. |
There was a problem hiding this comment.
nit: can make one line:
/// On iOS, an event emitted when a notification is tapped.| /// tapped on. | ||
| /// | ||
| /// See [notificationTapEvents]. | ||
| class IosNotificationTapEvent extends NotificationTapEvent { |
There was a problem hiding this comment.
notif [nfc]: Rename NotificationTapEvent to IosNotificationTapEvent
Also make NotificationTapEvent a base sealed class, because we will
soon introduce another variant for Android too.
This is really a refactor, not a rename: now there are two classes, where there was just one, and they have a certain relationship with each other. Could you look back through this commit to make sure all the code that refers to each class respects the meaning you've assigned to it?
For example, I see a dartdoc below that doesn't respect the new meaning of NotificationTapEvent—
@EventChannelApi()
abstract class NotificationEventChannelApi {
/// An event stream that emits a notification payload when the app
/// encounters a notification tap, while the app is running.
///
/// Emits an event when
/// `userNotificationCenter(_:didReceive:withCompletionHandler:)` gets
/// called, indicating that the user has tapped on a notification. The
/// emitted payload will be the raw APNs data dictionary from the
/// `UNNotificationResponse` passed to that method.
NotificationTapEvent notificationTapEvents();
}—because it assumes the context is iOS (by referring to iOS APIs) despite NotificationTapEvent now being explicitly not specific to iOS (because it has an Ios… subclass). Here, I think maybe the fix is to introduce the paragraph with "On iOS…", and have a parallel paragraph for Android saying that it's unimplemented / does nothing for now, but will soon.
pigeon/notifications.dart
Outdated
| /// An event stream that emits a notification payload when the app | ||
| /// encounters a notification tap, while the app is running. | ||
| /// encounters a notification tap, on iOS and Android while the app is | ||
| /// running, or only on Android when apps was launched by tapping a | ||
| /// notification. |
There was a problem hiding this comment.
Four lines is quite long for a dartdoc heading. How about:
/// An event stream that emits a notification payload
/// when a notification is tapped.and factor the rest into the "On iOS" and "On Android" paragraphs.
| * | ||
| * Do not call if `intent.action` is not ACTION_VIEW. | ||
| */ | ||
| fun maybeHandleViewNotif(intent: Intent): Boolean { |
There was a problem hiding this comment.
I don't love this method's name. An ACTION_VIEW intent is the thing this method will receive and decide whether to handle specially—I see that in the dartdoc and the assert—not a "view notif". In fact, "view notif" isn't really a coherent name for anything.
| case TargetPlatform.android: | ||
| // Do nothing; we do notification routing differently on Android. | ||
| // TODO migrate Android to use the new Pigeon API. | ||
| break; |
There was a problem hiding this comment.
I don't love having a defaultTargetPlatform == TargetPlatform.iOS condition as the first thing that runs in a case TargetPlatform.android.
I think it would be appropriate here for the .listen call to appear twice in the code (once in the iOS case, after .getNotificationDataFromLaunch(), once in the Android case). From reading your implementation comment here, it sounds like the stream effectively has different meanings between the two platforms, right? It's probably helpful to give .listen a dartdoc that documents those different meanings.
lib/notifications/open.dart
Outdated
| /// the given [AndroidNotificationTapEvent] which carries | ||
| /// `zulip://notification/…` Android intent data URL. |
There was a problem hiding this comment.
| /// the given [AndroidNotificationTapEvent] which carries | |
| /// `zulip://notification/…` Android intent data URL. | |
| /// the given [AndroidNotificationTapEvent] which carries a | |
| /// `zulip://notification/…` Android intent data URL. |
lib/notifications/open.dart
Outdated
| /// The URL should have been generated with | ||
| /// [NotificationOpenPayload.buildAndroidNotificationUrl] | ||
| /// when creating the notification. |
There was a problem hiding this comment.
Can we delegate to AndroidNotificationTapEvent the job of saying what dataUrl is? (I think just by referring to the relevant methods of NotificationOpenPayload in the field's dartdoc?)
Then this method's dartdoc can be…deleted, actually:
- the "Navigate appropriately" is clear from the context the method is called in, and from its name
- the details of what the input means and looks like are all provided in the param's type,
AndroidNotificationTapEvent.
pigeon/notifications.dart
Outdated
| /// An event that is only emitted on Android platform when a notification is | ||
| /// tapped on. |
There was a problem hiding this comment.
(Similarly, this can be shortened to one line.)
c791db0 to
d8cf3ef
Compare
|
Thanks for the review @chrisbobbe! Pushed an update, PTAL. CI failure is unrelated, it is failing because of dart-lang/sdk@19b06b5 in newer than checked-in version of Flutter SDK and #2086 should fix it. |
d8cf3ef to
d02e89c
Compare
|
Thanks! LGTM; marking for Greg's review. |
gnprice
left a comment
There was a problem hiding this comment.
Thanks @rajveermalviya for diagnosing and fixing this, and @chrisbobbe for the previous reviews! Comments below.
| ValueNotifier<String?> token = ValueNotifier(null); | ||
|
|
||
| Future<void> start() async { | ||
| await NotificationOpenService.instance.start(); |
There was a problem hiding this comment.
It sounds like this commit is NFC. Can it be marked as such?
notif: Move NotificationOpenService init to the start of NotificationService init
NotificationOpenService.instance.start already handles all the
platforms itself, by doing nothing on all except iOS, so move it's
initialization out of the platform-specific switch here.
| abstract class NotificationEventChannelApi { | ||
| /// An event stream that emits a notification payload when the app |
There was a problem hiding this comment.
nit:
pigeon [nfc]: Move event channel method dartdoc to it's class
s/it's/its/
The form "it's", with an apostrophe, is a contraction of "it is". So it only appears where you could have said "it is" and had the same meaning.
| } | ||
|
|
||
| /** | ||
| * Recognize if the ACTION_VIEW intent came from tapping a notification; handle it if so |
There was a problem hiding this comment.
nit:
| * Recognize if the ACTION_VIEW intent came from tapping a notification; handle it if so | |
| * Recognize if the ACTION_VIEW intent came from tapping a notification; handle it if so. |
| private var eventSink: PigeonEventSink<NotificationTapEvent>? = null | ||
| private val buffer = mutableListOf<NotificationTapEvent>() | ||
|
|
||
| override fun onListen(p0: Any?, sink: PigeonEventSink<NotificationTapEvent>) { |
There was a problem hiding this comment.
Probably it'd be good to fully implement the protocol, even though we don't currently intend to use this feature: override onCancel too, and have it remove the sink.
That way there isn't a latent bug waiting for us in case we someday do start using that feature of the protocol.
| _notifPigeonApi.notificationTapEventsStream() | ||
| .listen(_navigateForNotification); |
There was a problem hiding this comment.
Hmm, does this risk being out of order? It looks like we end up calling this before NotificationDisplayManager.init().
There was a problem hiding this comment.
AIUI, there isn't anything in NotificationOpenService that depends on NotificationDisplayManager being initialized first. So, the order shouldn't matter.
There was a problem hiding this comment.
Mmm, I think I was confusing this _navigateForNotification with something that would cause a notification itself to be received and attempt to display it. But that's NotificationService / receive.dart, not this. So yeah, this is fine.
| final route = defaultTargetPlatform == TargetPlatform.iOS | ||
| ? _initialRouteIos(context) | ||
| : _initialRouteAndroid(context, initialRoute); | ||
| if (route != null) { | ||
| return [ | ||
| HomePage.buildRoute(accountId: route.accountId), | ||
| route, | ||
| ]; | ||
| if (defaultTargetPlatform == TargetPlatform.iOS) { |
There was a problem hiding this comment.
It looks like this will mean that when launching the app on Android through a notification, we'll now first render a frame with the last visited account and its home page (as a loading spinner), then switch accounts if needed and then push the conversation page on top. Whereas previously, and on iOS still, we'd go straight to the intended conversation from the first frame.
I guess that's probably OK — certainly it's better than the bug this fixes — but it seems like it's probably a visible small glitch. What have you seen when manually testing this? Would you post a pair of before/after screen recordings showing how this behavior looks?
There was a problem hiding this comment.
Here are the recordings on my device in release mode, the navigation is not really noticeable:
| Before | Before slomo | After | After slomo |
|---|---|---|---|
before.mp4 |
before-slomo-export.mp4 |
after.mp4 |
after-slomo-export.mp4 |
There was a problem hiding this comment.
Thanks! I agree, that seems acceptable.
In the "After" video, I completely missed the glitch the first time I watched. On watching a second time, I spotted it but it was pretty quick. In the "After slomo" video, the glitch is easy to see, but I think it's an acceptable glitch.
Let's leave a short comment in the relevant code here, though (maybe two or three lines) mentioning that there is this glitch. That will help us think about our different options if at some point in the future we're again revising how this works.
d02e89c to
d401311
Compare
d401311 to
7d8ea18
Compare
|
Thanks for the revision! Pushed a version that adds the comment I mentioned above (it went a bit longer than I expected): + } else {
+ // On Android, we ignore any notification at this step, and handle
+ // any initial notification by a navigation after the first frame.
+ // See [NotificationOpenService.start], and the buffering in
+ // NotificationTapEventListener.kt when onListen is not yet called.
+ //
+ // The navigation causes a small visible glitch where one loading spinner
+ // gets replaced by another; see recordings:
+ // https://github.com/zulip/zulip-flutter/pull/2043#discussion_r2794138972
+ // TODO it'd be nice to avoid that glitch by controlling the initial route.
+ // We accept this glitch as a workaround for an upstream issue:
+ // https://github.com/flutter/flutter/issues/178305
}CI is failing. I think that's probably just the issue discussed at #2129, but I'm not sure, so I'll hold off on merging for now. (Hopefully that issue will be resolved within the next day or two.) |
7d8ea18 to
06407a3
Compare
|
(Rebased to main to fix CI) |
|
Great, and CI has passed. Merging. |
…cationService init NotificationOpenService.instance.start already handles all the platforms itself, by doing nothing on all except iOS, so move it's initialization out of the platform-specific switch here.
…cription Initialize StreamController early, this will allow scheduling mock notification tap events (via `addNotificationTapEvent`) even before `notificationTapEventsStream` is called.
The event channel API declaration in Pigeon are quite weird because it's declared as a method on an abstract class but the generated method in `*.g.dart` is a global function. AIUI, the declaration in the Pigeon file cannot be a global function, because then it will need an implementation and thus the abstract class "hack". But not sure why the generated code for it is a global function as opposed to a static method on a abstract class. Anyway, this change fixes the missing dartdoc on the generated files. My guess is that each event channel API declarations are supposed to be one abstract class + one method pair, but there doesn't seem to be any error when a new method is introduced on the same abstract class.
…cationTapEvent This change refactors NotificationTapEvent to be a base sealed class. This will be helpful because we will soon introduce another variant for Android.
Instead of relying on Flutter's deeplinks implementation for routing the notification URL, handle the Android Intents generated by notification taps ourselves using Pigeon to pass those events over to the Dart layer from the Java layer. The upstream Flutter's deeplinks implementation has a bug where if the deeplink is triggered after the app was killed by the OS when it was in background, the app will get launched again but the route/link will not reach the Flutter's navigation handlers. See: flutter/flutter#178305 In the failure case we seem to be receiving the Android Intent for the notification tap from the OS via `MainActivity.onNewIntent` without any problems. So, to workaround that upstream bug this commit changes the implementation to handle these Android Intents ourselves. Fixes: zulip#1567
06407a3 to
44042f5
Compare
Instead of relying on Flutter's deeplinks implementation for routing the notification URL, handle the Android Intents generated by notification taps ourselves using Pigeon to pass those events over to the Dart layer from the Java layer.
The upstream Flutter's deeplinks implementation has a bug where if the deeplink is triggered after the app was killed by the OS when it was in background, the app will get launched again but the route/link will not reach the Flutter's navigation handlers. See: flutter/flutter#178305
In the failure case we seem to be receiving the Android Intent for the notification tap from the OS via
MainActivity.onNewIntentwithout any problems. So, to workaround that upstream bug this commit changes the implementation to handle these Android Intents ourselves.Fixes: #1567