diff --git a/lib/matrix.dart b/lib/matrix.dart index 399f8a33..a8bd09db 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -42,6 +42,7 @@ export 'src/utils/matrix_file.dart'; export 'src/utils/matrix_id_string_extension.dart'; export 'src/utils/matrix_default_localizations.dart'; export 'src/utils/matrix_localizations.dart'; +export 'src/utils/push_notification.dart'; export 'src/utils/receipt.dart'; export 'src/utils/sync_update_extension.dart'; export 'src/utils/to_device_event.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index 3977e642..429fb083 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -967,6 +967,149 @@ class Client extends MatrixApi { bool _initLock = false; + /// Fetches the corresponding Event object from a notification including a + /// full Room object with the sender User object in it. Returns null if this + /// push notification is not corresponding to an existing event. + /// The client does **not** need to be initialized first. If it is not + /// initalized, it will only fetch the necessary parts of the database. This + /// should make it possible to run this parallel to another client with the + /// same client name. + Future getEventByPushNotification( + PushNotification notification, { + bool storeInDatabase = true, + Duration timeoutForServerRequests = const Duration(seconds: 8), + }) async { + // Get access token if necessary: + final database = _database ??= await databaseBuilder?.call(this); + if (!isLogged()) { + if (database == null) { + throw Exception( + 'Can not execute getEventByPushNotification() without a database'); + } + final clientInfoMap = await database.getClient(clientName); + final token = clientInfoMap?.tryGet('token'); + if (token == null) { + throw Exception('Client is not logged in.'); + } + accessToken = token; + } + + // Check if the notification contains an event at all: + final eventId = notification.eventId; + final roomId = notification.roomId; + if (eventId == null || roomId == null) return null; + + // Create the room object: + final room = getRoomById(roomId) ?? + await database?.getSingleRoom(this, roomId) ?? + Room( + id: roomId, + client: this, + ); + final roomName = notification.roomName; + final roomAlias = notification.roomAlias; + if (roomName != null) { + room.setState(Event( + eventId: 'TEMP', + stateKey: '', + type: EventTypes.RoomName, + content: {'name': roomName}, + room: room, + senderId: 'UNKNOWN', + originServerTs: DateTime.now(), + )); + } + if (roomAlias != null) { + room.setState(Event( + eventId: 'TEMP', + stateKey: '', + type: EventTypes.RoomCanonicalAlias, + content: {'alias': roomAlias}, + room: room, + senderId: 'UNKNOWN', + originServerTs: DateTime.now(), + )); + } + + // Load the event from the notification or from the database or from server: + MatrixEvent? matrixEvent; + final content = notification.content; + final sender = notification.sender; + final type = notification.type; + if (content != null && sender != null && type != null) { + matrixEvent = MatrixEvent( + content: content, + senderId: sender, + type: type, + originServerTs: DateTime.now(), + eventId: eventId, + roomId: roomId, + ); + } + matrixEvent ??= await database + ?.getEventById(eventId, room) + .timeout(timeoutForServerRequests); + try { + matrixEvent ??= await getOneRoomEvent(roomId, eventId) + .timeout(timeoutForServerRequests); + } on MatrixException catch (_) { + // No access to the MatrixEvent. Search in /notifications + final notificationsResponse = await getNotifications(); + matrixEvent ??= notificationsResponse.notifications + .firstWhereOrNull((notification) => + notification.roomId == roomId && + notification.event.eventId == eventId) + ?.event; + } + + if (matrixEvent == null) { + throw Exception('Unable to find event for this push notification!'); + } + + // Load the sender of this event + try { + await room + .requestUser(matrixEvent.senderId) + .timeout(timeoutForServerRequests); + } catch (e, s) { + Logs().w('Unable to request user for push helper', e, s); + final senderDisplayName = notification.senderDisplayName; + if (senderDisplayName != null && sender != null) { + room.setState(User(sender, displayName: senderDisplayName, room: room)); + } + } + + // Create Event object and decrypt if necessary + var event = Event.fromMatrixEvent( + matrixEvent, + room, + status: EventStatus.sent, + ); + + final encryption = this.encryption; + if (event.type == EventTypes.Encrypted && encryption != null) { + event = await encryption.decryptRoomEvent(roomId, event); + if (event.type == EventTypes.Encrypted && _currentSync != null) { + await _currentSync; + event = await encryption.decryptRoomEvent(roomId, event); + } + } + + if (storeInDatabase) { + await database?.transaction(() async { + await database.storeEventUpdate( + EventUpdate( + roomID: roomId, + type: EventUpdateType.timeline, + content: event.toJson(), + ), + this); + }); + } + + return event; + } + /// Sets the user credentials and starts the synchronisation. /// /// Before you can connect you need at least an [accessToken], a [homeserver], diff --git a/lib/src/database/database_api.dart b/lib/src/database/database_api.dart index 2e3b754e..1cc1807a 100644 --- a/lib/src/database/database_api.dart +++ b/lib/src/database/database_api.dart @@ -54,6 +54,9 @@ abstract class DatabaseApi { Future> getRoomList(Client client); + Future getSingleRoom(Client client, String roomId, + {bool loadImportantStates = true}); + Future> getAccountData(); /// Stores a RoomUpdate object in the database. Must be called inside of diff --git a/lib/src/database/fluffybox_database.dart b/lib/src/database/fluffybox_database.dart index 4aec0401..baa6b43e 100644 --- a/lib/src/database/fluffybox_database.dart +++ b/lib/src/database/fluffybox_database.dart @@ -458,6 +458,29 @@ class FluffyBoxDatabase extends DatabaseApi { return OutboundGroupSession.fromJson(copyMap(raw), userId); } + @override + Future getSingleRoom(Client client, String roomId, + {bool loadImportantStates = true}) async { + // Get raw room from database: + final roomData = await _roomsBox.get(roomId); + if (roomData == null) return null; + final room = Room.fromJson(copyMap(roomData), client); + + // Get important states: + if (loadImportantStates) { + final dbKeys = client.importantStateEvents + .map((state) => TupleKey(roomId, state).toString()) + .toList(); + final rawStates = await _roomStateBox.getAll(dbKeys); + for (final rawState in rawStates) { + if (rawState == null) continue; + room.setState(Event.fromJson(copyMap(rawState['']), room)); + } + } + + return room; + } + @override Future> getRoomList(Client client) => runBenchmarked>('Get room list from store', () async { diff --git a/lib/src/database/hive_database.dart b/lib/src/database/hive_database.dart index d2656aea..bd933eaa 100644 --- a/lib/src/database/hive_database.dart +++ b/lib/src/database/hive_database.dart @@ -515,6 +515,31 @@ class FamedlySdkHiveDatabase extends DatabaseApi { return OutboundGroupSession.fromJson(convertToJson(raw), userId); } + @override + Future getSingleRoom(Client client, String roomId, + {bool loadImportantStates = true}) async { + // Get raw room from database: + final roomData = await _roomsBox.get(roomId); + if (roomData == null) return null; + final room = Room.fromJson(copyMap(roomData), client); + + // Get important states: + if (loadImportantStates) { + final dbKeys = client.importantStateEvents + .map((state) => TupleKey(roomId, state).toString()) + .toList(); + final rawStates = await Future.wait( + dbKeys.map((key) => _roomStateBox.get(key)), + ); + for (final rawState in rawStates) { + if (rawState == null) continue; + room.setState(Event.fromJson(copyMap(rawState['']), room)); + } + } + + return room; + } + @override Future> getRoomList(Client client) => runBenchmarked>('Get room list from hive', () async { diff --git a/lib/src/utils/push_notification.dart b/lib/src/utils/push_notification.dart new file mode 100644 index 00000000..77c91300 --- /dev/null +++ b/lib/src/utils/push_notification.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; + +/// Push Notification object from https://spec.matrix.org/v1.2/push-gateway-api/ +class PushNotification { + final Map? content; + final PushNotificationCounts? counts; + final List devices; + final String? eventId; + final String? prio; + final String? roomAlias; + final String? roomId; + final String? roomName; + final String? sender; + final String? senderDisplayName; + final String? type; + + const PushNotification({ + this.content, + this.counts, + required this.devices, + this.eventId, + this.prio, + this.roomAlias, + this.roomId, + this.roomName, + this.sender, + this.senderDisplayName, + this.type, + }); + + /// Generate a Push Notification object from JSON. It also supports a + /// map which usually comes from Firebase Cloud Messaging. + factory PushNotification.fromJson(Map json) => + PushNotification( + content: json['content'] is Map + ? Map.from(json['content']) + : json['content'] is String + ? jsonDecode(json['content']) + : null, + counts: json['counts'] is Map + ? PushNotificationCounts.fromJson(json['counts']) + : json['counts'] is String + ? PushNotificationCounts.fromJson(jsonDecode(json['counts'])) + : null, + devices: json['devices'] is List + ? (json['devices'] as List) + .map((d) => PushNotificationDevice.fromJson(d)) + .toList() + : (jsonDecode(json['devices']) as List) + .map((d) => PushNotificationDevice.fromJson(d)) + .toList(), + eventId: json['event_id'], + prio: json['prio'], + roomAlias: json['room_alias'], + roomId: json['room_id'], + roomName: json['room_name'], + sender: json['sender'], + senderDisplayName: json['sender_display_name'], + type: json['type'], + ); + + Map toJson() => { + if (content != null) 'content': content, + if (counts != null) 'counts': counts?.toJson(), + 'devices': devices.map((i) => i.toJson()).toList(), + if (eventId != null) 'event_id': eventId, + if (prio != null) 'prio': prio, + if (roomAlias != null) 'room_alias': roomAlias, + if (roomId != null) 'room_id': roomId, + if (roomName != null) 'room_name': roomName, + if (sender != null) 'sender': sender, + if (senderDisplayName != null) 'sender_display_name': senderDisplayName, + if (type != null) 'type': type, + }; +} + +class PushNotificationCounts { + final int? missedCalls; + final int? unread; + + const PushNotificationCounts({ + this.missedCalls, + this.unread, + }); + + factory PushNotificationCounts.fromJson(Map json) => + PushNotificationCounts( + missedCalls: json['missed_calls'], + unread: json['unread'], + ); + + Map toJson() => { + if (missedCalls != null) 'missed_calls': missedCalls, + if (unread != null) 'unread': unread, + }; +} + +class PushNotificationDevice { + final String appId; + final Map? data; + final String pushkey; + final int? pushkeyTs; + final Tweaks? tweaks; + + const PushNotificationDevice({ + required this.appId, + this.data, + required this.pushkey, + this.pushkeyTs, + this.tweaks, + }); + + factory PushNotificationDevice.fromJson(Map json) => + PushNotificationDevice( + appId: json['app_id'], + data: json['data'] == null + ? null + : Map.from(json['data']), + pushkey: json['pushkey'], + pushkeyTs: json['pushkey_ts'], + tweaks: json['tweaks'] == null ? null : Tweaks.fromJson(json['tweaks']), + ); + + Map toJson() => { + 'app_id': appId, + if (data != null) 'data': data, + 'pushkey': pushkey, + if (pushkeyTs != null) 'pushkey_ts': pushkeyTs, + if (tweaks != null) 'tweaks': tweaks?.toJson(), + }; +} + +class Tweaks { + final String? sound; + + const Tweaks({ + this.sound, + }); + + factory Tweaks.fromJson(Map json) => Tweaks( + sound: json['sound'], + ); + + Map toJson() => { + if (sound != null) 'sound': sound, + }; +} diff --git a/test/client_test.dart b/test/client_test.dart index 4f6963a6..c210406f 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -917,5 +917,64 @@ void main() { await Future.delayed(Duration(milliseconds: 200)); expect(hiveClient.isLogged(), true); }); + + test('getEventByPushNotification', () async { + final client = Client( + 'testclient', + httpClient: FakeMatrixApi(), + databaseBuilder: getDatabase, + ) + ..accessToken = '1234' + ..baseUri = Uri.parse('https://fakeserver.notexisting'); + Event? event; + event = await client + .getEventByPushNotification(PushNotification(devices: [])); + expect(event, null); + + event = await client.getEventByPushNotification( + PushNotification( + devices: [], + eventId: '123', + roomId: '!localpart2:server.abc', + content: { + 'msgtype': 'm.text', + 'body': 'Hello world', + }, + roomAlias: '#testalias:blaaa', + roomName: 'TestRoomName', + sender: '@alicyy:example.com', + senderDisplayName: 'AlicE', + type: 'm.room.message', + ), + ); + expect(event?.eventId, '123'); + expect(event?.body, 'Hello world'); + expect(event?.senderId, '@alicyy:example.com'); + expect(event?.sender.calcDisplayname(), 'AlicE'); + expect(event?.type, 'm.room.message'); + expect(event?.messageType, 'm.text'); + expect(event?.room.id, '!localpart2:server.abc'); + expect(event?.room.name, 'TestRoomName'); + expect(event?.room.canonicalAlias, '#testalias:blaaa'); + final storedEvent = + await client.database?.getEventById('123', event!.room); + expect(storedEvent?.eventId, event?.eventId); + + event = await client.getEventByPushNotification( + PushNotification( + devices: [], + eventId: '1234', + roomId: '!localpart:server.abc', + ), + ); + expect(event?.eventId, '143273582443PhrSn:example.org'); + expect(event?.room.id, '!localpart:server.abc'); + expect(event?.body, 'This is an example text message'); + expect(event?.messageType, 'm.text'); + expect(event?.type, 'm.room.message'); + final storedEvent2 = await client.database + ?.getEventById('143273582443PhrSn:example.org', event!.room); + expect(storedEvent2?.eventId, event?.eventId); + }); }); } diff --git a/test/database_api_test.dart b/test/database_api_test.dart index 0d809a65..52168071 100644 --- a/test/database_api_test.dart +++ b/test/database_api_test.dart @@ -123,6 +123,11 @@ void testDatabase( final rooms = await database.getRoomList(client); expect(rooms.single.id, '!testroom'); }); + test('getRoomList', () async { + final room = + await database.getSingleRoom(Client('testclient'), '!testroom'); + expect(room?.id, '!testroom'); + }); test('getRoomList', () async { final list = await database.getRoomList(Client('testclient')); expect(list.single.id, '!testroom'); diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index 93fad68b..1ec43b2a 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -103,7 +103,8 @@ class FakeMatrixApi extends MockClient { } } else if (method == 'GET' && action.contains('/client/r0/rooms/') && - action.contains('/state/m.room.member/')) { + action.contains('/state/m.room.member/') && + !action.endsWith('%40alicyy%3Aexample.com')) { res = {'displayname': ''}; } else if (method == 'PUT' && action.contains( diff --git a/test/push_notification.dart b/test/push_notification.dart new file mode 100644 index 00000000..330a4c29 --- /dev/null +++ b/test/push_notification.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:matrix/matrix.dart'; +import 'package:test/test.dart'; + +void main() { + group('Push Notification', () { + Logs().level = Level.error; + + const json = { + 'content': { + 'body': "I'm floating in a most peculiar way.", + 'msgtype': 'm.text' + }, + 'counts': {'missed_calls': 1, 'unread': 2}, + 'devices': [ + { + 'app_id': 'org.matrix.matrixConsole.ios', + 'data': {}, + 'pushkey': 'V2h5IG9uIGVhcnRoIGRpZCB5b3UgZGVjb2RlIHRoaXM/', + 'pushkey_ts': 12345678, + 'tweaks': {'sound': 'bing'} + } + ], + 'event_id': '\$3957tyerfgewrf384', + 'prio': 'high', + 'room_alias': '#exampleroom:matrix.org', + 'room_id': '!slw48wfj34rtnrf:example.com', + 'room_name': 'Mission Control', + 'sender': '@exampleuser:matrix.org', + 'sender_display_name': 'Major Tom', + 'type': 'm.room.message' + }; + + test('fromJson and toJson', () async { + expect(PushNotification.fromJson(json).toJson(), json); + }); + test('fromJson and toJson with String keys only', () async { + final strJson = + json.map((k, v) => MapEntry(k, v is String ? v : jsonEncode(v))); + + expect(PushNotification.fromJson(strJson).toJson(), json); + }); + }); +}