Skip to content

Commit 9350346

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 55cab94 commit 9350346

File tree

4 files changed

+114
-10
lines changed

4 files changed

+114
-10
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

+17
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

+57-4
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

+33-6
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);
@@ -481,7 +484,8 @@ void main() {
481484
await init();
482485
final stream = eg.stream();
483486
final message = eg.streamMessage(stream: stream);
484-
await checkNotifications(async, messageFcmMessage(message, streamName: stream.name),
487+
await checkNotifications(async,
488+
messageFcmMessage(message, streamName: stream.name),
485489
expectedIsGroupConversation: true,
486490
expectedTitle: '#${stream.name} > ${message.topic}',
487491
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
@@ -613,7 +617,8 @@ void main() {
613617
await init();
614618
final stream = eg.stream();
615619
final message = eg.streamMessage(stream: stream);
616-
await checkNotifications(async, messageFcmMessage(message, streamName: null),
620+
await checkNotifications(async,
621+
messageFcmMessage(message, streamName: null),
617622
expectedIsGroupConversation: true,
618623
expectedTitle: '#(unknown channel) > ${message.topic}',
619624
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
@@ -730,6 +735,7 @@ void main() {
730735
await init();
731736
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
732737
final data = messageFcmMessage(message);
738+
733739
receiveFcmMessage(async, data);
734740
checkNotification(data,
735741
messageStyleMessages: [data],
@@ -746,6 +752,7 @@ void main() {
746752
await init();
747753
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
748754
final data = messageFcmMessage(message);
755+
749756
receiveFcmMessage(async, data);
750757
checkNotification(data,
751758
messageStyleMessages: [data],
@@ -872,7 +879,8 @@ void main() {
872879
})));
873880

874881
test('remove: different realm URLs but same user-ids and same message-ids', () => runWithHttpClient(() => awaitFakeAsync((async) async {
875-
await init();
882+
await init(addSelfAccount: false);
883+
876884
final stream = eg.stream();
877885
const topic = 'Some Topic';
878886
final conversationKey = 'stream:${stream.streamId}:some topic';
@@ -881,6 +889,7 @@ void main() {
881889
realmUrl: Uri.parse('https://1.chat.example'),
882890
id: 1001,
883891
user: eg.user(userId: 1001));
892+
await testBinding.globalStore.add(account1, eg.initialSnapshot());
884893
final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic);
885894
final data1 =
886895
messageFcmMessage(message1, account: account1, streamName: stream.name);
@@ -890,6 +899,7 @@ void main() {
890899
realmUrl: Uri.parse('https://2.chat.example'),
891900
id: 1002,
892901
user: eg.user(userId: 1001));
902+
await testBinding.globalStore.add(account2, eg.initialSnapshot());
893903
final message2 = eg.streamMessage(id: 1000, stream: stream, topic: topic);
894904
final data2 =
895905
messageFcmMessage(message2, account: account2, streamName: stream.name);
@@ -917,19 +927,21 @@ void main() {
917927
})));
918928

919929
test('remove: different user-ids but same realm URL and same message-ids', () => runWithHttpClient(() => awaitFakeAsync((async) async {
920-
await init();
930+
await init(addSelfAccount: false);
921931
final realmUrl = eg.realmUrl;
922932
final stream = eg.stream();
923933
const topic = 'Some Topic';
924934
final conversationKey = 'stream:${stream.streamId}:some topic';
925935

926936
final account1 = eg.account(id: 1001, user: eg.user(userId: 1001), realmUrl: realmUrl);
937+
await testBinding.globalStore.add(account1, eg.initialSnapshot());
927938
final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic);
928939
final data1 =
929940
messageFcmMessage(message1, account: account1, streamName: stream.name);
930941
final groupKey1 = '${account1.realmUrl}|${account1.userId}';
931942

932943
final account2 = eg.account(id: 1002, user: eg.user(userId: 1002), realmUrl: realmUrl);
944+
await testBinding.globalStore.add(account2, eg.initialSnapshot());
933945
final message2 = eg.streamMessage(id: 1000, stream: stream, topic: topic);
934946
final data2 =
935947
messageFcmMessage(message2, account: account2, streamName: stream.name);
@@ -955,6 +967,21 @@ void main() {
955967
receiveFcmMessage(async, removeFcmMessage([message2], account: account2));
956968
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
957969
})));
970+
971+
test('removeNotificationsForAccount removes notifications', () => runWithHttpClient(() => awaitFakeAsync((async) async {
972+
await init();
973+
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
974+
975+
await checkNotifications(async, messageFcmMessage(message),
976+
expectedIsGroupConversation: false,
977+
expectedTitle: eg.otherUser.fullName,
978+
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
979+
980+
check(testBinding.androidNotificationHost.activeNotifications).isNotEmpty();
981+
await NotificationDisplayManager.removeNotificationsForAccount(
982+
eg.selfAccount.realmUrl, eg.selfAccount.userId);
983+
check(testBinding.androidNotificationHost.activeNotifications).isEmpty();
984+
})));
958985
});
959986

960987
group('NotificationDisplayManager open', () {

0 commit comments

Comments
 (0)