From 275555c50b2dd96d650bf1efc9feb70312a4d73a Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 30 Jan 2024 21:18:19 +0100 Subject: [PATCH] refactor(neon_files): Allow file upload/download operations on web Signed-off-by: provokateurin --- .../neon/neon_files/lib/src/blocs/files.dart | 74 ++++++++--- .../lib/src/models/file_details.dart | 4 +- .../neon/neon_files/lib/src/utils/task.dart | 116 +++++++++++++++--- .../neon_files/lib/src/widgets/actions.dart | 2 +- .../neon_files/lib/src/widgets/dialog.dart | 48 ++++++-- 5 files changed, 194 insertions(+), 50 deletions(-) diff --git a/packages/neon/neon_files/lib/src/blocs/files.dart b/packages/neon/neon_files/lib/src/blocs/files.dart index 9f241d32f9a..faf7f323421 100644 --- a/packages/neon/neon_files/lib/src/blocs/files.dart +++ b/packages/neon/neon_files/lib/src/blocs/files.dart @@ -8,8 +8,9 @@ import 'package:neon_files/src/options.dart'; import 'package:neon_files/src/utils/task.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/models.dart'; +import 'package:neon_framework/platform.dart'; import 'package:neon_framework/utils.dart'; -import 'package:nextcloud/webdav.dart'; +import 'package:nextcloud/nextcloud.dart'; import 'package:open_file/open_file.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -31,9 +32,11 @@ sealed class FilesBloc implements InteractiveBloc { void uploadFile(PathUri uri, String localPath); + void uploadMemory(PathUri uri, Uint8List bytes, {DateTime? lastModified}); + void openFile(PathUri uri, String etag, String? mimeType); - void shareFileNative(PathUri uri, String etag); + void shareFileNative(PathUri uri, String etag, String? mimeType); void delete(PathUri uri); @@ -118,11 +121,15 @@ class _FilesBloc extends InteractiveBloc implements FilesBloc { void openFile(PathUri uri, String etag, String? mimeType) { wrapAction( () async { - final file = await cacheFile(uri, etag); - - final result = await OpenFile.open(file.path, type: mimeType); - if (result.type != ResultType.done) { - throw const UnableToOpenFileException(); + if (NeonPlatform.instance.canUsePaths) { + final file = await cacheFile(uri, etag); + final result = await OpenFile.open(file.path, type: mimeType); + if (result.type != ResultType.done) { + throw const UnableToOpenFileException(); + } + } else { + final bytes = await downloadMemory(uri); + await NeonPlatform.instance.saveFileWithPickDialog(uri.name, mimeType ?? 'application/octet-stream', bytes); } }, disableTimeout: true, @@ -130,12 +137,15 @@ class _FilesBloc extends InteractiveBloc implements FilesBloc { } @override - void shareFileNative(PathUri uri, String etag) { + void shareFileNative(PathUri uri, String etag, String? mimeType) { wrapAction( () async { - final file = await cacheFile(uri, etag); - - await Share.shareXFiles([XFile(file.path)]); + if (NeonPlatform.instance.canUsePaths) { + final file = await cacheFile(uri, etag); + await Share.shareXFiles([XFile(file.path)]); + } else { + throw UnimplementedError('Sharing is not supported on web'); + } }, disableTimeout: true, ); @@ -170,7 +180,7 @@ class _FilesBloc extends InteractiveBloc implements FilesBloc { void uploadFile(PathUri uri, String localPath) { wrapAction( () async { - final task = FilesUploadTask( + final task = FilesUploadTaskIO( uri: uri, file: File(localPath), ); @@ -182,6 +192,24 @@ class _FilesBloc extends InteractiveBloc implements FilesBloc { ); } + @override + void uploadMemory(PathUri uri, Uint8List bytes, {DateTime? lastModified}) { + wrapAction( + () async { + final task = FilesUploadTaskMemory( + uri: uri, + size: bytes.length, + lastModified: lastModified, + bytes: bytes, + ); + tasks.add(tasks.value..add(task)); + await uploadQueue.add(() => task.execute(account.client)); + tasks.add(tasks.value..remove(task)); + }, + disableTimeout: true, + ); + } + Future cacheFile(PathUri uri, String etag) async { final cacheDir = await getApplicationCacheDirectory(); final file = File(p.join(cacheDir.path, 'files', etag.replaceAll('"', ''), uri.name)); @@ -191,23 +219,33 @@ class _FilesBloc extends InteractiveBloc implements FilesBloc { if (!file.parent.existsSync()) { await file.parent.create(recursive: true); } - await downloadFile(uri, file); + await downloadIO(uri, file); } return file; } - Future downloadFile( - PathUri uri, - File file, - ) async { - final task = FilesDownloadTask( + Future downloadIO(PathUri uri, File file) async { + final task = FilesDownloadTaskIO( uri: uri, file: file, ); + + tasks.add(tasks.value..add(task)); + await downloadQueue.add(() => task.execute(account.client)); + tasks.add(tasks.value..remove(task)); + } + + Future downloadMemory(PathUri uri) async { + final task = FilesDownloadTaskMemory(uri: uri); + // We need to listen to the stream, otherwise it will get stuck. + final future = task.stream.bytes; + tasks.add(tasks.value..add(task)); await downloadQueue.add(() => task.execute(account.client)); tasks.add(tasks.value..remove(task)); + + return future; } @override diff --git a/packages/neon/neon_files/lib/src/models/file_details.dart b/packages/neon/neon_files/lib/src/models/file_details.dart index f803ae7c2f2..d1c954b7209 100644 --- a/packages/neon/neon_files/lib/src/models/file_details.dart +++ b/packages/neon/neon_files/lib/src/models/file_details.dart @@ -28,8 +28,8 @@ class FileDetails { FileDetails.fromUploadTask({ required FilesUploadTask this.task, }) : uri = task.uri, - size = task.stat.size, - lastModified = task.stat.modified, + size = task.size, + lastModified = task.lastModified, etag = null, mimeType = null, hasPreview = null, diff --git a/packages/neon/neon_files/lib/src/utils/task.dart b/packages/neon/neon_files/lib/src/utils/task.dart index 4f743da98ce..0c7822c4449 100644 --- a/packages/neon/neon_files/lib/src/utils/task.dart +++ b/packages/neon/neon_files/lib/src/utils/task.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:meta/meta.dart'; import 'package:nextcloud/nextcloud.dart'; @@ -7,23 +8,59 @@ import 'package:universal_io/io.dart'; sealed class FilesTask { FilesTask({ required this.uri, - required this.file, }); final PathUri uri; - final File file; - @protected - final streamController = StreamController(); + final progressController = StreamController(); /// Task progress in percent `[0, 1]`. - late final progress = streamController.stream.asBroadcastStream(); + late final progress = progressController.stream.asBroadcastStream(); } -class FilesDownloadTask extends FilesTask { +sealed class FilesDownloadTask extends FilesTask { FilesDownloadTask({ required super.uri, + }); +} + +sealed class FilesUploadTask extends FilesTask { + FilesUploadTask({ + required super.uri, + required this.size, + required this.lastModified, + }); + + final int? size; + + final DateTime? lastModified; +} + +sealed class FilesTaskIO extends FilesTask { + FilesTaskIO({ + required this.file, + required super.uri, + }); + + final File file; +} + +sealed class FilesTaskMemory extends FilesTask { + FilesTaskMemory({ + required super.uri, + }); + + final _stream = StreamController>(); + + Stream> get stream => _stream.stream; + + void add(Uint8List chunk) => _stream.add(chunk); +} + +class FilesDownloadTaskIO extends FilesTaskIO implements FilesDownloadTask { + FilesDownloadTaskIO({ + required super.uri, required super.file, }); @@ -31,29 +68,76 @@ class FilesDownloadTask extends FilesTask { await client.webdav.getFile( uri, file, - onProgress: streamController.add, + onProgress: progressController.add, ); - await streamController.close(); + await progressController.close(); } } -class FilesUploadTask extends FilesTask { - FilesUploadTask({ +class FilesUploadTaskIO extends FilesTaskIO implements FilesUploadTask { + FilesUploadTaskIO({ required super.uri, required super.file, }); - FileStat? _stat; - FileStat get stat => _stat ??= file.statSync(); + late final FileStat _stat = file.statSync(); + + @override + late int? size = _stat.size; + + @override + late DateTime? lastModified = _stat.modified; Future execute(NextcloudClient client) async { await client.webdav.putFile( file, - stat, + _stat, + uri, + lastModified: _stat.modified, + onProgress: progressController.add, + ); + await progressController.close(); + } +} + +class FilesDownloadTaskMemory extends FilesTaskMemory implements FilesDownloadTask { + FilesDownloadTaskMemory({ + required super.uri, + }); + + Future execute(NextcloudClient client) async { + final stream = client.webdav.getStream( + uri, + onProgress: progressController.add, + ); + await stream.pipe(_stream); + await progressController.close(); + } +} + +class FilesUploadTaskMemory extends FilesTaskMemory implements FilesUploadTask { + FilesUploadTaskMemory({ + required super.uri, + required this.size, + required this.lastModified, + required List bytes, + }) { + unawaited(Stream.value(bytes).pipe(_stream)); + } + + @override + final int? size; + + @override + final DateTime? lastModified; + + Future execute(NextcloudClient client) async { + await client.webdav.putStream( + _stream.stream, uri, - lastModified: stat.modified, - onProgress: streamController.add, + lastModified: lastModified, + onProgress: progressController.add, ); - await streamController.close(); + await progressController.close(); } } diff --git a/packages/neon/neon_files/lib/src/widgets/actions.dart b/packages/neon/neon_files/lib/src/widgets/actions.dart index a12f55a43e4..9ae1e6d09a9 100644 --- a/packages/neon/neon_files/lib/src/widgets/actions.dart +++ b/packages/neon/neon_files/lib/src/widgets/actions.dart @@ -20,7 +20,7 @@ class FileActions extends StatelessWidget { final bloc = NeonProvider.of(context); switch (action) { case FilesFileAction.share: - bloc.shareFileNative(details.uri, details.etag!); + bloc.shareFileNative(details.uri, details.etag!, details.mimeType); case FilesFileAction.toggleFavorite: if (details.isFavorite ?? false) { bloc.removeFavorite(details.uri); diff --git a/packages/neon/neon_files/lib/src/widgets/dialog.dart b/packages/neon/neon_files/lib/src/widgets/dialog.dart index 826e69a1531..8c732aeacd8 100644 --- a/packages/neon/neon_files/lib/src/widgets/dialog.dart +++ b/packages/neon/neon_files/lib/src/widgets/dialog.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:image_picker/image_picker.dart'; @@ -15,8 +16,6 @@ import 'package:neon_framework/theme.dart'; import 'package:neon_framework/utils.dart'; import 'package:neon_framework/widgets.dart'; import 'package:nextcloud/nextcloud.dart'; -import 'package:path/path.dart' as p; -import 'package:universal_io/io.dart'; /// Creates an adaptive bottom sheet to select an action to add a file. class FilesChooseCreateModal extends StatefulWidget { @@ -55,27 +54,36 @@ class _FilesChooseCreateModalState extends State { if (result != null) { for (final file in result.files) { - await upload(File(file.path!)); + if (!await confirmUpload(file.size)) { + return; + } + if (kIsWeb) { + widget.bloc.uploadMemory( + baseUri.join(PathUri.parse(file.name)), + file.bytes!, + ); + } else { + widget.bloc.uploadFile( + baseUri.join(PathUri.parse(file.name)), + file.path!, + ); + } } } } - Future upload(File file) async { + Future confirmUpload(int size) async { final sizeWarning = widget.bloc.options.uploadSizeWarning.value; if (sizeWarning != null) { - final stat = file.statSync(); - if (stat.size > sizeWarning) { - final result = await showUploadConfirmationDialog(context, sizeWarning, stat.size); + if (size > sizeWarning) { + final result = await showUploadConfirmationDialog(context, sizeWarning, size); if (!result) { - return; + return false; } } } - widget.bloc.uploadFile( - baseUri.join(PathUri.parse(p.basename(file.path))), - file.path, - ); + return true; } Widget wrapAction({ @@ -140,7 +148,21 @@ class _FilesChooseCreateModalState extends State { final picker = ImagePicker(); final result = await picker.pickImage(source: ImageSource.camera); if (result != null) { - await upload(File(result.path)); + final length = await result.length(); + if (!await confirmUpload(length)) { + return; + } + if (kIsWeb) { + widget.bloc.uploadMemory( + baseUri.join(PathUri.parse(result.name)), + await result.readAsBytes(), + ); + } else { + widget.bloc.uploadFile( + baseUri.join(PathUri.parse(result.name)), + result.path, + ); + } } }, ),