Skip to content

Commit

Permalink
feat(neon_files): Implement file syncing
Browse files Browse the repository at this point in the history
Signed-off-by: jld3103 <[email protected]>
  • Loading branch information
provokateurin committed Jun 12, 2024
1 parent 8f9c538 commit e3f97e6
Show file tree
Hide file tree
Showing 11 changed files with 463 additions and 13 deletions.
4 changes: 4 additions & 0 deletions packages/neon/neon_files/lib/neon_files.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:neon_files/src/blocs/files.dart';
import 'package:neon_files/src/options.dart';
import 'package:neon_files/src/pages/main.dart';
import 'package:neon_files/src/routes.dart';
import 'package:neon_files/src/sync/implementation.dart';
import 'package:neon_framework/models.dart';
import 'package:nextcloud/nextcloud.dart';

Expand Down Expand Up @@ -37,6 +38,9 @@ class FilesApp extends AppImplementation<FilesBloc, FilesOptions> {
@override
final Widget page = const FilesMainPage();

@override
final FilesSync syncImplementation = const FilesSync();

@override
final RouteBase route = $filesAppRoute;
}
110 changes: 110 additions & 0 deletions packages/neon/neon_files/lib/src/sync/implementation.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:neon_files/src/blocs/files.dart';
import 'package:neon_files/src/models/file_details.dart';
import 'package:neon_files/src/sync/mapping.dart';
import 'package:neon_files/src/sync/sources.dart';
import 'package:neon_files/src/utils/dialog.dart';
import 'package:neon_files/src/widgets/file_tile.dart';
import 'package:neon_framework/models.dart';
import 'package:neon_framework/sync.dart';
import 'package:neon_framework/utils.dart';
import 'package:nextcloud/ids.dart';
import 'package:nextcloud/webdav.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:universal_io/io.dart';

@immutable
class FilesSync implements SyncImplementation<FilesSyncMapping, WebDavFile, FileSystemEntity> {
const FilesSync();

@override
String get id => AppIDs.files;

@override
Future<FilesSyncSources> getSources(Account account, 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(FilesSyncMapping mapping) => mapping.toJson();

@override
FilesSyncMapping deserializeMapping(Map<String, dynamic> json) => FilesSyncMapping.fromJson(json);

@override
Future<FilesSyncMapping?> addMapping(BuildContext context, Account account) async {
final remotePath = await showChooseFolderDialog(context, PathUri.cwd());
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(FilesSyncMapping mapping) {
final path = mapping.remotePath.toString();
return path.substring(0, path.length - 1);
}

@override
String getMappingDisplaySubtitle(FilesSyncMapping mapping) => mapping.localPath.path;

@override
String getMappingId(FilesSyncMapping mapping) =>
'${Uri.encodeComponent(mapping.remotePath.toString())}-${Uri.encodeComponent(mapping.localPath.path)}';

@override
Widget getConflictDetailsLocal(BuildContext context, 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: tz.TZDateTime.from(stat.modified.toUtc(), tz.UTC),
hasPreview: false,
isFavorite: false,
blurHash: null,
),
);
}

@override
Widget getConflictDetailsRemote(BuildContext context, WebDavFile object) => FilesFileTile(
showFullPath: true,
filesBloc: NeonProvider.of<FilesBloc>(context),
details: FileDetails.fromWebDav(
file: object,
),
);
}
69 changes: 69 additions & 0 deletions packages/neon/neon_files/lib/src/sync/mapping.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:neon_framework/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(Map<String, dynamic> json) => _$FilesSyncMappingFromJson(json);
Map<String, dynamic> toJson() => _$FilesSyncMappingToJson(this);

@override
final String accountId;

@override
final String appId;

@override
final SyncJournal journal;

@JsonKey(
fromJson: PathUri.parse,
toJson: _pathUriToJson,
)
final PathUri remotePath;

static String _pathUriToJson(PathUri uri) => uri.toString();

@JsonKey(
fromJson: _directoryFromJson,
toJson: _directoryToJson,
)
final Directory localPath;

static Directory _directoryFromJson(String value) => Directory(value);
static String _directoryToJson(Directory value) => value.path;

StreamSubscription<WatchEvent>? _subscription;

@override
void watch(void Function() onUpdated) {
debugPrint('Watching file changes: $localPath');
_subscription ??= DirectoryWatcher(localPath.path).events.listen(
(event) {
debugPrint('Registered file change: ${event.path} ${event.type}');
onUpdated();
},
);
}

@override
void dispose() {
unawaited(_subscription?.cancel());
}
}
23 changes: 23 additions & 0 deletions packages/neon/neon_files/lib/src/sync/mapping.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

145 changes: 145 additions & 0 deletions packages/neon/neon_files/lib/src/sync/sources.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import 'package:neon_framework/sync.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:nextcloud/webdav.dart';
import 'package:path/path.dart' as p;
import 'package:universal_io/io.dart';

class FilesSyncSources implements SyncSources<WebDavFile, FileSystemEntity> {
FilesSyncSources(
NextcloudClient client,
PathUri webdavBaseDir,
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(SyncObject<WebDavFile> objectA, 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 = const WebDavPropWithoutValues.fromBools(
davGetetag: true,
davGetlastmodified: true,
ncHasPreview: true,
ocSize: true,
ocFavorite: true,
);

PathUri _uri(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(
(file) => (
id: file.path.pathSegments.sublist(baseDir.pathSegments.length).join('/'),
data: file,
),
)
.toList();

@override
Future<String> getObjectETag(SyncObject<WebDavFile> object) async => object.data.isDirectory ? '' : object.data.etag!;

@override
Future<SyncObject<WebDavFile>> writeObject(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(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(
(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(SyncObject<FileSystemEntity> object) async =>
object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString();

@override
Future<SyncObject<FileSystemEntity>> writeObject(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(SyncObject<FileSystemEntity> object) async => object.data.delete();
}
10 changes: 4 additions & 6 deletions packages/neon/neon_files/lib/src/utils/dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,14 @@ Future<bool> showUploadConfirmationDialog(
) ??
false;

/// Displays a [FilesChooseFolderDialog] to choose a new location for a file with the given [details].
/// Displays a [FilesChooseFolderDialog] to choose a location for a file with the given [uri].
///
/// Returns a future with the new location.
Future<PathUri?> showChooseFolderDialog(BuildContext context, FileDetails details) async {
/// Returns a future with the location.
Future<PathUri?> showChooseFolderDialog(BuildContext context, PathUri uri) async {
final bloc = NeonProvider.of<FilesBloc>(context);

final originalUri = details.uri;
final b = bloc.getNewFilesBrowserBloc(
initialUri: originalUri,
initialUri: uri,
mode: FilesBrowserMode.selectDirectory,
);

Expand All @@ -84,7 +83,6 @@ Future<PathUri?> showChooseFolderDialog(BuildContext context, FileDetails detail
builder: (context) => FilesChooseFolderDialog(
bloc: b,
filesBloc: bloc,
originalPath: originalUri,
),
);
b.dispose();
Expand Down
Loading

0 comments on commit e3f97e6

Please sign in to comment.