From 3fa91e79bf85c4c9e78b9c9ed7e40cc620fda456 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Tue, 8 Aug 2023 18:06:15 +0200 Subject: [PATCH] feat(neon_files): Implement file syncing Signed-off-by: jld3103 --- packages/app/pubspec.lock | 8 + packages/neon/neon_files/lib/blocs/files.dart | 65 +++---- .../neon_files/lib/models/file_details.dart | 3 +- packages/neon/neon_files/lib/neon_files.dart | 9 + .../neon_files/lib/sync/implementation.dart | 82 +++++++++ .../neon/neon_files/lib/sync/mapping.dart | 64 +++++++ .../neon/neon_files/lib/sync/mapping.g.dart | 27 +++ .../neon/neon_files/lib/sync/sources.dart | 161 ++++++++++++++++++ .../neon/neon_files/lib/widgets/actions.dart | 27 --- .../neon_files/lib/widgets/browser_view.dart | 1 - .../neon_files/lib/widgets/file_tile.dart | 89 ++++++++++ packages/neon/neon_files/pubspec.yaml | 3 + 12 files changed, 465 insertions(+), 74 deletions(-) create mode 100644 packages/neon/neon_files/lib/sync/implementation.dart create mode 100644 packages/neon/neon_files/lib/sync/mapping.dart create mode 100644 packages/neon/neon_files/lib/sync/mapping.g.dart create mode 100644 packages/neon/neon_files/lib/sync/sources.dart create mode 100644 packages/neon/neon_files/lib/widgets/file_tile.dart diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index bbbace8e605..2ae7b7e12ae 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -1430,6 +1430,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" web: dependency: transitive description: diff --git a/packages/neon/neon_files/lib/blocs/files.dart b/packages/neon/neon_files/lib/blocs/files.dart index 2898ce8f424..7de32d89093 100644 --- a/packages/neon/neon_files/lib/blocs/files.dart +++ b/packages/neon/neon_files/lib/blocs/files.dart @@ -3,8 +3,6 @@ part of '../neon_files.dart'; abstract class FilesBlocEvents { void uploadFile(final List path, final String localPath); - void syncFile(final List path); - void openFile(final List path, final String etag, final String? mimeType); void delete(final List path); @@ -29,8 +27,8 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta this.options, this.account, ) { - options.uploadQueueParallelism.addListener(_uploadParalelismListener); - options.downloadQueueParallelism.addListener(_downloadParalelismListener); + options.uploadQueueParallelism.addListener(_uploadParallelismListener); + options.downloadQueueParallelism.addListener(_downloadParallelismListener); } final FilesAppSpecificOptions options; @@ -46,13 +44,28 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta _downloadQueue.dispose(); unawaited(tasks.close()); - options.uploadQueueParallelism.removeListener(_uploadParalelismListener); - options.downloadQueueParallelism.removeListener(_downloadParalelismListener); + options.uploadQueueParallelism.removeListener(_uploadParallelismListener); + options.downloadQueueParallelism.removeListener(_downloadParallelismListener); } @override BehaviorSubject> tasks = BehaviorSubject>.seeded([]); + @override + Future refresh() async { + await browser.refresh(); + } + + @override + void removeFavorite(final List path) { + wrapAction( + () async => account.client.webdav.proppatch( + path.join('/'), + set: WebDavProp(ocfavorite: 0), + ), + ); + } + @override void addFavorite(final List path) { wrapAction( @@ -100,21 +113,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta ); } - @override - Future refresh() async { - await browser.refresh(); - } - - @override - void removeFavorite(final List path) { - wrapAction( - () async => account.client.webdav.proppatch( - path.join('/'), - set: WebDavProp(ocfavorite: 0), - ), - ); - } - @override void rename(final List path, final String name) { wrapAction( @@ -125,27 +123,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta ); } - @override - void syncFile(final List path) { - wrapAction( - () async { - final file = File( - p.join( - await NeonPlatform.instance.userAccessibleAppDataPath, - account.humanReadableID, - 'files', - path.join(Platform.pathSeparator), - ), - ); - if (!file.parent.existsSync()) { - file.parent.createSync(recursive: true); - } - await _downloadFile(path, file); - }, - disableTimeout: true, - ); - } - @override void uploadFile(final List path, final String localPath) { wrapAction( @@ -177,11 +154,11 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta FilesBrowserBloc getNewFilesBrowserBloc() => FilesBrowserBloc(options, account); - void _downloadParalelismListener() { + void _downloadParallelismListener() { _downloadQueue.parallel = options.downloadQueueParallelism.value; } - void _uploadParalelismListener() { + void _uploadParallelismListener() { _uploadQueue.parallel = options.uploadQueueParallelism.value; } } diff --git a/packages/neon/neon_files/lib/models/file_details.dart b/packages/neon/neon_files/lib/models/file_details.dart index c0d61b28bcf..5e0eb911370 100644 --- a/packages/neon/neon_files/lib/models/file_details.dart +++ b/packages/neon/neon_files/lib/models/file_details.dart @@ -15,8 +15,7 @@ class FileDetails { FileDetails.fromWebDav({ required final WebDavFile file, - required final List path, - }) : path = List.from(path)..add(file.name), + }) : path = file.path.split('/').where((final element) => element.isNotEmpty).toList(), isDirectory = file.isDirectory, size = file.size, etag = file.etag, diff --git a/packages/neon/neon_files/lib/neon_files.dart b/packages/neon/neon_files/lib/neon_files.dart index 3b41ab6328d..7804e2e8789 100644 --- a/packages/neon/neon_files/lib/neon_files.dart +++ b/packages/neon/neon_files/lib/neon_files.dart @@ -18,10 +18,13 @@ import 'package:neon/nextcloud.dart'; import 'package:neon/platform.dart'; import 'package:neon/settings.dart'; import 'package:neon/sort_box.dart'; +import 'package:neon/sync.dart'; +import 'package:neon/theme.dart'; import 'package:neon/utils.dart'; import 'package:neon/widgets.dart'; import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/routes.dart'; +import 'package:neon_files/sync/mapping.dart'; import 'package:neon_files/widgets/file_list_tile.dart'; import 'package:open_file/open_file.dart'; import 'package:path/path.dart' as p; @@ -40,9 +43,12 @@ part 'options.dart'; part 'pages/details.dart'; part 'pages/main.dart'; part 'sort/files.dart'; +part 'sync/implementation.dart'; +part 'sync/sources.dart'; part 'utils/task.dart'; part 'widgets/browser_view.dart'; part 'widgets/file_preview.dart'; +part 'widgets/file_tile.dart'; class FilesApp extends AppImplementation { FilesApp(); @@ -68,6 +74,9 @@ class FilesApp extends AppImplementation { @override final Widget page = const FilesMainPage(); + @override + FilesSync getSync() => FilesSync(); + @override final RouteBase route = $filesAppRoute; } diff --git a/packages/neon/neon_files/lib/sync/implementation.dart b/packages/neon/neon_files/lib/sync/implementation.dart new file mode 100644 index 00000000000..a9e103ee1e7 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/implementation.dart @@ -0,0 +1,82 @@ +part of '../neon_files.dart'; + +class FilesSync implements SyncImplementation { + @override + String appId = AppIDs.files; + + @override + FilesSyncSources getSources(final Account account, final FilesSyncMapping mapping) => FilesSyncSources( + account.client, + mapping.remotePath, + mapping.localPath, + ); + + @override + Map serializeMapping(final FilesSyncMapping mapping) => mapping.toJson(); + + @override + FilesSyncMapping deserializeMapping(final Map json) => FilesSyncMapping.fromJson(json); + + @override + Future addMapping(final BuildContext context, final Account account) async { + final accountsBloc = Provider.of(context, listen: false); + final appsBloc = accountsBloc.getAppsBlocFor(account); + final filesBloc = appsBloc.getAppBlocByID(AppIDs.files)! as FilesBloc; + final filesBrowserBloc = filesBloc.getNewFilesBrowserBloc(); + + final remotePath = await showDialog?>( + context: context, + builder: (final context) => FilesChooseFolderDialog( + bloc: filesBrowserBloc, + filesBloc: filesBloc, + originalPath: const [], + ), + ); + filesBrowserBloc.dispose(); + if (remotePath == null) { + return null; + } + + final localPath = await FileUtils.pickDirectory(); + // ignore: use_build_context_synchronously + if (localPath == null || !context.mounted) { + return null; + } + + return FilesSyncMapping( + appId: AppIDs.files, + accountId: account.id, + remotePath: remotePath, + localPath: localPath, + journal: SyncJournal({}), + ); + } + + @override + Widget getConflictDetailsLocal(final BuildContext context, final FileSystemEntity object) { + final stat = object.statSync(); + return FilesFileTile( + showFullPath: true, + filesBloc: Provider.of(context, listen: false), + details: FileDetails( + path: object.path.split('/'), + isDirectory: object is Directory, + size: stat.size, + etag: '', + mimeType: '', + lastModified: stat.modified, + hasPreview: false, + isFavorite: false, + ), + ); + } + + @override + Widget getConflictDetailsRemote(final BuildContext context, final WebDavFile object) => FilesFileTile( + showFullPath: true, + filesBloc: Provider.of(context, listen: false), + details: FileDetails.fromWebDav( + file: object, + ), + ); +} diff --git a/packages/neon/neon_files/lib/sync/mapping.dart b/packages/neon/neon_files/lib/sync/mapping.dart new file mode 100644 index 00000000000..b5f9a5a68e6 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/mapping.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:neon/nextcloud.dart'; +import 'package:neon/sync.dart'; +import 'package:watcher/watcher.dart'; + +part 'mapping.g.dart'; + +@JsonSerializable() +class FilesSyncMapping implements SyncMapping { + FilesSyncMapping({ + required this.accountId, + required this.appId, + required this.journal, + required this.remotePath, + required this.localPath, + }); + + factory FilesSyncMapping.fromJson(final Map json) => _$FilesSyncMappingFromJson(json); + Map toJson() => _$FilesSyncMappingToJson(this); + + @override + String accountId; + + @override + String appId; + + @override + SyncJournal journal; + + final List remotePath; + + final String localPath; + + @override + late String title = remotePath.join('/'); + + @override + late String subtitle = localPath; + + @override + late String id = '${Uri.encodeComponent(remotePath.join('/'))}-${Uri.encodeComponent(localPath)}'; + + StreamSubscription? _subscription; + + @override + void watch(final Function() onUpdated) { + debugPrint('Watching file changes: $localPath'); + _subscription ??= DirectoryWatcher(localPath).events.listen( + (final event) { + debugPrint('Registered file change: ${event.path} ${event.type}'); + onUpdated(); + }, + ); + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + } +} diff --git a/packages/neon/neon_files/lib/sync/mapping.g.dart b/packages/neon/neon_files/lib/sync/mapping.g.dart new file mode 100644 index 00000000000..13beee33f35 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/mapping.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mapping.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FilesSyncMapping _$FilesSyncMappingFromJson(Map json) => FilesSyncMapping( + accountId: json['accountId'] as String, + appId: json['appId'] as String, + journal: SyncJournal.fromJson(json['journal'] as Map), + remotePath: (json['remotePath'] as List).map((e) => e as String).toList(), + localPath: json['localPath'] as String, + ) + ..title = json['title'] as String + ..subtitle = json['subtitle'] as String; + +Map _$FilesSyncMappingToJson(FilesSyncMapping instance) => { + 'accountId': instance.accountId, + 'appId': instance.appId, + 'journal': instance.journal, + 'remotePath': instance.remotePath, + 'localPath': instance.localPath, + 'title': instance.title, + 'subtitle': instance.subtitle, + }; diff --git a/packages/neon/neon_files/lib/sync/sources.dart b/packages/neon/neon_files/lib/sync/sources.dart new file mode 100644 index 00000000000..16afd54c7f8 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/sources.dart @@ -0,0 +1,161 @@ +part of '../neon_files.dart'; + +class FilesSyncSources implements SyncSources { + FilesSyncSources( + final NextcloudClient client, + final List webdavBaseDir, + final String ioBaseDir, + ) : sourceA = FilesSyncSourceWebDavFile(client, webdavBaseDir), + sourceB = FilesSyncSourceFileSystemEntity(client, ioBaseDir); + + @override + SyncSource sourceA; + + @override + SyncSource sourceB; + + @override + SyncConflictSolution? findSolution(final SyncObject objectA, final SyncObject objectB) { + if (objectA.data.isDirectory && objectB.data is Directory) { + return SyncConflictSolution.overwriteA; + } + + return null; + } +} + +class FilesSyncSourceWebDavFile implements SyncSource { + FilesSyncSourceWebDavFile( + this.client, + this.webdavBaseDir, + ); + + /// [NextcloudClient] used by the WebDAV part. + final NextcloudClient client; + + /// Base directory on the WebDAV server. + final List webdavBaseDir; + + final props = WebDavPropWithoutValues.fromBools( + davgetetag: true, + davgetlastmodified: true, + nchaspreview: true, + ocsize: true, + ocfavorite: true, + ); + + String _path(final SyncObject object) => [...webdavBaseDir, object.id].join('/'); + + @override + Future>> listObjects() async => (await client.webdav.propfind( + webdavBaseDir.join('/'), + prop: props, + depth: WebDavDepth.infinity, + )) + .toWebDavFiles() + .sublist(1) + .map( + (final file) { + var id = file.path; + if (webdavBaseDir.isNotEmpty) { + id = id.replaceFirst('/${webdavBaseDir.join('/')}', ''); + } + id = id.replaceFirst('/', ''); + if (id.endsWith('/')) { + id = id.substring(0, id.length - 1); + } + + return ( + id, + file, + ); + }, + ).toList(); + + @override + Future getObjectETag(final SyncObject object) async => + object.data.isDirectory ? '' : object.data.etag!; + + @override + Future> writeObject(final SyncObject object) async { + if (object.data is File) { + await client.webdav.putFile( + object.data as File, + object.data.statSync(), + _path(object), + ); + } else if (object.data is Directory) { + final parts = [...webdavBaseDir]; + for (final part in object.id.split('/')) { + parts.add(part); + await client.webdav.mkcol(parts.join('/')); + } + } else { + throw Exception('Unable to sync FileSystemEntity of type ${object.data.runtimeType}'); + } + return ( + object.id, + (await client.webdav.propfind( + _path(object), + prop: props, + depth: WebDavDepth.zero, + )) + .toWebDavFiles() + .single, + ); + } + + @override + Future deleteObject(final SyncObject object) async => client.webdav.delete(_path(object)); +} + +class FilesSyncSourceFileSystemEntity implements SyncSource { + FilesSyncSourceFileSystemEntity( + this.client, + this.ioBaseDir, + ); + + /// [NextcloudClient] used by the WebDAV part. + final NextcloudClient client; + + /// Base directory on the local filesystem. + final String ioBaseDir; + + @override + Future>> listObjects() async => Directory(ioBaseDir) + .listSync(recursive: true) + .map( + (final e) => ( + p.relative( + e.path, + from: ioBaseDir, + ), + e, + ), + ) + .toList(); + + @override + Future getObjectETag(final SyncObject object) async => + object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString(); + + @override + Future> writeObject(final SyncObject object) async { + var path = object.data.path; + if (path.startsWith('/')) { + path = path.substring(1); + } + if (object.data.isDirectory) { + final dir = Directory(p.join(ioBaseDir, object.id))..createSync(); + return (object.id, dir); + } else { + final file = File(p.join(ioBaseDir, object.id)); + await client.webdav.getFile(path, file); + await file.setLastModified(object.data.lastModified!); + return (object.id, file); + } + } + + @override + Future deleteObject(final SyncObject object) async => object.data.delete(); +} diff --git a/packages/neon/neon_files/lib/widgets/actions.dart b/packages/neon/neon_files/lib/widgets/actions.dart index 1e39ea4123a..78e0c380d26 100644 --- a/packages/neon/neon_files/lib/widgets/actions.dart +++ b/packages/neon/neon_files/lib/widgets/actions.dart @@ -1,4 +1,3 @@ -import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:neon/utils.dart'; import 'package:neon_files/l10n/localizations.dart'; @@ -15,7 +14,6 @@ class FileActions extends StatelessWidget { Future onSelected(final BuildContext context, final FilesFileAction action) async { final bloc = Provider.of(context, listen: false); - final browserBloc = bloc.browser; switch (action) { case FilesFileAction.toggleFavorite: if (details.isFavorite ?? false) { @@ -83,23 +81,6 @@ class FileActions extends StatelessWidget { if (result != null) { bloc.copy(details.path, result..add(details.name)); } - case FilesFileAction.sync: - if (!context.mounted) { - return; - } - final sizeWarning = browserBloc.options.downloadSizeWarning.value; - if (sizeWarning != null && details.size != null && details.size! > sizeWarning) { - if (!(await showConfirmationDialog( - context, - AppLocalizations.of(context).downloadConfirmSizeWarning( - filesize(sizeWarning), - filesize(details.size), - ), - ))) { - return; - } - } - bloc.syncFile(details.path); case FilesFileAction.delete: if (!context.mounted) { return; @@ -144,13 +125,6 @@ class FileActions extends StatelessWidget { value: FilesFileAction.copy, child: Text(AppLocalizations.of(context).actionCopy), ), - // TODO: https://github.com/provokateurin/nextcloud-neon/issues/4 - if (!details.isDirectory) ...[ - PopupMenuItem( - value: FilesFileAction.sync, - child: Text(AppLocalizations.of(context).actionSync), - ), - ], PopupMenuItem( value: FilesFileAction.delete, child: Text(AppLocalizations.of(context).actionDelete), @@ -166,6 +140,5 @@ enum FilesFileAction { rename, move, copy, - sync, delete, } diff --git a/packages/neon/neon_files/lib/widgets/browser_view.dart b/packages/neon/neon_files/lib/widgets/browser_view.dart index 2d06b8aaa63..4a76c60accc 100644 --- a/packages/neon/neon_files/lib/widgets/browser_view.dart +++ b/packages/neon/neon_files/lib/widgets/browser_view.dart @@ -86,7 +86,6 @@ class _FilesBrowserViewState extends State { ) : FileDetails.fromWebDav( file: file, - path: widget.bloc.path.value, ); return FileListTile( diff --git a/packages/neon/neon_files/lib/widgets/file_tile.dart b/packages/neon/neon_files/lib/widgets/file_tile.dart new file mode 100644 index 00000000000..6250d319f61 --- /dev/null +++ b/packages/neon/neon_files/lib/widgets/file_tile.dart @@ -0,0 +1,89 @@ +part of '../neon_files.dart'; + +class FilesFileTile extends StatelessWidget { + const FilesFileTile({ + required this.filesBloc, + required this.details, + this.trailing, + this.onTap, + this.uploadProgress, + this.downloadProgress, + this.showFullPath = false, + super.key, + }); + + final FilesBloc filesBloc; + final FileDetails details; + final Widget? trailing; + final Function()? onTap; + final int? uploadProgress; + final int? downloadProgress; + final bool showFullPath; + + @override + Widget build(final BuildContext context) => ListTile( + onTap: onTap, + title: Text( + showFullPath ? details.path.join('/') : details.name, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + if (details.lastModified != null) ...[ + RelativeTime( + date: details.lastModified!, + ), + ], + if (details.size != null && details.size! > 0) ...[ + const SizedBox( + width: 10, + ), + Text( + filesize(details.size, 1), + style: DefaultTextStyle.of(context).style.copyWith( + color: Colors.grey, + ), + ), + ], + ], + ), + leading: SizedBox.square( + dimension: 40, + child: Stack( + children: [ + Center( + child: uploadProgress != null || downloadProgress != null + ? Column( + children: [ + Icon( + uploadProgress != null ? MdiIcons.upload : MdiIcons.download, + color: Theme.of(context).colorScheme.primary, + ), + LinearProgressIndicator( + value: (uploadProgress ?? downloadProgress)! / 100, + ), + ], + ) + : FilePreview( + bloc: filesBloc, + details: details, + withBackground: true, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + ), + if (details.isFavorite ?? false) ...[ + const Align( + alignment: Alignment.bottomRight, + child: Icon( + Icons.star, + size: 14, + color: NcColors.starredColor, + ), + ), + ], + ], + ), + ), + trailing: trailing, + ); +} diff --git a/packages/neon/neon_files/pubspec.yaml b/packages/neon/neon_files/pubspec.yaml index eddfa213c4a..db7b247204d 100644 --- a/packages/neon/neon_files/pubspec.yaml +++ b/packages/neon/neon_files/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: go_router: ^10.1.1 image_picker: ^0.8.9 intersperse: ^2.0.0 + json_annotation: ^4.8.1 material_design_icons_flutter: ^7.0.7296 neon: git: @@ -31,10 +32,12 @@ dependencies: queue: ^3.1.0+2 rxdart: ^0.27.7 share_plus: ^7.1.0 + watcher: ^1.1.0 dev_dependencies: build_runner: ^2.4.6 go_router_builder: ^2.3.0 + json_serializable: ^6.7.1 neon_lints: git: url: https://github.com/nextcloud/neon