Skip to content

Commit

Permalink
notif: Show notifications! on Android, in foreground
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
gnprice committed Oct 27, 2023
1 parent 7dcc575 commit 4d25e11
Show file tree
Hide file tree
Showing 7 changed files with 337 additions and 3 deletions.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
114 changes: 111 additions & 3 deletions lib/notifications.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

import 'api/notifications.dart';
import 'log.dart';
import 'model/binding.dart';
import 'widgets/app.dart';

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

await NotificationDisplayManager._init();
ZulipBinding.instance.firebaseMessagingOnMessage.listen(_onRemoteMessage);

// Get the FCM registration token, now and upon changes. See FCM API docs:
Expand Down Expand Up @@ -71,9 +74,114 @@ class NotificationService {
static void _onRemoteMessage(FirebaseRemoteMessage message) {
assert(debugLog("notif message: ${message.data}"));
final data = FcmMessage.fromJson(message.data);
if (data is MessageFcmMessage) {
assert(debugLog('notif message content: ${data.content}'));
// TODO(#122): show notification UI
switch (data) {
case MessageFcmMessage(): NotificationDisplayManager._onMessageFcmMessage(data, message.data);
case RemoveFcmMessage(): break; // TODO(#341) handle
case UnexpectedFcmMessage(): break; // TODO(log)
}
}
}

/// Service for configuring our Android "notification channel".
class NotificationChannelManager {
@visibleForTesting
static const kChannelId = 'messages-1';

/// The vibration pattern we set for notifications.
// We try to set a vibration pattern that, with the phone in one's pocket,
// is both distinctly present and distinctly different from the default.
// Discussion: https://chat.zulip.org/#narrow/stream/48-mobile/topic/notification.20vibration.20pattern/near/1284530
@visibleForTesting
static final kVibrationPattern = Int64List.fromList([0, 125, 100, 450]);

/// Create our notification channel, if it doesn't already exist.
//
// NOTE when changing anything here: the changes will not take effect
// for existing installs of the app! That's because we'll have already
// created the channel with the old settings, and they're in the user's
// hands from there. Our choices are:
//
// * Leave the old settings in place for existing installs, so the
// changes only apply to new installs.
//
// * Change `kChannelId`, so that we abandon the old channel and use
// a new one. Existing installs will get the new settings.
//
// This also means that if the user has changed any of the notification
// settings for the channel -- like "override Do Not Disturb", or "use
// a different sound", or "don't pop on screen" -- their changes get
// reset. So this has to be done sparingly.
//
// If we do this, we should also look for any channel with the old
// channel ID and delete it. See zulip-mobile's `createNotificationChannel`
// in android/app/src/main/java/com/zulipmobile/notifications/NotificationChannelManager.kt .
static Future<void> _ensureChannel() async {
final plugin = ZulipBinding.instance.notifications;
await plugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(AndroidNotificationChannel(
kChannelId,
'Messages', // TODO(i18n)
importance: Importance.high,
enableLights: true,
vibrationPattern: kVibrationPattern,
// TODO(#340) sound
));
}
}

/// Service for managing the notifications shown to the user.
class NotificationDisplayManager {
// We rely on the tag instead.
@visibleForTesting
static const kNotificationId = 0;

static Future<void> _init() async {
await ZulipBinding.instance.notifications.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('zulip_notification'),
),
);
await NotificationChannelManager._ensureChannel();
}

static void _onMessageFcmMessage(MessageFcmMessage data, Map<String, dynamic> dataJson) {
assert(debugLog('notif message content: ${data.content}'));
final title = switch (data.recipient) {
FcmMessageStreamRecipient(:var streamName?, :var topic) =>
'$streamName > $topic',
FcmMessageStreamRecipient(:var topic) =>
'(unknown stream) > $topic', // TODO get stream name from data
FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 =>
'${data.senderFullName} to you and ${allRecipientIds.length - 2} others', // TODO(i18n), also plural; TODO use others' names, from data
FcmMessageDmRecipient() =>
data.senderFullName,
};
ZulipBinding.instance.notifications.show(
kNotificationId,
title,
data.content,
NotificationDetails(android: AndroidNotificationDetails(
NotificationChannelManager.kChannelId,
'(Zulip internal error)', // TODO never implicitly create channel: https://github.com/MaikuB/flutter_local_notifications/issues/2135
tag: _conversationKey(data),
color: kZulipBrandColor,
icon: 'zulip_notification', // TODO vary for debug
// TODO(#128) inbox-style
)));
}

static String _conversationKey(MessageFcmMessage data) {
final groupKey = _groupKey(data);
final conversation = switch (data.recipient) {
FcmMessageStreamRecipient(:var streamId, :var topic) => 'stream:$streamId:$topic',
FcmMessageDmRecipient(:var allRecipientIds) => 'dm:${allRecipientIds.join(',')}',
};
return '$groupKey|$conversation';
}

