Skip to content

Commit 4d25e11

Browse files
committed
notif: Show notifications! on Android, in foreground
This implements most of the remaining work of #320. It doesn't yet deliver most of the user-facing value: for that, the remaining piece will be to make notifications appear when the app is in the background as well as when it's in the foreground. I have a draft for that and it works, but the tests require some more fiddling which I may not get to today, so I wanted to get the bulk of this posted for review. Many other improvements still to be made are tracked as their own issues: most notably iOS support #321, and opening a conversation by tapping the notification #123.
1 parent 7dcc575 commit 4d25e11

File tree

7 files changed

+337
-3
lines changed

7 files changed

+337
-3
lines changed
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

lib/notifications.dart

+111-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import 'package:flutter/foundation.dart';
2+
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
23

34
import 'api/notifications.dart';
45
import 'log.dart';
56
import 'model/binding.dart';
7+
import 'widgets/app.dart';
68

79
class NotificationService {
810
static NotificationService get instance => (_instance ??= NotificationService._());
@@ -38,6 +40,7 @@ class NotificationService {
3840
// TODO(#324) defer notif setup if user not logged into any accounts
3941
// (in order to avoid calling for permissions)
4042

43+
await NotificationDisplayManager._init();
4144
ZulipBinding.instance.firebaseMessagingOnMessage.listen(_onRemoteMessage);
4245

4346
// Get the FCM registration token, now and upon changes. See FCM API docs:
@@ -71,9 +74,114 @@ class NotificationService {
7174
static void _onRemoteMessage(FirebaseRemoteMessage message) {
7275
assert(debugLog("notif message: ${message.data}"));
7376
final data = FcmMessage.fromJson(message.data);
74-
if (data is MessageFcmMessage) {
75-
assert(debugLog('notif message content: ${data.content}'));
76-
// TODO(#122): show notification UI
77+
switch (data) {
78+
case MessageFcmMessage(): NotificationDisplayManager._onMessageFcmMessage(data, message.data);
79+
case RemoveFcmMessage(): break; // TODO(#341) handle
80+
case UnexpectedFcmMessage(): break; // TODO(log)
7781
}
7882
}
7983
}
84+
85+
/// Service for configuring our Android "notification channel".
86+
class NotificationChannelManager {
87+
@visibleForTesting
88+
static const kChannelId = 'messages-1';
89+
90+
/// The vibration pattern we set for notifications.
91+
// We try to set a vibration pattern that, with the phone in one's pocket,
92+
// is both distinctly present and distinctly different from the default.
93+
// Discussion: https://chat.zulip.org/#narrow/stream/48-mobile/topic/notification.20vibration.20pattern/near/1284530
94+
@visibleForTesting
95+
static final kVibrationPattern = Int64List.fromList([0, 125, 100, 450]);
96+
97+
/// Create our notification channel, if it doesn't already exist.
98+
//
99+
// NOTE when changing anything here: the changes will not take effect
100+
// for existing installs of the app! That's because we'll have already
101+
// created the channel with the old settings, and they're in the user's
102+
// hands from there. Our choices are:
103+
//
104+
// * Leave the old settings in place for existing installs, so the
105+
// changes only apply to new installs.
106+
//
107+
// * Change `kChannelId`, so that we abandon the old channel and use
108+
// a new one. Existing installs will get the new settings.
109+
//
110+
// This also means that if the user has changed any of the notification
111+
// settings for the channel -- like "override Do Not Disturb", or "use
112+
// a different sound", or "don't pop on screen" -- their changes get
113+
// reset. So this has to be done sparingly.
114+
//
115+
// If we do this, we should also look for any channel with the old
116+
// channel ID and delete it. See zulip-mobile's `createNotificationChannel`
117+
// in android/app/src/main/java/com/zulipmobile/notifications/NotificationChannelManager.kt .
118+
static Future<void> _ensureChannel() async {
119+
final plugin = ZulipBinding.instance.notifications;
120+
await plugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
121+
?.createNotificationChannel(AndroidNotificationChannel(
122+
kChannelId,
123+
'Messages', // TODO(i18n)
124+
importance: Importance.high,
125+
enableLights: true,
126+
vibrationPattern: kVibrationPattern,
127+
// TODO(#340) sound
128+
));
129+
}
130+
}
131+
132+
/// Service for managing the notifications shown to the user.
133+
class NotificationDisplayManager {
134+
// We rely on the tag instead.
135+
@visibleForTesting
136+
static const kNotificationId = 0;
137+
138+
static Future<void> _init() async {
139+
await ZulipBinding.instance.notifications.initialize(
140+
const InitializationSettings(
141+
android: AndroidInitializationSettings('zulip_notification'),
142+
),
143+
);
144+
await NotificationChannelManager._ensureChannel();
145+
}
146+
147+
static void _onMessageFcmMessage(MessageFcmMessage data, Map<String, dynamic> dataJson) {
148+
assert(debugLog('notif message content: ${data.content}'));
149+
final title = switch (data.recipient) {
150+
FcmMessageStreamRecipient(:var streamName?, :var topic) =>
151+
'$streamName > $topic',
152+
FcmMessageStreamRecipient(:var topic) =>
153+
'(unknown stream) > $topic', // TODO get stream name from data
154+
FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 =>
155+
'${data.senderFullName} to you and ${allRecipientIds.length - 2} others', // TODO(i18n), also plural; TODO use others' names, from data
156+
FcmMessageDmRecipient() =>
157+
data.senderFullName,
158+
};
159+
ZulipBinding.instance.notifications.show(
160+
kNotificationId,
161+
title,
162+
data.content,
163+
NotificationDetails(android: AndroidNotificationDetails(
164+
NotificationChannelManager.kChannelId,
165+
'(Zulip internal error)', // TODO never implicitly create channel: https://github.com/MaikuB/flutter_local_notifications/issues/2135
166+
tag: _conversationKey(data),
167+
color: kZulipBrandColor,
168+
icon: 'zulip_notification', // TODO vary for debug
169+
// TODO(#128) inbox-style
170+
)));
171+
}
172+
173+
static String _conversationKey(MessageFcmMessage data) {
174+
final groupKey = _groupKey(data);
175+
final conversation = switch (data.recipient) {
176+
FcmMessageStreamRecipient(:var streamId, :var topic) => 'stream:$streamId:$topic',
177+
FcmMessageDmRecipient(:var allRecipientIds) => 'dm:${allRecipientIds.join(',')}',
178+
};
179+
return '$groupKey|$conversation';
180+
}
181+
182+
static String _groupKey(FcmMessageWithIdentity data) {
183+
// The realm URL can't contain a `|`, because `|` is not a URL code point:
184+
// https://url.spec.whatwg.org/#url-code-points
185+
return "${data.realmUri}|${data.userId}";
186+
}
187+
}

test/notifications_test.dart

+226
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import 'dart:typed_data';
2+
import 'dart:ui';
3+
4+
import 'package:checks/checks.dart';
5+
import 'package:firebase_messaging/firebase_messaging.dart';
6+
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message;
7+
import 'package:test/scaffolding.dart';
8+
import 'package:zulip/api/model/model.dart';
9+
import 'package:zulip/api/notifications.dart';
10+
import 'package:zulip/model/narrow.dart';
11+
import 'package:zulip/model/store.dart';
12+
import 'package:zulip/notifications.dart';
13+
import 'package:zulip/widgets/app.dart';
14+
15+
import 'model/binding.dart';
16+
import 'example_data.dart' as eg;
17+
18+
FakeAndroidFlutterLocalNotificationsPlugin get notifAndroid =>
19+
testBinding.notifications
20+
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
21+
as FakeAndroidFlutterLocalNotificationsPlugin;
22+
23+
MessageFcmMessage messageFcmMessage(
24+
Message zulipMessage, {
25+
String? streamName,
26+
Account? account,
27+
}) {
28+
account ??= eg.selfAccount;
29+
final narrow = SendableNarrow.ofMessage(zulipMessage, selfUserId: account.userId);
30+
return FcmMessage.fromJson({
31+
"event": "message",
32+
33+
"server": "zulip.example.cloud",
34+
"realm_id": "4",
35+
"realm_uri": account.realmUrl.toString(),
36+
"user_id": account.userId.toString(),
37+
38+
"zulip_message_id": zulipMessage.id.toString(),
39+
"time": zulipMessage.timestamp.toString(),
40+
"content": zulipMessage.content,
41+
42+
"sender_id": zulipMessage.senderId.toString(),
43+
"sender_avatar_url": "${account.realmUrl}avatar/${zulipMessage.senderId}.jpeg",
44+
"sender_full_name": zulipMessage.senderFullName.toString(),
45+
46+
...(switch (narrow) {
47+
TopicNarrow(:var streamId, :var topic) => {
48+
"recipient_type": "stream",
49+
"stream_id": streamId.toString(),
50+
if (streamName != null) "stream": streamName,
51+
"topic": topic,
52+
},
53+
DmNarrow(allRecipientIds: [_, _, _, ...]) => {
54+
"recipient_type": "private",
55+
"pm_users": narrow.allRecipientIds.join(","),
56+
},
57+
DmNarrow() => {
58+
"recipient_type": "private",
59+
},
60+
}),
61+
}) as MessageFcmMessage;
62+
}
63+
64+
void main() {
65+
TestZulipBinding.ensureInitialized();
66+
67+
Future<void> init() async {
68+
addTearDown(testBinding.reset);
69+
testBinding.firebaseMessagingInitialToken = '012abc';
70+
addTearDown(NotificationService.debugReset);
71+
await NotificationService.instance.start();
72+
}
73+
74+
group('NotificationChannelManager', () {
75+
test('smoke', () async {
76+
await init();
77+
check(notifAndroid.takeCreatedChannels()).single
78+
..id.equals(NotificationChannelManager.kChannelId)
79+
..name.equals('Messages')
80+
..description.isNull()
81+
..groupId.isNull()
82+
..importance.equals(Importance.high)
83+
..playSound.isTrue()
84+
..sound.isNull()
85+
..enableVibration.isTrue()
86+
..vibrationPattern.isNotNull().deepEquals(
87+
NotificationChannelManager.kVibrationPattern)
88+
..showBadge.isTrue()
89+
..enableLights.isTrue()
90+
..ledColor.isNull()
91+
;
92+
});
93+
});
94+
95+
group('NotificationDisplayManager', () {
96+
Future<void> checkNotification(MessageFcmMessage data, {
97+
required String expectedTitle,
98+
required String expectedTagComponent,
99+
}) async {
100+
testBinding.firebaseMessaging.onMessage.add(
101+
RemoteMessage(data: data.toJson()));
102+
await null;
103+
check(testBinding.notifications.takeShowCalls()).single
104+
..id.equals(NotificationDisplayManager.kNotificationId)
105+
..title.equals(expectedTitle)
106+
..body.equals(data.content)
107+
..notificationDetails.isNotNull().android.isNotNull().which(it()
108+
..channelId.equals(NotificationChannelManager.kChannelId)
109+
..tag.equals('${data.realmUri}|${data.userId}|$expectedTagComponent')
110+
..color.equals(kZulipBrandColor)
111+
..icon.equals('zulip_notification')
112+
);
113+
}
114+
115+
test('stream message', () async {
116+
await init();
117+
final stream = eg.stream();
118+
final message = eg.streamMessage(stream: stream);
119+
await checkNotification(messageFcmMessage(message, streamName: stream.name),
120+
expectedTitle: '${stream.name} > ${message.subject}',
121+
expectedTagComponent: 'stream:${message.streamId}:${message.subject}');
122+
});
123+
124+
test('stream message, stream name omitted', () async {
125+
await init();
126+
final stream = eg.stream();
127+
final message = eg.streamMessage(stream: stream);
128+
await checkNotification(messageFcmMessage(message, streamName: null),
129+
expectedTitle: '(unknown stream) > ${message.subject}',
130+
expectedTagComponent: 'stream:${message.streamId}:${message.subject}');
131+
});
132+
133+
test('group DM', () async {
134+
await init();
135+
final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]);
136+
await checkNotification(messageFcmMessage(message),
137+
expectedTitle: "${eg.thirdUser.fullName} to you and 1 others",
138+
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
139+
});
140+
141+
test('1:1 DM', () async {
142+
await init();
143+
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
144+
await checkNotification(messageFcmMessage(message),
145+
expectedTitle: eg.otherUser.fullName,
146+
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
147+
});
148+
149+
test('self-DM', () async {
150+
await init();
151+
final message = eg.dmMessage(from: eg.selfUser, to: []);
152+
await checkNotification(messageFcmMessage(message),
153+
expectedTitle: eg.selfUser.fullName,
154+
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
155+
});
156+
});
157+
}
158+
159+
extension AndroidNotificationChannelChecks on Subject<AndroidNotificationChannel> {
160+
Subject<String> get id => has((x) => x.id, 'id');
161+
Subject<String> get name => has((x) => x.name, 'name');
162+
Subject<String?> get description => has((x) => x.description, 'description');
163+
Subject<String?> get groupId => has((x) => x.groupId, 'groupId');
164+
Subject<Importance> get importance => has((x) => x.importance, 'importance');
165+
Subject<bool> get playSound => has((x) => x.playSound, 'playSound');
166+
Subject<AndroidNotificationSound?> get sound => has((x) => x.sound, 'sound');
167+
Subject<bool> get enableVibration => has((x) => x.enableVibration, 'enableVibration');
168+
Subject<bool> get enableLights => has((x) => x.enableLights, 'enableLights');
169+
Subject<Int64List?> get vibrationPattern => has((x) => x.vibrationPattern, 'vibrationPattern');
170+
Subject<Color?> get ledColor => has((x) => x.ledColor, 'ledColor');
171+
Subject<bool> get showBadge => has((x) => x.showBadge, 'showBadge');
172+
}
173+
174+
extension ShowCallChecks on Subject<FlutterLocalNotificationsPluginShowCall> {
175+
Subject<int> get id => has((x) => x.$1, 'id');
176+
Subject<String?> get title => has((x) => x.$2, 'title');
177+
Subject<String?> get body => has((x) => x.$3, 'body');
178+
Subject<NotificationDetails?> get notificationDetails => has((x) => x.$4, 'notificationDetails');
179+
Subject<String?> get payload => has((x) => x.payload, 'payload');
180+
}
181+
182+
extension NotificationDetailsChecks on Subject<NotificationDetails> {
183+
Subject<AndroidNotificationDetails?> get android => has((x) => x.android, 'android');
184+
Subject<DarwinNotificationDetails?> get iOS => has((x) => x.iOS, 'iOS');
185+
Subject<DarwinNotificationDetails?> get macOS => has((x) => x.macOS, 'macOS');
186+
Subject<LinuxNotificationDetails?> get linux => has((x) => x.linux, 'linux');
187+
}
188+
189+
extension AndroidNotificationDetailsChecks on Subject<AndroidNotificationDetails> {
190+
// The upstream [AndroidNotificationDetails] has many more properties
191+
// which only apply to creating a channel, or to notifications before
192+
// channels were introduced in Android 8. We ignore those here.
193+
Subject<String?> get icon => has((x) => x.icon, 'icon');
194+
Subject<String> get channelId => has((x) => x.channelId, 'channelId');
195+
Subject<StyleInformation?> get styleInformation => has((x) => x.styleInformation, 'styleInformation');
196+
Subject<String?> get groupKey => has((x) => x.groupKey, 'groupKey');
197+
Subject<bool> get setAsGroupSummary => has((x) => x.setAsGroupSummary, 'setAsGroupSummary');
198+
Subject<GroupAlertBehavior> get groupAlertBehavior => has((x) => x.groupAlertBehavior, 'groupAlertBehavior');
199+
Subject<bool> get autoCancel => has((x) => x.autoCancel, 'autoCancel');
200+
Subject<bool> get ongoing => has((x) => x.ongoing, 'ongoing');
201+
Subject<Color?> get color => has((x) => x.color, 'color');
202+
Subject<AndroidBitmap<Object>?> get largeIcon => has((x) => x.largeIcon, 'largeIcon');
203+
Subject<bool> get onlyAlertOnce => has((x) => x.onlyAlertOnce, 'onlyAlertOnce');
204+
Subject<bool> get showWhen => has((x) => x.showWhen, 'showWhen');
205+
Subject<int?> get when => has((x) => x.when, 'when');
206+
Subject<bool> get usesChronometer => has((x) => x.usesChronometer, 'usesChronometer');
207+
Subject<bool> get chronometerCountDown => has((x) => x.chronometerCountDown, 'chronometerCountDown');
208+
Subject<bool> get showProgress => has((x) => x.showProgress, 'showProgress');
209+
Subject<int> get maxProgress => has((x) => x.maxProgress, 'maxProgress');
210+
Subject<int> get progress => has((x) => x.progress, 'progress');
211+
Subject<bool> get indeterminate => has((x) => x.indeterminate, 'indeterminate');
212+
Subject<String?> get ticker => has((x) => x.ticker, 'ticker');
213+
Subject<AndroidNotificationChannelAction> get channelAction => has((x) => x.channelAction, 'channelAction');
214+
Subject<NotificationVisibility?> get visibility => has((x) => x.visibility, 'visibility');
215+
Subject<int?> get timeoutAfter => has((x) => x.timeoutAfter, 'timeoutAfter');
216+
Subject<AndroidNotificationCategory?> get category => has((x) => x.category, 'category');
217+
Subject<bool> get fullScreenIntent => has((x) => x.fullScreenIntent, 'fullScreenIntent');
218+
Subject<String?> get shortcutId => has((x) => x.shortcutId, 'shortcutId');
219+
Subject<Int32List?> get additionalFlags => has((x) => x.additionalFlags, 'additionalFlags');
220+
Subject<List<AndroidNotificationAction>?> get actions => has((x) => x.actions, 'actions');
221+
Subject<String?> get subText => has((x) => x.subText, 'subText');
222+
Subject<String?> get tag => has((x) => x.tag, 'tag');
223+
Subject<bool> get colorized => has((x) => x.colorized, 'colorized');
224+
Subject<int?> get number => has((x) => x.number, 'number');
225+
Subject<AudioAttributesUsage> get audioAttributesUsage => has((x) => x.audioAttributesUsage, 'audioAttributesUsage');
226+
}

0 commit comments

Comments
 (0)