Skip to content

Commit ca41f01

Browse files
authored
[various] Add support for notification dismissed callbacks (#2790)
* [various] report when a notification is dismissed by the user Resolves #74. Adds an opt-in `emitDismissEvent` flag to `AndroidNotificationDetails` and maps iOS/macOS dismissals (for categories configured with `customDismissAction`) to a new `NotificationResponseType.notificationDismissed`. The dismissal is delivered through the existing notification response callbacks, reusing the background isolate pipeline already used for notification actions. * [flutter_local_notifications] let apps choose the isolate for the dismissal callback Adds a `dismissIsolate` preference (`NotificationDismissedIsolate`) to the platform-specific details, replacing the boolean flag, so apps can choose whether a dismissal fires on the main or a background isolate. Where a platform can't honour the choice it degrades: macOS has no background isolate so it always reports on the main isolate, and the main isolate only fires while the app is running.
1 parent 3208914 commit ca41f01

16 files changed

Lines changed: 360 additions & 18 deletions

File tree

flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/ActionBroadcastReceiver.java

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,17 @@
2323
import io.flutter.plugin.common.EventChannel;
2424
import io.flutter.plugin.common.EventChannel.EventSink;
2525
import io.flutter.plugin.common.EventChannel.StreamHandler;
26+
import io.flutter.plugin.common.MethodChannel;
2627
import io.flutter.view.FlutterCallbackInformation;
2728

2829
public class ActionBroadcastReceiver extends BroadcastReceiver {
2930
public static final String ACTION_TAPPED =
3031
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_TAPPED";
32+
public static final String ACTION_DISMISSED =
33+
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_DISMISSED";
34+
public static final String DISMISS_ISOLATE = "dismissIsolate";
35+
private static final int DISMISS_ISOLATE_MAIN = 0;
36+
private static final int DISMISS_ISOLATE_BACKGROUND = 1;
3137
private static final String TAG = "ActionBroadcastReceiver";
3238
@Nullable private static ActionEventSink actionEventSink;
3339
@Nullable private static FlutterEngine engine;
@@ -43,15 +49,27 @@ public ActionBroadcastReceiver() {}
4349

4450
@Override
4551
public void onReceive(Context context, Intent intent) {
46-
if (!ACTION_TAPPED.equalsIgnoreCase(intent.getAction())) {
52+
if (!ACTION_TAPPED.equalsIgnoreCase(intent.getAction())
53+
&& !ACTION_DISMISSED.equalsIgnoreCase(intent.getAction())) {
4754
return;
4855
}
4956

50-
preferences = preferences == null ? new IsolatePreferences(context) : preferences;
51-
5257
final Map<String, Object> action =
5358
FlutterLocalNotificationsPlugin.extractNotificationResponseMap(intent);
5459

60+
// A main-isolate dismissal is only delivered while the app is running.
61+
if (ACTION_DISMISSED.equalsIgnoreCase(intent.getAction())
62+
&& intent.getIntExtra(DISMISS_ISOLATE, DISMISS_ISOLATE_BACKGROUND)
63+
== DISMISS_ISOLATE_MAIN) {
64+
MethodChannel liveChannel = FlutterLocalNotificationsPlugin.liveChannel;
65+
if (liveChannel != null) {
66+
liveChannel.invokeMethod("didReceiveNotificationResponse", action);
67+
}
68+
return;
69+
}
70+
71+
preferences = preferences == null ? new IsolatePreferences(context) : preferences;
72+
5573
if (intent.getBooleanExtra(FlutterLocalNotificationsPlugin.CANCEL_NOTIFICATION, false)) {
5674
int notificationId = (int) action.get(FlutterLocalNotificationsPlugin.NOTIFICATION_ID);
5775
Object tag = action.get(FlutterLocalNotificationsPlugin.NOTIFICATION_TAG);

flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/FlutterLocalNotificationsPlugin.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ public class FlutterLocalNotificationsPlugin
211211
static String NOTIFICATION_DETAILS = "notificationDetails";
212212
static Gson gson;
213213
private MethodChannel channel;
214+
static MethodChannel liveChannel;
214215
private Context applicationContext;
215216
private Activity mainActivity;
216217
static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 1;
@@ -298,6 +299,23 @@ protected static Notification createNotification(
298299
.setSilent(BooleanUtils.getValue(notificationDetails.silent))
299300
.setOnlyAlertOnce(BooleanUtils.getValue(notificationDetails.onlyAlertOnce));
300301

302+
if (notificationDetails.dismissIsolate != null) {
303+
Intent deleteIntent = new Intent(context, ActionBroadcastReceiver.class);
304+
deleteIntent.setAction(ActionBroadcastReceiver.ACTION_DISMISSED);
305+
deleteIntent
306+
.putExtra(NOTIFICATION_ID, notificationDetails.id)
307+
.putExtra(NOTIFICATION_TAG, notificationDetails.tag)
308+
.putExtra(PAYLOAD, notificationDetails.payload)
309+
.putExtra(ActionBroadcastReceiver.DISMISS_ISOLATE, notificationDetails.dismissIsolate);
310+
int deleteFlags = PendingIntent.FLAG_UPDATE_CURRENT;
311+
if (VERSION.SDK_INT >= VERSION_CODES.M) {
312+
deleteFlags |= PendingIntent.FLAG_IMMUTABLE;
313+
}
314+
PendingIntent deletePendingIntent =
315+
PendingIntent.getBroadcast(context, notificationDetails.id, deleteIntent, deleteFlags);
316+
builder.setDeleteIntent(deletePendingIntent);
317+
}
318+
301319
if (notificationDetails.actions != null) {
302320
// Space out request codes by 16 so even with 16 actions they won't clash
303321
int requestCode = notificationDetails.id * 16;
@@ -660,6 +678,10 @@ static Map<String, Object> extractNotificationResponseMap(Intent intent) {
660678
notificationResponseMap.put(NOTIFICATION_RESPONSE_TYPE, 1);
661679
}
662680

681+
if (ActionBroadcastReceiver.ACTION_DISMISSED.equals(intent.getAction())) {
682+
notificationResponseMap.put(NOTIFICATION_RESPONSE_TYPE, 2);
683+
}
684+
663685
return notificationResponseMap;
664686
}
665687

@@ -1406,11 +1428,15 @@ public void onAttachedToEngine(FlutterPluginBinding binding) {
14061428
this.applicationContext = binding.getApplicationContext();
14071429
this.channel = new MethodChannel(binding.getBinaryMessenger(), METHOD_CHANNEL);
14081430
this.channel.setMethodCallHandler(this);
1431+
liveChannel = this.channel;
14091432
}
14101433

14111434
@Override
14121435
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
14131436
this.channel.setMethodCallHandler(null);
1437+
if (liveChannel == this.channel) {
1438+
liveChannel = null;
1439+
}
14141440
this.channel = null;
14151441
this.applicationContext = null;
14161442
}

flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/NotificationDetails.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ public class NotificationDetails implements Serializable {
123123
private static final String MATCH_DATE_TIME_COMPONENTS = "matchDateTimeComponents";
124124

125125
private static final String FULL_SCREEN_INTENT = "fullScreenIntent";
126+
private static final String DISMISS_ISOLATE = "dismissIsolate";
126127
private static final String SHORTCUT_ID = "shortcutId";
127128
private static final String SUB_TEXT = "subText";
128129
private static final String ACTIONS = "actions";
@@ -192,6 +193,7 @@ public class NotificationDetails implements Serializable {
192193
public DateTimeComponents matchDateTimeComponents;
193194
public Long when;
194195
public Boolean fullScreenIntent;
196+
public Integer dismissIsolate;
195197
public String shortcutId;
196198
public String subText;
197199
public @Nullable List<NotificationAction> actions;
@@ -291,6 +293,8 @@ private static void readPlatformSpecifics(
291293
notificationDetails.category = (String) platformChannelSpecifics.get(CATEGORY);
292294
notificationDetails.fullScreenIntent =
293295
(Boolean) platformChannelSpecifics.get((FULL_SCREEN_INTENT));
296+
notificationDetails.dismissIsolate =
297+
(Integer) platformChannelSpecifics.get(DISMISS_ISOLATE);
294298
notificationDetails.shortcutId = (String) platformChannelSpecifics.get(SHORTCUT_ID);
295299
notificationDetails.additionalFlags = (int[]) platformChannelSpecifics.get(ADDITIONAL_FLAGS);
296300
notificationDetails.subText = (String) platformChannelSpecifics.get(SUB_TEXT);

flutter_local_notifications/example/lib/main.dart

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,20 @@ const String darwinNotificationCategoryText = 'textCategory';
6262
/// Defines a iOS/MacOS notification category for plain actions.
6363
const String darwinNotificationCategoryPlain = 'plainCategory';
6464

65+
/// Defines a iOS/MacOS notification category that reports dismissals.
66+
const String dismissableNotificationCategory = 'dismissableCategory';
67+
6568
@pragma('vm:entry-point')
6669
void notificationTapBackground(NotificationResponse notificationResponse) {
70+
if (notificationResponse.notificationResponseType ==
71+
NotificationResponseType.notificationDismissed) {
72+
// ignore: avoid_print
73+
print(
74+
'notification(${notificationResponse.id}) dismissed on background isolate'
75+
' with payload: ${notificationResponse.payload}',
76+
);
77+
return;
78+
}
6779
// ignore: avoid_print
6880
print(
6981
'notification(${notificationResponse.id}) action tapped: '
@@ -135,6 +147,14 @@ Future<void> main() async {
135147
DarwinNotificationCategoryOption.hiddenPreviewShowTitle,
136148
},
137149
),
150+
const DarwinNotificationCategory(
151+
dismissableNotificationCategory,
152+
// The customDismissAction option is required for iOS/macOS to report
153+
// when the user dismisses a notification belonging to this category.
154+
options: <DarwinNotificationCategoryOption>{
155+
DarwinNotificationCategoryOption.customDismissAction,
156+
},
157+
),
138158
];
139159

140160
/// Note: permissions aren't requested here just to demonstrate that can be
@@ -372,6 +392,15 @@ class _HomePageState extends State<HomePage> {
372392
selectNotificationStream.stream.listen((
373393
NotificationResponse? response,
374394
) async {
395+
if (response?.notificationResponseType ==
396+
NotificationResponseType.notificationDismissed) {
397+
// ignore: avoid_print
398+
print(
399+
'notification(${response?.id}) dismissed on main isolate'
400+
' with payload: ${response?.payload}',
401+
);
402+
return;
403+
}
375404
await Navigator.of(context).push(
376405
MaterialPageRoute<void>(
377406
builder: (BuildContext context) => SecondPage(
@@ -447,6 +476,26 @@ class _HomePageState extends State<HomePage> {
447476
await _showNotification();
448477
},
449478
),
479+
PaddedElevatedButton(
480+
buttonText:
481+
'Show notification that reports its dismissal on '
482+
'the main isolate',
483+
onPressed: () async {
484+
await _showDismissibleNotification(
485+
NotificationDismissedIsolate.main,
486+
);
487+
},
488+
),
489+
PaddedElevatedButton(
490+
buttonText:
491+
'Show notification that reports its dismissal on '
492+
'a background isolate',
493+
onPressed: () async {
494+
await _showDismissibleNotification(
495+
NotificationDismissedIsolate.background,
496+
);
497+
},
498+
),
450499
PaddedElevatedButton(
451500
buttonText:
452501
'Show plain notification that has no title with '
@@ -1154,6 +1203,38 @@ class _HomePageState extends State<HomePage> {
11541203
);
11551204
}
11561205

1206+
Future<void> _showDismissibleNotification(
1207+
NotificationDismissedIsolate isolate,
1208+
) async {
1209+
final AndroidNotificationDetails androidNotificationDetails =
1210+
AndroidNotificationDetails(
1211+
'your channel id',
1212+
'your channel name',
1213+
channelDescription: 'your channel description',
1214+
importance: Importance.max,
1215+
priority: Priority.high,
1216+
ticker: 'ticker',
1217+
dismissIsolate: isolate,
1218+
);
1219+
final DarwinNotificationDetails darwinNotificationDetails =
1220+
DarwinNotificationDetails(
1221+
categoryIdentifier: dismissableNotificationCategory,
1222+
dismissIsolate: isolate,
1223+
);
1224+
final NotificationDetails notificationDetails = NotificationDetails(
1225+
android: androidNotificationDetails,
1226+
iOS: darwinNotificationDetails,
1227+
macOS: darwinNotificationDetails,
1228+
);
1229+
await flutterLocalNotificationsPlugin.show(
1230+
id: id++,
1231+
title: 'dismiss me',
1232+
body: 'swipe me away to trigger a dismiss event',
1233+
notificationDetails: notificationDetails,
1234+
payload: 'dismissible item',
1235+
);
1236+
}
1237+
11571238
Future<void> _showNotificationWithActions() async {
11581239
const AndroidNotificationDetails
11591240
androidNotificationDetails = AndroidNotificationDetails(

flutter_local_notifications/ios/flutter_local_notifications/Sources/flutter_local_notifications/FlutterLocalNotificationsPlugin.m

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ @implementation FlutterLocalNotificationsPlugin {
9999
NSString *const NOTIFICATION_LAUNCHED_APP = @"notificationLaunchedApp";
100100
NSString *const ACTION_ID = @"actionId";
101101
NSString *const NOTIFICATION_RESPONSE_TYPE = @"notificationResponseType";
102+
NSString *const DISMISS_ISOLATE = @"dismissIsolate";
102103

103104
NSString *const UNSUPPORTED_OS_VERSION_ERROR_CODE = @"unsupported_os_version";
104105
NSString *const GET_ACTIVE_NOTIFICATIONS_ERROR_MESSAGE =
@@ -798,14 +799,21 @@ - (void)cancelAllPendingNotifications:(FlutterResult _Nonnull)result
798799
if (presentSound && content.sound == nil) {
799800
content.sound = UNNotificationSound.defaultSound;
800801
}
801-
content.userInfo = [self buildUserDict:arguments[ID]
802-
title:content.title
803-
presentAlert:presentAlert
804-
presentSound:presentSound
805-
presentBadge:presentBadge
806-
presentBanner:presentBanner
807-
presentList:presentList
808-
payload:arguments[PAYLOAD]];
802+
NSMutableDictionary *userDict = [self buildUserDict:arguments[ID]
803+
title:content.title
804+
presentAlert:presentAlert
805+
presentSound:presentSound
806+
presentBadge:presentBadge
807+
presentBanner:presentBanner
808+
presentList:presentList
809+
payload:arguments[PAYLOAD]];
810+
if (arguments[PLATFORM_SPECIFICS] != [NSNull null]) {
811+
id dismissIsolate = arguments[PLATFORM_SPECIFICS][DISMISS_ISOLATE];
812+
if (dismissIsolate != nil && dismissIsolate != [NSNull null]) {
813+
userDict[DISMISS_ISOLATE] = dismissIsolate;
814+
}
815+
}
816+
content.userInfo = userDict;
809817
return content;
810818
}
811819

@@ -1029,9 +1037,11 @@ - (NSMutableDictionary *)extractNotificationResponseDict:
10291037
isEqualToString:UNNotificationDefaultActionIdentifier]) {
10301038
notitificationResponseDict[NOTIFICATION_RESPONSE_TYPE] =
10311039
[NSNumber numberWithInteger:0];
1032-
} else if (response.actionIdentifier != nil &&
1033-
![response.actionIdentifier
1040+
} else if ([response.actionIdentifier
10341041
isEqualToString:UNNotificationDismissActionIdentifier]) {
1042+
notitificationResponseDict[NOTIFICATION_RESPONSE_TYPE] =
1043+
[NSNumber numberWithInteger:2];
1044+
} else if (response.actionIdentifier != nil) {
10351045
notitificationResponseDict[ACTION_ID] = response.actionIdentifier;
10361046
notitificationResponseDict[NOTIFICATION_RESPONSE_TYPE] =
10371047
[NSNumber numberWithInteger:1];
@@ -1068,6 +1078,28 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
10681078
_launchingAppFromNotification = true;
10691079
}
10701080
completionHandler();
1081+
} else if ([response.actionIdentifier
1082+
isEqualToString:UNNotificationDismissActionIdentifier]) {
1083+
id dismissIsolate =
1084+
response.notification.request.content.userInfo[DISMISS_ISOLATE];
1085+
if (dismissIsolate != nil && dismissIsolate != [NSNull null]) {
1086+
NSMutableDictionary *notificationResponseDict =
1087+
[self extractNotificationResponseDict:response];
1088+
if ([dismissIsolate integerValue] == 0) {
1089+
if (_initialized) {
1090+
[_channel invokeMethod:@"didReceiveNotificationResponse"
1091+
arguments:notificationResponseDict];
1092+
}
1093+
} else {
1094+
if (!actionEventSink) {
1095+
actionEventSink = [[ActionEventSink alloc] init];
1096+
}
1097+
[actionEventSink addItem:notificationResponseDict];
1098+
[_flutterEngineManager startEngineIfNeeded:actionEventSink
1099+
registerPlugins:registerPlugins];
1100+
}
1101+
}
1102+
completionHandler();
10711103
} else if (response.actionIdentifier != nil) {
10721104
NSMutableDictionary *notificationResponseDict =
10731105
[self extractNotificationResponseDict:response];

flutter_local_notifications/lib/src/callback_dispatcher.dart

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,20 @@ void callbackDispatcher() {
4141
} else {
4242
id = -1;
4343
}
44+
// A plain action tap doesn't include a response type, so default to
45+
// it when none is provided.
46+
final Object? responseTypeIndex = event['notificationResponseType'];
47+
final NotificationResponseType notificationResponseType =
48+
responseTypeIndex is int
49+
? NotificationResponseType.values[responseTypeIndex]
50+
: NotificationResponseType.selectedNotificationAction;
4451
callback?.call(
4552
NotificationResponse(
4653
id: id,
4754
actionId: event['actionId'],
4855
input: event['input'],
4956
payload: event['payload'],
50-
notificationResponseType:
51-
NotificationResponseType.selectedNotificationAction,
57+
notificationResponseType: notificationResponseType,
5258
),
5359
);
5460
});

flutter_local_notifications/lib/src/platform_specifics/android/method_channel_mappers.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ extension AndroidNotificationDetailsMapper on AndroidNotificationDetails {
234234
'colorized': colorized,
235235
'number': number,
236236
'audioAttributesUsage': audioAttributesUsage.value,
237+
'dismissIsolate': dismissIsolate?.index,
237238
}
238239
..addAll(_convertActionsToMap(actions))
239240
..addAll(_convertStyleInformationToMap())

flutter_local_notifications/lib/src/platform_specifics/android/notification_details.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:typed_data';
22
import 'dart:ui';
33

4+
import '../../types.dart';
45
import 'bitmap.dart';
56
import 'enums.dart';
67
import 'notification_sound.dart';
@@ -156,6 +157,7 @@ class AndroidNotificationDetails {
156157
this.colorized = false,
157158
this.number,
158159
this.audioAttributesUsage = AudioAttributesUsage.notification,
160+
this.dismissIsolate,
159161
});
160162

161163
/// The icon that should be used when displaying the notification.
@@ -426,4 +428,13 @@ class AndroidNotificationDetails {
426428
/// such as alarm or ringtone set in [`AudioAttributes.Builder`](https://developer.android.com/reference/android/media/AudioAttributes.Builder#setUsage(int)).
427429
/// https://developer.android.com/reference/android/media/AudioAttributes
428430
final AudioAttributesUsage audioAttributesUsage;
431+
432+
/// The isolate a dismissal is reported on, or `null` to not report it.
433+
///
434+
/// When set, swiping the notification away triggers a [NotificationResponse]
435+
/// of type [NotificationResponseType.notificationDismissed] on that isolate.
436+
/// [NotificationDismissedIsolate.background] fires even when the app has been
437+
/// terminated; [NotificationDismissedIsolate.main] fires only while it runs.
438+
/// Dismissing via a tap or `cancel` is never reported.
439+
final NotificationDismissedIsolate? dismissIsolate;
429440
}

flutter_local_notifications/lib/src/platform_specifics/darwin/mappers.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,6 @@ extension DarwinNotificationDetailsMapper on DarwinNotificationDetails {
8686
'attachments': attachments?.map((a) => a.toMap()).toList(),
8787
'categoryIdentifier': categoryIdentifier,
8888
'criticalSoundVolume': criticalSoundVolume,
89+
'dismissIsolate': dismissIsolate?.index,
8990
};
9091
}

0 commit comments

Comments
 (0)