From 6866a996a37306df115f0fb8d37d0057ed60bfcc Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sun, 22 Sep 2024 14:36:35 +0200 Subject: [PATCH 1/2] chore: Follow up imageviewer --- lib/pages/chat/events/image_bubble.dart | 5 ++++- lib/pages/image_viewer/image_viewer.dart | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/pages/chat/events/image_bubble.dart b/lib/pages/chat/events/image_bubble.dart index 9dbdceb04..a2fcf23b3 100644 --- a/lib/pages/chat/events/image_bubble.dart +++ b/lib/pages/chat/events/image_bubble.dart @@ -60,7 +60,10 @@ class ImageBubble extends StatelessWidget { if (!tapToView) return; showDialog( context: context, - builder: (_) => ImageViewer(event), + builder: (_) => ImageViewer( + event, + outerContext: context, + ), ); } diff --git a/lib/pages/image_viewer/image_viewer.dart b/lib/pages/image_viewer/image_viewer.dart index 94ab19dd4..9e8cfe82c 100644 --- a/lib/pages/image_viewer/image_viewer.dart +++ b/lib/pages/image_viewer/image_viewer.dart @@ -10,8 +10,9 @@ import '../../utils/matrix_sdk_extensions/event_extension.dart'; class ImageViewer extends StatefulWidget { final Event event; + final BuildContext outerContext; - const ImageViewer(this.event, {super.key}); + const ImageViewer(this.event, {required this.outerContext, super.key}); @override ImageViewerController createState() => ImageViewerController(); @@ -20,8 +21,9 @@ class ImageViewer extends StatefulWidget { class ImageViewerController extends State { /// Forward this image to another room. void forwardAction() { - Matrix.of(context).shareContent = widget.event.content; - context.go('/rooms'); + Matrix.of(widget.outerContext).shareContent = widget.event.content; + Navigator.of(context).pop(); + widget.outerContext.go('/rooms'); } /// Save this file with a system call. From 5c9880f0b20772bcd916765a6ee1d77c8b0bccbd Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sun, 22 Sep 2024 15:45:41 +0200 Subject: [PATCH 2/2] refactor: Load bytes from sending files later to not let app crash --- assets/l10n/intl_en.arb | 3 +- lib/pages/chat/chat.dart | 74 +--- lib/pages/chat/send_file_dialog.dart | 350 +++++++++++------- lib/pages/chat_list/chat_list.dart | 9 +- .../{resize_image.dart => resize_video.dart} | 25 +- pubspec.lock | 4 +- pubspec.yaml | 2 + 7 files changed, 248 insertions(+), 219 deletions(-) rename lib/utils/{resize_image.dart => resize_video.dart} (56%) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 8c0c677f9..b8ef839d5 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2761,5 +2761,6 @@ "discoverHomeservers": "Discover homeservers", "whatIsAHomeserver": "What is a homeserver?", "homeserverDescription": "All your data is stored on the homeserver, just like an email provider. You can choose which homeserver you want to use, while you can still communicate with everyone. Learn more at at https://matrix.org.", - "doesNotSeemToBeAValidHomeserver": "Doesn't seem to be a compatible homeserver. Wrong URL?" + "doesNotSeemToBeAValidHomeserver": "Doesn't seem to be a compatible homeserver. Wrong URL?", + "calculatingFileSize": "Calculating file size..." } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index cd770ca5f..9b934e05b 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -37,7 +37,6 @@ import 'package:fluffychat/widgets/app_lock.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../utils/account_bundles.dart'; import '../../utils/localized_exception_extension.dart'; -import '../../utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'send_file_dialog.dart'; import 'send_location_dialog.dart'; @@ -123,36 +122,11 @@ class ChatController extends State void onDragDone(DropDoneDetails details) async { setState(() => dragging = false); if (details.files.isEmpty) return; - final result = await showFutureLoadingDialog( - context: context, - future: () async { - final clientConfig = await room.client.getConfig(); - final maxUploadSize = clientConfig.mUploadSize ?? 100 * 1024 * 1024; - final matrixFiles = await Future.wait( - details.files.map( - (xfile) async { - final length = await xfile.length(); - if (length > maxUploadSize) { - throw FileTooBigMatrixException(length, maxUploadSize); - } - return MatrixFile( - bytes: await xfile.readAsBytes(), - name: xfile.name, - mimeType: xfile.mimeType, - ).detectFileType; - }, - ), - ); - return matrixFiles; - }, - ); - final matrixFiles = result.result; - if (matrixFiles == null || matrixFiles.isEmpty) return; await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( - files: matrixFiles, + files: details.files, room: room, ), ); @@ -510,36 +484,24 @@ class ChatController extends State FilePicker.platform.pickFiles( compressionQuality: 0, allowMultiple: false, - withData: true, ), ); if (result == null || result.files.isEmpty) return; await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( - files: result.files - .map( - (xfile) => MatrixFile( - bytes: xfile.bytes!, - name: xfile.name, - ).detectFileType, - ) - .toList(), + files: result.xFiles, room: room, ), ); } void sendImageFromClipBoard(Uint8List? image) async { + if (image == null) return; await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( - files: [ - MatrixFile( - bytes: image!, - name: "image from Clipboard", - ).detectFileType, - ], + files: [XFile.fromData(image)], room: room, ), ); @@ -550,7 +512,6 @@ class ChatController extends State FilePicker.platform.pickFiles( compressionQuality: 0, type: FileType.image, - withData: true, allowMultiple: false, ), ); @@ -559,14 +520,7 @@ class ChatController extends State await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( - files: result.files - .map( - (xfile) => MatrixFile( - bytes: xfile.bytes!, - name: xfile.name, - ).detectFileType, - ) - .toList(), + files: result.xFiles, room: room, ), ); @@ -577,16 +531,11 @@ class ChatController extends State FocusScope.of(context).requestFocus(FocusNode()); final file = await ImagePicker().pickImage(source: ImageSource.camera); if (file == null) return; - final bytes = await file.readAsBytes(); + await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( - files: [ - MatrixImageFile( - bytes: bytes, - name: file.path, - ), - ], + files: [file], room: room, ), ); @@ -600,16 +549,11 @@ class ChatController extends State maxDuration: const Duration(minutes: 1), ); if (file == null) return; - final bytes = await file.readAsBytes(); + await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( - files: [ - MatrixVideoFile( - bytes: bytes, - name: file.path, - ), - ], + files: [file], room: room, ), ); diff --git a/lib/pages/chat/send_file_dialog.dart b/lib/pages/chat/send_file_dialog.dart index edcb981f3..5109a1d40 100644 --- a/lib/pages/chat/send_file_dialog.dart +++ b/lib/pages/chat/send_file_dialog.dart @@ -1,18 +1,25 @@ +import 'dart:io'; + import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:cross_file/cross_file.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; +import 'package:mime/mime.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/error_reporter.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/size_string.dart'; -import '../../utils/resize_image.dart'; +import '../../utils/resize_video.dart'; class SendFileDialog extends StatefulWidget { final Room room; - final List files; + final List files; const SendFileDialog({ required this.room, @@ -33,158 +40,233 @@ class SendFileDialogState extends State { Future _send() async { final scaffoldMessenger = ScaffoldMessenger.of(context); final l10n = L10n.of(context)!; - for (var file in widget.files) { - MatrixImageFile? thumbnail; - if (file is MatrixVideoFile && file.bytes.length > minSizeToCompress) { - await showFutureLoadingDialog( - context: context, - future: () async { - file = origImage ? file : await file.resizeVideo(); - thumbnail = await file.getVideoThumbnail(); - }, - ); - } - widget.room - .sendFileEvent( - file, - thumbnail: thumbnail, - shrinkImageMaxDimension: origImage ? null : 1600, - ) - .catchError( - (e, s) { - if (e is FileTooBigMatrixException) { - scaffoldMessenger.showSnackBar( - SnackBar(content: Text(l10n.fileIsTooBigForServer)), - ); - return null; - } - ErrorReporter(context, 'Unable to send file').onErrorCallback(e, s); - return null; - }, - ); - } + Navigator.of(context, rootNavigator: false).pop(); + showFutureLoadingDialog( + context: context, + future: () async { + final clientConfig = await widget.room.client.getConfig(); + final maxUploadSize = clientConfig.mUploadSize ?? 100 * 1024 * 1024; + + for (final xfile in widget.files) { + final MatrixFile file; + MatrixImageFile? thumbnail; + final length = await xfile.length(); + final mimeType = xfile.mimeType ?? lookupMimeType(xfile.path); + + // If file is a video, shrink it! + if (mimeType != null && + mimeType.startsWith('video') && + length > minSizeToCompress && + !origImage) { + file = await xfile.resizeVideo(); + thumbnail = await xfile.getVideoThumbnail(); + } else { + // Else we just create a MatrixFile + file = MatrixFile( + bytes: await xfile.readAsBytes(), + name: xfile.name, + mimeType: xfile.mimeType, + ).detectFileType; + } + + if (file.bytes.length > maxUploadSize) { + throw FileTooBigMatrixException(length, maxUploadSize); + } + + widget.room + .sendFileEvent( + file, + thumbnail: thumbnail, + shrinkImageMaxDimension: origImage ? null : 1600, + ) + .catchError( + (e, s) { + if (e is FileTooBigMatrixException) { + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(l10n.fileIsTooBigForServer)), + ); + return null; + } + ErrorReporter(context, 'Unable to send file') + .onErrorCallback(e, s); + return null; + }, + ); + } + }, + ); + return; } + Future _calcCombinedFileSize() async { + final lengths = + await Future.wait(widget.files.map((file) => file.length())); + return lengths.fold(0, (p, length) => p + length).sizeString; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); var sendStr = L10n.of(context)!.sendFile; - final allFilesAreImages = - widget.files.every((file) => file is MatrixImageFile); - final sizeString = widget.files - .fold(0, (p, file) => p + file.bytes.length) - .sizeString; + final uniqueMimeType = widget.files + .map((file) => file.mimeType ?? lookupMimeType(file.path)) + .toSet() + .singleOrNull; + final fileName = widget.files.length == 1 ? widget.files.single.name : L10n.of(context)!.countFiles(widget.files.length.toString()); - if (allFilesAreImages) { + if (uniqueMimeType?.startsWith('image') ?? false) { sendStr = L10n.of(context)!.sendImage; - } else if (widget.files.every((file) => file is MatrixAudioFile)) { + } else if (uniqueMimeType?.startsWith('audio') ?? false) { sendStr = L10n.of(context)!.sendAudio; - } else if (widget.files.every((file) => file is MatrixVideoFile)) { + } else if (uniqueMimeType?.startsWith('video') ?? false) { sendStr = L10n.of(context)!.sendVideo; } - Widget contentWidget; - if (allFilesAreImages) { - contentWidget = Column( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Material( - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - elevation: theme.appBarTheme.scrolledUnderElevation ?? 4, - shadowColor: theme.appBarTheme.shadowColor, - clipBehavior: Clip.hardEdge, - child: Image.memory( - widget.files.first.bytes, - fit: BoxFit.contain, - height: 256, + + return FutureBuilder( + future: _calcCombinedFileSize(), + builder: (context, snapshot) { + final sizeString = + snapshot.data ?? L10n.of(context)!.calculatingFileSize; + + Widget contentWidget; + if (uniqueMimeType?.startsWith('image') ?? false) { + contentWidget = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Material( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + elevation: theme.appBarTheme.scrolledUnderElevation ?? 4, + shadowColor: theme.appBarTheme.shadowColor, + clipBehavior: Clip.hardEdge, + child: kIsWeb + ? Image.network( + widget.files.first.path, + fit: BoxFit.contain, + height: 256, + ) + : Image.file( + File(widget.files.first.path), + fit: BoxFit.contain, + height: 256, + ), + ), ), + const SizedBox(height: 16), + // Workaround for SwitchListTile.adaptive crashes in CupertinoDialog + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CupertinoSwitch( + value: origImage, + onChanged: (v) => setState(() => origImage = v), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + L10n.of(context)!.sendOriginal, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text(sizeString), + ], + ), + ), + ], + ), + ], + ); + } else { + final fileNameParts = fileName.split('.'); + contentWidget = SizedBox( + width: 256, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon( + uniqueMimeType == null + ? Icons.description_outlined + : uniqueMimeType.startsWith('video') + ? Icons.video_file_outlined + : uniqueMimeType.startsWith('audio') + ? Icons.audio_file_outlined + : Icons.description_outlined, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + fileNameParts.first, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (fileNameParts.length > 1) + Text('.${fileNameParts.last}'), + Text(' ($sizeString)'), + ], + ), + // Workaround for SwitchListTile.adaptive crashes in CupertinoDialog + if (uniqueMimeType != null && + uniqueMimeType.startsWith('video') && + PlatformInfos.isMobile) + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CupertinoSwitch( + value: origImage, + onChanged: (v) => setState(() => origImage = v), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + L10n.of(context)!.sendOriginal, + style: + const TextStyle(fontWeight: FontWeight.bold), + ), + Text(sizeString), + ], + ), + ), + ], + ), + ], ), - ), - const SizedBox(height: 16), - // Workaround for SwitchListTile.adaptive crashes in CupertinoDialog - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - CupertinoSwitch( - value: origImage, - onChanged: (v) => setState(() => origImage = v), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - L10n.of(context)!.sendOriginal, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - Text(sizeString), - ], - ), - ), - ], - ), - ], - ); - } else if (widget.files.every((file) => file is MatrixVideoFile)) { - contentWidget = Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(fileName), - const SizedBox(height: 16), - // Workaround for SwitchListTile.adaptive crashes in CupertinoDialog - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - CupertinoSwitch( - value: origImage, - onChanged: (v) => setState(() => origImage = v), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - L10n.of(context)!.sendOriginal, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - Text(sizeString), - ], - ), - ), - ], - ), - ], - ); - } else { - contentWidget = Text('$fileName ($sizeString)'); - } - return AlertDialog.adaptive( - title: Text(sendStr), - content: contentWidget, - actions: [ - TextButton( - onPressed: () { - // just close the dialog - Navigator.of(context, rootNavigator: false).pop(); - }, - child: Text(L10n.of(context)!.cancel), - ), - TextButton( - onPressed: _send, - child: Text(L10n.of(context)!.send), - ), - ], + ); + } + return AlertDialog.adaptive( + title: Text(sendStr), + content: contentWidget, + actions: [ + TextButton( + onPressed: () { + // just close the dialog + Navigator.of(context, rootNavigator: false).pop(); + }, + child: Text(L10n.of(context)!.cancel), + ), + TextButton( + onPressed: _send, + child: Text(L10n.of(context)!.send), + ), + ], + ); + }, ); } } diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 9df247e4c..bc9b3fa02 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:cross_file/cross_file.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_shortcuts/flutter_shortcuts.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; @@ -205,7 +206,13 @@ class ChatListController extends State context: context, useRootNavigator: false, builder: (c) => SendFileDialog( - files: [shareFile], + files: [ + XFile.fromData( + shareFile.bytes, + name: shareFile.name, + mimeType: shareFile.mimeType, + ), + ], room: room, ), ); diff --git a/lib/utils/resize_image.dart b/lib/utils/resize_video.dart similarity index 56% rename from lib/utils/resize_image.dart rename to lib/utils/resize_video.dart index 3bfe81a6d..768677106 100644 --- a/lib/utils/resize_image.dart +++ b/lib/utils/resize_video.dart @@ -1,28 +1,25 @@ -import 'dart:io'; - +import 'package:cross_file/cross_file.dart'; import 'package:matrix/matrix.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:video_compress/video_compress.dart'; import 'package:fluffychat/utils/platform_infos.dart'; -extension ResizeImage on MatrixFile { +extension ResizeImage on XFile { static const int max = 1200; static const int quality = 40; Future resizeVideo() async { - final tmpDir = await getTemporaryDirectory(); - final tmpFile = File('${tmpDir.path}/$name'); MediaInfo? mediaInfo; - await tmpFile.writeAsBytes(bytes); try { - // will throw an error e.g. on Android SDK < 18 - mediaInfo = await VideoCompress.compressVideo(tmpFile.path); + if (PlatformInfos.isMobile) { + // will throw an error e.g. on Android SDK < 18 + mediaInfo = await VideoCompress.compressVideo(path); + } } catch (e, s) { Logs().w('Error while compressing video', e, s); } return MatrixVideoFile( - bytes: (await mediaInfo?.file?.readAsBytes()) ?? bytes, + bytes: (await mediaInfo?.file?.readAsBytes()) ?? await readAsBytes(), name: name, mimeType: mimeType, width: mediaInfo?.width, @@ -33,13 +30,9 @@ extension ResizeImage on MatrixFile { Future getVideoThumbnail() async { if (!PlatformInfos.isMobile) return null; - final tmpDir = await getTemporaryDirectory(); - final tmpFile = File('${tmpDir.path}/$name'); - if (await tmpFile.exists() == false) { - await tmpFile.writeAsBytes(bytes); - } + try { - final bytes = await VideoCompress.getByteThumbnail(tmpFile.path); + final bytes = await VideoCompress.getByteThumbnail(path); if (bytes == null) return null; return MatrixImageFile( bytes: bytes, diff --git a/pubspec.lock b/pubspec.lock index 993921887..3bd1a3047 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -231,7 +231,7 @@ packages: source: hosted version: "1.9.2" cross_file: - dependency: transitive + dependency: "direct main" description: name: cross_file sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" @@ -1228,7 +1228,7 @@ packages: source: hosted version: "2.0.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" diff --git a/pubspec.yaml b/pubspec.yaml index 55c052c38..363abfed5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: callkeep: ^0.3.2 chewie: ^1.8.1 collection: ^1.18.0 + cross_file: ^0.3.4+2 cupertino_icons: any desktop_drop: ^0.4.4 desktop_notifications: ^0.6.3 @@ -65,6 +66,7 @@ dependencies: latlong2: ^0.9.1 linkify: ^5.0.0 matrix: ^0.33.0 + mime: ^1.0.6 native_imaging: ^0.1.1 opus_caf_converter_dart: ^1.0.1 package_info_plus: ^6.0.0