diff --git a/lib/encryption/key_manager.dart b/lib/encryption/key_manager.dart index 5fc4c3fb..ea79f44d 100644 --- a/lib/encryption/key_manager.dart +++ b/lib/encryption/key_manager.dart @@ -34,6 +34,7 @@ const megolmKey = EventTypes.MegolmBackup; class KeyManager { final Encryption encryption; + Client get client => encryption.client; final outgoingShareRequests = {}; final incomingShareRequests = {}; @@ -568,6 +569,7 @@ class KeyManager { GetRoomKeysVersionCurrentResponse? _roomKeysVersionCache; DateTime? _roomKeysVersionCacheDate; + Future getRoomKeysBackupInfo( [bool useCache = true]) async { if (_roomKeysVersionCache != null && @@ -715,6 +717,7 @@ class KeyManager { bool _isUploadingKeys = false; bool _haveKeysToUpload = true; + Future backgroundTasks() async { final database = client.database; final userID = client.userID; @@ -745,7 +748,7 @@ class KeyManager { info.authData['public_key'] != backupPubKey) { return; } - final args = _GenerateUploadKeysArgs( + final args = GenerateUploadKeysArgs( pubkey: backupPubKey, dbSessions: <_DbInboundGroupSessionBundle>[], userId: userID, @@ -768,8 +771,7 @@ class KeyManager { } } final roomKeys = - await client.runInBackground( - _generateUploadKeys, args); + await client.nativeImplementations.generateUploadKeys(args); Logs().i('[Key Manager] Uploading ${dbSessions.length} room keys...'); // upload the payload... await client.putRoomKeys(info.version, roomKeys); @@ -988,6 +990,7 @@ class KeyManagerKeyShareRequest { class RoomKeyRequest extends ToDeviceEvent { KeyManager keyManager; KeyManagerKeyShareRequest request; + RoomKeyRequest.fromToDeviceEvent( ToDeviceEvent toDeviceEvent, this.keyManager, this.request) : super( @@ -1035,7 +1038,9 @@ class RoomKeyRequest extends ToDeviceEvent { } } -RoomKeys _generateUploadKeys(_GenerateUploadKeysArgs args) { +/// you would likely want to use [NativeImplementations] and +/// [Client.nativeImplementations] instead +RoomKeys generateUploadKeysImplementation(GenerateUploadKeysArgs args) { final enc = olm.PkEncryption(); try { enc.set_recipient_key(args.pubkey); @@ -1087,14 +1092,40 @@ class _DbInboundGroupSessionBundle { _DbInboundGroupSessionBundle( {required this.dbSession, required this.verified}); + factory _DbInboundGroupSessionBundle.fromJson(Map json) => + _DbInboundGroupSessionBundle( + dbSession: + StoredInboundGroupSession.fromJson(Map.from(json['dbSession'])), + verified: json['verified'], + ); + + Map toJson() => { + 'dbSession': dbSession.toJson(), + 'verified': verified, + }; StoredInboundGroupSession dbSession; bool verified; } -class _GenerateUploadKeysArgs { - _GenerateUploadKeysArgs( +class GenerateUploadKeysArgs { + GenerateUploadKeysArgs( {required this.pubkey, required this.dbSessions, required this.userId}); + factory GenerateUploadKeysArgs.fromJson(Map json) => + GenerateUploadKeysArgs( + pubkey: json['pubkey'], + dbSessions: (json['dbSessions'] as Iterable) + .map((e) => _DbInboundGroupSessionBundle.fromJson(e)) + .toList(), + userId: json['userId'], + ); + + Map toJson() => { + 'pubkey': pubkey, + 'dbSessions': dbSessions.map((e) => e.toJson()).toList(), + 'userId': userId, + }; + String pubkey; List<_DbInboundGroupSessionBundle> dbSessions; String userId; diff --git a/lib/encryption/ssss.dart b/lib/encryption/ssss.dart index 9941b873..6d001592 100644 --- a/lib/encryption/ssss.dart +++ b/lib/encryption/ssss.dart @@ -215,15 +215,14 @@ class SSSS { algorithm: AlgorithmTypes.pbkdf2, bits: ssssKeyLength * 8, ); - privateKey = await client - .runInBackground( - _keyFromPassphrase, - _KeyFromPassphraseArgs( - passphrase: passphrase, - info: content.passphrase!, - ), - ) - .timeout(Duration(seconds: 10)); + privateKey = await Future.value( + client.nativeImplementations.keyFromPassphrase( + KeyFromPassphraseArgs( + passphrase: passphrase, + info: content.passphrase!, + ), + ), + ).timeout(Duration(seconds: 10)); } else { // we need to just generate a new key from scratch privateKey = Uint8List.fromList(uc.secureRandomBytes(ssssKeyLength)); @@ -657,15 +656,14 @@ class OpenSSSS { throw Exception( 'Tried to unlock with passphrase while key does not have a passphrase'); } - privateKey = await ssss.client - .runInBackground( - _keyFromPassphrase, - _KeyFromPassphraseArgs( - passphrase: passphrase, - info: keyData.passphrase!, - ), - ) - .timeout(Duration(seconds: 10)); + privateKey = await Future.value( + ssss.client.nativeImplementations.keyFromPassphrase( + KeyFromPassphraseArgs( + passphrase: passphrase, + info: keyData.passphrase!, + ), + ), + ).timeout(Duration(seconds: 10)); } else if (recoveryKey != null) { privateKey = SSSS.decodeRecoveryKey(recoveryKey); } else { @@ -743,13 +741,15 @@ class OpenSSSS { } } -class _KeyFromPassphraseArgs { +class KeyFromPassphraseArgs { final String passphrase; final PassphraseInfo info; - _KeyFromPassphraseArgs({required this.passphrase, required this.info}); + KeyFromPassphraseArgs({required this.passphrase, required this.info}); } -Future _keyFromPassphrase(_KeyFromPassphraseArgs args) async { +/// you would likely want to use [NativeImplementations] and +/// [Client.nativeImplementations] instead +Future generateKeyFromPassphrase(KeyFromPassphraseArgs args) async { return await SSSS.keyFromPassphrase(args.passphrase, args.info); } diff --git a/lib/matrix.dart b/lib/matrix.dart index 6ddd30fb..cab9d87b 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -47,6 +47,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/native_implementations.dart'; export 'src/utils/push_notification.dart'; export 'src/utils/receipt.dart'; export 'src/utils/sync_update_extension.dart'; @@ -56,3 +57,9 @@ export 'src/utils/uri_extension.dart'; export 'msc_extensions/extension_recent_emoji/recent_emoji.dart'; export 'msc_extensions/msc_1236_widgets/msc_1236_widgets.dart'; + +export 'src/utils/web_worker/web_worker_stub.dart' + if (dart.library.html) 'src/utils/web_worker/web_worker.dart'; + +export 'src/utils/web_worker/native_implementations_web_worker_stub.dart' + if (dart.library.html) 'src/utils/web_worker/native_implementations_web_worker.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index e3312a7b..8481948a 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -33,6 +33,7 @@ import 'package:matrix/src/utils/sync_update_item_count.dart'; import '../encryption.dart'; import '../matrix.dart'; import 'models/timeline_chunk.dart'; +import 'utils/compute_callback.dart'; import 'utils/multilock.dart'; import 'utils/run_benchmarked.dart'; @@ -90,11 +91,13 @@ class Client extends MatrixApi { final Map Function(CommandArgs)> commands = {}; final Filter syncFilter; + final NativeImplementations nativeImplementations; + String? syncFilterId; - final Future Function(FutureOr Function(Q), Q, - {String debugLabel})? compute; + final ComputeCallback? compute; + @Deprecated('Use [nativeImplementations] instead') Future runInBackground( FutureOr Function(U arg) function, U arg) async { final compute = this.compute; @@ -142,8 +145,8 @@ class Client extends MatrixApi { /// If your client supports more login types like login with token or SSO, then add this to /// [supportedLoginTypes]. Set a custom [syncFilter] if you like. By default the app /// will use lazy_load_members. - /// Set [compute] to the Flutter compute method to enable the SDK to run some - /// code in background. + /// Set [nativeImplementations] to [NativeImplementationsIsolate] in order to + /// enable the SDK to compute some code in background. /// Set [timelineEventTimeout] to the preferred time the Client should retry /// sending events on connection problems or to `Duration.zero` to disable it. /// Set [customImageResizer] to your own implementation for a more advanced @@ -165,7 +168,8 @@ class Client extends MatrixApi { Set? supportedLoginTypes, this.mxidLocalPartFallback = true, this.formatLocalpart = true, - this.compute, + @Deprecated('Use [nativeImplementations] instead') this.compute, + NativeImplementations nativeImplementations = NativeImplementations.dummy, Level? logLevel, Filter? syncFilter, this.sendTimelineEventTimeout = const Duration(minutes: 1), @@ -182,6 +186,9 @@ class Client extends MatrixApi { supportedLoginTypes = supportedLoginTypes ?? {AuthenticationTypes.password}, verificationMethods = verificationMethods ?? {}, + nativeImplementations = compute != null + ? NativeImplementationsIsolate(compute) + : nativeImplementations, super( httpClient: VariableTimeoutHttpClient(httpClient ?? http.Client())) { @@ -227,7 +234,10 @@ class Client extends MatrixApi { String? _deviceName; // for group calls - // A unique identifier used for resolving duplicate group call sessions from a given device. When the session_id field changes from an incoming m.call.member event, any existing calls from this device in this call should be terminated. The id is generated once per client load. + // A unique identifier used for resolving duplicate group call + // sessions from a given device. When the session_id field changes from + // an incoming m.call.member event, any existing calls from this device in + // this call should be terminated. The id is generated once per client load. String? get groupCallSessionId => _groupCallSessionId; String? _groupCallSessionId; @@ -2906,5 +2916,6 @@ class HomeserverSummary { class ArchivedRoom { final Room room; final Timeline timeline; + ArchivedRoom({required this.room, required this.timeline}); } diff --git a/lib/src/event.dart b/lib/src/event.dart index 385564b9..8a2634e5 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -617,8 +617,8 @@ class Event extends MatrixEvent { k: fileMap['key']['k'], sha256: fileMap['hashes']['sha256'], ); - uint8list = await room.client.runInBackground( - decryptFile, encryptedFile); + uint8list = + await room.client.nativeImplementations.decryptFile(encryptedFile); if (uint8list == null) { throw ('Unable to decrypt file'); } diff --git a/lib/src/room.dart b/lib/src/room.dart index 207c6be5..c4599bf4 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -721,7 +721,7 @@ class Room { FileSendingStatus.generatingThumbnail.name; await _handleFakeSync(syncUpdate); thumbnail ??= await file.generateThumbnail( - compute: client.runInBackground, + nativeImplementations: client.nativeImplementations, customImageResizer: client.customImageResizer, ); if (shrinkImageMaxDimension != null) { @@ -730,7 +730,7 @@ class Room { name: file.name, maxDimension: shrinkImageMaxDimension, customImageResizer: client.customImageResizer, - compute: client.runInBackground, + nativeImplementations: client.nativeImplementations, ); } @@ -942,7 +942,7 @@ class Room { ? inReplyTo.formattedText : htmlEscape.convert(inReplyTo.body).replaceAll('\n', '
')) .replaceAll( - RegExp(r'.*<\/mx-reply>', + RegExp(r'.*', caseSensitive: false, multiLine: false, dotAll: true), ''); final repliedHtml = content.tryGet('formatted_body') ?? diff --git a/lib/src/utils/compute_callback.dart b/lib/src/utils/compute_callback.dart new file mode 100644 index 00000000..75ffe8d3 --- /dev/null +++ b/lib/src/utils/compute_callback.dart @@ -0,0 +1,15 @@ +import 'dart:async'; + +typedef ComputeCallback = Future Function( + FutureOr Function(Q message) callback, Q message, + {String? debugLabel}); + +// keep types in sync with [computeCallbackFromRunInBackground] +typedef ComputeRunner = Future Function( + FutureOr Function(U arg) function, U arg); + +ComputeCallback computeCallbackFromRunInBackground(ComputeRunner runner) { + return (FutureOr Function(U arg) callback, U arg, + {String? debugLabel}) => + runner.call(callback, arg); +} diff --git a/lib/src/utils/crypto/encrypted_file.dart b/lib/src/utils/crypto/encrypted_file.dart index 5d5dcbc1..163a420b 100644 --- a/lib/src/utils/crypto/encrypted_file.dart +++ b/lib/src/utils/crypto/encrypted_file.dart @@ -48,7 +48,9 @@ Future encryptFile(Uint8List input) async { ); } -Future decryptFile(EncryptedFile input) async { +/// you would likely want to use [NativeImplementations] and +/// [Client.nativeImplementations] instead +Future decryptFileImplementation(EncryptedFile input) async { if (base64.encode(await sha256(input.data)) != base64.normalize(input.sha256)) { return null; diff --git a/lib/src/utils/matrix_file.dart b/lib/src/utils/matrix_file.dart index 0e96b6a1..99d2c9ec 100644 --- a/lib/src/utils/matrix_file.dart +++ b/lib/src/utils/matrix_file.dart @@ -26,6 +26,7 @@ import 'package:image/image.dart'; import 'package:mime/mime.dart'; import '../../matrix.dart'; +import 'compute_callback.dart'; class MatrixFile { final Uint8List bytes; @@ -78,15 +79,18 @@ class MatrixImageFile extends MatrixFile { super(bytes: bytes, name: name, mimeType: mimeType); /// Creates a new image file and calculates the width, height and blurhash. - static Future create( - {required Uint8List bytes, - required String name, - String? mimeType, - Future Function(FutureOr Function(U arg) function, U arg)? - compute}) async { - final metaData = compute != null - ? await compute(_calcMetadata, bytes) - : _calcMetadata(bytes); + static Future create({ + required Uint8List bytes, + required String name, + String? mimeType, + @Deprecated('Use [nativeImplementations] instead') ComputeRunner? compute, + NativeImplementations nativeImplementations = NativeImplementations.dummy, + }) async { + if (compute != null) { + nativeImplementations = + NativeImplementationsIsolate.fromRunInBackground(compute); + } + final metaData = await nativeImplementations.calcImageMetadata(bytes); return MatrixImageFile( bytes: metaData?.bytes ?? bytes, @@ -101,22 +105,27 @@ class MatrixImageFile extends MatrixFile { /// Builds a [MatrixImageFile] and shrinks it in order to reduce traffic. /// If shrinking does not work (e.g. for unsupported MIME types), the /// initial image is preserved without shrinking it. - static Future shrink( - {required Uint8List bytes, - required String name, - int maxDimension = 1600, - String? mimeType, - Future Function( - MatrixImageFileResizeArguments)? - customImageResizer, - Future Function(FutureOr Function(U arg) function, U arg)? - compute}) async { + static Future shrink({ + required Uint8List bytes, + required String name, + int maxDimension = 1600, + String? mimeType, + Future Function( + MatrixImageFileResizeArguments)? + customImageResizer, + @Deprecated('Use [nativeImplementations] instead') ComputeRunner? compute, + NativeImplementations nativeImplementations = NativeImplementations.dummy, + }) async { + if (compute != null) { + nativeImplementations = + NativeImplementationsIsolate.fromRunInBackground(compute); + } final image = MatrixImageFile(name: name, mimeType: mimeType, bytes: bytes); return await image.generateThumbnail( dimension: maxDimension, customImageResizer: customImageResizer, - compute: compute) ?? + nativeImplementations: nativeImplementations) ?? image; } @@ -141,6 +150,7 @@ class MatrixImageFile extends MatrixFile { @override String get msgType => 'm.image'; + @override Map get info => ({ ...super.info, @@ -151,13 +161,18 @@ class MatrixImageFile extends MatrixFile { /// Computes a thumbnail for the image. /// Also sets height and width on the original image if they were unset. - Future generateThumbnail( - {int dimension = Client.defaultThumbnailSize, - Future Function( - MatrixImageFileResizeArguments)? - customImageResizer, - Future Function(FutureOr Function(U arg) function, U arg)? - compute}) async { + Future generateThumbnail({ + int dimension = Client.defaultThumbnailSize, + Future Function( + MatrixImageFileResizeArguments)? + customImageResizer, + @Deprecated('Use [nativeImplementations] instead') ComputeRunner? compute, + NativeImplementations nativeImplementations = NativeImplementations.dummy, + }) async { + if (compute != null) { + nativeImplementations = + NativeImplementationsIsolate.fromRunInBackground(compute); + } final arguments = MatrixImageFileResizeArguments( bytes: bytes, maxDimension: dimension, @@ -166,19 +181,17 @@ class MatrixImageFile extends MatrixFile { ); final resizedData = customImageResizer != null ? await customImageResizer(arguments) - : compute != null - ? await compute(_resize, arguments) - : _resize(arguments); + : await nativeImplementations.shrinkImage(arguments); if (resizedData == null) { return null; } - // we should take the opportinity to update the image dimmension + // we should take the opportunity to update the image dimension setImageSizeIfNull( width: resizedData.originalWidth, height: resizedData.originalHeight); - // the thumbnail should rather return null than the unshrinked image + // the thumbnail should rather return null than the enshrined image if (resizedData.width > dimension || resizedData.height > dimension) { return null; } @@ -194,7 +207,10 @@ class MatrixImageFile extends MatrixFile { return thumbnailFile; } - static MatrixImageFileResizedResponse? _calcMetadata(Uint8List bytes) { + /// you would likely want to use [NativeImplementations] and + /// [Client.nativeImplementations] instead + static MatrixImageFileResizedResponse? calcMetadataImplementation( + Uint8List bytes) { final image = decodeImage(bytes); if (image == null) return null; @@ -210,7 +226,9 @@ class MatrixImageFile extends MatrixFile { ); } - static MatrixImageFileResizedResponse? _resize( + /// you would likely want to use [NativeImplementations] and + /// [Client.nativeImplementations] instead + static MatrixImageFileResizedResponse? resizeImplementation( MatrixImageFileResizeArguments arguments) { final image = decodeImage(arguments.bytes); @@ -255,6 +273,28 @@ class MatrixImageFileResizedResponse { this.originalWidth, this.blurhash, }); + + factory MatrixImageFileResizedResponse.fromJson( + Map json, + ) => + MatrixImageFileResizedResponse( + bytes: Uint8List.fromList( + (json['bytes'] as Iterable).whereType().toList()), + width: json['width'], + height: json['height'], + originalHeight: json['originalHeight'], + originalWidth: json['originalWidth'], + blurhash: json['blurhash'], + ); + + Map toJson() => { + 'bytes': bytes, + 'width': width, + 'height': height, + if (blurhash != null) 'blurhash': blurhash, + if (originalHeight != null) 'originalHeight': originalHeight, + if (originalWidth != null) 'originalWidth': originalWidth, + }; } class MatrixImageFileResizeArguments { @@ -269,6 +309,21 @@ class MatrixImageFileResizeArguments { required this.fileName, required this.calcBlurhash, }); + + factory MatrixImageFileResizeArguments.fromJson(Map json) => + MatrixImageFileResizeArguments( + bytes: json['bytes'], + maxDimension: json['maxDimension'], + fileName: json['fileName'], + calcBlurhash: json['calcBlurhash'], + ); + + Map toJson() => { + 'bytes': bytes, + 'maxDimension': maxDimension, + 'fileName': fileName, + 'calcBlurhash': calcBlurhash, + }; } class MatrixVideoFile extends MatrixFile { @@ -284,8 +339,10 @@ class MatrixVideoFile extends MatrixFile { this.height, this.duration}) : super(bytes: bytes, name: name, mimeType: mimeType); + @override String get msgType => 'm.video'; + @override Map get info => ({ ...super.info, @@ -304,8 +361,10 @@ class MatrixAudioFile extends MatrixFile { String? mimeType, this.duration}) : super(bytes: bytes, name: name, mimeType: mimeType); + @override String get msgType => 'm.audio'; + @override Map get info => ({ ...super.info, diff --git a/lib/src/utils/native_implementations.dart b/lib/src/utils/native_implementations.dart new file mode 100644 index 00000000..59be73c7 --- /dev/null +++ b/lib/src/utils/native_implementations.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; +import 'compute_callback.dart'; + +/// provides native implementations for demanding arithmetic operations +/// in order to prevent the UI from blocking +/// +/// possible implementations might be: +/// - native code +/// - another Dart isolate +/// - a web worker +/// - a dummy implementations +/// +/// Rules for extension (important for [noSuchMethod] implementations) +/// - always only accept exactly *one* positioned argument +/// - catch the corresponding case in [NativeImplementations.noSuchMethod] +/// - always write a dummy implementations +abstract class NativeImplementations { + const NativeImplementations(); + + /// a dummy implementation executing all calls in the same thread causing + /// the UI to likely freeze + static const dummy = NativeImplementationsDummy(); + + FutureOr generateUploadKeys(GenerateUploadKeysArgs args); + + FutureOr keyFromPassphrase(KeyFromPassphraseArgs args); + + FutureOr decryptFile(EncryptedFile file); + + FutureOr shrinkImage( + MatrixImageFileResizeArguments args); + + FutureOr calcImageMetadata(Uint8List bytes); + + @override + + /// this implementation will catch any non-implemented method + dynamic noSuchMethod(Invocation invocation) { + final dynamic argument = invocation.positionalArguments.single; + final memberName = invocation.memberName.toString().split('"')[1]; + + Logs().w( + 'Missing implementations of Client.nativeImplementations.$memberName. ' + 'You should consider implementing it. ' + 'Fallback from NativeImplementations.dummy used.', + ); + switch (memberName) { + case 'generateUploadKeys': + return dummy.generateUploadKeys(argument); + case 'keyFromPassphrase': + return dummy.keyFromPassphrase(argument); + case 'decryptFile': + return dummy.decryptFile(argument); + case 'shrinkImage': + return dummy.shrinkImage(argument); + case 'calcImageMetadata': + return dummy.calcImageMetadata(argument); + default: + return super.noSuchMethod(invocation); + } + } +} + +class NativeImplementationsDummy extends NativeImplementations { + const NativeImplementationsDummy(); + + @override + Future decryptFile(EncryptedFile file) { + return decryptFileImplementation(file); + } + + @override + Future generateUploadKeys(GenerateUploadKeysArgs args) async { + return generateUploadKeysImplementation(args); + } + + @override + Future keyFromPassphrase(KeyFromPassphraseArgs args) { + return generateKeyFromPassphrase(args); + } + + @override + MatrixImageFileResizedResponse? shrinkImage( + MatrixImageFileResizeArguments args) { + return MatrixImageFile.resizeImplementation(args); + } + + @override + MatrixImageFileResizedResponse? calcImageMetadata(Uint8List bytes) { + return MatrixImageFile.calcMetadataImplementation(bytes); + } +} + +/// a [NativeImplementations] based on Flutter's `compute` function +/// +/// this implementations simply wraps the given [compute] function around +/// the implementation of [NativeImplementations.dummy] +class NativeImplementationsIsolate extends NativeImplementations { + /// pass by Flutter's compute function here + final ComputeCallback compute; + + NativeImplementationsIsolate(this.compute); + + /// creates a [NativeImplementationsIsolate] based on a [ComputeRunner] as + // ignore: deprecated_member_use_from_same_package + /// known from [Client.runInBackground] + factory NativeImplementationsIsolate.fromRunInBackground( + ComputeRunner runInBackground) { + return NativeImplementationsIsolate( + computeCallbackFromRunInBackground(runInBackground), + ); + } + + Future runInBackground( + FutureOr Function(U arg) function, U arg) async { + final compute = this.compute; + return await compute(function, arg); + } + + @override + Future decryptFile(EncryptedFile file) { + return runInBackground( + NativeImplementations.dummy.decryptFile, + file, + ); + } + + @override + Future generateUploadKeys(GenerateUploadKeysArgs args) async { + return runInBackground( + NativeImplementations.dummy.generateUploadKeys, + args, + ); + } + + @override + Future keyFromPassphrase(KeyFromPassphraseArgs args) { + return runInBackground( + NativeImplementations.dummy.keyFromPassphrase, + args, + ); + } + + @override + Future shrinkImage( + MatrixImageFileResizeArguments args) { + return runInBackground( + NativeImplementations.dummy.shrinkImage, + args, + ); + } + + @override + FutureOr calcImageMetadata(Uint8List bytes) { + return runInBackground( + NativeImplementations.dummy.calcImageMetadata, + bytes, + ); + } +} diff --git a/lib/src/utils/web_worker/native_implementations_web_worker.dart b/lib/src/utils/web_worker/native_implementations_web_worker.dart new file mode 100644 index 00000000..c4a61e8a --- /dev/null +++ b/lib/src/utils/web_worker/native_implementations_web_worker.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:html'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; + +class NativeImplementationsWebWorker extends NativeImplementations { + final Worker worker; + final Duration timeout; + final WebWorkerStackTraceCallback onStackTrace; + + final Map> _completers = {}; + final _random = Random(); + + /// the default handler for stackTraces in web workers + static StackTrace defaultStackTraceHandler(String obfuscatedStackTrace) { + return StackTrace.fromString(obfuscatedStackTrace); + } + + NativeImplementationsWebWorker( + Uri href, { + this.timeout = const Duration(seconds: 30), + this.onStackTrace = defaultStackTraceHandler, + }) : worker = Worker(href.toString()) { + worker.onMessage.listen(_handleIncomingMessage); + } + + Future operation(WebWorkerOperations name, U argument) async { + final label = _random.nextDouble(); + final completer = Completer(); + _completers[label] = completer; + final message = WebWorkerData(label, name, argument); + worker.postMessage(message.toJson()); + + return completer.future.timeout(timeout); + } + + void _handleIncomingMessage(MessageEvent event) { + final data = event.data; + // don't forget handling errors of our second thread... + if (data['label'] == 'stacktrace') { + final origin = event.data['origin']; + final completer = _completers[origin]; + + final error = event.data['error']!; + + Future.value( + onStackTrace.call(event.data['stacktrace'] as String), + ).then( + (stackTrace) => completer?.completeError( + WebWorkerError(error: error, stackTrace: stackTrace), + ), + ); + } else { + final response = WebWorkerData.fromJson(event.data); + _completers[response.label]!.complete(response.data); + } + } + + @override + Future calcImageMetadata( + Uint8List bytes) async { + try { + final result = await operation, Uint8List>( + WebWorkerOperations.calcImageMetadata, + bytes, + ); + return MatrixImageFileResizedResponse.fromJson(Map.from(result)); + } catch (e, s) { + Logs().e('Web worker computation error. Fallback to main thread', e, s); + return NativeImplementations.dummy.calcImageMetadata(bytes); + } + } + + @override + Future shrinkImage( + MatrixImageFileResizeArguments args) async { + try { + final result = + await operation, Map>( + WebWorkerOperations.shrinkImage, + args.toJson(), + ); + return MatrixImageFileResizedResponse.fromJson(Map.from(result)); + } catch (e, s) { + Logs().e('Web worker computation error. Fallback to main thread', e, s); + return NativeImplementations.dummy.shrinkImage(args); + } + } + + @override + Future generateUploadKeys(GenerateUploadKeysArgs args) async { + try { + final result = + await operation, Map>( + WebWorkerOperations.generateUploadKeys, + args.toJson(), + ); + return RoomKeys.fromJson(Map.from(result)); + } catch (e, s) { + Logs().e('Web worker computation error. Fallback to main thread', e, s); + return NativeImplementations.dummy.generateUploadKeys(args); + } + } +} + +class WebWorkerData { + final Object? label; + final WebWorkerOperations? name; + final Object? data; + + const WebWorkerData(this.label, this.name, this.data); + + factory WebWorkerData.fromJson(LinkedHashMap data) => + WebWorkerData( + data['label'], + data.containsKey('name') + ? WebWorkerOperations.values[data['name']] + : null, + data['data'], + ); + + Map toJson() => { + 'label': label, + if (name != null) 'name': name!.index, + 'data': data, + }; +} + +enum WebWorkerOperations { + shrinkImage, + calcImageMetadata, + generateUploadKeys, +} + +class WebWorkerError extends Error { + /// the error thrown in the web worker. Usually a [String] + final Object? error; + + /// de-serialized [StackTrace] + @override + final StackTrace stackTrace; + + WebWorkerError({required this.error, required this.stackTrace}); + + @override + String toString() { + return '$error, $stackTrace'; + } +} + +/// converts a stringifyed, obfuscated [StackTrace] into a [StackTrace] +typedef WebWorkerStackTraceCallback = FutureOr Function( + String obfuscatedStackTrace); diff --git a/lib/src/utils/web_worker/native_implementations_web_worker_stub.dart b/lib/src/utils/web_worker/native_implementations_web_worker_stub.dart new file mode 100644 index 00000000..88edeb2c --- /dev/null +++ b/lib/src/utils/web_worker/native_implementations_web_worker_stub.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:matrix/matrix.dart'; + +class NativeImplementationsWebWorker extends NativeImplementations { + /// the default handler for stackTraces in web workers + static StackTrace defaultStackTraceHandler(String obfuscatedStackTrace) { + return StackTrace.fromString(obfuscatedStackTrace); + } + + NativeImplementationsWebWorker( + Uri href, { + Duration timeout = const Duration(seconds: 30), + WebWorkerStackTraceCallback onStackTrace = defaultStackTraceHandler, + }); +} + +class WebWorkerError extends Error { + /// the error thrown in the web worker. Usually a [String] + final Object? error; + + /// de-serialized [StackTrace] + @override + final StackTrace stackTrace; + + WebWorkerError({required this.error, required this.stackTrace}); + + @override + String toString() { + return '$error, $stackTrace'; + } +} + +/// converts a stringifyed, obfuscated [StackTrace] into a [StackTrace] +typedef WebWorkerStackTraceCallback = FutureOr Function( + String obfuscatedStackTrace); diff --git a/lib/src/utils/web_worker/web_worker.dart b/lib/src/utils/web_worker/web_worker.dart new file mode 100644 index 00000000..f4fc5ee6 --- /dev/null +++ b/lib/src/utils/web_worker/web_worker.dart @@ -0,0 +1,125 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:html'; +import 'dart:indexed_db'; +import 'dart:js'; +import 'dart:typed_data'; + +import 'package:js/js.dart'; +import 'package:js/js_util.dart'; + +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart' hide Event; +import 'native_implementations_web_worker.dart'; + +/// +/// +/// CAUTION: THIS FILE NEEDS TO BE MANUALLY COMPILED +/// +/// 1. in your project, create a file `web/web_worker.dart` +/// 2. add the following contents: +/// ```dart +/// import 'package:hive/hive.dart'; +/// +/// Future main() => startWebWorker(); +/// ``` +/// 3. compile the file using: +/// ```shell +/// dart compile js -o web/web_worker.dart.js -m web/web_worker.dart +/// ``` +/// +/// You should not check in that file into your VCS. Instead, you should compile +/// the web worker in your CI pipeline. +/// + +@pragma('dart2js:tryInline') +Future startWebWorker() async { + print('[native implementations worker]: Starting...'); + setProperty( + context['self'] as Object, + 'onmessage', + allowInterop( + (MessageEvent event) async { + final data = event.data; + try { + final operation = WebWorkerData.fromJson(data); + switch (operation.name) { + case WebWorkerOperations.shrinkImage: + final result = MatrixImageFile.resizeImplementation( + MatrixImageFileResizeArguments.fromJson( + Map.from(operation.data as Map))); + sendResponse(operation.label as double, result?.toJson()); + break; + case WebWorkerOperations.calcImageMetadata: + final result = MatrixImageFile.calcMetadataImplementation( + Uint8List.fromList( + (operation.data as JsArray).whereType().toList())); + sendResponse(operation.label as double, result?.toJson()); + break; + case WebWorkerOperations.generateUploadKeys: + final result = generateUploadKeysImplementation( + GenerateUploadKeysArgs.fromJson( + Map.from(operation.data as Map), + ), + ); + sendResponse(operation.label as double, result.toJson()); + break; + default: + throw NullThrownError(); + } + } on Event catch (e, s) { + allowInterop(_replyError) + .call((e.target as Request).error, s, data['label'] as double); + } catch (e, s) { + allowInterop(_replyError).call(e, s, data['label'] as double); + } + }, + ), + ); +} + +void sendResponse(double label, dynamic response) { + try { + self.postMessage({ + 'label': label, + 'data': response, + }); + } catch (e, s) { + print('[native implementations worker] Error responding: $e, $s'); + } +} + +void _replyError(Object? error, StackTrace stackTrace, double origin) { + if (error != null) { + try { + final jsError = jsify(error); + if (jsError != null) { + error = jsError; + } + } catch (e) { + error = error.toString(); + } + } + try { + self.postMessage({ + 'label': 'stacktrace', + 'origin': origin, + 'error': error, + 'stacktrace': stackTrace.toString(), + }); + } catch (e, s) { + print('[native implementations worker] Error responding: $e, $s'); + } +} + +/// represents the [WorkerGlobalScope] the worker currently runs in. +@JS('self') +external WorkerGlobalScope get self; + +/// adding all missing WebWorker-only properties to the [WorkerGlobalScope] +extension on WorkerGlobalScope { + void postMessage(Object data) { + callMethod(self, 'postMessage', [jsify(data)]); + } +} diff --git a/lib/src/utils/web_worker/web_worker_stub.dart b/lib/src/utils/web_worker/web_worker_stub.dart new file mode 100644 index 00000000..ad7c3af1 --- /dev/null +++ b/lib/src/utils/web_worker/web_worker_stub.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +/// +/// +/// CAUTION: THIS FILE NEEDS TO BE MANUALLY COMPILED +/// +/// 1. in your project, create a file `web/web_worker.dart` +/// 2. add the following contents: +/// ```dart +/// import 'package:hive/hive.dart'; +/// +/// Future main() => startWebWorker(); +/// ``` +/// 3. compile the file using: +/// ```shell +/// dart compile js -o web/web_worker.dart.js -m web/web_worker.dart +/// ``` +/// +/// You should not check in that file into your VCS. Instead, you should compile +/// the web worker in your CI pipeline. +/// + +Future startWebWorker() => Future.value();