Skip to content
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

Replace flutter_local_notifications with pigeon bindings #856

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

Conversation

rajveermalviya
Copy link
Collaborator

@rajveermalviya rajveermalviya commented Aug 1, 2024

Fixes: #351

@gnprice
Copy link
Member

gnprice commented Aug 2, 2024

Exciting!

LMK if there are questions you have for me at this stage, or particular spots in the code you'd like an early high-level review of.

@rajveermalviya
Copy link
Collaborator Author

@gnprice, I have a question about whether this implementation is desirable. Currently it creates a pending intent using the ACTION_VIEW action with a URL payload for each notification. When the user opens the notification, the intent functions as a deeplink and is handled by the existing URL handling in Flutter. Then this url is parsed and handled here for incoming intents while the app is open and while the app is closed.

Also, when the intent is created we set explicity set the reciever component so that ensures that this instance of zulip://notificiation_open url intent is only handled by our app.

This way this implementation avoids creating a custom intent handler and reuses Flutter existing URL route handler.

@rajveermalviya
Copy link
Collaborator Author

Moved the discussion to chat thread here — https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Drop.20.60flutter_local_notifications.60.20.23F856/near/1910352

Copy link
Collaborator

@Khader-1 Khader-1 left a comment

Choose a reason for hiding this comment

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

Thanks @rajveermalviya! LGTM.

@Khader-1 Khader-1 added mentor review GSoC mentor review needed. and removed buddy review GSoC buddy review needed. labels Aug 23, 2024
Copy link
Collaborator

@kenclary kenclary left a comment

Choose a reason for hiding this comment

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

lgtm

@gnprice gnprice added the maintainer review PR ready for review by Zulip maintainers label Sep 5, 2024
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! Comments below, all small. Also, I haven't done any manual testing of this; did you?

@@ -87,7 +87,7 @@ private class AndroidNotificationHost(val context: Context)
// FlutterLocalNotificationsPlugin, which handles receiving the Intent.
// TODO take care of receiving the notification-opened Intent ourselves
Copy link
Collaborator

Choose a reason for hiding this comment

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

notif [nfc]: Replace intentPayload with AndroidIntent.extras

At this commit, the reference to "This […] extra name" seems like it's dangling; it's not clear from the code next to the comment that it means the string "payload". Can you make that fact clearer? It may also help to comment on the spot in the code where the string "payload" appears.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, I see that this special name "payload" remains after removing flutter_local_notifications. Is that because we're happy/neutral to keep it around, or is it a sign that there's still helpful cleanup of flutter_local_notifications to do?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated with main commit (notif: Handle opening of notification via URI intents) removing dangling references for payload.

HomePage.buildRoute(accountId: accountId),
InboxPage.buildRoute(accountId: accountId),
notificationRoute,
] else if (initialAccountId != null) ...[
HomePage.buildRoute(accountId: initialAccountId),
InboxPage.buildRoute(accountId: initialAccountId),
],
Copy link
Collaborator

Choose a reason for hiding this comment

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

notif: Introduce URL based notif open implementation

This looks like maybe a behavior change, right? Before, when you opened the app from a notification, the home page and inbox page would be for the first account in the list (the "initial account" in initialAccountId), even if that was different from the notification's account. Now, those pages are chosen to match the notification.

I agree the new behavior seems better. Let's mention the change, though, and any other user-visible behavior changes, in the commit message.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removed this behaviour (for now) to keep the diff smaller, this would probably be better done in a separate PR.

@chrisbobbe
Copy link
Collaborator

It looks like there's been some further discussion on these changes: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/narrow.20links.20with.20userid.3F/near/1941174

Please post here when that's resolved and this is ready for another review. 🙂

@chrisbobbe chrisbobbe removed the mentor review GSoC mentor review needed. label Oct 9, 2024
@chrisbobbe chrisbobbe added integration review Added by maintainers when PR may be ready for integration and removed maintainer review PR ready for review by Zulip maintainers labels Oct 18, 2024
This will be useful in the context of showing a notification, where
there may not exist an element tree at all (because we may be in a
background isolate), and even if there happens to be one (because we
happen to be in the foreground) it doesn't otherwise relate to
anything the code is doing.
Comment on lines 122 to 132
final GlobalStore globalStore;
if (isBackground) {
_backgroundIsolateGlobalStore ??= await ZulipBinding.instance.loadGlobalStore();
globalStore = _backgroundIsolateGlobalStore!;
} else {
NavigatorState navigator = await ZulipApp.navigator;
final context = navigator.context;
assert(context.mounted);
if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that
globalStore = GlobalStoreWidget.of(context);
}
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, it feels like the wrong structure for this NotificationDisplayManager class to be maintaining its own GlobalStore instance (in _backgroundIsolateGlobalStore).

