Skip to content

Commit

Permalink
wip notif: Navigate to conversation on tapping notification
Browse files Browse the repository at this point in the history
Fixes: 123
  • Loading branch information
gnprice committed Nov 2, 2023
1 parent 16bca41 commit 3f6ce0e
Show file tree
Hide file tree
Showing 3 changed files with 143 additions and 3 deletions.
35 changes: 35 additions & 0 deletions lib/notifications.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import 'dart:convert';

import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
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 'model/narrow.dart';
import 'widgets/app.dart';
import 'widgets/message_list.dart';
import 'widgets/page.dart';
import 'widgets/store.dart';

class NotificationService {
static NotificationService get instance => (_instance ??= NotificationService._());
Expand Down Expand Up @@ -187,6 +192,7 @@ class NotificationDisplayManager {
const InitializationSettings(
android: AndroidInitializationSettings('zulip_notification'),
),
onDidReceiveNotificationResponse: _onNotificationOpened,
);
await NotificationChannelManager._ensureChannel();
}
Expand All @@ -208,6 +214,7 @@ class NotificationDisplayManager {
notificationIdAsHashOf(conversationKey),
title,
data.content,
payload: jsonEncode(dataJson),
NotificationDetails(android: AndroidNotificationDetails(
NotificationChannelManager.kChannelId,
// This [FlutterLocalNotificationsPlugin.show] call can potentially create
Expand All @@ -222,6 +229,9 @@ class NotificationDisplayManager {
color: kZulipBrandColor,
icon: 'zulip_notification', // TODO vary for debug
// TODO(#128) inbox-style

// TODO plugin sets PendingIntent.FLAG_UPDATE_CURRENT; is that OK?
// TODO plugin doesn't set our Intent flags; is that OK?
)));
}

Expand Down Expand Up @@ -254,4 +264,29 @@ class NotificationDisplayManager {
// https://url.spec.whatwg.org/#url-code-points
return "${data.realmUri}|${data.userId}";
}

static void _onNotificationOpened(NotificationResponse response) async {
final data = MessageFcmMessage.fromJson(jsonDecode(response.payload!));
assert(debugLog('opened notif: message ${data.zulipMessageId}, content ${data.content}'));
final navigator = navigatorKey.currentState;
if (navigator == null) return; // TODO(log) handle

final globalStore = GlobalStoreWidget.of(navigator.context);
final account = globalStore.accounts.firstWhereOrNull((account) =>
account.realmUrl == data.realmUri && account.userId == data.userId);
if (account == null) return; // TODO(log)

final narrow = switch (data.recipient) {
FcmMessageStreamRecipient(:var streamId, :var topic) =>
TopicNarrow(streamId, topic),
FcmMessageDmRecipient(:var allRecipientIds) =>
DmNarrow(allRecipientIds: allRecipientIds, selfUserId: account.userId),
};

assert(debugLog(' account: $account, narrow: $narrow'));
// TODO(nav): Better interact with existing nav stack on notif open
navigator.push(MaterialWidgetRoute(
page: PerAccountStoreWidget(accountId: account.id,
child: MessageListPage(narrow: narrow))));
}
}
3 changes: 3 additions & 0 deletions lib/widgets/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import 'page.dart';
import 'recent_dm_conversations.dart';
import 'store.dart';

final navigatorKey = GlobalKey<NavigatorState>();

class ZulipApp extends StatelessWidget {
const ZulipApp({super.key, this.navigatorObservers});

Expand Down Expand Up @@ -54,6 +56,7 @@ class ZulipApp extends StatelessWidget {
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
supportedLocales: ZulipLocalizations.supportedLocales,
theme: theme,
navigatorKey: navigatorKey,
navigatorObservers: navigatorObservers ?? const [],
builder: (BuildContext context, Widget? child) {
GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context);
Expand Down
108 changes: 105 additions & 3 deletions test/notifications_test.dart
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui';

import 'package:checks/checks.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message;
import 'package:test/scaffolding.dart';
import 'package:flutter_test/flutter_test.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 'package:zulip/widgets/message_list.dart';
import 'package:zulip/widgets/page.dart';
import 'package:zulip/widgets/store.dart';

import 'model/binding.dart';
import 'example_data.dart' as eg;
import 'test_navigation.dart';
import 'widgets/message_list_checks.dart';
import 'widgets/page_checks.dart';
import 'widgets/store_checks.dart';

FakeAndroidFlutterLocalNotificationsPlugin get notifAndroid =>
testBinding.notifications
Expand Down Expand Up @@ -93,7 +101,7 @@ void main() {
});
});

