Skip to content

Commit f48f6b8

Browse files
committed
notif: Ignore notifications for logged out accounts
Fixes: #1264 Notifications are ignored for accounts which were logged out but the request to stop receiving notifications failed due to some reason. Also when an account is logged out, any existing notifications for that account is removed from the UI.
1 parent e42b443 commit f48f6b8

File tree

4 files changed

+171
-5
lines changed

4 files changed

+171
-5
lines changed

lib/model/actions.dart

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import 'dart:async';
22

3+
import 'package:flutter/foundation.dart';
4+
5+
import '../notifications/display.dart';
36
import '../notifications/receive.dart';
47
import 'store.dart';
58

@@ -11,6 +14,10 @@ Future<void> logOutAccount(GlobalStore globalStore, int accountId) async {
1114
// Unawaited, to not block removing the account on this request.
1215
unawaited(unregisterToken(globalStore, accountId));
1316

17+
if (defaultTargetPlatform == TargetPlatform.android) {
18+
unawaited(NotificationDisplayManager.removeNotificationsForAccount(account.realmUrl, account.userId));
19+
}
20+
1421
await globalStore.removeAccount(accountId);
1522
}
1623

lib/notifications/display.dart

+26
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,18 @@ class NotificationDisplayManager {
234234
final groupKey = _groupKey(data.realmUrl, data.userId);
235235
final conversationKey = _conversationKey(data, groupKey);
236236

237+
final globalStore = await ZulipBinding.instance.getGlobalStore();
238+
final account = globalStore.accounts.firstWhereOrNull((account) =>
239+
account.realmUrl.origin == data.realmUrl.origin && account.userId == data.userId);
240+
241+
// Skip showing notifications for a logged-out account. This can occur if
242+
// the unregisterToken request failed previously. It would be annoying
243+
// to the user if notifications keep showing up after they've logged out.
244+
// (Also alarming: it suggests the logout didn't fully work.)
245+
if (account == null) {
246+
return;
247+
}
248+
237249
final oldMessagingStyle = await _androidHost
238250
.getActiveNotificationMessagingStyleByTag(conversationKey);
239251

@@ -421,6 +433,20 @@ class NotificationDisplayManager {
421433
}
422434
}
423435

436+
static Future<void> removeNotificationsForAccount(Uri realmUrl, int userId) async {
437+
assert(defaultTargetPlatform == TargetPlatform.android);
438+
439+
final groupKey = _groupKey(realmUrl, userId);
440+
final activeNotifications = await _androidHost.getActiveNotifications(
441+
desiredExtras: []);
442+
for (final statusBarNotification in activeNotifications) {
443+
if (statusBarNotification.notification.group == groupKey) {
444+
await _androidHost.cancel(
445+
tag: statusBarNotification.tag, id: statusBarNotification.id);
446+
}
447+
}
448+
}
449+
424450
/// The constant numeric "ID" we use for all non-test notifications,
425451
/// along with unique tags.
426452
///

test/model/actions_test.dart

+63
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import 'dart:io';
2+
13
import 'package:checks/checks.dart';
4+
import 'package:firebase_messaging/firebase_messaging.dart';
25
import 'package:flutter/foundation.dart';
36
import 'package:flutter_test/flutter_test.dart';
47
import 'package:http/http.dart' as http;
8+
import 'package:http/testing.dart' as http_testing;
59
import 'package:zulip/model/actions.dart';
610
import 'package:zulip/model/store.dart';
711
import 'package:zulip/notifications/receive.dart';
@@ -12,7 +16,9 @@ import '../fake_async.dart';
1216
import '../model/binding.dart';
1317
import '../model/store_checks.dart';
1418
import '../model/test_store.dart';
19+
import '../notifications/display_test.dart';
1520
import '../stdlib_checks.dart';
21+
import '../test_images.dart';
1622
import 'store_test.dart';
1723