Having this method, and its caller chain, need to know about whether it's running in a background isolate or in the foreground seems messy too.

I've just sent #1005 to solve this a different way. Then this becomes:

Suggested change
final GlobalStore globalStore;
if (isBackground) {
_backgroundIsolateGlobalStore ??= await ZulipBinding.instance.loadGlobalStore();
globalStore = _backgroundIsolateGlobalStore!;
} else {
NavigatorState navigator = await ZulipApp.navigator;
final context = navigator.context;
assert(context.mounted);
if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that
globalStore = GlobalStoreWidget.of(context);
}
final globalStore = await ZulipBinding.instance.getGlobalStore();

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thanks yeah, that's certainly a lot simpler.

required MessageFcmMessage data,
}) async {
final account = globalStore.accounts.firstWhereOrNull((account) =>
account.realmUrl.origin == data.realmUri.origin && account.userId == data.userId);
Copy link
Member

Choose a reason for hiding this comment

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

notif: Match realmUri using only the origin part of the uri

Can you elaborate on the motivation for this change? What's the situation where it makes a difference, and why is the new behavior better than the old behavior in that situation?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added some reasoning in the commit message, but mainly it's because when re-creating the realmUri from narrowUri (using Uri.parse(narrowUri.origin)), we don't get the trailing / which is present in account.realmUri, resulting in matching to fail.

Copy link
Member

Choose a reason for hiding this comment

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

I see.

Let's definitely have a test if we make a change like this — it's the sort of thing that would be easy to accidentally revert in a future change, because it's so subtle, so it's therefore something that especially needs a test to protect it.

Do we actually have a trailing slash in the value of account.realmUrl, though? It looks like example_data.dart puts one there… but I think that may actually be a bug (a failure of realism) in that file. Empirically I don't see one in the data sent by chat.zulip.org:

$ curl -s https://chat.zulip.org/api/v1/server_settings | jq .realm_url -r
https://chat.zulip.org

And looking at the server implementation, it seems like it shouldn't ever end with a slash. It comes from realm.url, which is constructed like so:

    @property
    def url(self) -> str:
        return settings.EXTERNAL_URI_SCHEME + self.host

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 @rajveermalviya for taking care of this, and thanks @chrisbobbe for the previous reviews!

For tonight I've just skimmed this; a couple of comments above.

To prevent issues with URIs that may or may not include a trailing `/`,
match using only the origin part of the URI.
Prep for handling notification-open via intent URIs.
Replaces the `package:flutter_local_notifications`'s notification-open
handling with a custom Intent URI-based approach. Notifications will now
be created with a PendingIntent containing a `zulip://notification…`
URI that embeds the narrow URI (with messageId) and the recipient
userID. When a notification is opened, the intent URI will be passed
through Flutter's platform-navigation system allowing us to listen
for and parse the URI, to route to the specific conversation.
Intent URIs now embed `near` narrow links with `messageId`, so
`requestCode` has less significance now.
Set it to zero (matching zulip-mobile).
Docs: https://developer.android.com/reference/android/app/PendingIntent#FLAG_UPDATE_CURRENT

According to the documentation, this flag is only necessary when we want
to update the extras data without causing a new PendingIntent entry to
be created. Since we no longer pass any extras data, this flag is no
longer required.
@rajveermalviya
Copy link
Collaborator Author

Thanks for the review @chrisbobbe and @gnprice!
@gnprice I've now rebased this PR on top of #1005 with suggested changes, PTAL.

@chrisbobbe chrisbobbe removed their assignment Oct 18, 2024
@chrisbobbe

This comment was marked as off-topic.

@gnprice

This comment was marked as off-topic.

@chrisbobbe

This comment was marked as off-topic.

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 I've now finished reading up through these commits:

1f3c69c notif: Match realmUri using only the origin part of the URI
5bf5ad8 notif [nfc]: Introduce AndroidIntent; replace PendingIntent.intentPayload
b3b6041 internal_link [nfc]: Make encodeHashComponent public
0330389 notif test: Fix incorrect test accounts with same accountIds
22a9691 notif: Handle opening of notification via URI intents

Comments below. I haven't yet read the test code in the last commit, because I think those changes will get simplified significantly following some of these comments.

required MessageFcmMessage data,
}) async {
final account = globalStore.accounts.firstWhereOrNull((account) =>
account.realmUrl.origin == data.realmUri.origin && account.userId == data.userId);
Copy link
Member

Choose a reason for hiding this comment

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

I see.

Let's definitely have a test if we make a change like this — it's the sort of thing that would be easy to accidentally revert in a future change, because it's so subtle, so it's therefore something that especially needs a test to protect it.

Do we actually have a trailing slash in the value of account.realmUrl, though? It looks like example_data.dart puts one there… but I think that may actually be a bug (a failure of realism) in that file. Empirically I don't see one in the data sent by chat.zulip.org:

$ curl -s https://chat.zulip.org/api/v1/server_settings | jq .realm_url -r
https://chat.zulip.org

And looking at the server implementation, it seems like it shouldn't ever end with a slash. It comes from realm.url, which is constructed like so:

    @property
    def url(self) -> str:
        return settings.EXTERNAL_URI_SCHEME + self.host

Comment on lines +410 to +412
static Future<(Account, Narrow)?> _narrowFromNotificationIntentUri({
required GlobalStore globalStore,
required Uri uri,
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 term is "URL", not "URI"

Discussion here:
https://github.com/zulip/zulip-mobile/blob/main/docs/style.md#url-not-uri

So in particular that applies to the name of this method and its parameter. Also some other names in this PR (like some locals in this method), and some commit messages.

OTOH the Uri type is one of the few places we use the term "URI" — because it's a name from an external API we don't control.

AndroidIntent({required this.action, required this.uri, required this.flags});

final String action;
final String uri;
Copy link
Member

Choose a reason for hiding this comment

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

I was going to say that this field is a borderline case where we might say "URI", since it's wrapping an external API…

… but actually it seems like the name that's more faithful to that external API is data. That's what the corresponding field on Intent is called in the class's overall docs:
https://developer.android.com/reference/android/content/Intent#intent-structure
and the getter for it is called getData.

It's true that the constructor linked above calls it uri; but Java doesn't have named parameters, so the name that appears in a constructor's parameters is basically just documentation and not part of the machine-checked API. Seems like a mistake upstream to have the names not match. But given that they don't match, the name that actually appears in client code seems like the one to take as authoritative, and that'd be data. (A Java getter method called getData corresponds to a Kotlin getter called data, in a bit of a hack that the Kotlin folks put into the language to take advantage of a widely-applied Java convention.)

@@ -17,9 +17,11 @@ const _hashReplacements = {

final _encodeHashComponentRegex = RegExp(r'[%().]');

/// Perform Zulip's url hash dot-encoding on the given string.
Copy link
Member

Choose a reason for hiding this comment

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

nit: "URL" is an all-caps term when it appears in English text (as opposed to an identifier):

Suggested change
/// Perform Zulip's url hash dot-encoding on the given string.
/// Perform Zulip's URL hash dot-encoding on the given string.

Comment on lines -48 to -49
flutter_local_notifications: ^17.2.1
flutter_local_notifications_platform_interface: ^7.2.0
Copy link
Member

Choose a reason for hiding this comment

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

🎉

Can these commits that remove this dependency:
aecd320 notif [nfc]: Remove unused FlutterLocalNotificationsPlugin bindings
2278726 deps: Remove flutter_local_notifications
d1446b1 android build: Remove use of core-library desugaring

happen earlier? I believe the binding becomes unused after:
22a9691 notif: Handle opening of notification via URI intents

Putting them right after that commit would I think make the story a bit clearer, by showing that that was the last place the binding was used.

Then the remaining commits are cleaning up some other things, taking advantage of the fact that we're fully in control of how the notifications work; but it'd be clear to the reader that they're not also part of something we need in order to stop using the package.

static void _navigateForNotification(MessageFcmMessage data) async {
/// Navigates to the [MessageListPage] of the corresponding conversation
/// given the notification-open intent uri.
static Future<bool> navigateForNotification(Uri uri) async {
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 of the return value? Doesn't seem obvious from the name — so should mention in the dartdoc.

final account = globalStore.accounts.firstWhereOrNull((account) =>
account.realmUrl == data.realmUri && account.userId == data.userId);
if (account == null) return; // TODO(log)
final result = await _narrowFromNotificationIntentUri(globalStore: globalStore, uri: uri);
Copy link
Member

Choose a reason for hiding this comment

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

Hmm — I think this gets a bit sticky for the UX in the case where Zulip wasn't already running and you open a notification. (Or where it was already running but hadn't loaded data for the account the notification is for.) The trouble is that this adds an await for getting the per-account store, before we do any navigation.

In main, data contains all the information we need for constructing a Narrow; so this _navigateForNotification method synchronously constructs a Narrow and immediately calls navigator.push for the appropriate MessageListPage, with no waiting. If the data isn't already loaded, the user may then see a loading page for a while… but at least they do see a loading page.

In the current version of this PR, I believe what happens in that situation is that we'll get to this line, and to the await globalStore.perAccount(…) line inside _narrowFromNotificationIntentUri, and then it'll sit there for however long it takes for the data to load — and during that time, there'll be no feedback to the user indicating that the notification did anything different from just launching the app.

(Well, I guess if the notification came from the first account in their list, then launching the app would have started that account's data loading and shown a corresponding loading page anyway. So in that case, which is the most common case because (presumably) most users have only one account, it'll work out fine. But if the notification is for an account that's later in the list, and if the app was already open or if the first account in the list is faster to load than the one the notification is for, the problem arises: we'll be showing a non-loading page that has nothing to do with the notification, with no visible indication that the notification had any effect, and then some time later the notification suddenly belatedly causes a navigation.)


It'd be possible to solve this problem head-on: basically we'd add a page that just takes the narrow URL as an argument, instead of a Narrow, and navigate there. Then that would show a loading page until the data arrives, at which point it'd replace itself with a normal MessageListPage. We'd need to take some care to avoid unnecessary page-transition animations, as well as an unnecessary flicker in the case where the data is already present.

We'll probably end up wanting some form of that head-on solution eventually, as part of handling Zulip links from outside the app, which is a post-launch feature.

But I think a simpler solution is that we can change the form of these zulip://notification/… URLs so that they directly encode the data we need: it's either a channel/stream ID and a topic, or a list of recipient IDs. Let's go with that solution for now, to keep down the complexity of this transition.

Comment on lines +398 to +399
final perAccountStore = await globalStore.perAccount(account.id);
final narrowUri = narrowLink(perAccountStore, narrow, nearMessageId: data.zulipMessageId);
Copy link
Member

Choose a reason for hiding this comment

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

nit: use just store for the name of a PerAccountStore local variable

Suggested change
final perAccountStore = await globalStore.perAccount(account.id);
final narrowUri = narrowLink(perAccountStore, narrow, nearMessageId: data.zulipMessageId);
final store = await globalStore.perAccount(account.id);
final narrowUri = narrowLink(store, narrow, nearMessageId: data.zulipMessageId);

That's the convention we consistently use across the codebase. Then when there is a GlobalStore, it's distinguished explicitly as globalStore, even if there doesn't happen to be a PerAccountStore in scope at the same spot.

final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
final groupKey = _groupKey(data);
final conversationKey = _conversationKey(data, groupKey);

final intentUri = await notificationIntentUriFromFcmMessage(globalStore: globalStore, data: data);
Copy link
Member

Choose a reason for hiding this comment

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

This has a similar latency issue to the one in my previous comment on opening the notification.

Here, I think the symptom would be: if a notification arrives and the app isn't already running (or doesn't yet have data for the account the notification is for), we'd end up having to sit here for however long it takes to load the per-account data. That could be a while — even a few seconds (like users of chat.zulip.org would see when on a good network connection on the same continent as the server) is enough to make notifications feel laggy in the context of a messaging application.

For users on a flaky or slow network connection, especially in a big realm like chat.zulip.org, the delay could be more like a minute, and could be enough to make the OS time out and stop the app, so the notification doesn't appear at all. In particular I think if the device is asleep it might (though I'm not sure) remain in a lower-power state even while handling a notification, which would further slow down the download of a couple of MB of server data.

Unlike the situation when opening a notification, this would affect even users who have only one account.

So I think we definitely want to avoid pulling in the per-account store on the path to displaying a notification. We can do that with the same solution discussed at the other end: just encode the notification's narrow information directly, rather than converting to a full Zulip narrow URL.

I guess this means the getGlobalStore method from #1005 probably isn't needed right now after all; oh well.

At some point in the future (solidly post-launch), we'll want to use per-account server data in displaying a notification, to do things like showing the names of other recipients in a DM thread. A prerequisite for that will be to store server data in the app's local database, so that we can load a version for it without waiting to load everything from the server from scratch.

Comment on lines -172 to 168
// TODO the notification ID can be constant, instead of matching requestCode
// (This is a legacy of `flutter_local_notifications`.)
id: notificationIdAsHashOf(conversationKey),
Copy link
Member

Choose a reason for hiding this comment

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

This TODO suggests we can make the notification ID a constant, instead of this hash. Can we do that now, or is there still an obstacle to 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.

Replace flutter_local_notifications with a thin API binding using Pigeon
5 participants