static String _groupKey(FcmMessageWithIdentity data) {
// The realm URL can't contain a `|`, because `|` is not a URL code point:
// https://url.spec.whatwg.org/#url-code-points
return "${data.realmUri}|${data.userId}";
}
}
226 changes: 226 additions & 0 deletions test/notifications_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import 'dart:typed_data';
import 'dart:ui';

import 'package:checks/checks.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message;
import 'package:test/scaffolding.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/notifications.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/notifications.dart';
import 'package:zulip/widgets/app.dart';

import 'model/binding.dart';
import 'example_data.dart' as eg;

FakeAndroidFlutterLocalNotificationsPlugin get notifAndroid =>
testBinding.notifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
as FakeAndroidFlutterLocalNotificationsPlugin;

MessageFcmMessage messageFcmMessage(
Message zulipMessage, {
String? streamName,
Account? account,
}) {
account ??= eg.selfAccount;
final narrow = SendableNarrow.ofMessage(zulipMessage, selfUserId: account.userId);
return FcmMessage.fromJson({
"event": "message",

"server": "zulip.example.cloud",
"realm_id": "4",
"realm_uri": account.realmUrl.toString(),
"user_id": account.userId.toString(),

"zulip_message_id": zulipMessage.id.toString(),
"time": zulipMessage.timestamp.toString(),
"content": zulipMessage.content,

"sender_id": zulipMessage.senderId.toString(),
"sender_avatar_url": "${account.realmUrl}avatar/${zulipMessage.senderId}.jpeg",
"sender_full_name": zulipMessage.senderFullName.toString(),

...(switch (narrow) {
TopicNarrow(:var streamId, :var topic) => {
"recipient_type": "stream",
"stream_id": streamId.toString(),
if (streamName != null) "stream": streamName,
"topic": topic,
},
DmNarrow(allRecipientIds: [_, _, _, ...]) => {
"recipient_type": "private",
"pm_users": narrow.allRecipientIds.join(","),
},
DmNarrow() => {
"recipient_type": "private",
},
}),
}) as MessageFcmMessage;
}

void main() {
TestZulipBinding.ensureInitialized();

Future<void> init() async {
addTearDown(testBinding.reset);
testBinding.firebaseMessagingInitialToken = '012abc';
addTearDown(NotificationService.debugReset);
await NotificationService.instance.start();
}

group('NotificationChannelManager', () {
test('smoke', () async {
await init();
check(notifAndroid.takeCreatedChannels()).single
..id.equals(NotificationChannelManager.kChannelId)
..name.equals('Messages')
..description.isNull()
..groupId.isNull()
..importance.equals(Importance.high)
..playSound.isTrue()
..sound.isNull()
..enableVibration.isTrue()
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern)
..showBadge.isTrue()
..enableLights.isTrue()
..ledColor.isNull()
;
});
});

group('NotificationDisplayManager', () {
Future<void> checkNotification(MessageFcmMessage data, {
required String expectedTitle,
required String expectedTagComponent,
}) async {
testBinding.firebaseMessaging.onMessage.add(
RemoteMessage(data: data.toJson()));
await null;
check(testBinding.notifications.takeShowCalls()).single
..id.equals(NotificationDisplayManager.kNotificationId)
..title.equals(expectedTitle)
..body.equals(data.content)
..notificationDetails.isNotNull().android.isNotNull().which(it()
..channelId.equals(NotificationChannelManager.kChannelId)
..tag.equals('${data.realmUri}|${data.userId}|$expectedTagComponent')
..color.equals(kZulipBrandColor)
..icon.equals('zulip_notification')
);
}

test('stream message', () async {
await init();
final stream = eg.stream();
final message = eg.streamMessage(stream: stream);
await checkNotification(messageFcmMessage(message, streamName: stream.name),
expectedTitle: '${stream.name} > ${message.subject}',
expectedTagComponent: 'stream:${message.streamId}:${message.subject}');
});

test('stream message, stream name omitted', () async {
await init();
final stream = eg.stream();
final message = eg.streamMessage(stream: stream);
await checkNotification(messageFcmMessage(message, streamName: null),
expectedTitle: '(unknown stream) > ${message.subject}',
expectedTagComponent: 'stream:${message.streamId}:${message.subject}');
});

test('group DM', () async {
await init();
final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]);
await checkNotification(messageFcmMessage(message),
expectedTitle: "${eg.thirdUser.fullName} to you and 1 others",
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
});

test('1:1 DM', () async {
await init();
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
await checkNotification(messageFcmMessage(message),
expectedTitle: eg.otherUser.fullName,
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
});

test('self-DM', () async {
await init();
final message = eg.dmMessage(from: eg.selfUser, to: []);
await checkNotification(messageFcmMessage(message),
expectedTitle: eg.selfUser.fullName,
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
});
});
}