1824
void main() {
@@ -21,6 +27,24 @@ void main() {
2127
late PerAccountStore store;
2228
late FakeApiConnection connection;
2329

30+
http.Client makeFakeHttpClient({http.Response? response, Exception? exception}) {
31+
return http_testing.MockClient((request) async {
32+
assert((response != null) ^ (exception != null));
33+
if (exception != null) throw exception;
34+
return response!; // TODO return 404 on non avatar urls
35+
});
36+
}
37+
38+
final fakeHttpClientGivingSuccess = makeFakeHttpClient(
39+
response: http.Response.bytes(kSolidBlueAvatar, HttpStatus.ok));
40+
41+
T runWithHttpClient<T>(
42+
T Function() callback, {
43+
http.Client Function()? httpClientFactory,
44+
}) {
45+
return http.runWithClient(callback, httpClientFactory ?? () => fakeHttpClientGivingSuccess);
46+
}
47+
2448
Future<void> prepare({String? ackedPushToken = '123'}) async {
2549
addTearDown(testBinding.reset);
2650
final selfAccount = eg.selfAccount.copyWith(ackedPushToken: Value(ackedPushToken));
@@ -121,6 +145,45 @@ void main() {
121145
async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration);
122146
check(newConnection.isOpen).isFalse();
123147
}));
148+
149+
test('notifications are removed after logout', () => awaitFakeAsync((async) async {
150+
await prepare();
151+
testBinding.firebaseMessagingInitialToken = '123';
152+
addTearDown(NotificationService.debugReset);
153+
NotificationService.debugBackgroundIsolateIsLive = false;
154+
await runWithHttpClient(NotificationService.instance.start);
155+
156+
check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id);
157+
const unregisterDelay = Duration(seconds: 5);
158+
assert(unregisterDelay > TestGlobalStore.removeAccountDuration);
159+
final newConnection = separateConnection()
160+
..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'});
161+
162+
// Create a notification to check that it's removed after logout
163+
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
164+
testBinding.firebaseMessaging.onMessage.add(
165+
RemoteMessage(data: messageFcmMessage(message).toJson()));
166+
async.flushMicrotasks();
167+
168+
check(testBinding.androidNotificationHost.activeNotifications).isNotEmpty();
169+
170+
final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
171+
// Unregister-token request and account removal dispatched together
172+
checkSingleUnregisterRequest(newConnection);
173+
check(testBinding.globalStore.takeDoRemoveAccountCalls())
174+
.single.equals(eg.selfAccount.id);
175+
176+
async.elapse(TestGlobalStore.removeAccountDuration);
177+
await future;
178+
// Account removal not blocked on unregister-token response
179+
check(testBinding.globalStore).accountIds.isEmpty();
180+
check(connection.isOpen).isFalse();
181+
check(newConnection.isOpen).isTrue(); // for the unregister-token request
182+
183+
async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration);
184+
check(newConnection.isOpen).isFalse();
185+
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
186+
}));
124187
});
125188

