Skip to content

Commit a11db4f

Browse files
committed
[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.
1 parent 6b6fcc3 commit a11db4f

11 files changed

Lines changed: 235 additions & 8 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
public class ActionBroadcastReceiver extends BroadcastReceiver {
2929
public static final String ACTION_TAPPED =
3030
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_TAPPED";
31+
public static final String ACTION_DISMISSED =
32+
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_DISMISSED";
3133
private static final String TAG = "ActionBroadcastReceiver";
3234
@Nullable private static ActionEventSink actionEventSink;
3335
@Nullable private static FlutterEngine engine;
@@ -43,7 +45,8 @@ public ActionBroadcastReceiver() {}
4345

4446
@Override
4547
public void onReceive(Context context, Intent intent) {
46-
if (!ACTION_TAPPED.equalsIgnoreCase(intent.getAction())) {
48+
if (!ACTION_TAPPED.equalsIgnoreCase(intent.getAction())
49+
&& !ACTION_DISMISSED.equalsIgnoreCase(intent.getAction())) {
4750
return;
4851
}
4952

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,22 @@ protected static Notification createNotification(
298298
.setSilent(BooleanUtils.getValue(notificationDetails.silent))
299299
.setOnlyAlertOnce(BooleanUtils.getValue(notificationDetails.onlyAlertOnce));
300300

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

679+
if (ActionBroadcastReceiver.ACTION_DISMISSED.equals(intent.getAction())) {
680+
notificationResponseMap.put(NOTIFICATION_RESPONSE_TYPE, 2);
681+
}
682+
663683
return notificationResponseMap;
664684
}
665685

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
@@ -122,6 +122,7 @@ public class NotificationDetails implements Serializable {
122122
private static final String MATCH_DATE_TIME_COMPONENTS = "matchDateTimeComponents";
123123

124124
private static final String FULL_SCREEN_INTENT = "fullScreenIntent";
125+
private static final String EMIT_DISMISS_EVENT = "emitDismissEvent";
125126
private static final String SHORTCUT_ID = "shortcutId";
126127
private static final String SUB_TEXT = "subText";
127128
private static final String ACTIONS = "actions";
@@ -191,6 +192,7 @@ public class NotificationDetails implements Serializable {
191192
public DateTimeComponents matchDateTimeComponents;
192193
public Long when;
193194
public Boolean fullScreenIntent;
195+
public Boolean emitDismissEvent;
194196
public String shortcutId;
195197
public String subText;
196198
public @Nullable List<NotificationAction> actions;
@@ -290,6 +292,8 @@ private static void readPlatformSpecifics(
290292
notificationDetails.category = (String) platformChannelSpecifics.get(CATEGORY);
291293
notificationDetails.fullScreenIntent =
292294
(Boolean) platformChannelSpecifics.get((FULL_SCREEN_INTENT));
295+
notificationDetails.emitDismissEvent =
296+
(Boolean) platformChannelSpecifics.get(EMIT_DISMISS_EVENT);
293297
notificationDetails.shortcutId = (String) platformChannelSpecifics.get(SHORTCUT_ID);
294298
notificationDetails.additionalFlags = (int[]) platformChannelSpecifics.get(ADDITIONAL_FLAGS);
295299
notificationDetails.subText = (String) platformChannelSpecifics.get(SUB_TEXT);

flutter_local_notifications/example/lib/main.dart

Lines changed: 65 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 with'
75+
' 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 with'
400+
' payload: ${response?.payload}',
401+
);
402+
return;
403+
}
375404
await Navigator.of(context).push(
376405
MaterialPageRoute<void>(
377406
builder: (BuildContext context) => SecondPage(
@@ -447,6 +476,13 @@ class _HomePageState extends State<HomePage> {
447476
await _showNotification();
448477
},
449478
),
479+
PaddedElevatedButton(
480+
buttonText:
481+
'Show notification that reports when it is dismissed',
482+
onPressed: () async {
483+
await _showDismissibleNotification();
484+
},
485+
),
450486
PaddedElevatedButton(
451487
buttonText:
452488
'Show plain notification that has no title with '
@@ -1146,6 +1182,35 @@ class _HomePageState extends State<HomePage> {
11461182
);
11471183
}
11481184

1185+
Future<void> _showDismissibleNotification() async {
1186+
const AndroidNotificationDetails androidNotificationDetails =
1187+
AndroidNotificationDetails(
1188+
'your channel id',
1189+
'your channel name',
1190+
channelDescription: 'your channel description',
1191+
importance: Importance.max,
1192+
priority: Priority.high,
1193+
ticker: 'ticker',
1194+
emitDismissEvent: true,
1195+
);
1196+
const DarwinNotificationDetails darwinNotificationDetails =
1197+
DarwinNotificationDetails(
1198+
categoryIdentifier: dismissableNotificationCategory,
1199+
);
1200+
const NotificationDetails notificationDetails = NotificationDetails(
1201+
android: androidNotificationDetails,
1202+
iOS: darwinNotificationDetails,
1203+
macOS: darwinNotificationDetails,
1204+
);
1205+
await flutterLocalNotificationsPlugin.show(
1206+
id: id++,
1207+
title: 'dismiss me',
1208+
body: 'swipe me away to trigger a dismiss event',
1209+
notificationDetails: notificationDetails,
1210+
payload: 'dismissible item',
1211+
);
1212+
}
1213+
11491214
Future<void> _showNotificationWithActions() async {
11501215
const AndroidNotificationDetails
11511216
androidNotificationDetails = AndroidNotificationDetails(

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,9 +1029,11 @@ - (NSMutableDictionary *)extractNotificationResponseDict:
10291029
isEqualToString:UNNotificationDefaultActionIdentifier]) {
10301030
notitificationResponseDict[NOTIFICATION_RESPONSE_TYPE] =
10311031
[NSNumber numberWithInteger:0];
1032-
} else if (response.actionIdentifier != nil &&
1033-
![response.actionIdentifier
1032+
} else if ([response.actionIdentifier
10341033
isEqualToString:UNNotificationDismissActionIdentifier]) {
1034+
notitificationResponseDict[NOTIFICATION_RESPONSE_TYPE] =
1035+
[NSNumber numberWithInteger:2];
1036+
} else if (response.actionIdentifier != nil) {
10351037
notitificationResponseDict[ACTION_ID] = response.actionIdentifier;
10361038
notitificationResponseDict[NOTIFICATION_RESPONSE_TYPE] =
10371039
[NSNumber numberWithInteger:1];

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
@@ -233,6 +233,7 @@ extension AndroidNotificationDetailsMapper on AndroidNotificationDetails {
233233
'colorized': colorized,
234234
'number': number,
235235
'audioAttributesUsage': audioAttributesUsage.value,
236+
'emitDismissEvent': emitDismissEvent,
236237
}
237238
..addAll(_convertActionsToMap(actions))
238239
..addAll(_convertStyleInformationToMap())

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ class AndroidNotificationDetails {
156156
this.colorized = false,
157157
this.number,
158158
this.audioAttributesUsage = AudioAttributesUsage.notification,
159+
this.emitDismissEvent = false,
159160
});
160161

161162
/// The icon that should be used when displaying the notification.
@@ -426,4 +427,13 @@ class AndroidNotificationDetails {
426427
/// such as alarm or ringtone set in [`AudioAttributes.Builder`](https://developer.android.com/reference/android/media/AudioAttributes.Builder#setUsage(int)).
427428
/// https://developer.android.com/reference/android/media/AudioAttributes
428429
final AudioAttributesUsage audioAttributesUsage;
430+
431+
/// Whether to report when the notification is dismissed by the user.
432+
///
433+
/// When set to `true`, swiping the notification away or clearing it triggers
434+
/// the `onDidReceiveBackgroundNotificationResponse` callback with a
435+
/// [NotificationResponse] of type
436+
/// [NotificationResponseType.notificationDismissed]. Dismissals caused by
437+
/// tapping the notification or by calling `cancel` are not reported.
438+
final bool emitDismissEvent;
429439
}

flutter_local_notifications/macos/flutter_local_notifications/Sources/flutter_local_notifications/FlutterLocalNotificationsPlugin.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,6 @@ public class FlutterLocalNotificationsPlugin: NSObject, FlutterPlugin, UNUserNot
149149
launchingAppFromNotification = true
150150
}
151151

152-
completionHandler()
153-
} else if response.actionIdentifier == UNNotificationDismissActionIdentifier {
154152
completionHandler()
155153
} else {
156154
if initialized {
@@ -612,7 +610,9 @@ public class FlutterLocalNotificationsPlugin: NSObject, FlutterPlugin, UNUserNot
612610
notificationResponseDict["notificationId"] = Int(response.notification.request.identifier)!
613611
if response.actionIdentifier == UNNotificationDefaultActionIdentifier {
614612
notificationResponseDict[MethodCallArguments.notificationResponseType] = 0
615-
} else if response.actionIdentifier != UNNotificationDismissActionIdentifier {
613+
} else if response.actionIdentifier == UNNotificationDismissActionIdentifier {
614+
notificationResponseDict[MethodCallArguments.notificationResponseType] = 2
615+
} else {
616616
notificationResponseDict[MethodCallArguments.actionId] = response.actionIdentifier
617617
notificationResponseDict[MethodCallArguments.notificationResponseType] = 1
618618
}

0 commit comments

Comments
 (0)