extension AndroidNotificationChannelChecks on Subject<AndroidNotificationChannel> {
Subject<String> get id => has((x) => x.id, 'id');
Subject<String> get name => has((x) => x.name, 'name');
Subject<String?> get description => has((x) => x.description, 'description');
Subject<String?> get groupId => has((x) => x.groupId, 'groupId');
Subject<Importance> get importance => has((x) => x.importance, 'importance');
Subject<bool> get playSound => has((x) => x.playSound, 'playSound');
Subject<AndroidNotificationSound?> get sound => has((x) => x.sound, 'sound');
Subject<bool> get enableVibration => has((x) => x.enableVibration, 'enableVibration');
Subject<bool> get enableLights => has((x) => x.enableLights, 'enableLights');
Subject<Int64List?> get vibrationPattern => has((x) => x.vibrationPattern, 'vibrationPattern');
Subject<Color?> get ledColor => has((x) => x.ledColor, 'ledColor');
Subject<bool> get showBadge => has((x) => x.showBadge, 'showBadge');
}

extension ShowCallChecks on Subject<FlutterLocalNotificationsPluginShowCall> {
Subject<int> get id => has((x) => x.$1, 'id');
Subject<String?> get title => has((x) => x.$2, 'title');
Subject<String?> get body => has((x) => x.$3, 'body');
Subject<NotificationDetails?> get notificationDetails => has((x) => x.$4, 'notificationDetails');
Subject<String?> get payload => has((x) => x.payload, 'payload');
}

extension NotificationDetailsChecks on Subject<NotificationDetails> {
Subject<AndroidNotificationDetails?> get android => has((x) => x.android, 'android');
Subject<DarwinNotificationDetails?> get iOS => has((x) => x.iOS, 'iOS');
Subject<DarwinNotificationDetails?> get macOS => has((x) => x.macOS, 'macOS');
Subject<LinuxNotificationDetails?> get linux => has((x) => x.linux, 'linux');
}

extension AndroidNotificationDetailsChecks on Subject<AndroidNotificationDetails> {
// The upstream [AndroidNotificationDetails] has many more properties
// which only apply to creating a channel, or to notifications before
// channels were introduced in Android 8. We ignore those here.
Subject<String?> get icon => has((x) => x.icon, 'icon');
Subject<String> get channelId => has((x) => x.channelId, 'channelId');
Subject<StyleInformation?> get styleInformation => has((x) => x.styleInformation, 'styleInformation');
Subject<String?> get groupKey => has((x) => x.groupKey, 'groupKey');
Subject<bool> get setAsGroupSummary => has((x) => x.setAsGroupSummary, 'setAsGroupSummary');
Subject<GroupAlertBehavior> get groupAlertBehavior => has((x) => x.groupAlertBehavior, 'groupAlertBehavior');
Subject<bool> get autoCancel => has((x) => x.autoCancel, 'autoCancel');
Subject<bool> get ongoing => has((x) => x.ongoing, 'ongoing');
Subject<Color?> get color => has((x) => x.color, 'color');
Subject<AndroidBitmap<Object>?> get largeIcon => has((x) => x.largeIcon, 'largeIcon');
Subject<bool> get onlyAlertOnce => has((x) => x.onlyAlertOnce, 'onlyAlertOnce');
Subject<bool> get showWhen => has((x) => x.showWhen, 'showWhen');
Subject<int?> get when => has((x) => x.when, 'when');
Subject<bool> get usesChronometer => has((x) => x.usesChronometer, 'usesChronometer');
Subject<bool> get chronometerCountDown => has((x) => x.chronometerCountDown, 'chronometerCountDown');
Subject<bool> get showProgress => has((x) => x.showProgress, 'showProgress');
Subject<int> get maxProgress => has((x) => x.maxProgress, 'maxProgress');
Subject<int> get progress => has((x) => x.progress, 'progress');
Subject<bool> get indeterminate => has((x) => x.indeterminate, 'indeterminate');
Subject<String?> get ticker => has((x) => x.ticker, 'ticker');
Subject<AndroidNotificationChannelAction> get channelAction => has((x) => x.channelAction, 'channelAction');
Subject<NotificationVisibility?> get visibility => has((x) => x.visibility, 'visibility');
Subject<int?> get timeoutAfter => has((x) => x.timeoutAfter, 'timeoutAfter');
Subject<AndroidNotificationCategory?> get category => has((x) => x.category, 'category');
Subject<bool> get fullScreenIntent => has((x) => x.fullScreenIntent, 'fullScreenIntent');
Subject<String?> get shortcutId => has((x) => x.shortcutId, 'shortcutId');
Subject<Int32List?> get additionalFlags => has((x) => x.additionalFlags, 'additionalFlags');
Subject<List<AndroidNotificationAction>?> get actions => has((x) => x.actions, 'actions');
Subject<String?> get subText => has((x) => x.subText, 'subText');
Subject<String?> get tag => has((x) => x.tag, 'tag');
Subject<bool> get colorized => has((x) => x.colorized, 'colorized');
Subject<int?> get number => has((x) => x.number, 'number');
Subject<AudioAttributesUsage> get audioAttributesUsage => has((x) => x.audioAttributesUsage, 'audioAttributesUsage');
}

0 comments on commit 4d25e11

Please sign in to comment.