diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 7f761a87e6..864858c132 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/di/send_image/send_image_di.dart'; import 'package:fluffychat/pages/add_story/add_story.dart'; import 'package:fluffychat/pages/archive/archive.dart'; import 'package:fluffychat/pages/chat/chat.dart'; @@ -101,8 +102,9 @@ class AppRoutes { stackedRoutes: _chatDetailsRoutes, buildTransition: _rightToLeftTransition, ), - VWidget( + VWidgetWithDependency( path: '/rooms/:roomid', + di: SendImageDi(), widget: const Chat(), buildTransition: _rightToLeftTransition, stackedRoutes: [ diff --git a/lib/di/send_image/send_image_di.dart b/lib/di/send_image/send_image_di.dart new file mode 100644 index 0000000000..e9673b4a2c --- /dev/null +++ b/lib/di/send_image/send_image_di.dart @@ -0,0 +1,13 @@ +import 'package:fluffychat/di/base_di.dart'; +import 'package:fluffychat/domain/usecase/send_image_interactor.dart'; +import 'package:get_it/get_it.dart'; + +class SendImageDi extends BaseDI { + @override + String get scopeName => "Send image"; + + @override + void setUp(GetIt get) { + get.registerSingleton(SendImageInteractor()); + } +} \ No newline at end of file diff --git a/lib/domain/usecase/send_image_interactor.dart b/lib/domain/usecase/send_image_interactor.dart new file mode 100644 index 0000000000..defc206f79 --- /dev/null +++ b/lib/domain/usecase/send_image_interactor.dart @@ -0,0 +1,33 @@ + +import 'package:fluffychat/presentation/extensions/room_extension.dart'; +import 'package:matrix/matrix.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:fluffychat/presentation/extensions/asset_entity_extension.dart'; + +class SendImageInteractor { + Future execute({ + required Room room, + required AssetEntity entity, + String? txId, + Event? inReplyTo, + String? editEventId, + int? shrinkImageMaxDimension, + Map? extraContent, + }) async { + final matrixFile = await entity.toMatrixFile(); + if (matrixFile != null) { + try { + final mxcUri = await room.sendImageFileEvent( + matrixFile, + txid: txId, + editEventId: editEventId, + inReplyTo: inReplyTo, + shrinkImageMaxDimension: shrinkImageMaxDimension, + extraContent: extraContent, + ); + } catch(error) { + Logs().d("SendImageInteractor: execute(): $error"); + } + } + } +} \ No newline at end of file diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 9aa28f7db1..927da35697 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'dart:io'; import 'package:fluffychat/di/global/get_it_initializer.dart'; +import 'package:fluffychat/domain/usecase/send_image_interactor.dart'; import 'package:fluffychat/pages/forward/forward.dart'; import 'package:fluffychat/utils/network_connection_service.dart'; -import 'package:fluffychat/presentation/extensions/asset_entity_extension.dart'; import 'package:fluffychat/utils/voip/permission_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -16,16 +16,17 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:file_picker_cross/file_picker_cross.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:image_picker/image_picker.dart'; import 'package:linagora_design_flutter/images_picker/images_picker.dart' hide ImagePicker; import 'package:matrix/matrix.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:photo_manager/photo_manager.dart'; import 'package:record/record.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:vrouter/vrouter.dart'; +import 'package:fluffychat/presentation/extensions/room_extension.dart'; import 'package:fluffychat/pages/chat/chat_view.dart'; import 'package:fluffychat/pages/chat/event_info_dialog.dart'; @@ -397,28 +398,25 @@ class ChatController extends State { ); } - Future sendImage(IndexedAssetEntity entity) async { - final matrixFile = await entity.toMatrixFile(); - if (matrixFile != null) { - await room!.sendFileEvent( - matrixFile, - thumbnail: null, - ).catchError((e) { - Fluttertoast.showToast( - msg: "error: $e", - gravity: ToastGravity.BOTTOM, - ); - return null; - }); + void sendImage() { + final assetEntity = imagePickerController.selectedAssets.first; + final sendImageInteractor = getIt.get(); + if (assetEntity.asset.type == AssetType.image) { + sendImageInteractor.execute(room: room!, entity: assetEntity.asset); + imagePickerController.clearAssetCounter(); + numberSelectedImagesNotifier.value = 0; } } - Future sendImages() async { - final selectedAssets = imagePickerController.sortedSelectedAssets; - for (final entity in selectedAssets) { - await sendImage(entity); - } - } + // Future sendImages() async { + // final selectedAssets = imagePickerController.sortedSelectedAssets; + // for (final entity in selectedAssets) { + // await sendImage(); + // } + + // imagePickerController.clearAssetCounter(); + // numberSelectedImagesNotifier.value = 0; + // } void openCameraAction() async { // Make sure the textfield is unfocused before opening the camera diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index d884e60335..548ac7e75f 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -340,7 +340,7 @@ Future showImagesPickerBottomSheet({ InkWell( borderRadius: const BorderRadius.all(Radius.circular(100)), onTap: () { - controller.sendImages(); + controller.sendImage(); Navigator.of(context).pop(); }, child: SvgPicture.asset( diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 0120fb0720..fb8d5f75a0 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -1,4 +1,5 @@ import 'package:fluffychat/pages/chat/events/message_content_style.dart'; +import 'package:fluffychat/pages/chat/events/sending_image_widget.dart'; import 'package:fluffychat/widgets/twake_link_text.dart'; import 'package:flutter/material.dart'; @@ -9,6 +10,7 @@ import 'package:fluffychat/pages/chat/events/video_player.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/widgets/avatar/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../../config/app_config.dart'; @@ -112,6 +114,20 @@ class MessageContent extends StatelessWidget { case EventTypes.Sticker: switch (event.messageType) { case MessageTypes.Image: + if (event.status == EventStatus.error && event.messageType == MessageTypes.Image) { + return SizedBox( + width: MessageContentStyle.imageBubbleWidth, + height: MessageContentStyle.imageBubbleHeight, + child: const Center( + child: Icon(Icons.error, color: Colors.red), + ), + ); + } + + final sendingImageData = event.getSendingImageData(); + if (sendingImageData != null) { + return SendingImageWidget(sendingImageData: sendingImageData); + } return ImageBubble( event, width: MessageContentStyle.imageBubbleWidth, diff --git a/lib/pages/chat/events/sending_image_widget.dart b/lib/pages/chat/events/sending_image_widget.dart new file mode 100644 index 0000000000..db14a66941 --- /dev/null +++ b/lib/pages/chat/events/sending_image_widget.dart @@ -0,0 +1,27 @@ +import 'dart:typed_data'; + +import 'package:fluffychat/pages/chat/events/message_content_style.dart'; +import 'package:flutter/material.dart'; + +class SendingImageWidget extends StatelessWidget { + const SendingImageWidget({ + super.key, + required this.sendingImageData, + }); + + final Uint8List sendingImageData; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: Image.memory( + sendingImageData, + width: MessageContentStyle.imageBubbleWidth, + height: MessageContentStyle.imageBubbleHeight, + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/extensions/asset_entity_extension.dart b/lib/presentation/extensions/asset_entity_extension.dart index 065810cb02..b64d840eb8 100644 --- a/lib/presentation/extensions/asset_entity_extension.dart +++ b/lib/presentation/extensions/asset_entity_extension.dart @@ -1,11 +1,11 @@ -import 'package:linagora_design_flutter/images_picker/images_picker.dart'; import 'package:matrix/matrix.dart'; +import 'package:photo_manager/photo_manager.dart'; -extension AssetEntityExtension on IndexedAssetEntity { +extension AssetEntityExtension on AssetEntity { Future toMatrixFile() async { - final bytes = await asset.originBytes; + final bytes = await originBytes; if (bytes != null && bytes.isNotEmpty) { - return MatrixFile(bytes: bytes, name: await asset.titleAsync); + return MatrixFile(bytes: bytes, name: title ?? await titleAsync); } return null; } diff --git a/lib/presentation/extensions/room_extension.dart b/lib/presentation/extensions/room_extension.dart new file mode 100644 index 0000000000..cf3e0317a1 --- /dev/null +++ b/lib/presentation/extensions/room_extension.dart @@ -0,0 +1,216 @@ +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/utils/file_send_request_credentials.dart'; + +extension SendImage on Room { + + static const maxSendingImage = 20; + + Future sendImageFileEvent( + MatrixFile file, { + String? txid, + Event? inReplyTo, + String? editEventId, + int? shrinkImageMaxDimension, + Map? extraContent, + }) async { + if (sendingFileThumbnails.entries.length > maxSendingImage) { + sendingFileThumbnails.clear(); + } + if (sendingFilePlaceholders.entries.length > maxSendingImage) { + sendingFilePlaceholders.clear(); + } + + txid ??= client.generateUniqueTransactionId(); + sendingFilePlaceholders[txid] = file; + sendingFileThumbnails[txid] = MatrixImageFile(bytes: file.bytes, name: file.name); + + // Create a fake Event object as a placeholder for the uploading file: + final syncUpdate = SyncUpdate( + nextBatch: '', + rooms: RoomsUpdate( + join: { + id: JoinedRoomUpdate( + timeline: TimelineUpdate( + events: [ + MatrixEvent( + content: { + 'msgtype': file.msgType, + 'body': file.name, + 'filename': file.name, + }, + type: EventTypes.Message, + eventId: txid, + senderId: client.userID!, + originServerTs: DateTime.now(), + unsigned: { + messageSendingStatusKey: EventStatus.sending.intValue, + 'transaction_id': txid, + ...FileSendRequestCredentials( + inReplyTo: inReplyTo?.eventId, + editEventId: editEventId, + shrinkImageMaxDimension: shrinkImageMaxDimension, + extraContent: extraContent, + ).toJson(), + }, + ), + ], + ), + ), + }, + ), + ); + await handleImageFakeSync(syncUpdate); + // Check media config of the server before sending the file. Stop if the + // Media config is unreachable or the file is bigger than the given maxsize. + try { + final mediaConfig = await client.getConfig(); + final maxMediaSize = mediaConfig.mUploadSize; + if (maxMediaSize != null && maxMediaSize < file.bytes.lengthInBytes) { + throw FileTooBigMatrixException(file.bytes.lengthInBytes, maxMediaSize); + } + } catch (e) { + Logs().d('Config error while sending file', e); + syncUpdate.rooms!.join!.values.first.timeline!.events!.first + .unsigned![messageSendingStatusKey] = EventStatus.error.intValue; + await handleImageFakeSync(syncUpdate); + rethrow; + } + + MatrixFile uploadFile = file; // ignore: omit_local_variable_types + var thumbnail = sendingFileThumbnails[txid]; + // computing the thumbnail in case we can + if (file is MatrixImageFile && shrinkImageMaxDimension != null) { + file = await MatrixImageFile.shrink( + bytes: file.bytes, + name: file.name, + maxDimension: shrinkImageMaxDimension, + customImageResizer: client.customImageResizer, + nativeImplementations: client.nativeImplementations, + ); + + if (thumbnail != null && file.size < thumbnail.size) { + thumbnail = null; // in this case, the thumbnail is not usefull + } + } + + MatrixFile? uploadThumbnail = thumbnail; // ignore: omit_local_variable_types + EncryptedFile? encryptedFile; + EncryptedFile? encryptedThumbnail; + if (encrypted && client.fileEncryptionEnabled) { + syncUpdate.rooms!.join!.values.first.timeline!.events!.first + .unsigned![fileSendingStatusKey] = FileSendingStatus.encrypting.name; + await handleImageFakeSync(syncUpdate); + encryptedFile = await file.encrypt(); + uploadFile = encryptedFile.toMatrixFile(); + + if (thumbnail != null) { + encryptedThumbnail = await thumbnail.encrypt(); + uploadThumbnail = encryptedThumbnail.toMatrixFile(); + } + } + Uri? uploadResp, thumbnailUploadResp; + + final timeoutDate = DateTime.now(); + + syncUpdate.rooms!.join!.values.first.timeline!.events!.first + .unsigned![fileSendingStatusKey] = FileSendingStatus.uploading.name; + while (uploadResp == null || + (uploadThumbnail != null && thumbnailUploadResp == null)) { + try { + uploadResp = await client.uploadContent( + uploadFile.bytes, + filename: uploadFile.name, + contentType: uploadFile.mimeType, + ); + thumbnailUploadResp = uploadThumbnail != null + ? await client.uploadContent( + uploadThumbnail.bytes, + filename: uploadThumbnail.name, + contentType: uploadThumbnail.mimeType, + ) + : null; + } on MatrixException catch (_) { + syncUpdate.rooms!.join!.values.first.timeline!.events!.first + .unsigned![messageSendingStatusKey] = EventStatus.error.intValue; + await handleImageFakeSync(syncUpdate); + rethrow; + } catch (_) { + if (DateTime.now().isAfter(timeoutDate)) { + syncUpdate.rooms!.join!.values.first.timeline!.events!.first + .unsigned![messageSendingStatusKey] = EventStatus.error.intValue; + await handleImageFakeSync(syncUpdate); + rethrow; + } + Logs().v('Send File into room failed. Try again...'); + await Future.delayed(const Duration(seconds: 1)); + } + } + + // Send event + final content = { + 'msgtype': file.msgType, + 'body': file.name, + 'filename': file.name, + if (encryptedFile == null) 'url': uploadResp.toString(), + if (encryptedFile != null) + 'file': { + 'url': uploadResp.toString(), + 'mimetype': file.mimeType, + 'v': 'v2', + 'key': { + 'alg': 'A256CTR', + 'ext': true, + 'k': encryptedFile.k, + 'key_ops': ['encrypt', 'decrypt'], + 'kty': 'oct' + }, + 'iv': encryptedFile.iv, + 'hashes': {'sha256': encryptedFile.sha256} + }, + 'info': { + ...file.info, + if (thumbnail != null && encryptedThumbnail == null) + 'thumbnail_url': thumbnailUploadResp.toString(), + if (thumbnail != null && encryptedThumbnail != null) + 'thumbnail_file': { + 'url': thumbnailUploadResp.toString(), + 'mimetype': thumbnail.mimeType, + 'v': 'v2', + 'key': { + 'alg': 'A256CTR', + 'ext': true, + 'k': encryptedThumbnail.k, + 'key_ops': ['encrypt', 'decrypt'], + 'kty': 'oct' + }, + 'iv': encryptedThumbnail.iv, + 'hashes': {'sha256': encryptedThumbnail.sha256} + }, + if (thumbnail != null) 'thumbnail_info': thumbnail.info, + if (thumbnail?.blurhash != null && + file is MatrixImageFile && + file.blurhash == null) + 'xyz.amorgan.blurhash': thumbnail!.blurhash + }, + if (extraContent != null) ...extraContent, + }; + final eventId = await sendEvent( + content, + txid: txid, + inReplyTo: inReplyTo, + editEventId: editEventId, + ); + return eventId; + } + + Future handleImageFakeSync(SyncUpdate syncUpdate, + {Direction? direction}) async { + if (client.database != null) { + await client.database?.transaction(() async { + await client.handleSync(syncUpdate, direction: direction); + }); + } else { + await client.handleSync(syncUpdate, direction: direction); + } + } +} \ No newline at end of file diff --git a/lib/utils/matrix_sdk_extensions/event_extension.dart b/lib/utils/matrix_sdk_extensions/event_extension.dart index 358a8a958b..1ba54d9c27 100644 --- a/lib/utils/matrix_sdk_extensions/event_extension.dart +++ b/lib/utils/matrix_sdk_extensions/event_extension.dart @@ -46,4 +46,27 @@ extension LocalizedBody on Event { .tryGetMap('info') ?.tryGet('size') ?.sizeString; + + Uint8List? _getPlaceHolderMatrixFile(Event event) { + if (room.sendingFilePlaceholders.isNotEmpty) { + if (status == EventStatus.synced || status == EventStatus.sent) { + if (unsigned?.containsKey('transaction_id') == true) { + final transactionId = unsigned!['transaction_id']; + return room.sendingFilePlaceholders[transactionId]?.bytes; + } + } + } + return null; + } + + Uint8List? getSendingImageData() { + if (status == EventStatus.sending) { + return room.sendingFilePlaceholders[eventId]?.bytes; + } else if (status == EventStatus.synced) { + return _getPlaceHolderMatrixFile(this); + } else if (status == EventStatus.sent) { + return _getPlaceHolderMatrixFile(this); + } + return null; + } } diff --git a/pubspec.lock b/pubspec.lock index 05443b428a..6d5083e7ae 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1691,7 +1691,7 @@ packages: source: hosted version: "5.1.0" photo_manager: - dependency: transitive + dependency: "direct main" description: name: photo_manager sha256: bdc4ab1fa9fb064d8ccfea6ab44119f55b220293d7ce2e19eb5a5f998db86c88 diff --git a/pubspec.yaml b/pubspec.yaml index 0823e986bb..e2b117d489 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -114,6 +114,7 @@ dependencies: debounce_throttle: ^2.0.0 fluttertoast: ^8.2.2 rxdart: ^0.27.7 + photo_manager: ^2.6.0 dev_dependencies: build_runner: ^2.3.3 dart_code_metrics: ^5.7.3