diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index c782ad1a66..0787a61bee 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; import 'package:collection/collection.dart'; @@ -279,10 +280,12 @@ class NotificationDisplayManager { try { // TODO timeout to prevent waiting indefinitely final resp = await http.get(url); - return resp.bodyBytes; + if (resp.statusCode == HttpStatus.ok) { + return resp.bodyBytes; + } } catch (e) { // TODO(log) - return null; } + return null; } } diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index aadd1eb504..d65eb2b558 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; import 'package:checks/checks.dart'; @@ -8,6 +9,8 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message, Person; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/notifications.dart'; import 'package:zulip/host/android_notifications.dart'; @@ -25,6 +28,7 @@ import 'package:zulip/widgets/theme.dart'; import '../fake_async.dart'; import '../model/binding.dart'; import '../example_data.dart' as eg; +import '../test_images.dart'; import '../test_navigation.dart'; import '../widgets/message_list_checks.dart'; import '../widgets/page_checks.dart'; @@ -74,6 +78,24 @@ void main() { TestZulipBinding.ensureInitialized(); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + http.Client makeFakeHttpClient({http.Response? response, Exception? exception}) { + return http_testing.MockClient((request) async { + assert((response != null) ^ (exception != null)); + if (exception != null) throw exception; + return response!; // TODO return 404 on non avatar urls + }); + } + + final fakeHttpClientGivingSuccess = makeFakeHttpClient( + response: http.Response.bytes(kSolidBlueAvatar, HttpStatus.ok)); + + T runWithHttpClient( + T Function() callback, { + http.Client Function()? httpClientFactory, + }) { + return http.runWithClient(callback, httpClientFactory ?? () => fakeHttpClientGivingSuccess); + } + Future init() async { addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; @@ -102,7 +124,11 @@ void main() { required String expectedTitle, required String expectedTagComponent, required bool expectedIsGroupConversation, + List? expectedIconBitmap = kSolidBlueAvatar, }) { + assert(messageStyleMessages.every((e) => e.userId == data.userId)); + assert(messageStyleMessages.every((e) => e.realmUri == data.realmUri)); + final expectedTag = '${data.realmUri}|${data.userId}|$expectedTagComponent'; final expectedGroupKey = '${data.realmUri}|${data.userId}'; final expectedId = @@ -113,9 +139,6 @@ void main() { final messageStyleMessagesChecks = messageStyleMessages.mapIndexed((i, messageData) { - assert(messageData.realmUri == data.realmUri); - assert(messageData.userId == data.userId); - final expectedSenderKey = '${messageData.realmUri}|${messageData.senderId}'; final isLast = i == (messageStyleMessages.length - 1); @@ -123,7 +146,8 @@ void main() { ..text.equals(messageData.content) ..timestampMs.equals(messageData.time * 1000) ..person.which((it) => it.isNotNull() - ..iconBitmap.which((it) => isLast ? it.isNotNull() : it.isNull()) + ..iconBitmap.which((it) => (isLast && expectedIconBitmap != null) + ? it.isNotNull().deepEquals(expectedIconBitmap) : it.isNull()) ..key.equals(expectedSenderKey) ..name.equals(messageData.senderFullName)); }); @@ -209,7 +233,7 @@ void main() { async.flushMicrotasks(); } - test('stream message', () => awaitFakeAsync((async) async { + test('stream message', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final stream = eg.stream(); final message = eg.streamMessage(stream: stream); @@ -217,9 +241,9 @@ void main() { expectedIsGroupConversation: true, expectedTitle: '#${stream.name} > ${message.topic}', expectedTagComponent: 'stream:${message.streamId}:${message.topic}'); - })); + }))); - test('stream message: multiple messages, same topic', () => awaitFakeAsync((async) async { + test('stream message: multiple messages, same topic', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final stream = eg.stream(); const topic = 'topic 1'; @@ -253,9 +277,69 @@ void main() { expectedIsGroupConversation: true, expectedTitle: expectedTitle, expectedTagComponent: expectedTagComponent); - })); + }))); + + test('stream message: multiple messages, different topics', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + final stream = eg.stream(); + const topicA = 'topic A'; + const topicB = 'topic B'; + final message1 = eg.streamMessage(topic: topicA, stream: stream); + final data1 = messageFcmMessage(message1, streamName: stream.name); + final message2 = eg.streamMessage(topic: topicB, stream: stream); + final data2 = messageFcmMessage(message2, streamName: stream.name); + final message3 = eg.streamMessage(topic: topicA, stream: stream); + final data3 = messageFcmMessage(message3, streamName: stream.name); + + await receiveFcmMessage(async, data1); + checkNotification(data1, + messageStyleMessages: [data1], + expectedIsGroupConversation: true, + expectedTitle: '#${stream.name} > $topicA', + expectedTagComponent: 'stream:${stream.streamId}:$topicA'); + + await receiveFcmMessage(async, data2); + checkNotification(data2, + messageStyleMessages: [data2], + expectedIsGroupConversation: true, + expectedTitle: '#${stream.name} > $topicB', + expectedTagComponent: 'stream:${stream.streamId}:$topicB'); - test('stream message: stream name omitted', () => awaitFakeAsync((async) async { + await receiveFcmMessage(async, data3); + checkNotification(data3, + messageStyleMessages: [data1, data3], + expectedIsGroupConversation: true, + expectedTitle: '#${stream.name} > $topicA', + expectedTagComponent: 'stream:${stream.streamId}:$topicA'); + }))); + + test('stream message: conversation stays same when stream is renamed', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + var stream = eg.stream(streamId: 1, name: 'Before'); + const topic = 'topic'; + final message1 = eg.streamMessage(topic: topic, stream: stream); + final data1 = messageFcmMessage(message1, streamName: stream.name); + + await receiveFcmMessage(async, data1); + checkNotification(data1, + messageStyleMessages: [data1], + expectedIsGroupConversation: true, + expectedTitle: '#Before > $topic', + expectedTagComponent: 'stream:${stream.streamId}:$topic'); + + stream = eg.stream(streamId: 1, name: 'After'); + final message2 = eg.streamMessage(topic: topic, stream: stream); + final data2 = messageFcmMessage(message2, streamName: stream.name); + + await receiveFcmMessage(async, data2); + checkNotification(data2, + messageStyleMessages: [data1, data2], + expectedIsGroupConversation: true, + expectedTitle: '#After > $topic', + expectedTagComponent: 'stream:${stream.streamId}:$topic'); + }))); + + test('stream message: stream name omitted', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final stream = eg.stream(); final message = eg.streamMessage(stream: stream); @@ -263,18 +347,18 @@ void main() { expectedIsGroupConversation: true, expectedTitle: '#(unknown channel) > ${message.topic}', expectedTagComponent: 'stream:${message.streamId}:${message.topic}'); - })); + }))); - test('group DM: 3 users', () => awaitFakeAsync((async) async { + test('group DM: 3 users', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]); await checkNotifications(async, messageFcmMessage(message), expectedIsGroupConversation: true, expectedTitle: "${eg.thirdUser.fullName} to you and 1 other", expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); - })); + }))); - test('group DM: more than 3 users', () => awaitFakeAsync((async) async { + test('group DM: more than 3 users', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser, eg.fourthUser]); @@ -282,25 +366,135 @@ void main() { expectedIsGroupConversation: true, expectedTitle: "${eg.thirdUser.fullName} to you and 2 others", expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); - })); + }))); - test('1:1 DM', () => awaitFakeAsync((async) async { + test('group DM: title updates with latest sender', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + final message1 = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser, eg.thirdUser]); + final data1 = messageFcmMessage(message1); + final message2 = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]); + final data2 = messageFcmMessage(message2); + + final expectedTagComponent = 'dm:${message1.allRecipientIds.join(",")}'; + + await receiveFcmMessage(async, data1); + checkNotification(data1, + messageStyleMessages: [data1], + expectedIsGroupConversation: true, + expectedTitle: "${eg.otherUser.fullName} to you and 1 other", + expectedTagComponent: expectedTagComponent); + + await receiveFcmMessage(async, data2); + checkNotification(data2, + messageStyleMessages: [data1, data2], + expectedIsGroupConversation: true, + expectedTitle: "${eg.thirdUser.fullName} to you and 1 other", + expectedTagComponent: expectedTagComponent); + }))); + + test('1:1 DM', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); await checkNotifications(async, messageFcmMessage(message), expectedIsGroupConversation: false, expectedTitle: eg.otherUser.fullName, expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); - })); + }))); + + test('1:1 DM: title updates when sender name changes', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + final otherUser = eg.user(fullName: 'Before'); + final message1 = eg.dmMessage(from: otherUser, to: [eg.selfUser]); + final data1 = messageFcmMessage(message1); + + final expectedTagComponent = 'dm:${message1.allRecipientIds.join(",")}'; + + await receiveFcmMessage(async, data1); + checkNotification(data1, + messageStyleMessages: [data1], + expectedIsGroupConversation: false, + expectedTitle: 'Before', + expectedTagComponent: expectedTagComponent); - test('self-DM', () => awaitFakeAsync((async) async { + otherUser.fullName = 'After'; + final message2 = eg.dmMessage(from: otherUser, to: [eg.selfUser]); + final data2 = messageFcmMessage(message2); + + await receiveFcmMessage(async, data2); + checkNotification(data2, + messageStyleMessages: [data1, data2], + expectedIsGroupConversation: false, + expectedTitle: 'After', + expectedTagComponent: expectedTagComponent); + }))); + + test('1:1 DM: conversation stays same when sender email changes', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + final otherUser = eg.user(email: 'before@example.com'); + final message1 = eg.dmMessage(from: otherUser, to: [eg.selfUser]); + final data1 = messageFcmMessage(message1); + + final expectedTagComponent = 'dm:${message1.allRecipientIds.join(",")}'; + + await receiveFcmMessage(async, data1); + checkNotification(data1, + messageStyleMessages: [data1], + expectedIsGroupConversation: false, + expectedTitle: otherUser.fullName, + expectedTagComponent: expectedTagComponent); + + otherUser.email = 'after@example.com'; + final message2 = eg.dmMessage(from: otherUser, to: [eg.selfUser]); + final data2 = messageFcmMessage(message2); + + await receiveFcmMessage(async, data2); + checkNotification(data2, + messageStyleMessages: [data1, data2], + expectedIsGroupConversation: false, + expectedTitle: otherUser.fullName, + expectedTagComponent: expectedTagComponent); + }))); + + test('1:1 DM: sender avatar loading fails, remote error', () => runWithHttpClient( + () => awaitFakeAsync((async) async { + await init(); + final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + final data = messageFcmMessage(message); + await receiveFcmMessage(async, data); + checkNotification(data, + messageStyleMessages: [data], + expectedIsGroupConversation: false, + expectedTitle: eg.otherUser.fullName, + expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}', + expectedIconBitmap: null); // Failed to fetch avatar photo + }), + httpClientFactory: () => makeFakeHttpClient( + response: http.Response.bytes([], HttpStatus.internalServerError)))); + + test('1:1 DM: sender avatar loading fails, local error', () => runWithHttpClient( + () => awaitFakeAsync((async) async { + await init(); + final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + final data = messageFcmMessage(message); + await receiveFcmMessage(async, data); + checkNotification(data, + messageStyleMessages: [data], + expectedIsGroupConversation: false, + expectedTitle: eg.otherUser.fullName, + expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}', + expectedIconBitmap: null); // Failed to fetch avatar photo + }), + httpClientFactory: () => makeFakeHttpClient( + exception: http.ClientException('Network failure')))); + + test('self-DM', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final message = eg.dmMessage(from: eg.selfUser, to: []); await checkNotifications(async, messageFcmMessage(message), expectedIsGroupConversation: false, expectedTitle: eg.selfUser.fullName, expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); - })); + }))); }); group('NotificationDisplayManager open', () {