Browse Source

feat(neon_files): Implement file syncing

Signed-off-by: jld3103 <jld3103yt@gmail.com>
pull/600/head
jld3103 1 year ago
parent
commit
dfa7332eea
No known key found for this signature in database
GPG Key ID: 9062417B9E8EB7B3
  1. 8
      packages/app/pubspec.lock
  2. 53
      packages/neon/neon_files/lib/blocs/files.dart
  3. 9
      packages/neon/neon_files/lib/neon_files.dart
  4. 105
      packages/neon/neon_files/lib/sync/implementation.dart
  5. 63
      packages/neon/neon_files/lib/sync/mapping.dart
  6. 23
      packages/neon/neon_files/lib/sync/mapping.g.dart
  7. 142
      packages/neon/neon_files/lib/sync/sources.dart
  8. 27
      packages/neon/neon_files/lib/widgets/actions.dart
  9. 2
      packages/neon/neon_files/lib/widgets/file_list_tile.dart
  10. 94
      packages/neon/neon_files/lib/widgets/file_tile.dart
  11. 4
      packages/neon/neon_files/pubspec.yaml

8
packages/app/pubspec.lock

@ -1397,6 +1397,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:

53
packages/neon/neon_files/lib/blocs/files.dart

@ -3,8 +3,6 @@ part of '../neon_files.dart';
abstract interface class FilesBlocEvents {
void uploadFile(final PathUri uri, final String localPath);
void syncFile(final PathUri uri);
void openFile(final PathUri uri, final String etag, final String? mimeType);
void shareFileNative(final PathUri uri, final String etag);
@ -57,6 +55,21 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta
@override
BehaviorSubject<List<FilesTask>> tasks = BehaviorSubject<List<FilesTask>>.seeded([]);
@override
Future<void> refresh() async {
await browser.refresh();
}
@override
void removeFavorite(final PathUri uri) {
wrapAction(
() async => account.client.webdav.proppatch(
uri,
set: WebDavProp(ocfavorite: 0),
),
);
}
@override
void addFavorite(final PathUri uri) {
wrapAction(
@ -109,21 +122,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta
);
}
@override
Future<void> refresh() async {
await browser.refresh();
}
@override
void removeFavorite(final PathUri uri) {
wrapAction(
() async => account.client.webdav.proppatch(
uri,
set: WebDavProp(ocfavorite: 0),
),
);
}
@override
void rename(final PathUri uri, final String name) {
wrapAction(
@ -134,27 +132,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta
);
}
@override
void syncFile(final PathUri uri) {
wrapAction(
() async {
final file = File(
p.joinAll([
await NeonPlatform.instance.userAccessibleAppDataPath,
account.humanReadableID,
'files',
...uri.pathSegments,
]),
);
if (!file.parent.existsSync()) {
file.parent.createSync(recursive: true);
}
await _downloadFile(uri, file);
},
disableTimeout: true,
);
}
@override
void uploadFile(final PathUri uri, final String localPath) {
wrapAction(

9
packages/neon/neon_files/lib/neon_files.dart

@ -39,17 +39,20 @@ import 'package:neon/models.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:nextcloud/core.dart' as core;
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';
import 'package:permission_handler/permission_handler.dart';
import 'package:queue/queue.dart';
import 'package:rxdart/rxdart.dart';
import 'package:share_plus/share_plus.dart';
@ -65,9 +68,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';
part 'widgets/navigator.dart';
class FilesApp extends AppImplementation<FilesBloc, FilesAppSpecificOptions> {
@ -94,6 +100,9 @@ class FilesApp extends AppImplementation<FilesBloc, FilesAppSpecificOptions> {
@override
final Widget page = const FilesMainPage();
@override
final FilesSync syncImplementation = const FilesSync();
@override
final RouteBase route = $filesAppRoute;
}

105
packages/neon/neon_files/lib/sync/implementation.dart

@ -0,0 +1,105 @@
part of '../neon_files.dart';
@immutable
class FilesSync implements SyncImplementation<FilesSyncMapping, WebDavFile, FileSystemEntity> {
const FilesSync();
@override
String get appId => AppIDs.files;
@override
Future<FilesSyncSources> getSources(final Account account, final FilesSyncMapping mapping) async {
// This shouldn't be necessary, but it sadly is because of https://github.com/flutter/flutter/issues/25659.
// Alternative would be to use https://pub.dev/packages/shared_storage,
// but to be efficient we'd need https://github.com/alexrintt/shared-storage/issues/91
// or copy the files to the app cache (which is also not optimal).
if (Platform.isAndroid && !await Permission.manageExternalStorage.request().isGranted) {
throw const MissingPermissionException(Permission.manageExternalStorage);
}
return FilesSyncSources(
account.client,
mapping.remotePath,
mapping.localPath,
);
}
@override
Map<String, dynamic> serializeMapping(final FilesSyncMapping mapping) => mapping.toJson();
@override
FilesSyncMapping deserializeMapping(final Map<String, dynamic> json) => FilesSyncMapping.fromJson(json);
@override
Future<FilesSyncMapping?> addMapping(final BuildContext context, final Account account) async {
final accountsBloc = NeonProvider.of<AccountsBloc>(context);
final appsBloc = accountsBloc.getAppsBlocFor(account);
final filesBloc = appsBloc.getAppBlocByID(AppIDs.files)! as FilesBloc;
final filesBrowserBloc = filesBloc.getNewFilesBrowserBloc();
final remotePath = await showDialog<PathUri>(
context: context,
builder: (final context) => FilesChooseFolderDialog(
bloc: filesBrowserBloc,
filesBloc: filesBloc,
originalPath: PathUri.cwd(),
),
);
filesBrowserBloc.dispose();
if (remotePath == null) {
return null;
}
final localPath = await FileUtils.pickDirectory();
if (localPath == null) {
return null;
}
if (!context.mounted) {
return null;
}
return FilesSyncMapping(
appId: AppIDs.files,
accountId: account.id,
remotePath: remotePath,
localPath: Directory(localPath),
journal: SyncJournal(),
);
}
@override
String getMappingDisplayTitle(final FilesSyncMapping mapping) => mapping.remotePath.toString();
@override
String getMappingDisplaySubtitle(final FilesSyncMapping mapping) => mapping.localPath.path;
@override
String getMappingId(final FilesSyncMapping mapping) =>
'${Uri.encodeComponent(mapping.remotePath.toString())}-${Uri.encodeComponent(mapping.localPath.path)}';
@override
Widget getConflictDetailsLocal(final BuildContext context, final FileSystemEntity object) {
final stat = object.statSync();
return FilesFileTile(
showFullPath: true,
filesBloc: NeonProvider.of<FilesBloc>(context),
details: FileDetails(
uri: PathUri.parse(object.path),
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: NeonProvider.of<FilesBloc>(context),
details: FileDetails.fromWebDav(
file: object,
),
);
}

63
packages/neon/neon_files/lib/sync/mapping.dart

@ -0,0 +1,63 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:neon/sync.dart';
import 'package:nextcloud/webdav.dart' as webdav;
import 'package:nextcloud/webdav.dart';
import 'package:universal_io/io.dart';
import 'package:watcher/watcher.dart';
part 'mapping.g.dart';
@JsonSerializable()
class FilesSyncMapping implements SyncMapping<webdav.WebDavFile, FileSystemEntity> {
FilesSyncMapping({
required this.accountId,
required this.appId,
required this.journal,
required this.remotePath,
required this.localPath,
});
factory FilesSyncMapping.fromJson(final Map<String, dynamic> json) => _$FilesSyncMappingFromJson(json);
Map<String, dynamic> toJson() => _$FilesSyncMappingToJson(this);
@override
final String accountId;
@override
final String appId;
@override
final SyncJournal journal;
final PathUri remotePath;
@JsonKey(
fromJson: _directoryFromJson,
toJson: _directoryToJson,
)
final Directory localPath;
static Directory _directoryFromJson(final String value) => Directory(value);
static String _directoryToJson(final Directory value) => value.path;
StreamSubscription<WatchEvent>? _subscription;
@override
void watch(final void Function() onUpdated) {
debugPrint('Watching file changes: $localPath');
_subscription ??= DirectoryWatcher(localPath.path).events.listen(
(final event) {
debugPrint('Registered file change: ${event.path} ${event.type}');
onUpdated();
},
);
}
@override
void dispose() {
unawaited(_subscription?.cancel());
}
}

23
packages/neon/neon_files/lib/sync/mapping.g.dart

@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'mapping.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FilesSyncMapping _$FilesSyncMappingFromJson(Map<String, dynamic> json) => FilesSyncMapping(
accountId: json['accountId'] as String,
appId: json['appId'] as String,
journal: SyncJournal.fromJson(json['journal'] as Map<String, dynamic>),
remotePath: Uri.parse(json['remotePath'] as String),
localPath: FilesSyncMapping._directoryFromJson(json['localPath'] as String),
);
Map<String, dynamic> _$FilesSyncMappingToJson(FilesSyncMapping instance) => <String, dynamic>{
'accountId': instance.accountId,
'appId': instance.appId,
'journal': instance.journal,
'remotePath': instance.remotePath.toString(),
'localPath': FilesSyncMapping._directoryToJson(instance.localPath),
};

142
packages/neon/neon_files/lib/sync/sources.dart

@ -0,0 +1,142 @@
part of '../neon_files.dart';
class FilesSyncSources implements SyncSources<WebDavFile, FileSystemEntity> {
FilesSyncSources(
final NextcloudClient client,
final PathUri webdavBaseDir,
final Directory ioBaseDir,
) : sourceA = FilesSyncSourceWebDavFile(client, webdavBaseDir),
sourceB = FilesSyncSourceFileSystemEntity(client, ioBaseDir);
@override
final SyncSource<WebDavFile, FileSystemEntity> sourceA;
@override
final SyncSource<FileSystemEntity, WebDavFile> sourceB;
@override
SyncConflictSolution? findSolution(final SyncObject<WebDavFile> objectA, final SyncObject<FileSystemEntity> objectB) {
if (objectA.data.isDirectory && objectB.data is Directory) {
return SyncConflictSolution.overwriteA;
}
return null;
}
}
class FilesSyncSourceWebDavFile implements SyncSource<WebDavFile, FileSystemEntity> {
FilesSyncSourceWebDavFile(
this.client,
this.baseDir,
);
/// [NextcloudClient] used by the WebDAV part.
final NextcloudClient client;
/// Base directory on the WebDAV server.
final PathUri baseDir;
final props = WebDavPropWithoutValues.fromBools(
davgetetag: true,
davgetlastmodified: true,
nchaspreview: true,
ocsize: true,
ocfavorite: true,
);
PathUri _uri(final SyncObject<dynamic> object) => baseDir.join(PathUri.parse(object.id));
@override
Future<List<SyncObject<WebDavFile>>> listObjects() async => (await client.webdav.propfind(
baseDir,
prop: props,
depth: WebDavDepth.infinity,
))
.toWebDavFiles()
.sublist(1)
.map(
(final file) => (
id: file.path.pathSegments.sublist(baseDir.pathSegments.length).join('/'),
data: file,
),
)
.toList();
@override
Future<String> getObjectETag(final SyncObject<WebDavFile> object) async =>
object.data.isDirectory ? '' : object.data.etag!;
@override
Future<SyncObject<WebDavFile>> writeObject(final SyncObject<FileSystemEntity> object) async {
if (object.data is File) {
final stat = await object.data.stat();
await client.webdav.putFile(
object.data as File,
stat,
_uri(object),
lastModified: stat.modified,
);
} else if (object.data is Directory) {
await client.webdav.mkcol(_uri(object));
} else {
throw Exception('Unable to sync FileSystemEntity of type ${object.data.runtimeType}');
}
return (
id: object.id,
data: (await client.webdav.propfind(
_uri(object),
prop: props,
depth: WebDavDepth.zero,
))
.toWebDavFiles()
.single,
);
}
@override
Future<void> deleteObject(final SyncObject<WebDavFile> object) async => client.webdav.delete(_uri(object));
}
class FilesSyncSourceFileSystemEntity implements SyncSource<FileSystemEntity, WebDavFile> {
FilesSyncSourceFileSystemEntity(
this.client,
this.baseDir,
);
/// [NextcloudClient] used by the WebDAV part.
final NextcloudClient client;
/// Base directory on the local filesystem.
final Directory baseDir;
@override
Future<List<SyncObject<FileSystemEntity>>> listObjects() async => baseDir.listSync(recursive: true).map(
(final e) {
var path = p.relative(e.path, from: baseDir.path);
if (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
}
return (id: path, data: e);
},
).toList();
@override
Future<String> getObjectETag(final SyncObject<FileSystemEntity> object) async =>
object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString();
@override
Future<SyncObject<FileSystemEntity>> writeObject(final SyncObject<WebDavFile> object) async {
if (object.data.isDirectory) {
final dir = Directory(p.join(baseDir.path, object.id))..createSync();
return (id: object.id, data: dir);
} else {
final file = File(p.join(baseDir.path, object.id));
await client.webdav.getFile(object.data.path, file);
await file.setLastModified(object.data.lastModified!);
return (id: object.id, data: file);
}
}
@override
Future<void> deleteObject(final SyncObject<FileSystemEntity> object) async => object.data.delete();
}

27
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/platform.dart';
import 'package:neon/utils.dart';
@ -16,7 +15,6 @@ class FileActions extends StatelessWidget {
Future<void> onSelected(final BuildContext context, final FilesFileAction action) async {
final bloc = NeonProvider.of<FilesBloc>(context);
final browserBloc = bloc.browser;
switch (action) {
case FilesFileAction.share:
bloc.shareFileNative(details.uri, details.etag!);
@ -85,23 +83,6 @@ class FileActions extends StatelessWidget {
if (result != null) {
bloc.copy(details.uri, result.join(PathUri.parse(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,
FilesLocalizations.of(context).downloadConfirmSizeWarning(
filesize(sizeWarning),
filesize(details.size),
),
))) {
return;
}
}
bloc.syncFile(details.uri);
case FilesFileAction.delete:
if (!context.mounted) {
return;
@ -152,13 +133,6 @@ class FileActions extends StatelessWidget {
value: FilesFileAction.copy,
child: Text(FilesLocalizations.of(context).actionCopy),
),
// TODO: https://github.com/provokateurin/nextcloud-neon/issues/4
if (!details.isDirectory) ...[
PopupMenuItem(
value: FilesFileAction.sync,
child: Text(FilesLocalizations.of(context).actionSync),
),
],
PopupMenuItem(
value: FilesFileAction.delete,
child: Text(FilesLocalizations.of(context).actionDelete),
@ -175,6 +149,5 @@ enum FilesFileAction {
rename,
move,
copy,
sync,
delete,
}

2
packages/neon/neon_files/lib/widgets/file_list_tile.dart

@ -128,7 +128,7 @@ class _FileIcon extends StatelessWidget {
child: Icon(
Icons.star,
size: smallIconSize,
color: Colors.yellow,
color: NcColors.starredColor,
),
),
],

94
packages/neon/neon_files/lib/widgets/file_tile.dart

@ -0,0 +1,94 @@
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 GestureTapCallback? onTap;
final int? uploadProgress;
final int? downloadProgress;
final bool showFullPath;
@override
Widget build(final BuildContext context) {
Widget icon = 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) {
icon = Stack(
children: [
icon,
const Align(
alignment: Alignment.bottomRight,
child: Icon(
Icons.star,
size: 14,
color: NcColors.starredColor,
),
),
],
);
}
return ListTile(
onTap: onTap,
title: Text(
showFullPath ? details.uri.path : 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: icon,
),
trailing: trailing,
);
}
}

4
packages/neon/neon_files/pubspec.yaml

@ -22,6 +22,7 @@ dependencies:
go_router: ^12.0.0
image_picker: ^1.0.0
intl: ^0.18.0
json_annotation: ^4.8.1
neon:
git:
url: https://github.com/nextcloud/neon
@ -33,14 +34,17 @@ dependencies:
open_file: ^3.0.0
path: ^1.0.0
path_provider: ^2.0.0
permission_handler: ^11.0.0
queue: ^3.0.0
rxdart: ^0.27.0
share_plus: ^7.0.0
universal_io: ^2.0.0
watcher: ^1.1.0
dev_dependencies:
build_runner: ^2.4.6
go_router_builder: ^2.3.4
json_serializable: ^6.7.1
neon_lints:
git:
url: https://github.com/nextcloud/neon

Loading…
Cancel
Save