Skip to content

Conversation

@rajveermalviya
Copy link
Member

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: #1567

@rajveermalviya rajveermalviya added the maintainer review PR ready for review by Zulip maintainers label Dec 18, 2025
@rajveermalviya rajveermalviya force-pushed the pr-android-notif-intents branch 2 times, most recently from 2e2b67b to 3521955 Compare December 18, 2025 18:00
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! 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.)

Comment on lines 782 to 783
late final _notificationTapEventsStreamController =
StreamController<NotificationTapEvent>();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is late final intended, instead of just final?

}

/// Generated class from Pigeon that represents data sent in messages.
struct AndroidNotificationTapEvent: NotificationTapEvent {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is the name AndroidNotificationTapEvent appearing in a file in ios/? That looks like a smell to me; can it be avoided?

Copy link
Member Author

@rajveermalviya rajveermalviya Dec 29, 2025

Choose a reason for hiding this comment

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

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

Also I've pushed 4b1198b which separates the renaming of NotificationTapEvent -> IosNotificationTapEvent in a nfc commit, for easier review of the final commit.

@rajveermalviya rajveermalviya force-pushed the pr-android-notif-intents branch from 3521955 to b900a61 Compare December 29, 2025 17:31
@rajveermalviya
Copy link
Member Author

Thanks for the review @chrisbobbe! Pushed an update, PTAL. Also see #2043 (comment) for the reason why there are changes being made in ios/ directory.

@rajveermalviya
Copy link
Member Author

(Rebased to fix conflicts with #2059.)

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! Glad to be fixing this bug; comments below.

ValueNotifier<String?> token = ValueNotifier(null);

Future<void> start() async {
await NotificationOpenService.instance.start();
Copy link
Collaborator

Choose a reason for hiding this comment

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

notif: Move NotificationOpenService init at the start of NotificationService init

In the commit-message summary line, do you mean s/at/to/?

Comment on lines 391 to 400
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: keep newline at end of file

Comment on lines 24 to 25
/// An event that is only emitted on iOS platform when a notification is
/// tapped on.
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: can make one line:

/// On iOS, an event emitted when a notification is tapped.

/// tapped on.
///
/// See [notificationTapEvents].
class IosNotificationTapEvent extends NotificationTapEvent {
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]: 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.

Comment on lines 68 to 71
/// 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.
Copy link
Collaborator

Choose a reason for hiding this comment

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

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 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

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;
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added d914ce1 and a797865, which should point to correct docs now.

Comment on lines 207 to 208
/// the given [AndroidNotificationTapEvent] which carries
/// `zulip://notification/…` Android intent data URL.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/// the given [AndroidNotificationTapEvent] which carries
/// `zulip://notification/…` Android intent data URL.
/// the given [AndroidNotificationTapEvent] which carries a
/// `zulip://notification/…` Android intent data URL.

Comment on lines 210 to 212
/// The URL should have been generated with
/// [NotificationOpenPayload.buildAndroidNotificationUrl]
/// when creating the notification.
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines 40 to 41
/// An event that is only emitted on Android platform when a notification is
/// tapped on.
Copy link
Collaborator

Choose a reason for hiding this comment

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

(Similarly, this can be shortened to one line.)

@rajveermalviya rajveermalviya force-pushed the pr-android-notif-intents branch from c791db0 to d8cf3ef Compare January 20, 2026 19:47
@rajveermalviya
Copy link
Member Author

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.

…Service 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
@rajveermalviya rajveermalviya force-pushed the pr-android-notif-intents branch from d8cf3ef to d02e89c Compare January 23, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer review PR ready for review by Zulip maintainers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Opening notification sometimes doesn't navigate, on Android

2 participants