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