126189
group('unregisterToken', () {

test/notifications/display_test.dart

+75-5
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ import 'package:zulip/widgets/message_list.dart';
2626
import 'package:zulip/widgets/page.dart';
2727
import 'package:zulip/widgets/theme.dart';
2828

29+
import '../example_data.dart' as eg;
2930
import '../fake_async.dart';
3031
import '../model/binding.dart';
31-
import '../example_data.dart' as eg;
3232
import '../model/narrow_checks.dart';
3333
import '../stdlib_checks.dart';
3434
import '../test_images.dart';
@@ -114,7 +114,10 @@ void main() {
114114
return http.runWithClient(callback, httpClientFactory ?? () => fakeHttpClientGivingSuccess);
115115
}
116116

117-
Future<void> init() async {
117+
Future<void> init({bool addSelfAccount = true}) async {
118+
if (addSelfAccount) {
119+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
120+
}
118121
addTearDown(testBinding.reset);
119122
testBinding.firebaseMessagingInitialToken = '012abc';
120123
addTearDown(NotificationService.debugReset);
@@ -872,7 +875,8 @@ void main() {
872875
})));
873876

874877
test('remove: different realm URLs but same user-ids and same message-ids', () => runWithHttpClient(() => awaitFakeAsync((async) async {
875-
await init();
878+
await init(addSelfAccount: false);
879+
876880
final stream = eg.stream();
877881
const topic = 'Some Topic';
878882
final conversationKey = 'stream:${stream.streamId}:some topic';
@@ -881,6 +885,7 @@ void main() {
881885
realmUrl: Uri.parse('https://1.chat.example'),
882886
id: 1001,
883887
user: eg.user(userId: 1001));
888+
await testBinding.globalStore.add(account1, eg.initialSnapshot());
884889
final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic);
885890
final data1 =
886891
messageFcmMessage(message1, account: account1, streamName: stream.name);
@@ -890,6 +895,7 @@ void main() {
890895
realmUrl: Uri.parse('https://2.chat.example'),
891896
id: 1002,
892897
user: eg.user(userId: 1001));
898+
await testBinding.globalStore.add(account2, eg.initialSnapshot());
893899
final message2 = eg.streamMessage(id: 1000, stream: stream, topic: topic);
894900
final data2 =
895901
messageFcmMessage(message2, account: account2, streamName: stream.name);
@@ -917,19 +923,21 @@ void main() {
917923
})));
918924

919925
test('remove: different user-ids but same realm URL and same message-ids', () => runWithHttpClient(() => awaitFakeAsync((async) async {
920-
await init();
926+
await init(addSelfAccount: false);
921927
final realmUrl = eg.realmUrl;
922928
final stream = eg.stream();
923929
const topic = 'Some Topic';
924930
final conversationKey = 'stream:${stream.streamId}:some topic';
925931

926932
final account1 = eg.account(id: 1001, user: eg.user(userId: 1001), realmUrl: realmUrl);
933+
await testBinding.globalStore.add(account1, eg.initialSnapshot());
927934
final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic);
928935
final data1 =
929936
messageFcmMessage(message1, account: account1, streamName: stream.name);
930937
final groupKey1 = '${account1.realmUrl}|${account1.userId}';
931938

932939
final account2 = eg.account(id: 1002, user: eg.user(userId: 1002), realmUrl: realmUrl);
940+
await testBinding.globalStore.add(account2, eg.initialSnapshot());
933941
final message2 = eg.streamMessage(id: 1000, stream: stream, topic: topic);
934942
final data2 =
935943
messageFcmMessage(message2, account: account2, streamName: stream.name);
@@ -955,6 +963,68 @@ void main() {
955963
receiveFcmMessage(async, removeFcmMessage([message2], account: account2));
956964
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
957965
})));
966+
967+
test('removeNotificationsForAccount: removes notifications', () => runWithHttpClient(() => awaitFakeAsync((async) async {
968+
await init();
969+
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
970+
971+
receiveFcmMessage(async, messageFcmMessage(message));
972+
check(testBinding.androidNotificationHost.activeNotifications).isNotEmpty();
973+
await NotificationDisplayManager.removeNotificationsForAccount(
974+
eg.selfAccount.realmUrl, eg.selfAccount.userId);
975+
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
976+
})));
977+
978+
test('removeNotificationsForAccount: leaves notifications for other accounts (same realm URL)', () => runWithHttpClient(() => awaitFakeAsync((async) async {
979+
await init(addSelfAccount: false);
980+
981+
final realmUrl = eg.realmUrl;
982+
final account1 = eg.account(id: 1001, user: eg.user(userId: 1001), realmUrl: realmUrl);
983+
final account2 = eg.account(id: 1002, user: eg.user(userId: 1002), realmUrl: realmUrl);
984+
await testBinding.globalStore.add(account1, eg.initialSnapshot());
985+
await testBinding.globalStore.add(account2, eg.initialSnapshot());
986+
987+
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
988+
final message1 = eg.streamMessage();
989+
final message2 = eg.streamMessage();
990+
receiveFcmMessage(async, messageFcmMessage(message1, account: account1));
991+
receiveFcmMessage(async, messageFcmMessage(message2, account: account2));
992+
993+
check(testBinding.androidNotificationHost.activeNotifications).length.equals(4);
994+
await NotificationDisplayManager.removeNotificationsForAccount(realmUrl, account1.userId);
995+
check(testBinding.androidNotificationHost.activeNotifications).length.equals(2);
996+
check(testBinding.androidNotificationHost.activeNotifications.first.notification.group)
997+
.equals('$realmUrl|${account2.userId}');
998+
})));
999+
1000+
test('removeNotificationsForAccount leaves notifications for other accounts (same user-ids)', () => runWithHttpClient(() => awaitFakeAsync((async) async {
1001+
await init(addSelfAccount: false);
1002+
1003+
final userId = 1001;
1004+
final account1 = eg.account(id: 1001, user: eg.user(userId: userId), realmUrl: Uri.parse('https://realm1.example'));
1005+
final account2 = eg.account(id: 1002, user: eg.user(userId: userId), realmUrl: Uri.parse('https://realm2.example'));
1006+
await testBinding.globalStore.add(account1, eg.initialSnapshot());
1007+
await testBinding.globalStore.add(account2, eg.initialSnapshot());
1008+
1009+
final message1 = eg.streamMessage();
1010+
final message2 = eg.streamMessage();
1011+
receiveFcmMessage(async, messageFcmMessage(message1, account: account1));
1012+
receiveFcmMessage(async, messageFcmMessage(message2, account: account2));
1013+
1014+
check(testBinding.androidNotificationHost.activeNotifications).length.equals(4);
1015+
await NotificationDisplayManager.removeNotificationsForAccount(account1.realmUrl, userId);
1016+
check(testBinding.androidNotificationHost.activeNotifications).length.equals(2);
1017+
check(testBinding.androidNotificationHost.activeNotifications.first.notification.group)
1018+
.equals('${account2.realmUrl}|$userId');
1019+
})));
1020+
1021+
test('removeNotificationsForAccount does nothing if there are no notifications', () => runWithHttpClient(() => awaitFakeAsync((async) async {
1022+
await init();
1023+
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
1024+
await NotificationDisplayManager.removeNotificationsForAccount(eg.selfAccount.realmUrl, eg.selfAccount.userId);
1025+
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
1026+
})));
1027+
9581028
});
9591029

9601030
group('NotificationDisplayManager open', () {
@@ -976,7 +1046,7 @@ void main() {
9761046

9771047
Future<void> prepare(WidgetTester tester,
9781048
{bool early = false, bool withAccount = true}) async {
979-
await init();
1049+
await init(addSelfAccount: false);
9801050
pushedRoutes = [];
9811051
final testNavObserver = TestNavigatorObserver()
9821052
..onPushed = (route, prevRoute) => pushedRoutes.add(route);

0 commit comments

Comments
 (0)