Skip to content

Commit f70a02c

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 f70a02c

File tree

4 files changed

+108
-8
lines changed

4 files changed

+108
-8
lines changed

lib/model/actions.dart

Lines changed: 7 additions & 0 deletions
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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,13 @@ 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+
if (account == null) {
241+
return;
242+
}
243+
237244
final oldMessagingStyle = await _androidHost
238245
.getActiveNotificationMessagingStyleByTag(conversationKey);
239246

@@ -518,6 +525,16 @@ class NotificationDisplayManager {
518525
}
519526
return null;
520527
}
528+
529+
static Future<void> removeNotificationsForAccount(Uri realmUri, int userId) async {
530+
final groupKey = _groupKey(realmUri, userId);
531+
final activeNotifications = await _androidHost.getActiveNotifications(desiredExtras: [kExtraLastZulipMessageId]);
532+
for (final statusBarNotification in activeNotifications) {
533+
if (statusBarNotification.notification.group == groupKey) {
534+
await _androidHost.cancel(tag: statusBarNotification.tag, id: statusBarNotification.id);
535+
}
536+
}
537+
}
521538
}
522539

523540
/// The information contained in 'zulip://notification/…' internal

test/model/actions_test.dart

Lines changed: 57 additions & 4 deletions
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,12 +27,35 @@ 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));
2751
await testBinding.globalStore.add(selfAccount, eg.initialSnapshot());
2852
store = await testBinding.globalStore.perAccount(selfAccount.id);
2953
connection = store.connection as FakeApiConnection;
54+
55+
testBinding.firebaseMessagingInitialToken = '123';
56+
addTearDown(NotificationService.debugReset);
57+
NotificationService.debugBackgroundIsolateIsLive = false;
58+
await NotificationService.instance.start();
3059
}
3160

3261
/// Creates and caches a new [FakeApiConnection] in [TestGlobalStore].
@@ -71,14 +100,23 @@ void main() {
71100
}
72101

73102
group('logOutAccount', () {
74-
test('smoke', () => awaitFakeAsync((async) async {
103+
test('smoke', () => runWithHttpClient(() => awaitFakeAsync((async) async {
75104
await prepare();
76105
check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id);
77106
const unregisterDelay = Duration(seconds: 5);
78107
assert(unregisterDelay > TestGlobalStore.removeAccountDuration);
79108
final newConnection = separateConnection()
80109
..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'});
81110

111+
// Create a notification to check that it's removed after logout
112+
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
113+
testBinding.firebaseMessaging.onMessage.add(
114+
RemoteMessage(data: messageFcmMessage(message).toJson()));
115+
async.flushMicrotasks();
116+
117+
// Check that notifications were created
118+
check(testBinding.androidNotificationHost.activeNotifications).isNotEmpty();
119+
82120
final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
83121
// Unregister-token request and account removal dispatched together
84122
checkSingleUnregisterRequest(newConnection);
@@ -94,9 +132,12 @@ void main() {
94132

95133
async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration);
96134
check(newConnection.isOpen).isFalse();
97-
}));
98135

99-
test('unregister request has an error', () => awaitFakeAsync((async) async {
136+
// Check that notifications were removed
137+
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
138+
})));
139+
140+
test('unregister request has an error', () => runWithHttpClient(() => awaitFakeAsync((async) async {
100141
await prepare();
101142
check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id);
102143
const unregisterDelay = Duration(seconds: 5);
@@ -105,6 +146,15 @@ void main() {
105146
final newConnection = separateConnection()
106147
..prepare(delay: unregisterDelay, apiException: exception);
107148

149+
// Create a notification to check that it's removed after logout
150+
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
151+
testBinding.firebaseMessaging.onMessage.add(
152+
RemoteMessage(data: messageFcmMessage(message).toJson()));
153+
async.flushMicrotasks();
154+
155+
// Check that notifications were created
156+
check(testBinding.androidNotificationHost.activeNotifications).isNotEmpty();
157+
108158
final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
109159
// Unregister-token request and account removal dispatched together
110160
checkSingleUnregisterRequest(newConnection);
@@ -120,7 +170,10 @@ void main() {
120170

121171
async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration);
122172
check(newConnection.isOpen).isFalse();
123-
}));
173+
174+
// Check that notifications were removed
175+
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
176+
})));
124177
});
125178

126179
group('unregisterToken', () {

test/notifications/display_test.dart

Lines changed: 27 additions & 4 deletions
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,21 @@ 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+
await checkNotifications(async, messageFcmMessage(message),
972+
expectedIsGroupConversation: false,
973+
expectedTitle: eg.otherUser.fullName,
974+
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
975+
976+
check(testBinding.androidNotificationHost.activeNotifications).isNotEmpty();
977+
await NotificationDisplayManager.removeNotificationsForAccount(
978+
eg.selfAccount.realmUrl, eg.selfAccount.userId);
979+
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
980+
})));
958981
});
959982

960983
group('NotificationDisplayManager open', () {

0 commit comments

Comments
 (0)