54 changed files with 2588 additions and 464 deletions
@ -0,0 +1,216 @@
|
||||
import 'dart:async'; |
||||
import 'dart:convert'; |
||||
import 'dart:io'; |
||||
|
||||
import 'package:neon/src/apps/files/models/sync_mapping.dart'; |
||||
import 'package:neon/src/blocs/accounts.dart'; |
||||
import 'package:neon/src/models/account.dart'; |
||||
import 'package:neon/src/neon.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
import 'package:permission_handler/permission_handler.dart'; |
||||
import 'package:rx_bloc/rx_bloc.dart'; |
||||
import 'package:rxdart/rxdart.dart'; |
||||
import 'package:watcher/watcher.dart'; |
||||
|
||||
part 'sync.rxb.g.dart'; |
||||
|
||||
abstract class FilesSyncBlocEvents { |
||||
void addMapping(final FilesSyncMapping mapping); |
||||
|
||||
void removeMapping(final FilesSyncMapping mapping); |
||||
|
||||
void syncMapping(final FilesSyncMapping mapping, final Map<String, SyncConflictSolution> solutions); |
||||
} |
||||
|
||||
abstract class FilesSyncBlocStates { |
||||
BehaviorSubject<Map<FilesSyncMapping, bool?>> get mappingStatuses; |
||||
|
||||
Stream<FilesSyncConflicts> get conflicts; |
||||
|
||||
Stream<Exception> get errors; |
||||
} |
||||
|
||||
@RxBloc() |
||||
class FilesSyncBloc extends $FilesSyncBloc { |
||||
FilesSyncBloc( |
||||
this._storage, |
||||
this._accountsBloc, |
||||
) { |
||||
_$addMappingEvent.listen((final mapping) async { |
||||
_mappingStatusesSubject.add({ |
||||
..._mappingStatusesSubject.value, |
||||
mapping: null, |
||||
}); |
||||
await _saveMappings(); |
||||
// Directly trigger sync after adding the mapping |
||||
syncMapping(mapping, {}); |
||||
}); |
||||
|
||||
_$removeMappingEvent.listen((final mapping) async { |
||||
_mappingStatusesSubject.add( |
||||
Map.fromEntries( |
||||
_mappingStatusesSubject.value.entries.where( |
||||
(final e) => |
||||
e.key.accountId != mapping.accountId && |
||||
e.key.localPath != mapping.localPath && |
||||
e.key.remotePath != mapping.remotePath, |
||||
), |
||||
), |
||||
); |
||||
await _saveMappings(); |
||||
}); |
||||
|
||||
_$syncMappingEvent.listen((final event) async { |
||||
final account = _accountsBloc.accounts.value.find(event.mapping.accountId); |
||||
if (account == null) { |
||||
removeMapping(event.mapping); |
||||
return; |
||||
} |
||||
|
||||
// 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) { |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
final sources = _sourcesForMapping(account, event.mapping); |
||||
|
||||
final conflicts = await sync<WebDavFile, FileSystemEntity>( |
||||
sources, |
||||
event.mapping.status, |
||||
event.solutions, |
||||
); |
||||
if (conflicts.isNotEmpty) { |
||||
_conflictsController.add(FilesSyncConflicts(event.mapping, conflicts)); |
||||
} |
||||
|
||||
_mappingStatusesSubject.add( |
||||
Map<FilesSyncMapping, bool?>.fromEntries( |
||||
_mappingStatusesSubject.value.entries.map( |
||||
(final e) => MapEntry(e.key, event.mapping == e.key ? conflicts.isEmpty : e.value), |
||||
), |
||||
), |
||||
); |
||||
} catch (e) { |
||||
_errorsStreamController.add(e as Exception); |
||||
} |
||||
await _saveMappings(); |
||||
}); |
||||
|
||||
mappingStatuses.listen((final statuses) async { |
||||
for (final mapping in statuses.keys) { |
||||
if (_watchers[mapping] == null) { |
||||
_watchers[mapping] = DirectoryWatcher(mapping.localPath).events.listen( |
||||
(final watchEvent) { |
||||
print('watchEvent: $watchEvent'); |
||||
}, |
||||
); |
||||
} |
||||
} |
||||
for (final entries in _watchers.entries.toList()) { |
||||
if (statuses[entries.key] == null) { |
||||
await entries.value.cancel(); |
||||
_watchers.remove(entries.key); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
_loadMappings(); |
||||
unawaited(_syncMappingStatuses()); |
||||
} |
||||
|
||||
void _loadMappings() { |
||||
if (_storage.containsKey(_keyMappings)) { |
||||
_mappingStatusesSubject.add( |
||||
{ |
||||
for (final mapping in _storage |
||||
.getStringList(_keyMappings)! |
||||
.map<FilesSyncMapping>((final m) => FilesSyncMapping.fromJson(json.decode(m) as Map<String, dynamic>)) |
||||
.toList()) ...{ |
||||
mapping: null, |
||||
}, |
||||
}, |
||||
); |
||||
} |
||||
} |
||||
|
||||
Future _saveMappings() async { |
||||
await _storage.setStringList( |
||||
_keyMappings, |
||||
_mappingStatusesSubject.value.keys.map((final m) => json.encode(m.toJson())).toList(), |
||||
); |
||||
} |
||||
|
||||
Future _syncMappingStatuses() async { |
||||
final statuses = <FilesSyncMapping, bool?>{}; |
||||
final mappings = _mappingStatusesSubject.value.keys.toList(); |
||||
for (final mapping in mappings) { |
||||
final account = _accountsBloc.accounts.value.find(mapping.accountId); |
||||
if (account == null) { |
||||
removeMapping(mapping); |
||||
continue; |
||||
} |
||||
|
||||
try { |
||||
final sources = _sourcesForMapping(account, mapping); |
||||
final diff = await computeSyncDiff(sources, mapping.status, {}); |
||||
statuses[mapping] = diff.actions.isEmpty && diff.conflicts.isEmpty; |
||||
} on Exception catch (e) { |
||||
_errorsStreamController.add(e); |
||||
} |
||||
} |
||||
_mappingStatusesSubject.add(statuses); |
||||
} |
||||
|
||||
WebDavIOSyncSources _sourcesForMapping(final Account account, final FilesSyncMapping mapping) => WebDavIOSyncSources( |
||||
account.client, |
||||
mapping.remotePath, |
||||
mapping.localPath, |
||||
extraProps: [ |
||||
WebDavProps.davLastModified, |
||||
WebDavProps.ncHasPreview, |
||||
WebDavProps.ocSize, |
||||
WebDavProps.ocFavorite, |
||||
], |
||||
); |
||||
|
||||
final Storage _storage; |
||||
final AccountsBloc _accountsBloc; |
||||
|
||||
final _keyMappings = 'mappings'; |
||||
|
||||
final _mappingStatusesSubject = BehaviorSubject<Map<FilesSyncMapping, bool?>>.seeded({}); |
||||
final _conflictsController = StreamController<FilesSyncConflicts>(); |
||||
final _errorsStreamController = StreamController<Exception>(); |
||||
final _watchers = <FilesSyncMapping, StreamSubscription<WatchEvent>>{}; |
||||
|
||||
@override |
||||
void dispose() { |
||||
unawaited(_mappingStatusesSubject.close()); |
||||
unawaited(_conflictsController.close()); |
||||
unawaited(_errorsStreamController.close()); |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
BehaviorSubject<Map<FilesSyncMapping, bool?>> _mapToMappingStatusesState() => _mappingStatusesSubject; |
||||
|
||||
@override |
||||
Stream<FilesSyncConflicts> _mapToConflictsState() => _conflictsController.stream.asBroadcastStream(); |
||||
|
||||
@override |
||||
Stream<Exception> _mapToErrorsState() => _errorsStreamController.stream.asBroadcastStream(); |
||||
} |
||||
|
||||
class FilesSyncConflicts { |
||||
FilesSyncConflicts( |
||||
this.mapping, |
||||
this.conflicts, |
||||
); |
||||
|
||||
final FilesSyncMapping mapping; |
||||
final List<SyncConflict<WebDavFile, FileSystemEntity>> conflicts; |
||||
} |
@ -0,0 +1,98 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND |
||||
|
||||
// ************************************************************************** |
||||
// Generator: RxBlocGeneratorForAnnotation |
||||
// ************************************************************************** |
||||
|
||||
part of 'sync.dart'; |
||||
|
||||
/// Used as a contractor for the bloc, events and states classes |
||||
/// {@nodoc} |
||||
abstract class FilesSyncBlocType extends RxBlocTypeBase { |
||||
FilesSyncBlocEvents get events; |
||||
FilesSyncBlocStates get states; |
||||
} |
||||
|
||||
/// [$FilesSyncBloc] extended by the [FilesSyncBloc] |
||||
/// {@nodoc} |
||||
abstract class $FilesSyncBloc extends RxBlocBase |
||||
implements FilesSyncBlocEvents, FilesSyncBlocStates, FilesSyncBlocType { |
||||
final _compositeSubscription = CompositeSubscription(); |
||||
|
||||
/// Тhe [Subject] where events sink to by calling [addMapping] |
||||
final _$addMappingEvent = PublishSubject<FilesSyncMapping>(); |
||||
|
||||
/// Тhe [Subject] where events sink to by calling [removeMapping] |
||||
final _$removeMappingEvent = PublishSubject<FilesSyncMapping>(); |
||||
|
||||
/// Тhe [Subject] where events sink to by calling [syncMapping] |
||||
final _$syncMappingEvent = PublishSubject<_SyncMappingEventArgs>(); |
||||
|
||||
/// The state of [mappingStatuses] implemented in [_mapToMappingStatusesState] |
||||
late final BehaviorSubject<Map<FilesSyncMapping, bool?>> _mappingStatusesState = _mapToMappingStatusesState(); |
||||
|
||||
/// The state of [conflicts] implemented in [_mapToConflictsState] |
||||
late final Stream<FilesSyncConflicts> _conflictsState = _mapToConflictsState(); |
||||
|
||||
/// The state of [errors] implemented in [_mapToErrorsState] |
||||
late final Stream<Exception> _errorsState = _mapToErrorsState(); |
||||
|
||||
@override |
||||
void addMapping(FilesSyncMapping mapping) => _$addMappingEvent.add(mapping); |
||||
|
||||
@override |
||||
void removeMapping(FilesSyncMapping mapping) => _$removeMappingEvent.add(mapping); |
||||
|
||||
@override |
||||
void syncMapping( |
||||
FilesSyncMapping mapping, |
||||
Map<String, SyncConflictSolution> solutions, |
||||
) => |
||||
_$syncMappingEvent.add(_SyncMappingEventArgs( |
||||
mapping, |
||||
solutions, |
||||
)); |
||||
|
||||
@override |
||||
BehaviorSubject<Map<FilesSyncMapping, bool?>> get mappingStatuses => _mappingStatusesState; |
||||
|
||||
@override |
||||
Stream<FilesSyncConflicts> get conflicts => _conflictsState; |
||||
|
||||
@override |
||||
Stream<Exception> get errors => _errorsState; |
||||
|
||||
BehaviorSubject<Map<FilesSyncMapping, bool?>> _mapToMappingStatusesState(); |
||||
|
||||
Stream<FilesSyncConflicts> _mapToConflictsState(); |
||||
|
||||
Stream<Exception> _mapToErrorsState(); |
||||
|
||||
@override |
||||
FilesSyncBlocEvents get events => this; |
||||
|
||||
@override |
||||
FilesSyncBlocStates get states => this; |
||||
|
||||
@override |
||||
void dispose() { |
||||
_$addMappingEvent.close(); |
||||
_$removeMappingEvent.close(); |
||||
_$syncMappingEvent.close(); |
||||
_compositeSubscription.dispose(); |
||||
super.dispose(); |
||||
} |
||||
} |
||||
|
||||
/// Helps providing the arguments in the [Subject.add] for |
||||
/// [FilesSyncBlocEvents.syncMapping] event |
||||
class _SyncMappingEventArgs { |
||||
const _SyncMappingEventArgs( |
||||
this.mapping, |
||||
this.solutions, |
||||
); |
||||
|
||||
final FilesSyncMapping mapping; |
||||
|
||||
final Map<String, SyncConflictSolution> solutions; |
||||
} |
@ -0,0 +1,218 @@
|
||||
part of '../app.dart'; |
||||
|
||||
class FilesSyncConflictDialog extends StatefulWidget { |
||||
const FilesSyncConflictDialog({ |
||||
required this.bloc, |
||||
required this.conflicts, |
||||
super.key, |
||||
}); |
||||
|
||||
final FilesBloc bloc; |
||||
final FilesSyncConflicts conflicts; |
||||
|
||||
@override |
||||
State<FilesSyncConflictDialog> createState() => _FilesSyncConflictDialogState(); |
||||
} |
||||
|
||||
class _FilesSyncConflictDialogState extends State<FilesSyncConflictDialog> { |
||||
var _all = false; |
||||
SyncConflictSolution? _allSolution; |
||||
|
||||
var _index = 0; |
||||
final _solutions = <String, SyncConflictSolution>{}; |
||||
|
||||
final visualDensity = const VisualDensity( |
||||
horizontal: -4, |
||||
vertical: -4, |
||||
); |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
final conflict = widget.conflicts.conflicts[_index]; |
||||
return AlertDialog( |
||||
title: Text(AppLocalizations.of(context).filesSyncNConflicts(widget.conflicts.conflicts.length)), |
||||
content: Column( |
||||
mainAxisSize: MainAxisSize.min, |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
||||
children: [ |
||||
if (widget.conflicts.conflicts.length > 1) ...[ |
||||
CheckboxListTile( |
||||
visualDensity: visualDensity, |
||||
value: _all, |
||||
onChanged: (final value) { |
||||
setState(() { |
||||
_all = value!; |
||||
}); |
||||
}, |
||||
title: Text(AppLocalizations.of(context).filesSyncForAllConflicts), |
||||
), |
||||
const Divider(), |
||||
], |
||||
if (_all) ...[ |
||||
...<String, SyncConflictSolution>{ |
||||
AppLocalizations.of(context).filesSyncLocal: SyncConflictSolution.overwriteA, |
||||
AppLocalizations.of(context).filesSyncRemote: SyncConflictSolution.overwriteB, |
||||
AppLocalizations.of(context).filesSyncSkip: SyncConflictSolution.skip, |
||||
}.entries.map( |
||||
(final e) => ListTile( |
||||
visualDensity: visualDensity, |
||||
title: Text( |
||||
e.key, |
||||
style: Theme.of(context).textTheme.titleMedium, |
||||
), |
||||
trailing: Radio<SyncConflictSolution>( |
||||
value: e.value, |
||||
groupValue: _allSolution, |
||||
onChanged: (final solution) { |
||||
setState(() { |
||||
_allSolution = solution!; |
||||
}); |
||||
}, |
||||
), |
||||
), |
||||
), |
||||
] else ...[ |
||||
Text( |
||||
conflict.id, |
||||
style: Theme.of(context).textTheme.titleLarge, |
||||
), |
||||
Builder( |
||||
builder: (final context) { |
||||
final file = conflict.objectB.data; |
||||
final stat = file.statSync(); |
||||
return FilesFileTile( |
||||
visualDensity: visualDensity, |
||||
filesBloc: widget.bloc, |
||||
titleOverride: Text(AppLocalizations.of(context).filesSyncLocal), |
||||
details: FilesFileDetails( |
||||
path: conflict.id.split('/'), |
||||
isDirectory: file is Directory, |
||||
size: stat.size, |
||||
etag: '', |
||||
mimeType: '', |
||||
lastModified: stat.modified, |
||||
hasPreview: false, |
||||
isFavorite: false, |
||||
), |
||||
onTap: (final details) { |
||||
setState(() { |
||||
_solutions[conflict.id] = SyncConflictSolution.overwriteA; |
||||
}); |
||||
}, |
||||
trailing: Radio<SyncConflictSolution>( |
||||
value: SyncConflictSolution.overwriteA, |
||||
groupValue: _solutions[conflict.id], |
||||
onChanged: (final solution) { |
||||
setState(() { |
||||
_solutions[conflict.id] = solution!; |
||||
}); |
||||
}, |
||||
), |
||||
); |
||||
}, |
||||
), |
||||
Builder( |
||||
builder: (final context) { |
||||
final file = conflict.objectA.data; |
||||
return FilesFileTile( |
||||
visualDensity: visualDensity, |
||||
filesBloc: widget.bloc, |
||||
titleOverride: Text(AppLocalizations.of(context).filesSyncRemote), |
||||
details: FilesFileDetails( |
||||
path: conflict.id.split('/'), |
||||
isDirectory: file.isDirectory, |
||||
size: file.size!, |
||||
etag: '', |
||||
mimeType: file.mimeType, |
||||
lastModified: file.lastModified!, |
||||
hasPreview: file.hasPreview, |
||||
isFavorite: conflict.objectA.data.favorite, |
||||
), |
||||
onTap: (final details) { |
||||
setState(() { |
||||
_solutions[conflict.id] = SyncConflictSolution.overwriteB; |
||||
}); |
||||
}, |
||||
trailing: Radio<SyncConflictSolution>( |
||||
value: SyncConflictSolution.overwriteB, |
||||
groupValue: _solutions[conflict.id], |
||||
onChanged: (final solution) { |
||||
setState(() { |
||||
_solutions[conflict.id] = solution!; |
||||
}); |
||||
}, |
||||
), |
||||
); |
||||
}, |
||||
), |
||||
ListTile( |
||||
visualDensity: visualDensity, |
||||
title: Text(AppLocalizations.of(context).filesSyncSkip), |
||||
leading: const SizedBox( |
||||
height: 40, |
||||
width: 40, |
||||
), |
||||
onTap: () { |
||||
setState(() { |
||||
_solutions[conflict.id] = SyncConflictSolution.skip; |
||||
}); |
||||
}, |
||||
trailing: Radio<SyncConflictSolution>( |
||||
value: SyncConflictSolution.skip, |
||||
groupValue: _solutions[conflict.id], |
||||
onChanged: (final solution) { |
||||
setState(() { |
||||
_solutions[conflict.id] = solution!; |
||||
}); |
||||
}, |
||||
), |
||||
), |
||||
], |
||||
], |
||||
), |
||||
actionsAlignment: MainAxisAlignment.spaceBetween, |
||||
actions: [ |
||||
if (_index > 0 && !_all) ...[ |
||||
OutlinedButton( |
||||
onPressed: () { |
||||
setState(() { |
||||
_index--; |
||||
}); |
||||
}, |
||||
child: Text(AppLocalizations.of(context).previous), |
||||
), |
||||
] else ...[ |
||||
OutlinedButton( |
||||
onPressed: () { |
||||
Navigator.of(context).pop(); |
||||
}, |
||||
child: Text(AppLocalizations.of(context).cancel), |
||||
), |
||||
], |
||||
if (_index < widget.conflicts.conflicts.length - 1 && !_all) ...[ |
||||
ElevatedButton( |
||||
onPressed: () { |
||||
setState(() { |
||||
_index++; |
||||
}); |
||||
}, |
||||
child: Text(AppLocalizations.of(context).next), |
||||
), |
||||
] else ...[ |
||||
ElevatedButton( |
||||
onPressed: () { |
||||
if (_all && _allSolution == null) { |
||||
return; |
||||
} |
||||
if (!_all && _solutions.length != widget.conflicts.conflicts.length) { |
||||
return; |
||||
} |
||||
Navigator.of(context).pop(_all ? _allSolution : _solutions); |
||||
}, |
||||
child: Text(AppLocalizations.of(context).finish), |
||||
), |
||||
], |
||||
], |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,31 @@
|
||||
import 'package:json_annotation/json_annotation.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
|
||||
part 'sync_mapping.g.dart'; |
||||
|
||||
@JsonSerializable() |
||||
class FilesSyncMapping { |
||||
FilesSyncMapping({ |
||||
required this.accountId, |
||||
required this.remotePath, |
||||
required this.localPath, |
||||
final SyncStatus? status, |
||||
}) { |
||||
this.status = status ?? SyncStatus([]); |
||||
} |
||||
|
||||
factory FilesSyncMapping.fromJson(final Map<String, dynamic> json) => _$FilesSyncMappingFromJson(json); |
||||
Map<String, dynamic> toJson() => _$FilesSyncMappingToJson(this); |
||||
|
||||
final String accountId; |
||||
|
||||
final List<String> remotePath; |
||||
|
||||
final String localPath; |
||||
|
||||
@JsonKey( |
||||
fromJson: syncStatusFromJson, |
||||
toJson: syncStatusToJson, |
||||
) |
||||
late final SyncStatus status; |
||||
} |
@ -0,0 +1,21 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND |
||||
|
||||
part of 'sync_mapping.dart'; |
||||
|
||||
// ************************************************************************** |
||||
// JsonSerializableGenerator |
||||
// ************************************************************************** |
||||
|
||||
FilesSyncMapping _$FilesSyncMappingFromJson(Map<String, dynamic> json) => FilesSyncMapping( |
||||
accountId: json['accountId'] as String, |
||||
remotePath: (json['remotePath'] as List<dynamic>).map((e) => e as String).toList(), |
||||
localPath: json['localPath'] as String, |
||||
status: syncStatusFromJson(json['status'] as List), |
||||
); |
||||
|
||||
Map<String, dynamic> _$FilesSyncMappingToJson(FilesSyncMapping instance) => <String, dynamic>{ |
||||
'accountId': instance.accountId, |
||||
'remotePath': instance.remotePath, |
||||
'localPath': instance.localPath, |
||||
'status': syncStatusToJson(instance.status), |
||||
}; |
@ -0,0 +1,95 @@
|
||||
part of '../app.dart'; |
||||
|
||||
class FilesFileTile extends StatelessWidget { |
||||
const FilesFileTile({ |
||||
required this.filesBloc, |
||||
required this.details, |
||||
this.titleOverride, |
||||
this.trailing, |
||||
this.visualDensity, |
||||
this.onTap, |
||||
this.uploadProgress, |
||||
this.downloadProgress, |
||||
super.key, |
||||
}); |
||||
|
||||
final FilesBloc filesBloc; |
||||
final FilesFileDetails details; |
||||
final Widget? titleOverride; |
||||
final Widget? trailing; |
||||
final VisualDensity? visualDensity; |
||||
final Function(FilesFileDetails)? onTap; |
||||
final int? uploadProgress; |
||||
final int? downloadProgress; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => ListTile( |
||||
visualDensity: visualDensity, |
||||
onTap: () { |
||||
onTap?.call(details); |
||||
}, |
||||
title: titleOverride ?? |
||||
Text( |
||||
details.name, |
||||
overflow: TextOverflow.ellipsis, |
||||
), |
||||
subtitle: Row( |
||||
children: [ |
||||
Text(CustomTimeAgo.format(details.lastModified)), |
||||
if (details.size > 0) ...[ |
||||
const SizedBox( |
||||
width: 10, |
||||
), |
||||
Flexible( |
||||
child: Text( |
||||
filesize(details.size, 1), |
||||
style: DefaultTextStyle.of(context).style.copyWith( |
||||
color: Colors.grey, |
||||
), |
||||
overflow: TextOverflow.ellipsis, |
||||
), |
||||
), |
||||
], |
||||
], |
||||
), |
||||
leading: SizedBox( |
||||
height: 40, |
||||
width: 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: Colors.yellow, |
||||
), |
||||
), |
||||
], |
||||
], |
||||
), |
||||
), |
||||
trailing: trailing, |
||||
); |
||||
} |
@ -0,0 +1,38 @@
|
||||
part of '../app.dart'; |
||||
|
||||
class FilesSyncStatusIcon extends StatelessWidget { |
||||
const FilesSyncStatusIcon({ |
||||
required this.status, |
||||
this.size, |
||||
super.key, |
||||
}); |
||||
|
||||
final bool? status; |
||||
final double? size; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
// Status unknown |
||||
if (status == null) { |
||||
return Icon( |
||||
Icons.cloud_off, |
||||
color: Colors.red, |
||||
size: size, |
||||
); |
||||
} |
||||
// Partially synced |
||||
if (!status!) { |
||||
return Icon( |
||||
Icons.cloud_queue, |
||||
color: Colors.orange, |
||||
size: size, |
||||
); |
||||
} |
||||
// Completely sync |
||||
return Icon( |
||||
Icons.cloud_done, |
||||
color: Colors.green, |
||||
size: size, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,54 @@
|
||||
part of '../../../neon.dart'; |
||||
|
||||
class FilesSyncListener extends StatefulWidget { |
||||
const FilesSyncListener({ |
||||
required this.appsBloc, |
||||
super.key, |
||||
}); |
||||
|
||||
final AppsBloc appsBloc; |
||||
|
||||
@override |
||||
State<FilesSyncListener> createState() => _FilesSyncListenerState(); |
||||
} |
||||
|
||||
class _FilesSyncListenerState extends State<FilesSyncListener> { |
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
final filesSyncBloc = RxBlocProvider.of<FilesSyncBloc>(context); |
||||
filesSyncBloc.errors.listen((final error) { |
||||
ExceptionWidget.showSnackbar(context, error); |
||||
}); |
||||
filesSyncBloc.conflicts.listen((final conflicts) async { |
||||
if (mounted) { |
||||
final result = await showDialog( |
||||
context: context, |
||||
builder: (final context) => FilesSyncConflictDialog( |
||||
bloc: widget.appsBloc.getAppBlocByID('files'), |
||||
conflicts: conflicts, |
||||
), |
||||
); |
||||
if (result == null) { |
||||
return; |
||||
} |
||||
if (result is Map<String, SyncConflictSolution>) { |
||||
filesSyncBloc.syncMapping(conflicts.mapping, result); |
||||
} else if (result is SyncConflictSolution) { |
||||
filesSyncBloc.syncMapping( |
||||
conflicts.mapping, |
||||
{ |
||||
for (final c in conflicts.conflicts) ...{ |
||||
c.id: result, |
||||
}, |
||||
}, |
||||
); |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => Container(); |
||||
} |
@ -0,0 +1,40 @@
|
||||
part of '../neon.dart'; |
||||
|
||||
class FileUtils { |
||||
static Future<String?> saveFileWithPickDialog(final String fileName, final Uint8List data) async { |
||||
if (Platform.isAndroid || Platform.isIOS) { |
||||
// TODO: https://github.com/jld3103/nextcloud-neon/issues/8 |
||||
return FlutterFileDialog.saveFile( |
||||
params: SaveFileDialogParams( |
||||
data: data, |
||||
fileName: fileName, |
||||
), |
||||
); |
||||
} else { |
||||
final result = await FilePicker.platform.saveFile( |
||||
fileName: fileName, |
||||
); |
||||
if (result != null) { |
||||
await File(result).writeAsBytes(data); |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
} |
||||
|
||||
static Future<FilePickerResult?> loadFileWithPickDialog({ |
||||
final bool withData = false, |
||||
final bool allowMultiple = false, |
||||
final FileType type = FileType.any, |
||||
}) async { |
||||
final result = await FilePicker.platform.pickFiles( |
||||
withData: withData, |
||||
allowMultiple: allowMultiple, |
||||
type: type, |
||||
); |
||||
|
||||
return result; |
||||
} |
||||
|
||||
static Future<String?> pickDirectory() async => FilePicker.platform.getDirectoryPath(); |
||||
} |
@ -1,22 +0,0 @@
|
||||
part of '../neon.dart'; |
||||
|
||||
Future<String?> saveFileWithPickDialog(final String fileName, final Uint8List data) async { |
||||
if (Platform.isAndroid || Platform.isIOS) { |
||||
// TODO: https://github.com/jld3103/nextcloud-neon/issues/8 |
||||
return FlutterFileDialog.saveFile( |
||||
params: SaveFileDialogParams( |
||||
data: data, |
||||
fileName: fileName, |
||||
), |
||||
); |
||||
} else { |
||||
final result = await FilePicker.platform.saveFile( |
||||
fileName: fileName, |
||||
); |
||||
if (result != null) { |
||||
await File(result).writeAsBytes(data); |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
} |
@ -0,0 +1,40 @@
|
||||
part of 'sync.dart'; |
||||
|
||||
/// Action to be executed in the sync process |
||||
abstract class SyncAction<T1, T2> {} |
||||
|
||||
/// Action to delete object from A |
||||
class SyncActionDeleteFromA<T1, T2> extends SyncAction<T1, T2> { |
||||
// ignore: public_member_api_docs |
||||
SyncActionDeleteFromA(this.object); |
||||
|
||||
// ignore: public_member_api_docs |
||||
final SyncObject<T1> object; |
||||
} |
||||
|
||||
/// Action to delete object from B |
||||
class SyncActionDeleteFromB<T1, T2> extends SyncAction<T1, T2> { |
||||
// ignore: public_member_api_docs |
||||
SyncActionDeleteFromB(this.object); |
||||
|
||||
// ignore: public_member_api_docs |
||||
final SyncObject<T2> object; |
||||
} |
||||
|
||||
/// Action to write object to A |
||||
class SyncActionWriteToA<T1, T2> extends SyncAction<T1, T2> { |
||||
// ignore: public_member_api_docs |
||||
SyncActionWriteToA(this.object); |
||||
|
||||
// ignore: public_member_api_docs |
||||
final SyncObject<T2> object; |
||||
} |
||||
|
||||
/// Action to write object to B |
||||
class SyncActionWriteToB<T1, T2> extends SyncAction<T1, T2> { |
||||
// ignore: public_member_api_docs |
||||
SyncActionWriteToB(this.object); |
||||
|
||||
// ignore: public_member_api_docs |
||||
final SyncObject<T1> object; |
||||
} |
@ -0,0 +1,45 @@
|
||||
part of 'sync.dart'; |
||||
|
||||
/// Contains information about a conflict that appeared during sync. |
||||
class SyncConflict<T1, T2> { |
||||
// ignore: public_member_api_docs |
||||
SyncConflict({ |
||||
required this.id, |
||||
required this.type, |
||||
required this.objectA, |
||||
required this.objectB, |
||||
}); |
||||
|
||||
/// Id of the objects involved in the conflict. |
||||
final String id; |
||||
|
||||
/// Type of the conflict that appeared. See [SyncConflictType] for more info. |
||||
final SyncConflictType type; |
||||
|
||||
/// Object A involved in the conflict. |
||||
final SyncObject<T1> objectA; |
||||
|
||||
/// Object B involved in the conflict. |
||||
final SyncObject<T2> objectB; |
||||
} |
||||
|
||||
/// Types of conflicts that can appear during sync. |
||||
enum SyncConflictType { |
||||
/// New objects with the same id exist on both sides. |
||||
bothNew, |
||||
|
||||
/// Both objects with the same id have changed. |
||||
bothChanged, |
||||
} |
||||
|
||||
/// Ways to resolve [SyncConflict]s. |
||||
enum SyncConflictSolution { |
||||
/// Overwrite the content of object A with the content of object B. |
||||
overwriteA, |
||||
|
||||
/// Overwrite the content of object B with the content of object A. |
||||
overwriteB, |
||||
|
||||
/// Skip the conflict and just do nothing. |
||||
skip, |
||||
} |
@ -0,0 +1,30 @@
|
||||
part of 'sync.dart'; |
||||
|
||||
/// Wraps the actual data contained on each side. |
||||
class SyncObject<T> { |
||||
// ignore: public_member_api_docs |
||||
SyncObject( |
||||
this.id, |
||||
this.data, |
||||
); |
||||
|
||||
/// Id of the object. |
||||
final String id; |
||||
|
||||
/// Actual data of the object, can be anything. |
||||
final T data; |
||||
} |
||||
|
||||
// ignore: public_member_api_docs |
||||
extension SyncObjectsFind<T> on List<SyncObject<T>> { |
||||
// ignore: public_member_api_docs |
||||
SyncObject<T>? find(final String id) { |
||||
for (final object in this) { |
||||
if (object.id == id) { |
||||
return object; |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
} |
@ -0,0 +1,38 @@
|
||||
part of 'sync.dart'; |
||||
|
||||
/// The sources the sync uses to sync from and to. |
||||
abstract class SyncSources<T1, T2> { |
||||
/// List all the objects of type [T1] of the source. |
||||
Future<List<SyncObject<T1>>> listObjectsA(); |
||||
|
||||
/// List all the objects of type [T2] of the source. |
||||
Future<List<SyncObject<T2>>> listObjectsB(); |
||||
|
||||
/// Calculates the ETag of a given [object] of type [T1]. |
||||
/// |
||||
/// Should be something easy to compute like the mtime of a file and preferably not the hash of the whole content in order to be fast |
||||
Future<String> getObjectETagA(final SyncObject<T1> object); |
||||
|
||||
/// Calculates the ETag of a given [object] of type [T2]. |
||||
/// |
||||
/// Should be something easy to compute like the mtime of a file and preferably not the hash of the whole content in order to be fast |
||||
Future<String> getObjectETagB(final SyncObject<T2> object); |
||||
|
||||
/// Writes the given [object] of type [T1] to the source. |
||||
Future<SyncObject<T2>> writeObjectA(final SyncObject<T1> object); |
||||
|
||||
/// Writes the given [object] of type [T2] to the source. |
||||
Future<SyncObject<T1>> writeObjectB(final SyncObject<T2> object); |
||||
|
||||
/// Deletes the given [object] of type [T1] from the source. |
||||
Future deleteObjectA(final SyncObject<T1> object); |
||||
|
||||
/// Deletes the given [object] of type [T2] from the source. |
||||
Future deleteObjectB(final SyncObject<T2> object); |
||||
|
||||
/// Sorts the actions before executing them. Useful e.g. for creating directories before creating files and deleting files before deleting directories. |
||||
List<SyncAction<T1, T2>> sortActions(final List<SyncAction<T1, T2>> actions); |
||||
|
||||
/// Automatically find a solution for conflicts that don't matter. Useful e.g. for ignoring new directories. |
||||
SyncConflictSolution? findSolution(final SyncConflict<T1, T2> conflict); |
||||
} |
@ -0,0 +1,167 @@
|
||||
part of '../sync.dart'; |
||||
|
||||
/// [SyncSources] to sync from [WebDavFile]s to [FileSystemEntity]s |
||||
class WebDavIOSyncSources extends SyncSources<WebDavFile, FileSystemEntity> { |
||||
// ignore: public_member_api_docs |
||||
WebDavIOSyncSources( |
||||
this.client, |
||||
this.webdavBaseDir, |
||||
this.ioBaseDir, { |
||||
this.extraProps = const [], |
||||
}); |
||||
|
||||
/// [NextcloudClient] used by the WebDAV part. |
||||
final NextcloudClient client; |
||||
|
||||
/// Base directory on the WebDAV server. |
||||
final List<String> webdavBaseDir; |
||||
|
||||
/// Base directory on the local filesystem. |
||||
final String ioBaseDir; |
||||
|
||||
/// Extra props to request from the WebDAV server |
||||
final List<WebDavProps> extraProps; |
||||
|
||||
String _path(final SyncObject object) => [...webdavBaseDir, object.id].join('/'); |
||||
|
||||
@override |
||||
Future<List<SyncObject<WebDavFile>>> listObjectsA() async => (await client.webdav.ls( |
||||
webdavBaseDir.join('/'), |
||||
props: [ |
||||
WebDavProps.davResourceType, |
||||
WebDavProps.davETag, |
||||
...extraProps, |
||||
].map((final p) => p.name).toSet(), |
||||
depth: 'infinity', |
||||
)) |
||||
.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 SyncObject<WebDavFile>( |
||||
id, |
||||
file, |
||||
); |
||||
}, |
||||
).toList(); |
||||
|
||||
@override |
||||
Future<List<SyncObject<FileSystemEntity>>> listObjectsB() async => Directory(ioBaseDir) |
||||
.listSync(recursive: true) |
||||
.map( |
||||
(final e) => SyncObject<FileSystemEntity>( |
||||
p.relative( |
||||
e.path, |
||||
from: ioBaseDir, |
||||
), |
||||
e, |
||||
), |
||||
) |
||||
.toList(); |
||||
|
||||
@override |
||||
Future<String> getObjectETagA(final SyncObject<WebDavFile> object) async => |
||||
object.data.isDirectory ? '' : object.data.etag!; |
||||
|
||||
@override |
||||
Future<String> getObjectETagB(final SyncObject<FileSystemEntity> object) async => |
||||
object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString(); |
||||
|
||||
@override |
||||
Future<SyncObject<FileSystemEntity>> writeObjectA(final SyncObject<WebDavFile> 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 SyncObject<FileSystemEntity>(object.id, dir); |
||||
} else { |
||||
final file = File(p.join(ioBaseDir, object.id)); |
||||
await client.webdav.downloadFile(path, file); |
||||
return SyncObject<FileSystemEntity>(object.id, file); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Future<SyncObject<WebDavFile>> writeObjectB(final SyncObject<FileSystemEntity> object) async { |
||||
if (object.data is File) { |
||||
await client.webdav.uploadFile( |
||||
object.data as File, |
||||
object.data.statSync(), |
||||
_path(object), |
||||
); |
||||
} else if (object.data is Directory) { |
||||
await client.webdav.mkdirs(_path(object)); |
||||
} else { |
||||
print('Unable to sync FileSystemEntity of type ${object.data.runtimeType}'); |
||||
} |
||||
return SyncObject<WebDavFile>(object.id, await client.webdav.getProps(_path(object))); |
||||
} |
||||
|
||||
@override |
||||
Future deleteObjectA(final SyncObject<WebDavFile> object) async => client.webdav.delete(_path(object)); |
||||
|
||||
@override |
||||
Future deleteObjectB(final SyncObject<FileSystemEntity> object) async => object.data.delete(); |
||||
|
||||
@override |
||||
List<SyncAction<WebDavFile, FileSystemEntity>> sortActions( |
||||
final List<SyncAction<WebDavFile, FileSystemEntity>> actions, |
||||
) { |
||||
final addActions = <SyncAction<WebDavFile, FileSystemEntity>>[]; |
||||
final removeActions = <SyncAction<WebDavFile, FileSystemEntity>>[]; |
||||
for (final action in actions) { |
||||
if (action is SyncActionWriteToA) { |
||||
addActions.add(action); |
||||
} else if (action is SyncActionWriteToB) { |
||||
addActions.add(action); |
||||
} else if (action is SyncActionDeleteFromA) { |
||||
removeActions.add(action); |
||||
} else if (action is SyncActionDeleteFromB) { |
||||
removeActions.add(action); |
||||
} else { |
||||
throw Exception('illegal action for sorting'); |
||||
} |
||||
} |
||||
return [ |
||||
..._innerSortActions(addActions), |
||||
..._innerSortActions(removeActions).reversed, |
||||
]; |
||||
} |
||||
|
||||
List<SyncAction<WebDavFile, FileSystemEntity>> _innerSortActions( |
||||
final List<SyncAction<WebDavFile, FileSystemEntity>> actions, |
||||
) => |
||||
actions..sort((final a, final b) => _idForAction(a).compareTo(_idForAction(b))); |
||||
|
||||
String _idForAction(final SyncAction<WebDavFile, FileSystemEntity> action) { |
||||
if (action is SyncActionWriteToA<WebDavFile, FileSystemEntity>) { |
||||
return action.object.id; |
||||
} else if (action is SyncActionWriteToB<WebDavFile, FileSystemEntity>) { |
||||
return action.object.id; |
||||
} else if (action is SyncActionDeleteFromA<WebDavFile, FileSystemEntity>) { |
||||
return action.object.id; |
||||
} else if (action is SyncActionDeleteFromB<WebDavFile, FileSystemEntity>) { |
||||
return action.object.id; |
||||
} else { |
||||
throw Exception('illegal action for getting id'); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
SyncConflictSolution? findSolution(final SyncConflict<WebDavFile, FileSystemEntity> conflict) { |
||||
if (conflict.objectA.data.isDirectory && conflict.objectB.data is Directory) { |
||||
return SyncConflictSolution.overwriteA; |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
} |
@ -0,0 +1,54 @@
|
||||
part of 'sync.dart'; |
||||
|
||||
/// Contains the local state of the whole synced. |
||||
/// |
||||
/// Used for detecting changes and new or deleted files. |
||||
class SyncStatus { |
||||
// ignore: public_member_api_docs |
||||
SyncStatus(this._entries); |
||||
|
||||
final List<SyncStatusEntry> _entries; |
||||
|
||||
/// All entries contained in the status. |
||||
List<SyncStatusEntry> get entries => _entries; |
||||
|
||||
/// Update an [entry]. |
||||
void updateEntry(final SyncStatusEntry entry) { |
||||
for (var i = 0; i < _entries.length; i++) { |
||||
if (_entries[i].id == entry.id) { |
||||
_entries[i] = entry; |
||||
return; |
||||
} |
||||
} |
||||
_entries.add(entry); |
||||
} |
||||
|
||||
/// Remove an entry by it's [id]. |
||||
void removeEntry(final String id) => |
||||
_entries.replaceRange(0, _entries.length, _entries.where((final entry) => entry.id != id)); |
||||
} |
||||
|
||||
// ignore: public_member_api_docs |
||||
SyncStatus syncStatusFromJson(final List data) => SyncStatus( |
||||
data.map( |
||||
(final a) { |
||||
final b = a as Map<String, dynamic>; |
||||
return SyncStatusEntry( |
||||
b['id'] as String, |
||||
b['etagA'] as String, |
||||
b['etagB'] as String, |
||||
); |
||||
}, |
||||
).toList(), |
||||
); |
||||
|
||||
// ignore: public_member_api_docs |
||||
List syncStatusToJson(final SyncStatus status) => status.entries |
||||
.map( |
||||
(final e) => { |
||||
'id': e.id, |
||||
'etagA': e.etagA, |
||||
'etagB': e.etagB, |
||||
}, |
||||
) |
||||
.toList(); |
@ -0,0 +1,39 @@
|
||||
part of 'sync.dart'; |
||||
|
||||
/// Stores a single entry in the [SyncStatus]. |
||||
/// |
||||
/// It contains an [id] and ETags for each object, [etagA] and [etagB] respectively. |
||||
class SyncStatusEntry { |
||||
// ignore: public_member_api_docs |
||||
SyncStatusEntry( |
||||
this.id, |
||||
this.etagA, |
||||
this.etagB, |
||||
); |
||||
|
||||
// ignore: public_member_api_docs |
||||
final String id; |
||||
|
||||
/// ETag of the object A. |
||||
final String etagA; |
||||
|
||||
/// ETag of the object B. |
||||
final String etagB; |
||||
|
||||
@override |
||||
String toString() => 'SyncStatusEntry(id: $id, etagA: $etagA, etagB: $etagB)'; |
||||
} |
||||
|
||||
// ignore: public_member_api_docs |
||||
extension SyncStatusEntriesFind on List<SyncStatusEntry> { |
||||
// ignore: public_member_api_docs |
||||
SyncStatusEntry? find(final String id) { |
||||
for (final entry in this) { |
||||
if (entry.id == id) { |
||||
return entry; |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
} |
@ -0,0 +1,229 @@
|
||||
library syncer; |
||||
|
||||
import 'dart:io'; |
||||
|
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
import 'package:path/path.dart' as p; |
||||
|
||||
part 'action.dart'; |
||||
part 'conflict.dart'; |
||||
part 'object.dart'; |
||||
part 'sources.dart'; |
||||
part 'sources/webdav_io_sources.dart'; |
||||
part 'status.dart'; |
||||
part 'status_entry.dart'; |
||||
|
||||
/// Sync between two [SyncSources]s. |
||||
/// |
||||
/// This implementation follows https://unterwaditzer.net/2016/sync-algorithm.html in a generic and abstract way |
||||
/// and should work for any two kinds of sources and objects. |
||||
Future<List<SyncConflict<T1, T2>>> sync<T1, T2>( |
||||
final SyncSources<T1, T2> sources, |
||||
final SyncStatus syncStatus, |
||||
final Map<String, SyncConflictSolution> conflictSolutions, |
||||
) async => |
||||
executeSyncDiff<T1, T2>( |
||||
sources, |
||||
syncStatus, |
||||
await computeSyncDiff<T1, T2>( |
||||
sources, |
||||
syncStatus, |
||||
conflictSolutions, |
||||
), |
||||
); |
||||
|
||||
/// Differences between the two sources |
||||
class SyncDiff<T1, T2> { |
||||
// ignore: public_member_api_docs |
||||
SyncDiff( |
||||
this.actions, |
||||
this.conflicts, |
||||
); |
||||
|
||||
/// Actions required to solve the difference |
||||
final List<SyncAction<T1, T2>> actions; |
||||
|
||||
/// Conflicts without solutions that need to be solved |
||||
final List<SyncConflict<T1, T2>> conflicts; |
||||
} |
||||
|
||||
/// Executes the actions required to solve the difference |
||||
Future<List<SyncConflict<T1, T2>>> executeSyncDiff<T1, T2>( |
||||
final SyncSources<T1, T2> sources, |
||||
final SyncStatus syncStatus, |
||||
final SyncDiff<T1, T2> sync, |
||||
) async { |
||||
for (final action in sync.actions) { |
||||
if (action is SyncActionDeleteFromA<T1, T2>) { |
||||
await sources.deleteObjectA(action.object); |
||||
syncStatus.removeEntry(action.object.id); |
||||
} else if (action is SyncActionDeleteFromB<T1, T2>) { |
||||
await sources.deleteObjectB(action.object); |
||||
syncStatus.removeEntry(action.object.id); |
||||
} else if (action is SyncActionWriteToA<T1, T2>) { |
||||
final objectA = await sources.writeObjectB(action.object); |
||||
syncStatus.updateEntry( |
||||
SyncStatusEntry( |
||||
action.object.id, |
||||
await sources.getObjectETagA(objectA), |
||||
await sources.getObjectETagB(action.object), |
||||
), |
||||
); |
||||
} else if (action is SyncActionWriteToB<T1, T2>) { |
||||
final objectB = await sources.writeObjectA(action.object); |
||||
syncStatus.updateEntry( |
||||
SyncStatusEntry( |
||||
action.object.id, |
||||
await sources.getObjectETagA(action.object), |
||||
await sources.getObjectETagB(objectB), |
||||
), |
||||
); |
||||
} |
||||
} |
||||
|
||||
return sync.conflicts; |
||||
} |
||||
|
||||
/// Computes the difference, useful for displaying if a sync is up to date. |
||||
Future<SyncDiff<T1, T2>> computeSyncDiff<T1, T2>( |
||||
final SyncSources<T1, T2> sources, |
||||
final SyncStatus syncStatus, |
||||
final Map<String, SyncConflictSolution> conflictSolutions, |
||||
) async { |
||||
final actions = <SyncAction<T1, T2>>[]; |
||||
var conflicts = <SyncConflict<T1, T2>>[]; |
||||
var objectsA = await sources.listObjectsA(); |
||||
var objectsB = await sources.listObjectsB(); |
||||
|
||||
for (final objectA in objectsA) { |
||||
final objectB = objectsB.find(objectA.id); |
||||
final statusEntry = syncStatus.entries.find(objectA.id); |
||||
|
||||
// If the ID exists on side A and the status, but not on B, it has been deleted on B. Delete it from A and the status. |
||||
if (statusEntry != null && objectB == null) { |
||||
actions.add(SyncActionDeleteFromA(objectA)); |
||||
continue; |
||||
} |
||||
|
||||
// If the ID exists on side A and side B, but not in status, we can not just create it in status, since the two items might contain different content each. |
||||
if (objectB != null && statusEntry == null) { |
||||
conflicts.add( |
||||
SyncConflict( |
||||
id: objectA.id, |
||||
type: SyncConflictType.bothNew, |
||||
objectA: objectA, |
||||
objectB: objectB, |
||||
), |
||||
); |
||||
continue; |
||||
} |
||||
|
||||
// If the ID exists on side A, but not on B or the status, it must have been created on A. Copy the item from A to B and also insert it into status. |
||||
if (objectB == null || statusEntry == null) { |
||||
actions.add(SyncActionWriteToB(objectA)); |
||||
continue; |
||||
} |
||||
} |
||||
|
||||
for (final objectB in objectsB) { |
||||
final objectA = objectsA.find(objectB.id); |
||||
final statusEntry = syncStatus.entries.find(objectB.id); |
||||
|
||||
// If the ID exists on side B and the status, but not on A, it has been deleted on A. Delete it from B and the status. |
||||
if (statusEntry != null && objectA == null) { |
||||
actions.add(SyncActionDeleteFromB(objectB)); |
||||
continue; |
||||
} |
||||
|
||||
// If the ID exists on side B and side A, but not in status, we can not just create it in status, since the two items might contain different content each. |
||||
if (objectA != null && statusEntry == null) { |
||||
conflicts.add( |
||||
SyncConflict( |
||||
id: objectA.id, |
||||
type: SyncConflictType.bothNew, |
||||
objectA: objectA, |
||||
objectB: objectB, |
||||
), |
||||
); |
||||
continue; |
||||
} |
||||
|
||||
// If the ID exists on side B, but not on A or the status, it must have been created on B. Copy the item from B to A and also insert it into status. |
||||
if (objectA == null || statusEntry == null) { |
||||
actions.add(SyncActionWriteToA(objectB)); |
||||
continue; |
||||
} |
||||
} |
||||
|
||||
objectsA = await sources.listObjectsA(); |
||||
objectsB = await sources.listObjectsB(); |
||||
final entries = syncStatus.entries.toList(); |
||||
for (final entry in entries) { |
||||
final objectA = objectsA.find(entry.id); |
||||
final objectB = objectsB.find(entry.id); |
||||
|
||||
// Remove all entries from status that don't exist anymore |
||||
if (objectA == null && objectB == null) { |
||||
syncStatus.removeEntry(entry.id); |
||||
continue; |
||||
} |
||||
|
||||
if (objectA != null && objectB != null) { |
||||
final changedA = entry.etagA != await sources.getObjectETagA(objectA); |
||||
final changedB = entry.etagB != await sources.getObjectETagB(objectB); |
||||
|
||||
if (changedA && changedB) { |
||||
conflicts.add( |
||||
SyncConflict( |
||||
id: objectA.id, |
||||
type: SyncConflictType.bothChanged, |
||||
objectA: objectA, |
||||
objectB: objectB, |
||||
), |
||||
); |
||||
continue; |
||||
} |
||||
|
||||
if (changedA && !changedB) { |
||||
actions.add(SyncActionWriteToB(objectA)); |
||||
continue; |
||||
} |
||||
|
||||
if (changedB && !changedA) { |
||||
actions.add(SyncActionWriteToA(objectB)); |
||||
continue; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Set of conflicts by id |
||||
conflicts = conflicts |
||||
.map((final conflict) => conflict.id) |
||||
.toSet() |
||||
.map((final id) => conflicts.firstWhere((final conflict) => conflict.id == id)) |
||||
.toList(); |
||||
|
||||
final unsolvedConflicts = <SyncConflict<T1, T2>>[]; |
||||
for (final conflict in conflicts) { |
||||
final solution = conflictSolutions[conflict.id] ?? sources.findSolution(conflict); |
||||
if (solution != null) { |
||||
switch (solution) { |
||||
case SyncConflictSolution.overwriteA: |
||||
actions.add(SyncActionWriteToA(conflict.objectB)); |
||||
break; |
||||
case SyncConflictSolution.overwriteB: |
||||
actions.add(SyncActionWriteToB(conflict.objectA)); |
||||
break; |
||||
case SyncConflictSolution.skip: |
||||
break; |
||||
} |
||||
} else { |
||||
unsolvedConflicts.add(conflict); |
||||
} |
||||
} |
||||
|
||||
return SyncDiff( |
||||
sources.sortActions(actions), |
||||
unsolvedConflicts, |
||||
); |
||||
} |
@ -0,0 +1,469 @@
|
||||
import 'dart:convert'; |
||||
import 'dart:math'; |
||||
|
||||
import 'package:crypto/crypto.dart'; |
||||
import 'package:nextcloud/nextcloud.dart'; |
||||
import 'package:test/test.dart'; |
||||
|
||||
abstract class Wrap { |
||||
Wrap(this.content); |
||||
|
||||
final String content; |
||||
} |
||||
|
||||
class WrapA extends Wrap { |
||||
WrapA(super.content); |
||||
} |
||||
|
||||
class WrapB extends Wrap { |
||||
WrapB(super.content); |
||||
} |
||||
|
||||
class TestSyncSources extends SyncSources<WrapA, WrapB> { |
||||
TestSyncSources( |
||||
this.stateA, |
||||
this.stateB, |
||||
); |
||||
|
||||
final Map<String, WrapA> stateA; |
||||
final Map<String, WrapB> stateB; |
||||
|
||||
@override |
||||
Future<List<SyncObject<WrapA>>> listObjectsA() async => |
||||
stateA.keys.map((final key) => SyncObject<WrapA>(key, stateA[key]!)).toList(); |
||||
|
||||
@override |
||||
Future<List<SyncObject<WrapB>>> listObjectsB() async => |
||||
stateB.keys.map((final key) => SyncObject<WrapB>(key, stateB[key]!)).toList(); |
||||
|
||||
@override |
||||
Future<String> getObjectETagA(final SyncObject<WrapA> object) async => etagA(object.data.content); |
||||
|
||||
@override |
||||
Future<String> getObjectETagB(final SyncObject<WrapB> object) async => etagB(object.data.content); |
||||
|
||||
@override |
||||
Future<SyncObject<WrapB>> writeObjectA(final SyncObject<WrapA> object) async { |
||||
final wrap = WrapB(object.data.content); |
||||
stateB[object.id] = wrap; |
||||
return SyncObject<WrapB>(object.id, wrap); |
||||
} |
||||
|
||||
@override |
||||
Future<SyncObject<WrapA>> writeObjectB(final SyncObject<WrapB> object) async { |
||||
final wrap = WrapA(object.data.content); |
||||
stateA[object.id] = wrap; |
||||
return SyncObject<WrapA>(object.id, wrap); |
||||
} |
||||
|
||||
@override |
||||
Future deleteObjectA(final SyncObject<WrapA> object) async => stateA.remove(object.id); |
||||
|
||||
@override |
||||
Future deleteObjectB(final SyncObject<WrapB> object) async => stateB.remove(object.id); |
||||
|
||||
@override |
||||
List<SyncAction<WrapA, WrapB>> sortActions(final List<SyncAction<WrapA, WrapB>> actions) => actions; |
||||
|
||||
@override |
||||
SyncConflictSolution? findSolution(final SyncConflict<WrapA, WrapB> conflict) => null; |
||||
} |
||||
|
||||
String etagA(final String content) => sha1.convert(utf8.encode('A$content')).toString(); |
||||
|
||||
String etagB(final String content) => sha1.convert(utf8.encode('B$content')).toString(); |
||||
|
||||
String randomEtag() => sha1.convert(utf8.encode(Random().nextDouble().toString())).toString(); |
||||
|
||||
Future main() async { |
||||
group('sync', () { |
||||
group('stub', () { |
||||
test('all empty', () async { |
||||
final sources = TestSyncSources({}, {}); |
||||
final status = SyncStatus([]); |
||||
|
||||
final conflicts = await sync(sources, status, {}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, isEmpty); |
||||
expect(sources.stateB, isEmpty); |
||||
expect(status.entries, isEmpty); |
||||
}); |
||||
|
||||
group('copy', () { |
||||
group('missing', () { |
||||
test('to A', () async { |
||||
const id = '123'; |
||||
const content = '456'; |
||||
final sources = TestSyncSources( |
||||
{}, |
||||
{ |
||||
id: WrapB(content), |
||||
}, |
||||
); |
||||
final status = SyncStatus([]); |
||||
|
||||
final conflicts = await sync(sources, status, {}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, content); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, content); |
||||
expect(status.entries, hasLength(1)); |
||||
expect(status.entries.find(id)!.etagA, etagA(content)); |
||||
expect(status.entries.find(id)!.etagB, etagB(content)); |
||||
}); |
||||
|
||||
test('to B', () async { |
||||
const id = '123'; |
||||
const content = '456'; |
||||
final sources = TestSyncSources( |
||||
{ |
||||
id: WrapA(content), |
||||
}, |
||||
{}, |
||||
); |
||||
final status = SyncStatus([]); |
||||
|
||||
final conflicts = await sync(sources, status, {}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, content); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, content); |
||||
expect(status.entries, hasLength(1)); |
||||
expect(status.entries.find(id)!.etagA, etagA(content)); |
||||
expect(status.entries.find(id)!.etagB, etagB(content)); |
||||
}); |
||||
}); |
||||
|
||||
group('changed', () { |
||||
test('to A', () async { |
||||
const id = '123'; |
||||
const contentA = '456'; |
||||
const contentB = '789'; |
||||
final sources = TestSyncSources( |
||||
{ |
||||
id: WrapA(contentA), |
||||
}, |
||||
{ |
||||
id: WrapB(contentB), |
||||
}, |
||||
); |
||||
final status = SyncStatus([ |
||||
SyncStatusEntry(id, etagA(contentA), randomEtag()), |
||||
]); |
||||
|
||||
final conflicts = await sync(sources, status, {}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, contentB); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, contentB); |
||||
expect(status.entries, hasLength(1)); |
||||
expect(status.entries.find(id)!.etagA, etagA(contentB)); |
||||
expect(status.entries.find(id)!.etagB, etagB(contentB)); |
||||
}); |
||||
|
||||
test('to B', () async { |
||||
const id = '123'; |
||||
const contentA = '456'; |
||||
const contentB = '789'; |
||||
final sources = TestSyncSources( |
||||
{ |
||||
id: WrapA(contentA), |
||||
}, |
||||
{ |
||||
id: WrapB(contentB), |
||||
}, |
||||
); |
||||
final status = SyncStatus([ |
||||
SyncStatusEntry(id, randomEtag(), etagB(contentB)), |
||||
]); |
||||
|
||||
final conflicts = await sync(sources, status, {}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, contentA); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, contentA); |
||||
expect(status.entries, hasLength(1)); |
||||
expect(status.entries.find(id)!.etagA, etagA(contentA)); |
||||
expect(status.entries.find(id)!.etagB, etagB(contentA)); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
group('delete', () { |
||||
test('from A', () async { |
||||
const id = '123'; |
||||
const content = '456'; |
||||
final sources = TestSyncSources( |
||||
{ |
||||
id: WrapA(content), |
||||
}, |
||||
{}, |
||||
); |
||||
final status = SyncStatus([ |
||||
SyncStatusEntry(id, etagA(content), etagB(content)), |
||||
]); |
||||
|
||||
final conflicts = await sync(sources, status, {}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, isEmpty); |
||||
expect(sources.stateB, isEmpty); |
||||
expect(status.entries, isEmpty); |
||||
}); |
||||
|
||||
test('from B', () async { |
||||
const id = '123'; |
||||
const content = '456'; |
||||
final sources = TestSyncSources( |
||||
{}, |
||||
{ |
||||
id: WrapB(content), |
||||
}, |
||||
); |
||||
final status = SyncStatus([ |
||||
SyncStatusEntry(id, etagA(content), etagB(content)), |
||||
]); |
||||
|
||||
final conflicts = await sync(sources, status, {}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, isEmpty); |
||||
expect(sources.stateB, isEmpty); |
||||
expect(status.entries, isEmpty); |
||||
}); |
||||
|
||||
test('from status', () async { |
||||
const id = '123'; |
||||
const content = '456'; |
||||
final sources = TestSyncSources({}, {}); |
||||
final status = SyncStatus([ |
||||
SyncStatusEntry(id, etagA(content), etagB(content)), |
||||
]); |
||||
|
||||
final conflicts = await sync(sources, status, {}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, isEmpty); |
||||
expect(sources.stateB, isEmpty); |
||||
expect(status.entries, isEmpty); |
||||
}); |
||||
}); |
||||
|
||||
group('conflict', () { |
||||
test('status missing', () async { |
||||
const id = '123'; |
||||
const content = '456'; |
||||
final sources = TestSyncSources( |
||||
{ |
||||
id: WrapA(content), |
||||
}, |
||||
{ |
||||
id: WrapB(content), |
||||
}, |
||||
); |
||||
final status = SyncStatus([]); |
||||
|
||||
final conflicts = await sync(sources, status, {}); |
||||
expect(conflicts, hasLength(1)); |
||||
expect(conflicts[0].type, SyncConflictType.bothNew); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, content); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, content); |
||||
expect(status.entries, isEmpty); |
||||
}); |
||||
|
||||
test('both changed', () async { |
||||
const id = '123'; |
||||
const contentA = '456'; |
||||
const contentB = '789'; |
||||
final sources = TestSyncSources( |
||||
{ |
||||
id: WrapA(contentA), |
||||
}, |
||||
{ |
||||
id: WrapB(contentB), |
||||
}, |
||||
); |
||||
final status = SyncStatus([ |
||||
SyncStatusEntry(id, randomEtag(), randomEtag()), |
||||
]); |
||||
|
||||
final conflicts = await sync(sources, status, {}); |
||||
expect(conflicts, hasLength(1)); |
||||
expect(conflicts[0].type, SyncConflictType.bothChanged); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, contentA); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, contentB); |
||||
expect(status.entries, hasLength(1)); |
||||
}); |
||||
|
||||
group('solution', () { |
||||
group('status missing', () { |
||||
test('skip', () async { |
||||
const id = '123'; |
||||
const contentA = '456'; |
||||
const contentB = '789'; |
||||
final sources = TestSyncSources( |
||||
{ |
||||
id: WrapA(contentA), |
||||
}, |
||||
{ |
||||
id: WrapB(contentB), |
||||
}, |
||||
); |
||||
final status = SyncStatus([]); |
||||
|
||||
final conflicts = await sync(sources, status, { |
||||
id: SyncConflictSolution.skip, |
||||
}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, contentA); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, contentB); |
||||
expect(status.entries, isEmpty); |
||||
}); |
||||
|
||||
test('overwrite A', () async { |
||||
const id = '123'; |
||||
const contentA = '456'; |
||||
const contentB = '789'; |
||||
final sources = TestSyncSources( |
||||
{ |
||||
id: WrapA(contentA), |
||||
}, |
||||
{ |
||||
id: WrapB(contentB), |
||||
}, |
||||
); |
||||
final status = SyncStatus([]); |
||||
|
||||
final conflicts = await sync(sources, status, { |
||||
id: SyncConflictSolution.overwriteA, |
||||
}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, contentB); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, contentB); |
||||
expect(status.entries, hasLength(1)); |
||||
expect(status.entries.find(id)!.etagA, etagA(contentB)); |
||||
expect(status.entries.find(id)!.etagB, etagB(contentB)); |
||||
}); |
||||
|
||||
test('overwrite B', () async { |
||||
const id = '123'; |
||||
const contentA = '456'; |
||||
const contentB = '789'; |
||||
final sources = TestSyncSources( |
||||
{ |
||||
id: WrapA(contentA), |
||||
}, |
||||
{ |
||||
id: WrapB(contentB), |
||||
}, |
||||
); |
||||
final status = SyncStatus([]); |
||||
|
||||
final conflicts = await sync(sources, status, { |
||||
id: SyncConflictSolution.overwriteB, |
||||
}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, contentA); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, contentA); |
||||
expect(status.entries, hasLength(1)); |
||||
expect(status.entries.find(id)!.etagA, etagA(contentA)); |
||||
expect(status.entries.find(id)!.etagB, etagB(contentA)); |
||||
}); |
||||
}); |
||||
|
||||
group('both changed', () { |
||||
test('skip', () async { |
||||
const id = '123'; |
||||
const contentA = '456'; |
||||
const contentB = '789'; |
||||
final sources = TestSyncSources( |
||||
{ |
||||
id: WrapA(contentA), |
||||
}, |
||||
{ |
||||
id: WrapB(contentB), |
||||
}, |
||||
); |
||||
final status = SyncStatus([ |
||||
SyncStatusEntry(id, randomEtag(), randomEtag()), |
||||
]); |
||||
|
||||
final conflicts = await sync(sources, status, { |
||||
id: SyncConflictSolution.skip, |
||||
}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, contentA); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, contentB); |
||||
expect(status.entries, hasLength(1)); |
||||
}); |
||||
|
||||
test('overwrite A', () async { |
||||
const id = '123'; |
||||
const contentA = '456'; |
||||
const contentB = '789'; |
||||
final sources = TestSyncSources({ |
||||
id: WrapA(contentA), |
||||
}, { |
||||
id: WrapB(contentB), |
||||
}); |
||||
final status = SyncStatus([ |
||||
SyncStatusEntry(id, randomEtag(), randomEtag()), |
||||
]); |
||||
|
||||
final conflicts = await sync(sources, status, { |
||||
id: SyncConflictSolution.overwriteA, |
||||
}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, contentB); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, contentB); |
||||
expect(status.entries, hasLength(1)); |
||||
expect(status.entries.find(id)!.etagA, etagA(contentB)); |
||||
expect(status.entries.find(id)!.etagB, etagB(contentB)); |
||||
}); |
||||
|
||||
test('overwrite B', () async { |
||||
const id = '123'; |
||||
const contentA = '456'; |
||||
const contentB = '789'; |
||||
final sources = TestSyncSources({ |
||||
id: WrapA(contentA), |
||||
}, { |
||||
id: WrapB(contentB), |
||||
}); |
||||
final status = SyncStatus([ |
||||
SyncStatusEntry(id, randomEtag(), randomEtag()), |
||||
]); |
||||
|
||||
final conflicts = await sync(sources, status, { |
||||
id: SyncConflictSolution.overwriteB, |
||||
}); |
||||
expect(conflicts, isEmpty); |
||||
expect(sources.stateA, hasLength(1)); |
||||
expect(sources.stateA[id]!.content, contentA); |
||||
expect(sources.stateB, hasLength(1)); |
||||
expect(sources.stateB[id]!.content, contentA); |
||||
expect(status.entries, hasLength(1)); |
||||
expect(status.entries.find(id)!.etagA, etagA(contentA)); |
||||
expect(status.entries.find(id)!.etagB, etagB(contentA)); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
} |
Loading…
Reference in new issue