group('NotificationDisplayManager', () {
group('NotificationDisplayManager show', () {
void checkNotification(MessageFcmMessage data, {
required String expectedTitle,
required String expectedTagComponent,
Expand All @@ -105,6 +113,7 @@ void main() {
..id.equals(expectedId)
..title.equals(expectedTitle)
..body.equals(data.content)
..payload.equals(jsonEncode(data.toJson()))
..notificationDetails.isNotNull().android.isNotNull().which(it()
..channelId.equals(NotificationChannelManager.kChannelId)
..tag.equals(expectedTag)
Expand Down Expand Up @@ -172,6 +181,99 @@ void main() {
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
});
});

group('NotificationDisplayManager open', () {
late List<Route<dynamic>> pushedRoutes;

Future<void> prepare(WidgetTester tester) async {
await init();
pushedRoutes = [];
final testNavObserver = TestNavigatorObserver()
..onPushed = (route, prevRoute) => pushedRoutes.add(route);
await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver]));
await tester.pump();
check(pushedRoutes).length.equals(1);
pushedRoutes.clear();
}

void openNotification(Account account, Message message) {
final fcmMessage = messageFcmMessage(message, account: account);
testBinding.notifications.receiveNotificationResponse(NotificationResponse(
notificationResponseType: NotificationResponseType.selectedNotification,
payload: jsonEncode(fcmMessage)));
}

void checkOpenedMessageList({required int expectedAccountId, required Narrow expectedNarrow}) {
check(pushedRoutes).single.isA<WidgetRoute>().page
.isA<PerAccountStoreWidget>()
..accountId.equals(expectedAccountId)
..child.isA<MessageListPage>()
.narrow.equals(expectedNarrow);
pushedRoutes.clear();
}

void checkOpenNotification(Account account, Message message) {
openNotification(account, message);
checkOpenedMessageList(
expectedAccountId: account.id,
expectedNarrow: SendableNarrow.ofMessage(message,
selfUserId: eg.selfUser.userId));
}

testWidgets('stream message', (tester) async {
testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
await prepare(tester);
checkOpenNotification(eg.selfAccount, eg.streamMessage());
});

testWidgets('direct message', (tester) async {
testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
await prepare(tester);
checkOpenNotification(eg.selfAccount,
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]));
});

testWidgets('no widgets in tree', (tester) async {
await init();
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);

openNotification(eg.selfAccount, message);
// nothing happened, but nothing blew up
});

testWidgets('no accounts', (tester) async {
await prepare(tester);
openNotification(eg.selfAccount, eg.streamMessage());
check(pushedRoutes).isEmpty();
});

testWidgets('mismatching account', (tester) async {
testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
await prepare(tester);
openNotification(eg.otherAccount, eg.streamMessage());
check(pushedRoutes).isEmpty();
});

testWidgets('find account among several', (tester) async {
final realmUrlA = Uri.parse('https://a-chat.example/');
final realmUrlB = Uri.parse('https://chat-b.example/');
final accounts = [
eg.account(id: 1001, realmUrl: realmUrlA, user: eg.user(userId: 123)),
eg.account(id: 1002, realmUrl: realmUrlA, user: eg.user(userId: 234)),
eg.account(id: 1003, realmUrl: realmUrlB, user: eg.user(userId: 123)),
eg.account(id: 1004, realmUrl: realmUrlB, user: eg.user(userId: 234)),
];
for (final account in accounts) {
testBinding.globalStore.insertAccount(account.toCompanion(false));
}
await prepare(tester);

checkOpenNotification(accounts[0], eg.streamMessage());
checkOpenNotification(accounts[1], eg.streamMessage());
checkOpenNotification(accounts[2], eg.streamMessage());
checkOpenNotification(accounts[3], eg.streamMessage());
});
});
}

extension AndroidNotificationChannelChecks on Subject<AndroidNotificationChannel> {
Expand Down

0 comments on commit 3f6ce0e

Please sign in to comment.