From b1709ca8c3409f58ed711cff3fbe329e9b3c3a9c Mon Sep 17 00:00:00 2001 From: Sorunome Date: Wed, 18 Nov 2020 13:50:32 +0100 Subject: [PATCH] feat: More advanced attchment handling methods --- lib/src/event.dart | 163 ++++++++++++++++++++++++++++++++++++------- test/event_test.dart | 46 +++++++++++- 2 files changed, 183 insertions(+), 26 deletions(-) diff --git a/lib/src/event.dart b/lib/src/event.dart index 752074d2..718ab627 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -370,10 +370,135 @@ class Event extends MatrixEvent { return; } + /// Gets the info map of file events, or a blank map if none present + Map get infoMap => + content['info'] is Map ? content['info'] : {}; + + /// Gets the thumbnail info map of file events, or a blank map if nonepresent + Map get thumbnailInfoMap => infoMap['thumbnail_info'] is Map + ? infoMap['thumbnail_info'] + : {}; + + /// Returns if a file event has an attachment + bool get hasAttachment => content['url'] is String || content['file'] is Map; + + /// Returns if a file event has a thumbnail bool get hasThumbnail => - content['info'] is Map && - (content['info']['thumbnail_url'] is String || - content['info']['thumbnail_file'] is Map); + infoMap['thumbnail_url'] is String || infoMap['thumbnail_file'] is Map; + + /// Returns if a file events attachment is encrypted + bool get isAttachmentEncrypted => content['file'] is Map; + + /// Returns if a file events thumbnail is encrypted + bool get isThumbnailEncrypted => infoMap['thumbnail_file'] is Map; + + /// Gets the mimetipe of the attachment of a file event, or a blank string if not present + String get attachmentMimetype => infoMap['mimetype'] is String + ? infoMap['mimetype'].toLowerCase() + : (content['file'] is Map && content['file']['mimetype'] is String + ? content['file']['mimetype'] + : ''); + + /// Gets the mimetype of the thumbnail of a file event, or a blank string if not present + String get thumbnailMimetype => thumbnailInfoMap['mimetype'] is String + ? thumbnailInfoMap['mimetype'].toLowerCase() + : (infoMap['thumbnail_file'] is Map && + infoMap['thumbnail_file']['mimetype'] is String + ? infoMap['thumbnail_file']['mimetype'] + : ''); + + /// Gets the underyling mxc url of an attachment of a file event, or null if not present + String get attachmentMxcUrl => + isAttachmentEncrypted ? content['file']['url'] : content['url']; + + /// Gets the underyling mxc url of a thumbnail of a file event, or null if not present + String get thumbnailMxcUrl => isThumbnailEncrypted + ? infoMap['thumbnail_file']['url'] + : infoMap['thumbnail_url']; + + /// Gets the mxc url of an attachemnt/thumbnail of a file event, taking sizes into account, or null if not present + String attachmentOrThumbnailMxcUrl({bool getThumbnail = false}) { + if (getThumbnail && + infoMap['size'] is int && + thumbnailInfoMap['size'] is int && + infoMap['size'] <= thumbnailInfoMap['size']) { + getThumbnail = false; + } + if (getThumbnail && !hasThumbnail) { + getThumbnail = false; + } + return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl; + } + + // size determined from an approximate 800x800 jpeg thumbnail with method=scale + static const _minNoThumbSize = 80 * 1024; + + /// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny. + /// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment. + /// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method] + /// for the respective thumbnailing properties. + /// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k + /// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment + String getAttachmentUrl( + {bool getThumbnail = false, + bool useThumbnailMxcUrl = false, + double width = 800.0, + double height = 800.0, + ThumbnailMethod method = ThumbnailMethod.scale, + int minNoThumbSize = _minNoThumbSize}) { + if (![EventTypes.Message, EventTypes.Sticker].contains(type) || + !hasAttachment || + isAttachmentEncrypted) { + return null; // can't url-thumbnail in encrypted rooms + } + if (useThumbnailMxcUrl && !hasThumbnail) { + return null; // can't fetch from thumbnail + } + final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap; + final thisMxcUrl = + useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url']; + // if we have as method scale, we can return safely the original image, should it be small enough + if (getThumbnail && + method == ThumbnailMethod.scale && + thisInfoMap['size'] is int && + thisInfoMap['size'] < minNoThumbSize) { + getThumbnail = false; + } + // now generate the actual URLs + if (getThumbnail) { + return Uri.parse(thisMxcUrl).getThumbnail( + room.client, + width: width, + height: height, + method: method, + ); + } else { + return Uri.parse(thisMxcUrl).getDownloadLink(room.client); + } + } + + /// Returns if an attachment is in the local store + Future isAttachmentInLocalStore({bool getThumbnail = false}) async { + if (![EventTypes.Message, EventTypes.Sticker].contains(type)) { + throw ("This event has the type '$type' and so it can't contain an attachment."); + } + final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail); + if (!(mxcUrl is String)) { + throw ("This event hasn't any attachment or thumbnail."); + } + getThumbnail = mxcUrl != attachmentMxcUrl; + // Is this file storeable? + final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap; + var storeable = room.client.database != null && + thisInfoMap['size'] is int && + thisInfoMap['size'] <= room.client.database.maxFileSize; + + Uint8List uint8list; + if (storeable) { + uint8list = await room.client.database.getFile(mxcUrl); + } + return uint8list != null; + } /// Downloads (and decryptes if necessary) the attachment of this /// event and returns it as a [MatrixFile]. If this event doesn't @@ -385,36 +510,26 @@ class Event extends MatrixEvent { if (![EventTypes.Message, EventTypes.Sticker].contains(type)) { throw ("This event has the type '$type' and so it can't contain an attachment."); } - if (!getThumbnail && - !(content['url'] is String) && - !(content['file'] is Map)) { - throw ("This event hasn't any attachment."); + final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail); + if (!(mxcUrl is String)) { + throw ("This event hasn't any attachment or thumbnail."); } - if (getThumbnail && !hasThumbnail) { - throw ("This event hasn't any thumbnail."); - } - final isEncrypted = getThumbnail - ? !(content['info']['thumbnail_url'] is String) - : !(content['url'] is String); + getThumbnail = mxcUrl != attachmentMxcUrl; + final isEncrypted = + getThumbnail ? isThumbnailEncrypted : isAttachmentEncrypted; if (isEncrypted && !room.client.encryptionEnabled) { throw ('Encryption is not enabled in your Client.'); } - var mxContent = getThumbnail - ? Uri.parse(isEncrypted - ? content['info']['thumbnail_file']['url'] - : content['info']['thumbnail_url']) - : Uri.parse(isEncrypted ? content['file']['url'] : content['url']); + final mxContent = Uri.parse(mxcUrl); Uint8List uint8list; // Is this file storeable? - final infoMap = - getThumbnail ? content['info']['thumbnail_info'] : content['info']; + final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap; var storeable = room.client.database != null && - infoMap is Map && - infoMap['size'] is int && - infoMap['size'] <= room.client.database.maxFileSize; + thisInfoMap['size'] is int && + thisInfoMap['size'] <= room.client.database.maxFileSize; if (storeable) { uint8list = await room.client.database.getFile(mxContent.toString()); @@ -438,7 +553,7 @@ class Event extends MatrixEvent { // Decrypt the file if (isEncrypted) { final fileMap = - getThumbnail ? content['info']['thumbnail_file'] : content['file']; + getThumbnail ? infoMap['thumbnail_file'] : content['file']; if (!fileMap['key']['key_ops'].contains('decrypt')) { throw ("Missing 'decrypt' in 'key_ops'."); } diff --git a/test/event_test.dart b/test/event_test.dart index 1fafc567..85ccf87d 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -1040,7 +1040,7 @@ void main() { Timeline(events: [event, edit1, edit2, edit3], room: room)); expect(displayEvent.body, 'Redacted'); }); - test('downloadAndDecryptAttachment', () async { + test('attachments', () async { final FILE_BUFF = Uint8List.fromList([0]); final THUMBNAIL_BUFF = Uint8List.fromList([2]); final downloadCallback = (String url) async { @@ -1066,6 +1066,9 @@ void main() { var buffer = await event.downloadAndDecryptAttachment( downloadCallback: downloadCallback); expect(buffer.bytes, FILE_BUFF); + expect(event.attachmentOrThumbnailMxcUrl(), 'mxc://example.org/file'); + expect(event.attachmentOrThumbnailMxcUrl(getThumbnail: true), + 'mxc://example.org/file'); event = Event.fromJson({ 'type': EventTypes.Message, @@ -1074,12 +1077,41 @@ void main() { 'msgtype': 'm.image', 'url': 'mxc://example.org/file', 'info': { + 'size': 8000000, 'thumbnail_url': 'mxc://example.org/thumb', + 'thumbnail_info': { + 'mimetype': 'thumbnail/mimetype', + }, + 'mimetype': 'application/octet-stream', }, }, 'event_id': '\$edit2', 'sender': '@alice:example.org', }, room); + expect(event.hasAttachment, true); + expect(event.hasThumbnail, true); + expect(event.isAttachmentEncrypted, false); + expect(event.isThumbnailEncrypted, false); + expect(event.attachmentMimetype, 'application/octet-stream'); + expect(event.thumbnailMimetype, 'thumbnail/mimetype'); + expect(event.attachmentMxcUrl, 'mxc://example.org/file'); + expect(event.thumbnailMxcUrl, 'mxc://example.org/thumb'); + expect(event.attachmentOrThumbnailMxcUrl(), 'mxc://example.org/file'); + expect(event.attachmentOrThumbnailMxcUrl(getThumbnail: true), + 'mxc://example.org/thumb'); + expect(event.getAttachmentUrl(), + 'https://fakeserver.notexisting/_matrix/media/r0/download/example.org/file'); + expect(event.getAttachmentUrl(getThumbnail: true), + 'https://fakeserver.notexisting/_matrix/media/r0/thumbnail/example.org/file?width=800&height=800&method=scale'); + expect(event.getAttachmentUrl(useThumbnailMxcUrl: true), + 'https://fakeserver.notexisting/_matrix/media/r0/download/example.org/thumb'); + expect( + event.getAttachmentUrl(getThumbnail: true, useThumbnailMxcUrl: true), + 'https://fakeserver.notexisting/_matrix/media/r0/thumbnail/example.org/thumb?width=800&height=800&method=scale'); + expect( + event.getAttachmentUrl(getThumbnail: true, minNoThumbSize: 9000000), + 'https://fakeserver.notexisting/_matrix/media/r0/download/example.org/file'); + buffer = await event.downloadAndDecryptAttachment( downloadCallback: downloadCallback); expect(buffer.bytes, FILE_BUFF); @@ -1088,7 +1120,7 @@ void main() { getThumbnail: true, downloadCallback: downloadCallback); expect(buffer.bytes, THUMBNAIL_BUFF); }); - test('downloadAndDecryptAttachment encrypted', () async { + test('encrypted attachments', () async { if (!olmEnabled) return; final FILE_BUFF_ENC = Uint8List.fromList([0x3B, 0x6B, 0xB2, 0x8C, 0xAF]); @@ -1174,6 +1206,14 @@ void main() { 'event_id': '\$edit2', 'sender': '@alice:example.org', }, room); + expect(event.hasAttachment, true); + expect(event.hasThumbnail, true); + expect(event.isAttachmentEncrypted, true); + expect(event.isThumbnailEncrypted, true); + expect(event.attachmentMimetype, 'text/plain'); + expect(event.thumbnailMimetype, 'text/plain'); + expect(event.attachmentMxcUrl, 'mxc://example.com/file'); + expect(event.thumbnailMxcUrl, 'mxc://example.com/thumb'); buffer = await event.downloadAndDecryptAttachment( downloadCallback: downloadCallback); expect(buffer.bytes, FILE_BUFF_DEC); @@ -1209,8 +1249,10 @@ void main() { 'event_id': '\$edit2', 'sender': '@alice:example.org', }, room); + expect(await event.isAttachmentInLocalStore(), false); var buffer = await event.downloadAndDecryptAttachment( downloadCallback: downloadCallback); + expect(await event.isAttachmentInLocalStore(), true); expect(buffer.bytes, FILE_BUFF); expect(serverHits, 1); buffer = await event.downloadAndDecryptAttachment(