diff --git a/lib/src/room.dart b/lib/src/room.dart index 9f7d42bf..0399c179 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -625,6 +625,9 @@ class Room { /// the message event has received the server. Otherwise the future will only /// wait until the file has been uploaded. /// Optionally specify [extraContent] to tack on to the event. + /// + /// In case [file] is a [MatrixImageFile], [thumbnail] is automatically + /// computed unless it is explicitly provided. Future sendFileEvent( MatrixFile file, { String? txid, @@ -635,6 +638,10 @@ class Room { Map? extraContent, }) async { MatrixFile uploadFile = file; // ignore: omit_local_variable_types + // computing the thumbnail in case we can + thumbnail ??= (file is MatrixImageFile && encrypted + ? await file.generateThumbnail(compute: client.runInBackground) + : null); MatrixFile? uploadThumbnail = thumbnail; // ignore: omit_local_variable_types EncryptedFile? encryptedFile; diff --git a/lib/src/utils/matrix_file.dart b/lib/src/utils/matrix_file.dart index 04154156..c18ca473 100644 --- a/lib/src/utils/matrix_file.dart +++ b/lib/src/utils/matrix_file.dart @@ -18,8 +18,11 @@ /// Workaround until [File] in dart:io and dart:html is unified +import 'dart:async'; import 'dart:typed_data'; +import 'package:blurhash_dart/blurhash_dart.dart'; +import 'package:image/image.dart'; import 'package:mime/mime.dart'; import '../../matrix.dart'; @@ -63,18 +66,76 @@ class MatrixFile { } class MatrixImageFile extends MatrixFile { - int? width; - int? height; - String? blurhash; + Image? _image; - MatrixImageFile( + MatrixImageFile({ + required Uint8List bytes, + required String name, + String? mimeType, + }) : super(bytes: bytes, name: name, mimeType: mimeType); + + /// builds a [MatrixImageFile] and shrinks it in order to reduce traffic + /// + /// in case shrinking does not work (e.g. for unsupported MIME types), the + /// initial image is simply preserved + static Future shrink( {required Uint8List bytes, required String name, + int maxDimension = 1600, String? mimeType, - this.width, - this.height, - this.blurhash}) - : super(bytes: bytes, name: name, mimeType: mimeType); + Future Function(FutureOr Function(U arg) function, U arg)? + compute}) async { + Image? image; + final resizedData = compute != null + ? await compute(_resize, [bytes, maxDimension]) + : _resize([bytes, maxDimension, name]); + + if (resizedData == null) { + return MatrixImageFile(bytes: bytes, name: name, mimeType: mimeType); + } + image = decodeImage(resizedData); + + if (image == null) { + return MatrixImageFile(bytes: bytes, name: name, mimeType: mimeType); + } + + final encoded = encodeNamedImage(image, name); + if (encoded == null) { + return MatrixImageFile(bytes: bytes, name: name, mimeType: mimeType); + } + + final thumbnailFile = MatrixImageFile( + bytes: Uint8List.fromList(encoded), + name: name, + mimeType: mimeType, + ); + // preserving the previously generated image + thumbnailFile._image = image; + return thumbnailFile; + } + + /// returns the width of the image + int? get width { + _image ??= decodeImage(bytes); + return _image?.width; + } + + /// returns the height of the image + int? get height { + _image ??= decodeImage(bytes); + return _image?.height; + } + + /// generates the blur hash for the image + String? get blurhash { + _image ??= decodeImage(bytes)!; + if (_image != null) { + final blur = BlurHash.encode(_image!, numCompX: 4, numCompY: 3); + return blur.hash; + } + return null; + } + @override String get msgType => 'm.image'; @override @@ -84,6 +145,41 @@ class MatrixImageFile extends MatrixFile { if (height != null) 'h': height, if (blurhash != null) 'xyz.amorgan.blurhash': blurhash, }); + + /// computes a thumbnail for the image + Future generateThumbnail( + {int dimension = Client.defaultThumbnailSize, + Future Function(FutureOr Function(U arg) function, U arg)? + compute}) async { + final thumbnailFile = await shrink( + bytes: bytes, + name: name, + mimeType: mimeType, + compute: compute, + maxDimension: dimension, + ); + // the thumbnail should rather return null than the unshrinked image + if ((thumbnailFile.width ?? 0) > dimension || + (thumbnailFile.height ?? 0) > dimension) { + return null; + } + return thumbnailFile; + } + + static Uint8List? _resize(List arguments) { + final bytes = arguments[0] as Uint8List; + final maxDimension = arguments[1] as int; + final fileName = arguments[2] as String; + final image = decodeImage(bytes); + + final resized = copyResize(image!, + height: image.height > image.width ? maxDimension : null, + width: image.width >= image.height ? maxDimension : null); + + final encoded = encodeNamedImage(resized, fileName); + if (encoded == null) return null; + return Uint8List.fromList(encoded); + } } class MatrixVideoFile extends MatrixFile { diff --git a/pubspec.yaml b/pubspec.yaml index 4ca804ee..85842c07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: ">=2.12.0 <3.0.0" dependencies: + blurhash_dart: ^1.1.0 http: ^0.13.0 mime: ^1.0.0 canonical_json: ^1.1.0 @@ -18,6 +19,7 @@ dependencies: olm: ^2.0.0 matrix_api_lite: ^0.5.1 hive: ^2.0.4 + image: ^3.1.1 ffi: ^1.0.0 js: ^0.6.3 slugify: ^2.0.0 diff --git a/test/encryption/utils_test.dart b/test/encryption/utils_test.dart index 242703ac..79a7f8d2 100644 --- a/test/encryption/utils_test.dart +++ b/test/encryption/utils_test.dart @@ -19,6 +19,7 @@ import 'dart:convert'; import 'package:matrix/encryption/utils/base64_unpadded.dart'; +import 'package:matrix/matrix.dart'; import 'package:test/test.dart'; void main() { @@ -40,4 +41,36 @@ void main() { expect(decodedUnpadded, base64input, reason: 'Unpadded base64 decode'); }); }); + + group('MatrixFile', () { + test('MatrixImageFile', () async { + const base64Image = + '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'; + final data = base64Decode(base64Image); + + final image = MatrixImageFile( + bytes: data, + name: 'bomb.png', + mimeType: 'image/png', + ); + expect(image.width, 220, reason: 'Unexpected image width'); + expect(image.height, 220, reason: 'Unexpected image heigth'); + expect(image.blurhash, 'L75NyU5krSbx=zAF#kSNZxOZ%4NE', + reason: 'Unexpected image blur'); + + final thumbnail = await image.generateThumbnail(dimension: 64); + expect(thumbnail!.height, 64, reason: 'Unexpected thumbnail height'); + + final shrinkedImage = await MatrixImageFile.shrink( + bytes: data, + name: 'bomb.png', + mimeType: 'image/png', + maxDimension: 150); + expect(shrinkedImage.width, 150, reason: 'Unexpected scaled image width'); + expect(shrinkedImage.height, 150, + reason: 'Unexpected scaled image heigth'); + expect(shrinkedImage.blurhash, 'L75NyU5kvvbx^7AF#kSgZxOZ%5NE', + reason: 'Unexpected scaled image blur'); + }); + }); }