From 30deb2e654f15bacf320f8d9af3c3b3c2b8b8ced Mon Sep 17 00:00:00 2001 From: jld3103 Date: Wed, 26 Oct 2022 08:07:33 +0200 Subject: [PATCH 1/3] feat(synchronize): Init Signed-off-by: jld3103 --- commitlint.yaml | 1 + packages/synchronize/LICENSE | 1 + packages/synchronize/README.md | 3 + packages/synchronize/analysis_options.yaml | 1 + packages/synchronize/lib/src/action.dart | 60 ++ packages/synchronize/lib/src/conflict.dart | 61 ++ packages/synchronize/lib/src/journal.dart | 33 ++ packages/synchronize/lib/src/journal.g.dart | 15 + .../synchronize/lib/src/journal_entry.dart | 52 ++ .../synchronize/lib/src/journal_entry.g.dart | 19 + packages/synchronize/lib/src/object.dart | 12 + packages/synchronize/lib/src/sources.dart | 39 ++ packages/synchronize/lib/src/sync.dart | 246 ++++++++ packages/synchronize/lib/synchronize.dart | 6 + packages/synchronize/pubspec.yaml | 20 + packages/synchronize/pubspec_overrides.yaml | 4 + packages/synchronize/test/sync_test.dart | 542 ++++++++++++++++++ 17 files changed, 1115 insertions(+) create mode 120000 packages/synchronize/LICENSE create mode 100644 packages/synchronize/README.md create mode 100644 packages/synchronize/analysis_options.yaml create mode 100644 packages/synchronize/lib/src/action.dart create mode 100644 packages/synchronize/lib/src/conflict.dart create mode 100644 packages/synchronize/lib/src/journal.dart create mode 100644 packages/synchronize/lib/src/journal.g.dart create mode 100644 packages/synchronize/lib/src/journal_entry.dart create mode 100644 packages/synchronize/lib/src/journal_entry.g.dart create mode 100644 packages/synchronize/lib/src/object.dart create mode 100644 packages/synchronize/lib/src/sources.dart create mode 100644 packages/synchronize/lib/src/sync.dart create mode 100644 packages/synchronize/lib/synchronize.dart create mode 100644 packages/synchronize/pubspec.yaml create mode 100644 packages/synchronize/pubspec_overrides.yaml create mode 100644 packages/synchronize/test/sync_test.dart diff --git a/commitlint.yaml b/commitlint.yaml index ee82dc00..06238476 100644 --- a/commitlint.yaml +++ b/commitlint.yaml @@ -26,3 +26,4 @@ rules: - neon_lints - nextcloud - sort_box + - synchronize diff --git a/packages/synchronize/LICENSE b/packages/synchronize/LICENSE new file mode 120000 index 00000000..30cff740 --- /dev/null +++ b/packages/synchronize/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/packages/synchronize/README.md b/packages/synchronize/README.md new file mode 100644 index 00000000..97032ba0 --- /dev/null +++ b/packages/synchronize/README.md @@ -0,0 +1,3 @@ +# synchronize + +A simple generic implementation of https://unterwaditzer.net/2016/sync-algorithm.html diff --git a/packages/synchronize/analysis_options.yaml b/packages/synchronize/analysis_options.yaml new file mode 100644 index 00000000..4db3c296 --- /dev/null +++ b/packages/synchronize/analysis_options.yaml @@ -0,0 +1 @@ +include: package:neon_lints/dart.yaml diff --git a/packages/synchronize/lib/src/action.dart b/packages/synchronize/lib/src/action.dart new file mode 100644 index 00000000..31f5ca03 --- /dev/null +++ b/packages/synchronize/lib/src/action.dart @@ -0,0 +1,60 @@ +import 'package:meta/meta.dart'; +import 'package:synchronize/src/object.dart'; + +/// Action to be executed in the sync process. +@internal +@immutable +sealed class SyncAction { + /// Creates a new action. + const SyncAction(this.object); + + /// The object that is part of the action. + final SyncObject object; + + @override + String toString() => 'SyncAction<$T>(object: $object)'; +} + +/// Action to delete on object from A. +@internal +@immutable +interface class SyncActionDeleteFromA extends SyncAction { + /// Creates a new action to delete an object from A. + const SyncActionDeleteFromA(super.object); + + @override + String toString() => 'SyncActionDeleteFromA<$T1, $T2>(object: $object)'; +} + +/// Action to delete an object from B. +@internal +@immutable +interface class SyncActionDeleteFromB extends SyncAction { + /// Creates a new action to delete an object from B. + const SyncActionDeleteFromB(super.object); + + @override + String toString() => 'SyncActionDeleteFromB<$T1, $T2>(object: $object)'; +} + +/// Action to write an object to A. +@internal +@immutable +interface class SyncActionWriteToA extends SyncAction { + /// Creates a new action to write an object to A. + const SyncActionWriteToA(super.object); + + @override + String toString() => 'SyncActionWriteToA<$T1, $T2>(object: $object)'; +} + +/// Action to write an object to B. +@internal +@immutable +interface class SyncActionWriteToB extends SyncAction { + /// Creates a new action to write an object to B. + const SyncActionWriteToB(super.object); + + @override + String toString() => 'SyncActionWriteToB<$T1, $T2>(object: $object)'; +} diff --git a/packages/synchronize/lib/src/conflict.dart b/packages/synchronize/lib/src/conflict.dart new file mode 100644 index 00000000..2f024b92 --- /dev/null +++ b/packages/synchronize/lib/src/conflict.dart @@ -0,0 +1,61 @@ +import 'package:meta/meta.dart'; +import 'package:synchronize/src/object.dart'; + +/// Contains information about a conflict that appeared during sync. +@immutable +class SyncConflict { + /// Creates a new conflict. + const SyncConflict({ + required this.id, + required this.type, + required this.objectA, + required this.objectB, + this.skipped = false, + }); + + /// 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 objectA; + + /// Object B involved in the conflict. + final SyncObject objectB; + + /// Whether the conflict was skipped by the user, useful for ignoring it later on. + final bool skipped; + + @override + bool operator ==(final dynamic other) => other is SyncConflict && other.id == id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() => + 'SyncConflict<$T1, $T2>(id: $id, type: $type, objectA: $objectA, objectB: $objectB, skipped: $skipped)'; +} + +/// 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, +} diff --git a/packages/synchronize/lib/src/journal.dart b/packages/synchronize/lib/src/journal.dart new file mode 100644 index 00000000..c68ed39c --- /dev/null +++ b/packages/synchronize/lib/src/journal.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:synchronize/src/journal_entry.dart'; + +part 'journal.g.dart'; + +/// Contains the journal. +/// +/// Used for detecting changes and new or deleted files. +@JsonSerializable() +class SyncJournal { + /// Creates a new journal. + // Note: This must not be const as otherwise the entries are not modifiable when a const set is used! + SyncJournal([final Set? entries]) : entries = entries ?? {}; + + /// Deserializes a journal from [json]. + factory SyncJournal.fromJson(final Map json) => _$SyncJournalFromJson(json); + + /// Serializes a journal to JSON. + Map toJson() => _$SyncJournalToJson(this); + + /// All entries contained in the journal. + final Set entries; + + /// Updates an [entry]. + void updateEntry(final SyncJournalEntry entry) { + entries + ..remove(entry) + ..add(entry); + } + + @override + String toString() => 'SyncJournal(entries: $entries)'; +} diff --git a/packages/synchronize/lib/src/journal.g.dart b/packages/synchronize/lib/src/journal.g.dart new file mode 100644 index 00000000..87a17272 --- /dev/null +++ b/packages/synchronize/lib/src/journal.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'journal.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SyncJournal _$SyncJournalFromJson(Map json) => SyncJournal( + (json['entries'] as List).map((e) => SyncJournalEntry.fromJson(e as Map)).toSet(), + ); + +Map _$SyncJournalToJson(SyncJournal instance) => { + 'entries': instance.entries.toList(), + }; diff --git a/packages/synchronize/lib/src/journal_entry.dart b/packages/synchronize/lib/src/journal_entry.dart new file mode 100644 index 00000000..e8111420 --- /dev/null +++ b/packages/synchronize/lib/src/journal_entry.dart @@ -0,0 +1,52 @@ +import 'package:collection/collection.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:meta/meta.dart'; +import 'package:synchronize/src/journal.dart'; + +part 'journal_entry.g.dart'; + +/// Stores a single entry in the [SyncJournal]. +/// +/// It contains an [id] and ETags for each object, [etagA] and [etagB] respectively. +@immutable +@JsonSerializable() +class SyncJournalEntry { + /// Creates a new journal entry. + const SyncJournalEntry( + this.id, + this.etagA, + this.etagB, + ); + + /// Deserializes a journal entry from [json]. + factory SyncJournalEntry.fromJson(final Map json) => _$SyncJournalEntryFromJson(json); + + /// Serializes a journal entry to JSON. + Map toJson() => _$SyncJournalEntryToJson(this); + + /// Unique ID of the journal entry. + final String id; + + /// ETag of the object A. + final String etagA; + + /// ETag of the object B. + final String etagB; + + @override + bool operator ==(final Object other) => other is SyncJournalEntry && other.id == id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() => 'SyncJournalEntry(id: $id, etagA: $etagA, etagB: $etagB)'; +} + +/// Extension to find a [SyncJournalEntry]. +extension SyncJournalEntriesFind on Iterable { + /// Finds the first [SyncJournalEntry] that has the [SyncJournalEntry.id] set to [id]. + /// + /// Returns `null` if no matching [SyncJournalEntry] was found. + SyncJournalEntry? tryFind(final String id) => firstWhereOrNull((final entry) => entry.id == id); +} diff --git a/packages/synchronize/lib/src/journal_entry.g.dart b/packages/synchronize/lib/src/journal_entry.g.dart new file mode 100644 index 00000000..67e59dec --- /dev/null +++ b/packages/synchronize/lib/src/journal_entry.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'journal_entry.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SyncJournalEntry _$SyncJournalEntryFromJson(Map json) => SyncJournalEntry( + json['id'] as String, + json['etagA'] as String, + json['etagB'] as String, + ); + +Map _$SyncJournalEntryToJson(SyncJournalEntry instance) => { + 'id': instance.id, + 'etagA': instance.etagA, + 'etagB': instance.etagB, + }; diff --git a/packages/synchronize/lib/src/object.dart b/packages/synchronize/lib/src/object.dart new file mode 100644 index 00000000..bac03aa8 --- /dev/null +++ b/packages/synchronize/lib/src/object.dart @@ -0,0 +1,12 @@ +import 'package:collection/collection.dart'; + +/// Wraps the actual data contained on each side. +typedef SyncObject = ({String id, T data}); + +/// Extension to find a [SyncObject]. +extension SyncObjectsFind on Iterable> { + /// Finds the first [SyncObject] that has the `id` set to [id]. + /// + /// Returns `null` if no matching [SyncObject] was found. + SyncObject? tryFind(final String id) => firstWhereOrNull((final object) => object.id == id); +} diff --git a/packages/synchronize/lib/src/sources.dart b/packages/synchronize/lib/src/sources.dart new file mode 100644 index 00000000..d07f2e7a --- /dev/null +++ b/packages/synchronize/lib/src/sources.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:synchronize/src/conflict.dart'; +import 'package:synchronize/src/object.dart'; + +/// The source the sync uses to sync from and to. +@immutable +abstract interface class SyncSource { + /// List all the objects. + FutureOr>> listObjects(); + + /// Calculates the ETag of a given [object]. + /// + /// Must 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. + FutureOr getObjectETag(final SyncObject object); + + /// Writes the given [object]. + FutureOr> writeObject(final SyncObject object); + + /// Deletes the given [object]. + FutureOr deleteObject(final SyncObject object); +} + +/// The sources the sync uses to sync from and to. +@immutable +abstract interface class SyncSources { + /// Source A. + SyncSource get sourceA; + + /// Source B. + SyncSource get sourceB; + + /// Automatically find a solution for conflicts that don't matter. Useful e.g. for ignoring new directories. + SyncConflictSolution? findSolution(final SyncObject objectA, final SyncObject objectB); + + @override + String toString() => 'SyncSources<$T1, $T2>(sourceA: $sourceA, sourceB: $sourceB)'; +} diff --git a/packages/synchronize/lib/src/sync.dart b/packages/synchronize/lib/src/sync.dart new file mode 100644 index 00000000..19f503d3 --- /dev/null +++ b/packages/synchronize/lib/src/sync.dart @@ -0,0 +1,246 @@ +import 'package:synchronize/src/action.dart'; +import 'package:synchronize/src/conflict.dart'; +import 'package:synchronize/src/journal.dart'; +import 'package:synchronize/src/journal_entry.dart'; +import 'package:synchronize/src/object.dart'; +import 'package:synchronize/src/sources.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>> sync( + final SyncSources sources, + final SyncJournal journal, { + final Map? conflictSolutions, + final bool keepSkipsAsConflicts = false, +}) async { + final diff = await computeSyncDiff( + sources, + journal, + conflictSolutions: conflictSolutions, + keepSkipsAsConflicts: keepSkipsAsConflicts, + ); + await executeSyncDiff( + sources, + journal, + diff, + ); + return diff.conflicts; +} + +/// Differences between the two sources. +class SyncDiff { + /// Creates a new diff. + SyncDiff( + this.actions, + this.conflicts, + ); + + /// Actions required to solve the difference. + final List> actions; + + /// Conflicts without solutions that need to be solved. + final List> conflicts; +} + +/// Executes the actions required to solve the difference. +Future executeSyncDiff( + final SyncSources sources, + final SyncJournal journal, + final SyncDiff diff, +) async { + for (final action in diff.actions) { + switch (action) { + case SyncActionDeleteFromA(): + await sources.sourceA.deleteObject(action.object as SyncObject); + journal.entries.removeWhere((final entry) => entry.id == action.object.id); + case SyncActionDeleteFromB(): + await sources.sourceB.deleteObject(action.object as SyncObject); + journal.entries.removeWhere((final entry) => entry.id == action.object.id); + case SyncActionWriteToA(): + final objectA = await sources.sourceA.writeObject(action.object as SyncObject); + journal.updateEntry( + SyncJournalEntry( + action.object.id, + await sources.sourceA.getObjectETag(objectA), + await sources.sourceB.getObjectETag(action.object as SyncObject), + ), + ); + case SyncActionWriteToB(): + final objectB = await sources.sourceB.writeObject(action.object as SyncObject); + journal.updateEntry( + SyncJournalEntry( + action.object.id, + await sources.sourceA.getObjectETag(action.object as SyncObject), + await sources.sourceB.getObjectETag(objectB), + ), + ); + } + } +} + +/// Computes the difference, useful for displaying if a sync is up to date. +Future> computeSyncDiff( + final SyncSources sources, + final SyncJournal journal, { + final Map? conflictSolutions, + final bool keepSkipsAsConflicts = false, +}) async { + final actions = >[]; + final conflicts = >{}; + var objectsA = await sources.sourceA.listObjects(); + var objectsB = await sources.sourceB.listObjects(); + + for (final objectA in objectsA) { + final objectB = objectsB.tryFind(objectA.id); + final journalEntry = journal.entries.tryFind(objectA.id); + + // If the ID exists on side A and the journal, but not on B, it has been deleted on B. Delete it from A and the journal. + if (journalEntry != null && objectB == null) { + actions.add(SyncActionDeleteFromA(objectA)); + continue; + } + + // If the ID exists on side A and side B, but not in journal, we can not just create it in journal, since the two items might contain different content each. + if (objectB != null && journalEntry == 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 journal, it must have been created on A. Copy the item from A to B and also insert it into journal. + if (objectB == null || journalEntry == null) { + actions.add(SyncActionWriteToB(objectA)); + continue; + } + } + + for (final objectB in objectsB) { + final objectA = objectsA.tryFind(objectB.id); + final journalEntry = journal.entries.tryFind(objectB.id); + + // If the ID exists on side B and the journal, but not on A, it has been deleted on A. Delete it from B and the journal. + if (journalEntry != null && objectA == null) { + actions.add(SyncActionDeleteFromB(objectB)); + continue; + } + + // If the ID exists on side B and side A, but not in journal, we can not just create it in journal, since the two items might contain different content each. + if (objectA != null && journalEntry == 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 journal, it must have been created on B. Copy the item from B to A and also insert it into journal. + if (objectA == null || journalEntry == null) { + actions.add(SyncActionWriteToA(objectB)); + continue; + } + } + + objectsA = await sources.sourceA.listObjects(); + objectsB = await sources.sourceB.listObjects(); + final entries = journal.entries.toList(); + for (final entry in entries) { + final objectA = objectsA.tryFind(entry.id); + final objectB = objectsB.tryFind(entry.id); + + // Remove all entries from journal that don't exist anymore + if (objectA == null && objectB == null) { + journal.entries.removeWhere((final e) => e.id == entry.id); + continue; + } + + if (objectA != null && objectB != null) { + final changedA = entry.etagA != await sources.sourceA.getObjectETag(objectA); + final changedB = entry.etagB != await sources.sourceB.getObjectETag(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; + } + } + } + + final unsolvedConflicts = >[]; + for (final conflict in conflicts) { + final solution = conflictSolutions?[conflict.id] ?? sources.findSolution(conflict.objectA, conflict.objectB); + switch (solution) { + case SyncConflictSolution.overwriteA: + actions.add(SyncActionWriteToA(conflict.objectB)); + case SyncConflictSolution.overwriteB: + actions.add(SyncActionWriteToB(conflict.objectA)); + case SyncConflictSolution.skip: + if (keepSkipsAsConflicts) { + unsolvedConflicts.add( + SyncConflict( + id: conflict.id, + type: conflict.type, + objectA: conflict.objectA, + objectB: conflict.objectB, + skipped: true, + ), + ); + } + case null: + unsolvedConflicts.add(conflict); + } + } + + return SyncDiff( + _sortActions(actions), + unsolvedConflicts, + ); +} + +List> _sortActions(final List> actions) { + final addActions = >[]; + final removeActions = >[]; + for (final action in actions) { + switch (action) { + case SyncActionWriteToA(): + addActions.add(action); + case SyncActionWriteToB(): + addActions.add(action); + case SyncActionDeleteFromA(): + removeActions.add(action); + case SyncActionDeleteFromB(): + removeActions.add(action); + } + } + return _innerSortActions(addActions)..addAll(_innerSortActions(removeActions).reversed); +} + +List> _innerSortActions(final List> actions) => + actions..sort((final a, final b) => a.object.id.compareTo(b.object.id)); diff --git a/packages/synchronize/lib/synchronize.dart b/packages/synchronize/lib/synchronize.dart new file mode 100644 index 00000000..5b448e17 --- /dev/null +++ b/packages/synchronize/lib/synchronize.dart @@ -0,0 +1,6 @@ +export 'package:synchronize/src/conflict.dart'; +export 'package:synchronize/src/journal.dart'; +export 'package:synchronize/src/journal_entry.dart'; +export 'package:synchronize/src/object.dart'; +export 'package:synchronize/src/sources.dart'; +export 'package:synchronize/src/sync.dart'; diff --git a/packages/synchronize/pubspec.yaml b/packages/synchronize/pubspec.yaml new file mode 100644 index 00000000..0f5a2126 --- /dev/null +++ b/packages/synchronize/pubspec.yaml @@ -0,0 +1,20 @@ +name: synchronize +version: 1.0.0 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + collection: ^1.0.0 + json_annotation: ^4.8.1 + meta: ^1.0.0 + +dev_dependencies: + build_runner: ^2.4.6 + crypto: ^3.0.0 + json_serializable: ^6.7.1 + neon_lints: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_lints + test: ^1.24.9 diff --git a/packages/synchronize/pubspec_overrides.yaml b/packages/synchronize/pubspec_overrides.yaml new file mode 100644 index 00000000..4abc9fdc --- /dev/null +++ b/packages/synchronize/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: neon_lints +dependency_overrides: + neon_lints: + path: ../neon_lints diff --git a/packages/synchronize/test/sync_test.dart b/packages/synchronize/test/sync_test.dart new file mode 100644 index 00000000..b7baf829 --- /dev/null +++ b/packages/synchronize/test/sync_test.dart @@ -0,0 +1,542 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:synchronize/synchronize.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 TestSyncState { + TestSyncState( + this.stateA, + this.stateB, + ); + + final Map stateA; + final Map stateB; +} + +class TestSyncSourceA implements SyncSource { + TestSyncSourceA(this.state); + + final Map state; + + @override + Future>> listObjects() async => + state.keys.map((final key) => (id: key, data: state[key]!)).toList(); + + @override + Future getObjectETag(final SyncObject object) async => etagA(object.data.content); + + @override + Future> writeObject(final SyncObject object) async { + final wrap = WrapA(object.data.content); + state[object.id] = wrap; + return (id: object.id, data: wrap); + } + + @override + Future deleteObject(final SyncObject object) async => state.remove(object.id); +} + +class TestSyncSourceB implements SyncSource { + TestSyncSourceB(this.state); + + final Map state; + + @override + Future>> listObjects() async => + state.keys.map((final key) => (id: key, data: state[key]!)).toList(); + + @override + Future getObjectETag(final SyncObject object) async => etagB(object.data.content); + + @override + Future> writeObject(final SyncObject object) async { + final wrap = WrapB(object.data.content); + state[object.id] = wrap; + return (id: object.id, data: wrap); + } + + @override + Future deleteObject(final SyncObject object) async => state.remove(object.id); +} + +class TestSyncSources implements SyncSources { + TestSyncSources( + this.sourceA, + this.sourceB, + ); + + factory TestSyncSources.fromState(final TestSyncState state) => TestSyncSources( + TestSyncSourceA(state.stateA), + TestSyncSourceB(state.stateB), + ); + + @override + final SyncSource sourceA; + + @override + final SyncSource sourceB; + + @override + SyncConflictSolution? findSolution(final SyncObject objectA, final SyncObject objectB) => 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 state = TestSyncState({}, {}); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal(); + + final conflicts = await sync(sources, journal); + expect(conflicts, isEmpty); + expect(state.stateA, isEmpty); + expect(state.stateB, isEmpty); + expect(journal.entries, isEmpty); + }); + + group('copy', () { + group('missing', () { + test('to A', () async { + const id = '123'; + const content = '456'; + final state = TestSyncState( + {}, + { + id: WrapB(content), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal(); + + final conflicts = await sync(sources, journal); + expect(conflicts, isEmpty); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, content); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, content); + expect(journal.entries, hasLength(1)); + expect(journal.entries.tryFind(id)!.etagA, etagA(content)); + expect(journal.entries.tryFind(id)!.etagB, etagB(content)); + }); + + test('to B', () async { + const id = '123'; + const content = '456'; + final state = TestSyncState( + { + id: WrapA(content), + }, + {}, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal(); + + final conflicts = await sync(sources, journal); + expect(conflicts, isEmpty); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, content); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, content); + expect(journal.entries, hasLength(1)); + expect(journal.entries.tryFind(id)!.etagA, etagA(content)); + expect(journal.entries.tryFind(id)!.etagB, etagB(content)); + }); + }); + + group('changed', () { + test('to A', () async { + const id = '123'; + const contentA = '456'; + const contentB = '789'; + final state = TestSyncState( + { + id: WrapA(contentA), + }, + { + id: WrapB(contentB), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal({ + SyncJournalEntry(id, etagA(contentA), randomEtag()), + }); + + final conflicts = await sync(sources, journal); + expect(conflicts, isEmpty); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, contentB); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, contentB); + expect(journal.entries, hasLength(1)); + expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); + expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); + }); + + test('to B', () async { + const id = '123'; + const contentA = '456'; + const contentB = '789'; + final state = TestSyncState( + { + id: WrapA(contentA), + }, + { + id: WrapB(contentB), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal({ + SyncJournalEntry(id, randomEtag(), etagB(contentB)), + }); + + final conflicts = await sync(sources, journal); + expect(conflicts, isEmpty); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, contentA); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, contentA); + expect(journal.entries, hasLength(1)); + expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); + expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); + }); + }); + }); + + group('delete', () { + test('from A', () async { + const id = '123'; + const content = '456'; + final state = TestSyncState( + { + id: WrapA(content), + }, + {}, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal({ + SyncJournalEntry(id, etagA(content), etagB(content)), + }); + + final conflicts = await sync(sources, journal); + expect(conflicts, isEmpty); + expect(state.stateA, isEmpty); + expect(state.stateB, isEmpty); + expect(journal.entries, isEmpty); + }); + + test('from B', () async { + const id = '123'; + const content = '456'; + final state = TestSyncState( + {}, + { + id: WrapB(content), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal({ + SyncJournalEntry(id, etagA(content), etagB(content)), + }); + + final conflicts = await sync(sources, journal); + expect(conflicts, isEmpty); + expect(state.stateA, isEmpty); + expect(state.stateB, isEmpty); + expect(journal.entries, isEmpty); + }); + + test('from journal', () async { + const id = '123'; + const content = '456'; + final state = TestSyncState({}, {}); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal({ + SyncJournalEntry(id, etagA(content), etagB(content)), + }); + + final conflicts = await sync(sources, journal); + expect(conflicts, isEmpty); + expect(state.stateA, isEmpty); + expect(state.stateB, isEmpty); + expect(journal.entries, isEmpty); + }); + }); + + group('conflict', () { + test('journal missing', () async { + const id = '123'; + const content = '456'; + final state = TestSyncState( + { + id: WrapA(content), + }, + { + id: WrapB(content), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal(); + + final conflicts = await sync(sources, journal); + expect(conflicts, hasLength(1)); + expect(conflicts[0].type, SyncConflictType.bothNew); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, content); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, content); + expect(journal.entries, isEmpty); + }); + + test('both changed', () async { + const id = '123'; + const contentA = '456'; + const contentB = '789'; + final state = TestSyncState( + { + id: WrapA(contentA), + }, + { + id: WrapB(contentB), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal({ + SyncJournalEntry(id, randomEtag(), randomEtag()), + }); + + final conflicts = await sync(sources, journal); + expect(conflicts, hasLength(1)); + expect(conflicts[0].type, SyncConflictType.bothChanged); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, contentA); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, contentB); + expect(journal.entries, hasLength(1)); + }); + + group('solution', () { + group('journal missing', () { + test('skip', () async { + const id = '123'; + const contentA = '456'; + const contentB = '789'; + final state = TestSyncState( + { + id: WrapA(contentA), + }, + { + id: WrapB(contentB), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal(); + + final conflicts = await sync( + sources, + journal, + conflictSolutions: { + id: SyncConflictSolution.skip, + }, + ); + expect(conflicts, isEmpty); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, contentA); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, contentB); + expect(journal.entries, isEmpty); + }); + + test('overwrite A', () async { + const id = '123'; + const contentA = '456'; + const contentB = '789'; + final state = TestSyncState( + { + id: WrapA(contentA), + }, + { + id: WrapB(contentB), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal(); + + final conflicts = await sync( + sources, + journal, + conflictSolutions: { + id: SyncConflictSolution.overwriteA, + }, + ); + expect(conflicts, isEmpty); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, contentB); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, contentB); + expect(journal.entries, hasLength(1)); + expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); + expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); + }); + + test('overwrite B', () async { + const id = '123'; + const contentA = '456'; + const contentB = '789'; + final state = TestSyncState( + { + id: WrapA(contentA), + }, + { + id: WrapB(contentB), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal(); + + final conflicts = await sync( + sources, + journal, + conflictSolutions: { + id: SyncConflictSolution.overwriteB, + }, + ); + expect(conflicts, isEmpty); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, contentA); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, contentA); + expect(journal.entries, hasLength(1)); + expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); + expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); + }); + }); + + group('both changed', () { + test('skip', () async { + const id = '123'; + const contentA = '456'; + const contentB = '789'; + final state = TestSyncState( + { + id: WrapA(contentA), + }, + { + id: WrapB(contentB), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal({ + SyncJournalEntry(id, randomEtag(), randomEtag()), + }); + + final conflicts = await sync( + sources, + journal, + conflictSolutions: { + id: SyncConflictSolution.skip, + }, + ); + expect(conflicts, isEmpty); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, contentA); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, contentB); + expect(journal.entries, hasLength(1)); + }); + + test('overwrite A', () async { + const id = '123'; + const contentA = '456'; + const contentB = '789'; + final state = TestSyncState( + { + id: WrapA(contentA), + }, + { + id: WrapB(contentB), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal({ + SyncJournalEntry(id, randomEtag(), randomEtag()), + }); + + final conflicts = await sync( + sources, + journal, + conflictSolutions: { + id: SyncConflictSolution.overwriteA, + }, + ); + expect(conflicts, isEmpty); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, contentB); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, contentB); + expect(journal.entries, hasLength(1)); + expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); + expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); + }); + + test('overwrite B', () async { + const id = '123'; + const contentA = '456'; + const contentB = '789'; + final state = TestSyncState( + { + id: WrapA(contentA), + }, + { + id: WrapB(contentB), + }, + ); + final sources = TestSyncSources.fromState(state); + final journal = SyncJournal({ + SyncJournalEntry(id, randomEtag(), randomEtag()), + }); + + final conflicts = await sync( + sources, + journal, + conflictSolutions: { + id: SyncConflictSolution.overwriteB, + }, + ); + expect(conflicts, isEmpty); + expect(state.stateA, hasLength(1)); + expect(state.stateA[id]!.content, contentA); + expect(state.stateB, hasLength(1)); + expect(state.stateB[id]!.content, contentA); + expect(journal.entries, hasLength(1)); + expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); + expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); + }); + }); + }); + }); + }); + }); +} From be4dbb70733fa447c6f980b0a89f3ba747925ef4 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Tue, 8 Aug 2023 18:05:51 +0200 Subject: [PATCH 2/3] feat(neon): Implement syncing Signed-off-by: jld3103 --- packages/app/pubspec.lock | 7 + packages/app/pubspec_overrides.yaml | 4 +- packages/neon/neon/lib/l10n/en.arb | 28 +- .../neon/neon/lib/l10n/localizations.dart | 96 +++++++ .../neon/neon/lib/l10n/localizations_en.dart | 50 ++++ packages/neon/neon/lib/neon.dart | 6 + packages/neon/neon/lib/src/blocs/apps.dart | 8 + packages/neon/neon/lib/src/blocs/sync.dart | 272 ++++++++++++++++++ .../lib/src/models/app_implementation.dart | 5 + .../pages/app_implementation_settings.dart | 156 ++++++++++ .../lib/src/pages/nextcloud_app_settings.dart | 74 ----- .../neon/neon/lib/src/pages/settings.dart | 4 +- .../lib/src/pages/sync_mapping_settings.dart | 72 +++++ packages/neon/neon/lib/src/router.dart | 6 +- .../neon/lib/src/settings/models/storage.dart | 3 + .../widgets/custom_settings_tile.dart | 3 + .../neon/lib/src/sync/models/conflicts.dart | 18 ++ .../lib/src/sync/models/implementation.dart | 43 +++ .../neon/lib/src/sync/models/mapping.dart | 23 ++ .../resolve_sync_conflicts_dialog.dart | 115 ++++++++ .../src/sync/widgets/sync_conflict_card.dart | 49 ++++ .../neon/neon/lib/src/utils/file_utils.dart | 52 ++++ .../neon/lib/src/utils/global_popups.dart | 35 ++- .../neon/neon/lib/src/utils/save_file.dart | 32 --- .../lib/src/utils/sync_mapping_options.dart | 30 ++ .../widgets/adaptive_widgets/list_tile.dart | 14 + .../lib/src/widgets/sync_status_icon.dart | 44 +++ packages/neon/neon/lib/sync.dart | 4 + packages/neon/neon/lib/utils.dart | 1 + packages/neon/neon/pubspec.yaml | 4 + packages/neon/neon/pubspec_overrides.yaml | 4 +- .../neon_dashboard/pubspec_overrides.yaml | 4 +- .../neon/neon_files/pubspec_overrides.yaml | 4 +- .../neon/neon_news/pubspec_overrides.yaml | 4 +- .../neon/neon_notes/pubspec_overrides.yaml | 4 +- .../neon_notifications/pubspec_overrides.yaml | 4 +- 36 files changed, 1162 insertions(+), 120 deletions(-) create mode 100644 packages/neon/neon/lib/src/blocs/sync.dart create mode 100644 packages/neon/neon/lib/src/pages/app_implementation_settings.dart delete mode 100644 packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart create mode 100644 packages/neon/neon/lib/src/pages/sync_mapping_settings.dart create mode 100644 packages/neon/neon/lib/src/sync/models/conflicts.dart create mode 100644 packages/neon/neon/lib/src/sync/models/implementation.dart create mode 100644 packages/neon/neon/lib/src/sync/models/mapping.dart create mode 100644 packages/neon/neon/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart create mode 100644 packages/neon/neon/lib/src/sync/widgets/sync_conflict_card.dart create mode 100644 packages/neon/neon/lib/src/utils/file_utils.dart delete mode 100644 packages/neon/neon/lib/src/utils/save_file.dart create mode 100644 packages/neon/neon/lib/src/utils/sync_mapping_options.dart create mode 100644 packages/neon/neon/lib/src/widgets/sync_status_icon.dart create mode 100644 packages/neon/neon/lib/sync.dart diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index ca92ca8e..143185d2 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -1174,6 +1174,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + synchronize: + dependency: "direct overridden" + description: + path: "../synchronize" + relative: true + source: path + version: "1.0.0" synchronized: dependency: transitive description: diff --git a/packages/app/pubspec_overrides.yaml b/packages/app/pubspec_overrides.yaml index e896bfc2..eb48b49a 100644 --- a/packages/app/pubspec_overrides.yaml +++ b/packages/app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,neon_files,neon_news,neon_notes,neon_notifications,nextcloud,sort_box,neon_lints,neon_dashboard +# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,neon_dashboard,neon_files,neon_lints,neon_news,neon_notes,neon_notifications,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../dynamite/dynamite_runtime @@ -22,3 +22,5 @@ dependency_overrides: path: ../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon/neon/lib/l10n/en.arb b/packages/neon/neon/lib/l10n/en.arb index cfc0f28e..90197e16 100644 --- a/packages/neon/neon/lib/l10n/en.arb +++ b/packages/neon/neon/lib/l10n/en.arb @@ -84,6 +84,10 @@ "actionShowSlashHide": "Show/Hide", "actionExit": "Exit", "actionContinue": "Continue", + "actionPrevious": "Previous", + "actionNext": "Next", + "actionCancel": "Cancel", + "actionFinish": "Finish", "firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications", "nextPushSupported": "NextPush is supported!", "nextPushSupportedText": "NextPush is a FOSS way of receiving push notifications using the UnifiedPush protocol via a Nextcloud instance.\nYou can install NextPush from the F-Droid app store.", @@ -124,6 +128,7 @@ "optionsCategoryStartup": "Startup", "optionsCategorySystemTray": "System tray", "optionsCategoryNavigation": "Navigation", + "optionsCategorySync": "Synchronization", "optionsSortOrderAscending": "Ascending", "optionsSortOrderDescending": "Descending", "globalOptionsThemeMode": "Theme mode", @@ -180,5 +185,26 @@ "accountOptionsAutomatic": "Automatic", "licenses": "Licenses", "sourceCode": "Source code", - "issueTracker": "Report a bug or request a feature" + "issueTracker": "Report a bug or request a feature", + "syncOptionsAdd": "Add synchronization", + "syncOptionsRemove": "Remove synchronization", + "syncOptionsSyncNow": "Synchronize now", + "syncOptionsStatusUnknown": "Unknown synchronization status", + "syncOptionsStatusIncomplete": "Not completely synchronized", + "syncOptionsStatusComplete": "Completely synchronized", + "syncOptionsRemoveConfirmation": "Do you want to remove the synchronization?", + "syncOptionsAutomaticSync": "Sync automatically", + "syncResolveConflictsLocal": "Local", + "syncResolveConflictsRemote": "Remote", + "syncResolveConflictsTitle": "Found {count} conflicts for syncing {name}", + "@syncResolveConflictsTitle": { + "placeholders": { + "count": { + "type": "int" + }, + "name": { + "type": "String" + } + } + } } diff --git a/packages/neon/neon/lib/l10n/localizations.dart b/packages/neon/neon/lib/l10n/localizations.dart index b588aaf2..469b8e11 100644 --- a/packages/neon/neon/lib/l10n/localizations.dart +++ b/packages/neon/neon/lib/l10n/localizations.dart @@ -311,6 +311,30 @@ abstract class NeonLocalizations { /// **'Continue'** String get actionContinue; + /// No description provided for @actionPrevious. + /// + /// In en, this message translates to: + /// **'Previous'** + String get actionPrevious; + + /// No description provided for @actionNext. + /// + /// In en, this message translates to: + /// **'Next'** + String get actionNext; + + /// No description provided for @actionCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get actionCancel; + + /// No description provided for @actionFinish. + /// + /// In en, this message translates to: + /// **'Finish'** + String get actionFinish; + /// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications. /// /// In en, this message translates to: @@ -467,6 +491,12 @@ abstract class NeonLocalizations { /// **'Navigation'** String get optionsCategoryNavigation; + /// No description provided for @optionsCategorySync. + /// + /// In en, this message translates to: + /// **'Synchronization'** + String get optionsCategorySync; + /// No description provided for @optionsSortOrderAscending. /// /// In en, this message translates to: @@ -688,6 +718,72 @@ abstract class NeonLocalizations { /// In en, this message translates to: /// **'Report a bug or request a feature'** String get issueTracker; + + /// No description provided for @syncOptionsAdd. + /// + /// In en, this message translates to: + /// **'Add synchronization'** + String get syncOptionsAdd; + + /// No description provided for @syncOptionsRemove. + /// + /// In en, this message translates to: + /// **'Remove synchronization'** + String get syncOptionsRemove; + + /// No description provided for @syncOptionsSyncNow. + /// + /// In en, this message translates to: + /// **'Synchronize now'** + String get syncOptionsSyncNow; + + /// No description provided for @syncOptionsStatusUnknown. + /// + /// In en, this message translates to: + /// **'Unknown synchronization status'** + String get syncOptionsStatusUnknown; + + /// No description provided for @syncOptionsStatusIncomplete. + /// + /// In en, this message translates to: + /// **'Not completely synchronized'** + String get syncOptionsStatusIncomplete; + + /// No description provided for @syncOptionsStatusComplete. + /// + /// In en, this message translates to: + /// **'Completely synchronized'** + String get syncOptionsStatusComplete; + + /// No description provided for @syncOptionsRemoveConfirmation. + /// + /// In en, this message translates to: + /// **'Do you want to remove the synchronization?'** + String get syncOptionsRemoveConfirmation; + + /// No description provided for @syncOptionsAutomaticSync. + /// + /// In en, this message translates to: + /// **'Sync automatically'** + String get syncOptionsAutomaticSync; + + /// No description provided for @syncResolveConflictsLocal. + /// + /// In en, this message translates to: + /// **'Local'** + String get syncResolveConflictsLocal; + + /// No description provided for @syncResolveConflictsRemote. + /// + /// In en, this message translates to: + /// **'Remote'** + String get syncResolveConflictsRemote; + + /// No description provided for @syncResolveConflictsTitle. + /// + /// In en, this message translates to: + /// **'Found {count} conflicts for syncing {name}'** + String syncResolveConflictsTitle(int count, String name); } class _NeonLocalizationsDelegate extends LocalizationsDelegate { diff --git a/packages/neon/neon/lib/l10n/localizations_en.dart b/packages/neon/neon/lib/l10n/localizations_en.dart index e8df967a..ef5cc843 100644 --- a/packages/neon/neon/lib/l10n/localizations_en.dart +++ b/packages/neon/neon/lib/l10n/localizations_en.dart @@ -147,6 +147,18 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get actionContinue => 'Continue'; + @override + String get actionPrevious => 'Previous'; + + @override + String get actionNext => 'Next'; + + @override + String get actionCancel => 'Cancel'; + + @override + String get actionFinish => 'Finish'; + @override String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; @@ -230,6 +242,9 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get optionsCategoryNavigation => 'Navigation'; + @override + String get optionsCategorySync => 'Synchronization'; + @override String get optionsSortOrderAscending => 'Ascending'; @@ -345,4 +360,39 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get issueTracker => 'Report a bug or request a feature'; + + @override + String get syncOptionsAdd => 'Add synchronization'; + + @override + String get syncOptionsRemove => 'Remove synchronization'; + + @override + String get syncOptionsSyncNow => 'Synchronize now'; + + @override + String get syncOptionsStatusUnknown => 'Unknown synchronization status'; + + @override + String get syncOptionsStatusIncomplete => 'Not completely synchronized'; + + @override + String get syncOptionsStatusComplete => 'Completely synchronized'; + + @override + String get syncOptionsRemoveConfirmation => 'Do you want to remove the synchronization?'; + + @override + String get syncOptionsAutomaticSync => 'Sync automatically'; + + @override + String get syncResolveConflictsLocal => 'Local'; + + @override + String get syncResolveConflictsRemote => 'Remote'; + + @override + String syncResolveConflictsTitle(int count, String name) { + return 'Found $count conflicts for syncing $name'; + } } diff --git a/packages/neon/neon/lib/neon.dart b/packages/neon/neon/lib/neon.dart index c7fe8520..70418eac 100644 --- a/packages/neon/neon/lib/neon.dart +++ b/packages/neon/neon/lib/neon.dart @@ -7,6 +7,7 @@ import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/blocs/first_launch.dart'; import 'package:neon/src/blocs/next_push.dart'; import 'package:neon/src/blocs/push_notifications.dart'; +import 'package:neon/src/blocs/sync.dart'; import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/models/disposable.dart'; @@ -66,6 +67,10 @@ Future runNeon({ globalOptions, disabled: nextPushDisabled, ); + final syncBloc = SyncBloc( + accountsBloc, + appImplementations, + ); runApp( MultiProvider( @@ -74,6 +79,7 @@ Future runNeon({ NeonProvider.value(value: accountsBloc), NeonProvider.value(value: firstLaunchBloc), NeonProvider.value(value: nextPushBloc), + NeonProvider.value(value: syncBloc), Provider>( create: (final _) => appImplementations, dispose: (final _, final appImplementations) => appImplementations.disposeAll(), diff --git a/packages/neon/neon/lib/src/blocs/apps.dart b/packages/neon/neon/lib/src/blocs/apps.dart index b65a67a3..4a68351a 100644 --- a/packages/neon/neon/lib/src/blocs/apps.dart +++ b/packages/neon/neon/lib/src/blocs/apps.dart @@ -243,9 +243,17 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates /// Returns the active [Bloc] for the given [appImplementation]. /// /// If no bloc exists yet a new one will be instantiated and cached in [AppImplementation.blocsCache]. + /// See [getAppBlocByID] for getting the [Bloc] by the [AppImplementation.id]. T getAppBloc(final AppImplementation appImplementation) => appImplementation.getBloc(_account); + /// Returns the active [Bloc] for the given [appId]. + /// + /// If no bloc exists yet a new one will be instantiated and cached in [AppImplementation.blocsCache]. + /// See [getAppBloc] for getting the [Bloc] by the [AppImplementation]. + T? getAppBlocByID(final String appId) => + _allAppImplementations.tryFind(appId)?.getBloc(_account) as T?; + /// Returns the active [Bloc] for every registered [AppImplementation] wrapped in a Provider. List> get appBlocProviders => _allAppImplementations.map((final appImplementation) => appImplementation.blocProvider).toList(); diff --git a/packages/neon/neon/lib/src/blocs/sync.dart b/packages/neon/neon/lib/src/blocs/sync.dart new file mode 100644 index 00000000..11546816 --- /dev/null +++ b/packages/neon/neon/lib/src/blocs/sync.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:neon/blocs.dart'; +import 'package:neon/src/models/account.dart'; +import 'package:neon/src/models/app_implementation.dart'; +import 'package:neon/src/settings/models/storage.dart'; +import 'package:neon/src/sync/models/conflicts.dart'; +import 'package:neon/src/sync/models/implementation.dart'; +import 'package:neon/src/sync/models/mapping.dart'; +import 'package:neon/src/utils/sync_mapping_options.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:synchronize/synchronize.dart'; + +abstract interface class SyncBlocEvents { + /// Adds a new [mapping] that will be synced. + void addMapping(final SyncMapping mapping); + + /// Removes an existing [mapping] that will no longer be synced. + void removeMapping(final SyncMapping mapping); + + /// Explicitly trigger a sync for the [mapping]. + /// [solutions] can be use to apply solutions for conflicts. + void syncMapping( + final SyncMapping mapping, { + final Map solutions = const {}, + }); +} + +abstract interface class SyncBlocStates { + /// Map of [SyncMapping]s and their [SyncMappingStatus]es + BehaviorSubject, SyncMappingStatus>> get mappingStatuses; + + /// Stream of conflicts that have arisen during syncing. + Stream> get conflicts; +} + +class SyncBloc extends InteractiveBloc implements SyncBlocEvents, SyncBlocStates { + SyncBloc( + this._accountsBloc, + final Iterable appImplementations, + ) { + _syncImplementations = appImplementations.map((final app) => app.syncImplementation).whereNotNull(); + _timer = TimerBloc().registerTimer(const Duration(minutes: 1), refresh); + + _loadMappings(); + mappingStatuses.value.keys.forEach(_watchMapping); + unawaited(refresh()); + } + + final AccountsBloc _accountsBloc; + static const _storage = SingleValueStorage(StorageKeys.sync); + late final Iterable, dynamic, dynamic>> _syncImplementations; + late final NeonTimer _timer; + final _conflictsController = StreamController>(); + final _watchControllers = >{}; + final _syncMappingOptions = {}; + + @override + void dispose() { + _timer.cancel(); + for (final options in _syncMappingOptions.values) { + options.dispose(); + } + for (final mapping in mappingStatuses.value.keys) { + mapping.dispose(); + } + unawaited(mappingStatuses.close()); + for (final controller in _watchControllers.values) { + unawaited(controller.close()); + } + unawaited(_conflictsController.close()); + + super.dispose(); + } + + @override + late final Stream> conflicts = _conflictsController.stream.asBroadcastStream(); + + @override + final BehaviorSubject, SyncMappingStatus>> mappingStatuses = BehaviorSubject(); + + @override + Future refresh() async { + for (final mapping in mappingStatuses.value.keys) { + await _updateMapping(mapping); + } + } + + @override + Future addMapping(final SyncMapping mapping) async { + debugPrint('Adding mapping: $mapping'); + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: SyncMappingStatus.unknown, + }); + await _saveMappings(); + // Directly trigger sync after adding the mapping + await syncMapping(mapping); + // And start watching for local or remote changes + _watchMapping(mapping); + } + + @override + Future removeMapping(final SyncMapping mapping) async { + debugPrint('Removing mapping: $mapping'); + mappingStatuses.add(Map.fromEntries(mappingStatuses.value.entries.where((final m) => m.key != mapping))); + mapping.dispose(); + await _saveMappings(); + } + + @override + Future syncMapping( + final SyncMapping mapping, { + final Map solutions = const {}, + }) async { + debugPrint('Syncing mapping: $mapping'); + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: SyncMappingStatus.incomplete, + }); + + final account = _accountsBloc.accounts.value.tryFind(mapping.accountId); + if (account == null) { + await removeMapping(mapping); + return; + } + + try { + final implementation = _syncImplementations.find(mapping.appId); + final sources = await implementation.getSources(account, mapping); + + final diff = await computeSyncDiff( + sources, + mapping.journal, + conflictSolutions: solutions, + keepSkipsAsConflicts: true, + ); + debugPrint('Journal: ${mapping.journal}'); + debugPrint('Conflicts: ${diff.conflicts}'); + debugPrint('Actions: ${diff.actions}'); + + if (diff.conflicts.isNotEmpty && diff.conflicts.whereNot((final conflict) => conflict.skipped).isNotEmpty) { + _conflictsController.add( + SyncConflicts( + account, + implementation, + mapping, + diff.conflicts, + ), + ); + } + + await executeSyncDiff(sources, mapping.journal, diff); + + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: diff.conflicts.isEmpty ? SyncMappingStatus.complete : SyncMappingStatus.incomplete, + }); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + addError(e); + } + + // Save after syncing even if an error occurred + await _saveMappings(); + } + + Future _updateMapping(final SyncMapping mapping) async { + final account = _accountsBloc.accounts.value.tryFind(mapping.accountId); + if (account == null) { + await removeMapping(mapping); + return; + } + + final options = getSyncMappingOptionsFor(mapping); + if (options.automaticSync.value) { + await syncMapping(mapping); + } else { + try { + final status = await _getMappingStatus(account, mapping); + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: status, + }); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + addError(e); + } + } + } + + Future _getMappingStatus( + final Account account, + final SyncMapping mapping, + ) async { + final implementation = _syncImplementations.find(mapping.appId); + final sources = await implementation.getSources(account, mapping); + final diff = await computeSyncDiff(sources, mapping.journal); + return diff.actions.isEmpty && diff.conflicts.isEmpty ? SyncMappingStatus.complete : SyncMappingStatus.incomplete; + } + + void _loadMappings() { + debugPrint('Loading mappings'); + final loadedMappings = >[]; + + if (_storage.hasValue()) { + final serializedMappings = (json.decode(_storage.getString()!) as Map) + .map((final key, final value) => MapEntry(key, (value as List).map((final e) => e as Map))); + + for (final mapping in serializedMappings.entries) { + final syncImplementation = _syncImplementations.tryFind(mapping.key); + if (syncImplementation == null) { + continue; + } + + for (final serializedMapping in mapping.value) { + loadedMappings.add(syncImplementation.deserializeMapping(serializedMapping)); + } + } + } + + mappingStatuses.add({ + for (final mapping in loadedMappings) mapping: SyncMappingStatus.unknown, + }); + } + + Future _saveMappings() async { + debugPrint('Saving mappings'); + final serializedMappings = >>{}; + + for (final mapping in mappingStatuses.value.keys) { + final syncImplementation = _syncImplementations.find(mapping.appId); + serializedMappings[mapping.appId] ??= []; + serializedMappings[mapping.appId]!.add(syncImplementation.serializeMapping(mapping)); + } + + await _storage.setString(json.encode(serializedMappings)); + } + + void _watchMapping(final SyncMapping mapping) { + final syncImplementation = _syncImplementations.find(mapping.appId); + if (_watchControllers.containsKey(syncImplementation.getMappingId(mapping))) { + return; + } + + // ignore: close_sinks + final controller = StreamController(); + // Debounce is required to stop bulk operations flooding the sync and potentially creating race conditions. + controller.stream.debounceTime(const Duration(seconds: 1)).listen((final _) async { + await _updateMapping(mapping); + }); + + _watchControllers[syncImplementation.getMappingId(mapping)] = controller; + + mapping.watch(() { + controller.add(null); + }); + } + + SyncMappingOptions getSyncMappingOptionsFor(final SyncMapping mapping) { + final syncImplementation = _syncImplementations.find(mapping.appId); + final id = syncImplementation.getGlobalUniqueMappingId(mapping); + return _syncMappingOptions[id] ??= SyncMappingOptions( + AppStorage(StorageKeys.sync, id), + ); + } +} diff --git a/packages/neon/neon/lib/src/models/app_implementation.dart b/packages/neon/neon/lib/src/models/app_implementation.dart index b70cffa1..90cd55e3 100644 --- a/packages/neon/neon/lib/src/models/app_implementation.dart +++ b/packages/neon/neon/lib/src/models/app_implementation.dart @@ -12,6 +12,8 @@ import 'package:neon/src/models/account_cache.dart'; import 'package:neon/src/models/disposable.dart'; import 'package:neon/src/settings/models/options_collection.dart'; import 'package:neon/src/settings/models/storage.dart'; +import 'package:neon/src/sync/models/implementation.dart'; +import 'package:neon/src/sync/models/mapping.dart'; import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/widgets/drawer_destination.dart'; import 'package:nextcloud/core.dart' as core; @@ -82,6 +84,9 @@ abstract class AppImplementation @protected T buildBloc(final Account account); + /// Optional [SyncImplementation] for this [AppImplementation]. + SyncImplementation, dynamic, dynamic>? get syncImplementation => null; + /// The [Provider] building the bloc [T] the currently active account. /// /// Blocs will not be disposed on disposal of the provider. You must handle diff --git a/packages/neon/neon/lib/src/pages/app_implementation_settings.dart b/packages/neon/neon/lib/src/pages/app_implementation_settings.dart new file mode 100644 index 00000000..4f8e0e9c --- /dev/null +++ b/packages/neon/neon/lib/src/pages/app_implementation_settings.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:meta/meta.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/blocs/accounts.dart'; +import 'package:neon/src/blocs/sync.dart'; +import 'package:neon/src/models/account.dart'; +import 'package:neon/src/models/app_implementation.dart'; +import 'package:neon/src/pages/sync_mapping_settings.dart'; +import 'package:neon/src/settings/widgets/custom_settings_tile.dart'; +import 'package:neon/src/settings/widgets/option_settings_tile.dart'; +import 'package:neon/src/settings/widgets/settings_category.dart'; +import 'package:neon/src/settings/widgets/settings_list.dart'; +import 'package:neon/src/theme/dialog.dart'; +import 'package:neon/src/utils/confirmation_dialog.dart'; +import 'package:neon/src/widgets/account_selection_dialog.dart'; +import 'package:neon/src/widgets/sync_status_icon.dart'; +import 'package:neon/src/widgets/user_avatar.dart'; +import 'package:provider/provider.dart'; + +@internal +class AppImplementationSettingsPage extends StatelessWidget { + const AppImplementationSettingsPage({ + required this.appImplementation, + super.key, + }); + + final AppImplementation appImplementation; + + @override + Widget build(final BuildContext context) { + final accountsBloc = Provider.of(context, listen: false); + final syncBloc = Provider.of(context, listen: false); + + final appBar = AppBar( + title: Text(appImplementation.name(context)), + actions: [ + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context, + NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), + )) { + appImplementation.options.reset(); + } + }, + tooltip: NeonLocalizations.of(context).settingsResetFor(appImplementation.name(context)), + icon: const Icon(MdiIcons.cogRefresh), + ), + ], + ); + + final body = SettingsList( + categories: [ + for (final category in [...appImplementation.options.categories, null]) ...[ + if (appImplementation.options.options.where((final option) => option.category == category).isNotEmpty) ...[ + SettingsCategory( + title: Text( + category != null ? category.name(context) : NeonLocalizations.of(context).optionsCategoryOther, + ), + tiles: [ + for (final option + in appImplementation.options.options.where((final option) => option.category == category)) ...[ + OptionSettingsTile(option: option), + ], + ], + ), + ], + ], + if (appImplementation.syncImplementation != null) ...[ + StreamBuilder( + stream: syncBloc.mappingStatuses, + builder: (final context, final mappingStatuses) => !mappingStatuses.hasData + ? const SizedBox.shrink() + : SettingsCategory( + title: Text(NeonLocalizations.of(context).optionsCategorySync), + tiles: [ + for (final mappingStatus in mappingStatuses.requireData.entries + .where((final mappingStatus) => mappingStatus.key.appId == appImplementation.id)) ...[ + CustomSettingsTile( + title: Text(appImplementation.syncImplementation!.getMappingDisplayTitle(mappingStatus.key)), + subtitle: + Text(appImplementation.syncImplementation!.getMappingDisplaySubtitle(mappingStatus.key)), + leading: NeonUserAvatar( + account: accountsBloc.accounts.value + .singleWhere((final account) => account.id == mappingStatus.key.accountId), + showStatus: false, + ), + trailing: IconButton( + onPressed: () async { + await syncBloc.syncMapping(mappingStatus.key); + }, + tooltip: NeonLocalizations.of(context).syncOptionsSyncNow, + iconSize: 30, + icon: SyncStatusIcon( + status: mappingStatus.value, + ), + ), + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => SyncMappingSettingsPage( + mapping: mappingStatus.key, + ), + ), + ); + }, + ), + ], + CustomSettingsTile( + title: ElevatedButton.icon( + onPressed: () async { + final account = await showDialog( + context: context, + builder: (final context) => const NeonAccountSelectionDialog(), + ); + if (account == null) { + return; + } + + if (!context.mounted) { + return; + } + + final mapping = await appImplementation.syncImplementation!.addMapping(context, account); + if (mapping == null) { + return; + } + + await syncBloc.addMapping(mapping); + }, + icon: const Icon(MdiIcons.cloudSync), + label: Text(NeonLocalizations.of(context).syncOptionsAdd), + ), + ), + ], + ), + ), + ], + ], + ); + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: appBar, + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: NeonDialogTheme.of(context).constraints, + child: body, + ), + ), + ), + ); + } +} diff --git a/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart b/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart deleted file mode 100644 index b26b5451..00000000 --- a/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:meta/meta.dart'; -import 'package:neon/l10n/localizations.dart'; -import 'package:neon/src/models/app_implementation.dart'; -import 'package:neon/src/settings/widgets/option_settings_tile.dart'; -import 'package:neon/src/settings/widgets/settings_category.dart'; -import 'package:neon/src/settings/widgets/settings_list.dart'; -import 'package:neon/src/theme/dialog.dart'; -import 'package:neon/src/utils/confirmation_dialog.dart'; - -@internal -class NextcloudAppSettingsPage extends StatelessWidget { - const NextcloudAppSettingsPage({ - required this.appImplementation, - super.key, - }); - - final AppImplementation appImplementation; - - @override - Widget build(final BuildContext context) { - final appBar = AppBar( - title: Text(appImplementation.name(context)), - actions: [ - IconButton( - onPressed: () async { - if (await showConfirmationDialog( - context, - NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), - )) { - appImplementation.options.reset(); - } - }, - tooltip: NeonLocalizations.of(context).settingsResetFor(appImplementation.name(context)), - icon: const Icon(MdiIcons.cogRefresh), - ), - ], - ); - - final body = SettingsList( - categories: [ - for (final category in [...appImplementation.options.categories, null]) ...[ - if (appImplementation.options.options.where((final option) => option.category == category).isNotEmpty) ...[ - SettingsCategory( - title: Text( - category != null ? category.name(context) : NeonLocalizations.of(context).optionsCategoryOther, - ), - tiles: [ - for (final option - in appImplementation.options.options.where((final option) => option.category == category)) ...[ - OptionSettingsTile(option: option), - ], - ], - ), - ], - ], - ], - ); - - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: appBar, - body: SafeArea( - child: Center( - child: ConstrainedBox( - constraints: NeonDialogTheme.of(context).constraints, - child: body, - ), - ), - ), - ); - } -} diff --git a/packages/neon/neon/lib/src/pages/settings.dart b/packages/neon/neon/lib/src/pages/settings.dart index c2eb3098..1f0057e9 100644 --- a/packages/neon/neon/lib/src/pages/settings.dart +++ b/packages/neon/neon/lib/src/pages/settings.dart @@ -20,9 +20,9 @@ import 'package:neon/src/theme/branding.dart'; import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/utils/adaptive.dart'; import 'package:neon/src/utils/confirmation_dialog.dart'; +import 'package:neon/src/utils/file_utils.dart'; import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/provider.dart'; -import 'package:neon/src/utils/save_file.dart'; import 'package:neon/src/widgets/error.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -250,7 +250,7 @@ class _SettingsPageState extends State { final fileName = 'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json'; final data = settingsExportHelper.exportToFile(); - await saveFileWithPickDialog(fileName, data); + await FileUtils.saveFileWithPickDialog(fileName, data); } catch (e, s) { debugPrint(e.toString()); debugPrint(s.toString()); diff --git a/packages/neon/neon/lib/src/pages/sync_mapping_settings.dart b/packages/neon/neon/lib/src/pages/sync_mapping_settings.dart new file mode 100644 index 00000000..27bee63c --- /dev/null +++ b/packages/neon/neon/lib/src/pages/sync_mapping_settings.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/blocs/sync.dart'; +import 'package:neon/src/settings/widgets/option_settings_tile.dart'; +import 'package:neon/src/settings/widgets/settings_category.dart'; +import 'package:neon/src/settings/widgets/settings_list.dart'; +import 'package:neon/src/sync/models/mapping.dart'; +import 'package:neon/src/utils/confirmation_dialog.dart'; +import 'package:provider/provider.dart'; + +class SyncMappingSettingsPage extends StatelessWidget { + const SyncMappingSettingsPage({ + required this.mapping, + super.key, + }); + + final SyncMapping mapping; + + @override + Widget build(final BuildContext context) { + final syncBloc = Provider.of(context, listen: false); + final options = syncBloc.getSyncMappingOptionsFor(mapping); + + return Scaffold( + appBar: AppBar( + actions: [ + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context, + NeonLocalizations.of(context).syncOptionsRemoveConfirmation, + )) { + await syncBloc.removeMapping(mapping); + + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + tooltip: NeonLocalizations.of(context).syncOptionsRemove, + icon: const Icon(MdiIcons.delete), + ), + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context, + NeonLocalizations.of(context).settingsResetAllConfirmation, + )) { + options.reset(); + } + }, + tooltip: NeonLocalizations.of(context).settingsResetAll, + icon: const Icon(MdiIcons.cogRefresh), + ), + ], + ), + body: SettingsList( + categories: [ + SettingsCategory( + title: Text(NeonLocalizations.of(context).optionsCategoryGeneral), + tiles: [ + ToggleSettingsTile( + option: options.automaticSync, + ), + ], + ), + ], + ), + ); + } +} diff --git a/packages/neon/neon/lib/src/router.dart b/packages/neon/neon/lib/src/router.dart index 5ba8289f..c3b9d752 100644 --- a/packages/neon/neon/lib/src/router.dart +++ b/packages/neon/neon/lib/src/router.dart @@ -10,13 +10,13 @@ import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/pages/account_settings.dart'; +import 'package:neon/src/pages/app_implementation_settings.dart'; import 'package:neon/src/pages/home.dart'; import 'package:neon/src/pages/login.dart'; import 'package:neon/src/pages/login_check_account.dart'; import 'package:neon/src/pages/login_check_server_status.dart'; import 'package:neon/src/pages/login_flow.dart'; import 'package:neon/src/pages/login_qr_code.dart'; -import 'package:neon/src/pages/nextcloud_app_settings.dart'; import 'package:neon/src/pages/route_not_found.dart'; import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/utils/provider.dart'; @@ -439,7 +439,7 @@ class _AddAccountCheckAccountRoute extends LoginCheckAccountRoute { } /// {@template AppRoutes.NextcloudAppSettingsRoute} -/// Route for the the [NextcloudAppSettingsPage]. +/// Route for the the [AppImplementationSettingsPage]. /// {@endtemplate} @immutable class NextcloudAppSettingsRoute extends GoRouteData { @@ -456,7 +456,7 @@ class NextcloudAppSettingsRoute extends GoRouteData { final appImplementations = NeonProvider.of>(context); final appImplementation = appImplementations.tryFind(appid)!; - return NextcloudAppSettingsPage(appImplementation: appImplementation); + return AppImplementationSettingsPage(appImplementation: appImplementation); } } diff --git a/packages/neon/neon/lib/src/settings/models/storage.dart b/packages/neon/neon/lib/src/settings/models/storage.dart index 9dd1f394..fea850af 100644 --- a/packages/neon/neon/lib/src/settings/models/storage.dart +++ b/packages/neon/neon/lib/src/settings/models/storage.dart @@ -70,6 +70,9 @@ enum StorageKeys implements Storable { /// The key for the `Account`s and their `AccountSpecificOptions`. accounts._('accounts'), + /// The key for the `SyncImplementation`s. + sync._('sync'), + /// The key for the `GlobalOptions`. global._('global'), diff --git a/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart b/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart index 6161651b..0d992c5a 100644 --- a/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart +++ b/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart @@ -13,6 +13,7 @@ class CustomSettingsTile extends SettingsTile { this.leading, this.trailing, this.onTap, + this.onLongPress, super.key, }); @@ -21,6 +22,7 @@ class CustomSettingsTile extends SettingsTile { final Widget? leading; final Widget? trailing; final FutureOr Function()? onTap; + final FutureOr Function()? onLongPress; @override Widget build(final BuildContext context) => AdaptiveListTile( @@ -29,5 +31,6 @@ class CustomSettingsTile extends SettingsTile { leading: leading, trailing: trailing, onTap: onTap, + onLongPress: onLongPress, ); } diff --git a/packages/neon/neon/lib/src/sync/models/conflicts.dart b/packages/neon/neon/lib/src/sync/models/conflicts.dart new file mode 100644 index 00000000..68cfa5b7 --- /dev/null +++ b/packages/neon/neon/lib/src/sync/models/conflicts.dart @@ -0,0 +1,18 @@ +import 'package:neon/src/models/account.dart'; +import 'package:neon/src/sync/models/implementation.dart'; +import 'package:neon/src/sync/models/mapping.dart'; +import 'package:synchronize/synchronize.dart'; + +class SyncConflicts { + SyncConflicts( + this.account, + this.implementation, + this.mapping, + this.conflicts, + ); + + final Account account; + final SyncImplementation, T1, T2> implementation; + final SyncMapping mapping; + final List> conflicts; +} diff --git a/packages/neon/neon/lib/src/sync/models/implementation.dart b/packages/neon/neon/lib/src/sync/models/implementation.dart new file mode 100644 index 00000000..40f0404d --- /dev/null +++ b/packages/neon/neon/lib/src/sync/models/implementation.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:neon/src/models/account.dart'; +import 'package:neon/src/sync/models/mapping.dart'; +import 'package:synchronize/synchronize.dart'; + +@immutable +abstract interface class SyncImplementation, T1, T2> { + String get appId; + + FutureOr> getSources(final Account account, final S mapping); + + Map serializeMapping(final S mapping); + + S deserializeMapping(final Map json); + + FutureOr addMapping(final BuildContext context, final Account account); + + String getMappingDisplayTitle(final S mapping); + + String getMappingDisplaySubtitle(final S mapping); + + String getMappingId(final S mapping); + + Widget getConflictDetailsLocal(final BuildContext context, final T2 object); + + Widget getConflictDetailsRemote(final BuildContext context, final T1 object); +} + +extension SyncImplementationGlobalUniqueMappingId + on SyncImplementation, dynamic, dynamic> { + String getGlobalUniqueMappingId(final SyncMapping mapping) => + '${mapping.accountId}-${mapping.appId}-${getMappingId(mapping)}'; +} + +extension SyncImplementationFind on Iterable, dynamic, dynamic>> { + SyncImplementation, dynamic, dynamic>? tryFind(final String appId) => + singleWhereOrNull((final syncImplementation) => appId == syncImplementation.appId); + + SyncImplementation, dynamic, dynamic> find(final String appId) => tryFind(appId)!; +} diff --git a/packages/neon/neon/lib/src/sync/models/mapping.dart b/packages/neon/neon/lib/src/sync/models/mapping.dart new file mode 100644 index 00000000..a5a5150c --- /dev/null +++ b/packages/neon/neon/lib/src/sync/models/mapping.dart @@ -0,0 +1,23 @@ +import 'package:meta/meta.dart'; +import 'package:synchronize/synchronize.dart'; + +abstract interface class SyncMapping { + String get accountId; + String get appId; + SyncJournal get journal; + + /// This method can be implemented to watch local or remote changes and update the status accordingly. + void watch(final void Function() onUpdated) {} + + @mustBeOverridden + void dispose() {} + + @override + String toString() => 'SyncMapping(accountId: $accountId, appId: $appId)'; +} + +enum SyncMappingStatus { + unknown, + incomplete, + complete, +} diff --git a/packages/neon/neon/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart b/packages/neon/neon/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart new file mode 100644 index 00000000..ed7ddc75 --- /dev/null +++ b/packages/neon/neon/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/sync/models/conflicts.dart'; +import 'package:neon/src/sync/widgets/sync_conflict_card.dart'; +import 'package:neon/src/theme/dialog.dart'; +import 'package:synchronize/synchronize.dart'; + +class NeonResolveSyncConflictsDialog extends StatefulWidget { + const NeonResolveSyncConflictsDialog({ + required this.conflicts, + super.key, + }); + + final SyncConflicts conflicts; + + @override + State> createState() => _NeonResolveSyncConflictsDialogState(); +} + +class _NeonResolveSyncConflictsDialogState extends State> { + var _index = 0; + final _solutions = {}; + + SyncConflict get conflict => widget.conflicts.conflicts[_index]; + + SyncConflictSolution? get selectedSolution => _solutions[conflict.id]; + + void onSolution(final SyncConflictSolution solution) { + setState(() { + _solutions[conflict.id] = solution; + }); + } + + bool get isFirst => _index == 0; + bool get isLast => _index == widget.conflicts.conflicts.length - 1; + + @override + Widget build(final BuildContext context) { + final body = Column( + children: [ + Text( + NeonLocalizations.of(context).syncResolveConflictsTitle( + widget.conflicts.conflicts.length, + NeonLocalizations.of(context).appImplementationName(widget.conflicts.implementation.appId), + ), + style: Theme.of(context).textTheme.headlineMedium, + ), + const Divider(), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SyncConflictCard( + title: NeonLocalizations.of(context).syncResolveConflictsLocal, + solution: SyncConflictSolution.overwriteA, + selected: selectedSolution == SyncConflictSolution.overwriteA, + onSelected: onSolution, + child: widget.conflicts.implementation.getConflictDetailsLocal(context, conflict.objectB.data), + ), + SyncConflictCard( + title: NeonLocalizations.of(context).syncResolveConflictsRemote, + solution: SyncConflictSolution.overwriteB, + selected: selectedSolution == SyncConflictSolution.overwriteB, + onSelected: onSolution, + child: widget.conflicts.implementation.getConflictDetailsRemote(context, conflict.objectA.data), + ), + ], + ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + OutlinedButton( + onPressed: () { + if (isFirst) { + Navigator.of(context).pop(); + } else { + setState(() { + _index--; + }); + } + }, + child: Text( + isFirst ? NeonLocalizations.of(context).actionCancel : NeonLocalizations.of(context).actionPrevious, + ), + ), + ElevatedButton( + onPressed: () { + if (isLast) { + Navigator.of(context).pop(_solutions); + } else { + setState(() { + _index++; + }); + } + }, + child: Text( + isLast ? NeonLocalizations.of(context).actionFinish : NeonLocalizations.of(context).actionNext, + ), + ), + ], + ), + ], + ); + + return Dialog( + child: IntrinsicHeight( + child: Container( + padding: const EdgeInsets.all(24), + constraints: NeonDialogTheme.of(context).constraints, + child: body, + ), + ), + ); + } +} diff --git a/packages/neon/neon/lib/src/sync/widgets/sync_conflict_card.dart b/packages/neon/neon/lib/src/sync/widgets/sync_conflict_card.dart new file mode 100644 index 00000000..b82b0d88 --- /dev/null +++ b/packages/neon/neon/lib/src/sync/widgets/sync_conflict_card.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:synchronize/synchronize.dart'; + +class SyncConflictCard extends StatelessWidget { + const SyncConflictCard({ + required this.title, + required this.child, + required this.selected, + required this.solution, + required this.onSelected, + super.key, + }); + + final String title; + final Widget child; + final bool selected; + final SyncConflictSolution solution; + final void Function(SyncConflictSolution solution) onSelected; + + @override + Widget build(final BuildContext context) => Card( + shape: selected + ? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).colorScheme.onBackground, + ), + ) + : null, + child: InkWell( + onTap: () { + onSelected(solution); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + title, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + child, + ], + ), + ), + ); +} diff --git a/packages/neon/neon/lib/src/utils/file_utils.dart b/packages/neon/neon/lib/src/utils/file_utils.dart new file mode 100644 index 00000000..04929f20 --- /dev/null +++ b/packages/neon/neon/lib/src/utils/file_utils.dart @@ -0,0 +1,52 @@ +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_file_dialog/flutter_file_dialog.dart'; +import 'package:neon/src/platform/platform.dart'; +import 'package:universal_io/io.dart'; + +class FileUtils { + FileUtils._(); + + /// Displays a dialog for selecting a location where to save a file with the [data] content. + /// + /// Set the the suggested [fileName] to use when saving the file. + /// + /// Returns the path of the saved file or `null` if the operation was cancelled. + static Future saveFileWithPickDialog(final String fileName, final Uint8List data) async { + if (NeonPlatform.instance.shouldUseFileDialog) { + // TODO: https://github.com/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 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 pickDirectory() async => FilePicker.platform.getDirectoryPath(); +} diff --git a/packages/neon/neon/lib/src/utils/global_popups.dart b/packages/neon/neon/lib/src/utils/global_popups.dart index 897e28f5..c2c29733 100644 --- a/packages/neon/neon/lib/src/utils/global_popups.dart +++ b/packages/neon/neon/lib/src/utils/global_popups.dart @@ -3,13 +3,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/blocs/first_launch.dart'; import 'package:neon/src/blocs/next_push.dart'; +import 'package:neon/src/blocs/sync.dart'; import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/platform/platform.dart'; import 'package:neon/src/router.dart'; +import 'package:neon/src/sync/widgets/resolve_sync_conflicts_dialog.dart'; import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/provider.dart'; +import 'package:neon/src/widgets/error.dart'; +import 'package:provider/provider.dart'; +import 'package:synchronize/synchronize.dart'; import 'package:url_launcher/url_launcher_string.dart'; /// Singleton class managing global popups. @@ -62,10 +68,11 @@ class GlobalPopups { final globalOptions = NeonProvider.of(context); final firstLaunchBloc = NeonProvider.of(context); final nextPushBloc = NeonProvider.of(context); + final syncBloc = NeonProvider.of(context); if (NeonPlatform.instance.canUsePushNotifications) { _subscriptions.addAll([ firstLaunchBloc.onFirstLaunch.listen((final _) { - assert(context.mounted, 'Context should be mounted'); + assert(_context.mounted, 'Context should be mounted'); if (!globalOptions.pushNotificationsEnabled.enabled) { return; } @@ -116,5 +123,31 @@ class GlobalPopups { }), ]); } + _subscriptions.addAll([ + syncBloc.errors.listen((final error) { + assert(_context.mounted, 'Context should be mounted'); + NeonError.showSnackbar(_context, error); + }), + syncBloc.conflicts.listen((final conflicts) async { + assert(_context.mounted, 'Context should be mounted'); + + final providers = NeonProvider.of(context).getAppsBlocFor(conflicts.account).appBlocProviders; + final result = await showDialog>( + context: _context, + builder: (final context) => MultiProvider( + providers: providers, + child: NeonResolveSyncConflictsDialog(conflicts: conflicts), + ), + ); + if (result == null) { + return; + } + + await syncBloc.syncMapping( + conflicts.mapping, + solutions: result, + ); + }), + ]); } } diff --git a/packages/neon/neon/lib/src/utils/save_file.dart b/packages/neon/neon/lib/src/utils/save_file.dart deleted file mode 100644 index d25c5286..00000000 --- a/packages/neon/neon/lib/src/utils/save_file.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:typed_data'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter_file_dialog/flutter_file_dialog.dart'; -import 'package:neon/src/platform/platform.dart'; -import 'package:universal_io/io.dart'; - -/// Displays a dialog for selecting a location where to save a file with the [data] content. -/// -/// Set the the suggested [fileName] to use when saving the file. -/// -/// Returns the path of the saved file or `null` if the operation was cancelled. -Future saveFileWithPickDialog(final String fileName, final Uint8List data) async { - if (NeonPlatform.instance.shouldUseFileDialog) { - // TODO: https://github.com/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; - } -} diff --git a/packages/neon/neon/lib/src/utils/sync_mapping_options.dart b/packages/neon/neon/lib/src/utils/sync_mapping_options.dart new file mode 100644 index 00000000..d8432546 --- /dev/null +++ b/packages/neon/neon/lib/src/utils/sync_mapping_options.dart @@ -0,0 +1,30 @@ +import 'package:meta/meta.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/settings.dart'; + +@internal +@immutable +class SyncMappingOptions extends OptionsCollection { + SyncMappingOptions(super.storage); + + @override + late final List> options = [ + automaticSync, + ]; + + late final automaticSync = ToggleOption( + storage: storage, + key: SyncMappingOptionKeys.automaticSync, + label: (final context) => NeonLocalizations.of(context).syncOptionsAutomaticSync, + defaultValue: true, + ); +} + +enum SyncMappingOptionKeys implements Storable { + automaticSync._('automatic-sync'); + + const SyncMappingOptionKeys._(this.value); + + @override + final String value; +} diff --git a/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart b/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart index b7d2f120..849fbcf9 100644 --- a/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart +++ b/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart @@ -16,6 +16,7 @@ class AdaptiveListTile extends StatelessWidget { this.leading, this.trailing, this.onTap, + this.onLongPress, super.key, }) : additionalInfo = null; @@ -30,6 +31,7 @@ class AdaptiveListTile extends StatelessWidget { this.leading, this.trailing, this.onTap, + this.onLongPress, super.key, }) : subtitle = additionalInfo; @@ -76,6 +78,18 @@ class AdaptiveListTile extends StatelessWidget { /// {@endtemplate} final FutureOr Function()? onTap; + /// {@template neon.AdaptiveListTile.onLongPress} + /// The [onLongPress] function is called when a user long presses on the[AdaptiveListTile]. + /// If left `null`, the [AdaptiveListTile] will not react to long presses. + /// + /// If the platform is a Cupertino one and this is a `Future Function()`, + /// then the [AdaptiveListTile] remains activated until the returned future is + /// awaited. This is according to iOS behavior. + /// However, if this function is a `void Function()`, then the tile is active + /// only for the duration of invocation. + /// {@endtemplate} + final FutureOr Function()? onLongPress; + /// {@template neon.AdaptiveListTile.enabled} /// Whether this list tile is interactive. /// diff --git a/packages/neon/neon/lib/src/widgets/sync_status_icon.dart b/packages/neon/neon/lib/src/widgets/sync_status_icon.dart new file mode 100644 index 00000000..7ad3d919 --- /dev/null +++ b/packages/neon/neon/lib/src/widgets/sync_status_icon.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/sync/models/mapping.dart'; +import 'package:neon/src/theme/colors.dart'; + +class SyncStatusIcon extends StatelessWidget { + const SyncStatusIcon({ + required this.status, + this.size, + super.key, + }); + + final SyncMappingStatus status; + final double? size; + + @override + Widget build(final BuildContext context) { + final (icon, color, semanticLabel) = switch (status) { + SyncMappingStatus.unknown => ( + MdiIcons.cloudQuestion, + NcColors.error, + NeonLocalizations.of(context).syncOptionsStatusUnknown, + ), + SyncMappingStatus.incomplete => ( + MdiIcons.cloudSync, + NcColors.warning, + NeonLocalizations.of(context).syncOptionsStatusIncomplete, + ), + SyncMappingStatus.complete => ( + MdiIcons.cloudCheck, + NcColors.success, + NeonLocalizations.of(context).syncOptionsStatusComplete, + ), + }; + + return Icon( + icon, + color: color, + size: size, + semanticLabel: semanticLabel, + ); + } +} diff --git a/packages/neon/neon/lib/sync.dart b/packages/neon/neon/lib/sync.dart new file mode 100644 index 00000000..ffa733cd --- /dev/null +++ b/packages/neon/neon/lib/sync.dart @@ -0,0 +1,4 @@ +export 'package:neon/src/sync/models/conflicts.dart'; +export 'package:neon/src/sync/models/implementation.dart'; +export 'package:neon/src/sync/models/mapping.dart'; +export 'package:synchronize/synchronize.dart'; diff --git a/packages/neon/neon/lib/utils.dart b/packages/neon/neon/lib/utils.dart index f3d8012e..59173a44 100644 --- a/packages/neon/neon/lib/utils.dart +++ b/packages/neon/neon/lib/utils.dart @@ -2,6 +2,7 @@ export 'package:neon/l10n/localizations.dart'; export 'package:neon/src/utils/app_route.dart'; export 'package:neon/src/utils/confirmation_dialog.dart'; export 'package:neon/src/utils/exceptions.dart'; +export 'package:neon/src/utils/file_utils.dart'; export 'package:neon/src/utils/hex_color.dart'; export 'package:neon/src/utils/provider.dart'; export 'package:neon/src/utils/rename_dialog.dart'; diff --git a/packages/neon/neon/pubspec.yaml b/packages/neon/neon/pubspec.yaml index a8ace978..891b41c5 100644 --- a/packages/neon/neon/pubspec.yaml +++ b/packages/neon/neon/pubspec.yaml @@ -50,6 +50,10 @@ dependencies: path: packages/sort_box sqflite: ^2.0.0 sqflite_common_ffi: ^2.2.8-2 + synchronize: + git: + url: https://github.com/nextcloud/neon + path: packages/synchronize tray_manager: ^0.2.0 unifiedpush: ^5.0.0 unifiedpush_android: ^2.0.0 diff --git a/packages/neon/neon/pubspec_overrides.yaml b/packages/neon/neon/pubspec_overrides.yaml index 3ea13b42..e5c89426 100644 --- a/packages/neon/neon/pubspec_overrides.yaml +++ b/packages/neon/neon/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -8,3 +8,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_dashboard/pubspec_overrides.yaml b/packages/neon/neon_dashboard/pubspec_overrides.yaml index e247d2d9..b52ca2c1 100644 --- a/packages/neon/neon_dashboard/pubspec_overrides.yaml +++ b/packages/neon/neon_dashboard/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: dynamite_runtime,neon,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_files/pubspec_overrides.yaml b/packages/neon/neon_files/pubspec_overrides.yaml index 84933267..06ac1faf 100644 --- a/packages/neon/neon_files/pubspec_overrides.yaml +++ b/packages/neon/neon_files/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -12,3 +12,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_news/pubspec_overrides.yaml b/packages/neon/neon_news/pubspec_overrides.yaml index 3bec69c6..d5cb1778 100644 --- a/packages/neon/neon_news/pubspec_overrides.yaml +++ b/packages/neon/neon_news/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_notes/pubspec_overrides.yaml b/packages/neon/neon_notes/pubspec_overrides.yaml index 3bec69c6..d5cb1778 100644 --- a/packages/neon/neon_notes/pubspec_overrides.yaml +++ b/packages/neon/neon_notes/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_notifications/pubspec_overrides.yaml b/packages/neon/neon_notifications/pubspec_overrides.yaml index 3bec69c6..d5cb1778 100644 --- a/packages/neon/neon_notifications/pubspec_overrides.yaml +++ b/packages/neon/neon_notifications/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize From dfa7332eead3d50e7fbee801adf2e39f27d3a498 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Tue, 8 Aug 2023 18:06:15 +0200 Subject: [PATCH 3/3] feat(neon_files): Implement file syncing Signed-off-by: jld3103 --- packages/app/pubspec.lock | 8 + packages/neon/neon_files/lib/blocs/files.dart | 53 ++----- packages/neon/neon_files/lib/neon_files.dart | 9 ++ .../neon_files/lib/sync/implementation.dart | 105 +++++++++++++ .../neon/neon_files/lib/sync/mapping.dart | 63 ++++++++ .../neon/neon_files/lib/sync/mapping.g.dart | 23 +++ .../neon/neon_files/lib/sync/sources.dart | 142 ++++++++++++++++++ .../neon/neon_files/lib/widgets/actions.dart | 27 ---- .../lib/widgets/file_list_tile.dart | 2 +- .../neon_files/lib/widgets/file_tile.dart | 94 ++++++++++++ packages/neon/neon_files/pubspec.yaml | 4 + 11 files changed, 464 insertions(+), 66 deletions(-) create mode 100644 packages/neon/neon_files/lib/sync/implementation.dart create mode 100644 packages/neon/neon_files/lib/sync/mapping.dart create mode 100644 packages/neon/neon_files/lib/sync/mapping.g.dart create mode 100644 packages/neon/neon_files/lib/sync/sources.dart create mode 100644 packages/neon/neon_files/lib/widgets/file_tile.dart diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index 143185d2..0ad16aff 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -1397,6 +1397,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" web: dependency: transitive description: diff --git a/packages/neon/neon_files/lib/blocs/files.dart b/packages/neon/neon_files/lib/blocs/files.dart index 5ec0f9d5..cb49ea1c 100644 --- a/packages/neon/neon_files/lib/blocs/files.dart +++ b/packages/neon/neon_files/lib/blocs/files.dart @@ -3,8 +3,6 @@ part of '../neon_files.dart'; abstract interface class FilesBlocEvents { void uploadFile(final PathUri uri, final String localPath); - void syncFile(final PathUri uri); - void openFile(final PathUri uri, final String etag, final String? mimeType); void shareFileNative(final PathUri uri, final String etag); @@ -57,6 +55,21 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta @override BehaviorSubject> tasks = BehaviorSubject>.seeded([]); + @override + Future refresh() async { + await browser.refresh(); + } + + @override + void removeFavorite(final PathUri uri) { + wrapAction( + () async => account.client.webdav.proppatch( + uri, + set: WebDavProp(ocfavorite: 0), + ), + ); + } + @override void addFavorite(final PathUri uri) { wrapAction( @@ -109,21 +122,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta ); } - @override - Future refresh() async { - await browser.refresh(); - } - - @override - void removeFavorite(final PathUri uri) { - wrapAction( - () async => account.client.webdav.proppatch( - uri, - set: WebDavProp(ocfavorite: 0), - ), - ); - } - @override void rename(final PathUri uri, final String name) { wrapAction( @@ -134,27 +132,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta ); } - @override - void syncFile(final PathUri uri) { - wrapAction( - () async { - final file = File( - p.joinAll([ - await NeonPlatform.instance.userAccessibleAppDataPath, - account.humanReadableID, - 'files', - ...uri.pathSegments, - ]), - ); - if (!file.parent.existsSync()) { - file.parent.createSync(recursive: true); - } - await _downloadFile(uri, file); - }, - disableTimeout: true, - ); - } - @override void uploadFile(final PathUri uri, final String localPath) { wrapAction( diff --git a/packages/neon/neon_files/lib/neon_files.dart b/packages/neon/neon_files/lib/neon_files.dart index 31882f28..ab1a24bc 100644 --- a/packages/neon/neon_files/lib/neon_files.dart +++ b/packages/neon/neon_files/lib/neon_files.dart @@ -39,17 +39,20 @@ import 'package:neon/models.dart'; import 'package:neon/platform.dart'; import 'package:neon/settings.dart'; import 'package:neon/sort_box.dart'; +import 'package:neon/sync.dart'; import 'package:neon/theme.dart'; import 'package:neon/utils.dart'; import 'package:neon/widgets.dart'; import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/routes.dart'; +import 'package:neon_files/sync/mapping.dart'; import 'package:neon_files/widgets/file_list_tile.dart'; import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/nextcloud.dart'; import 'package:open_file/open_file.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:queue/queue.dart'; import 'package:rxdart/rxdart.dart'; import 'package:share_plus/share_plus.dart'; @@ -65,9 +68,12 @@ part 'options.dart'; part 'pages/details.dart'; part 'pages/main.dart'; part 'sort/files.dart'; +part 'sync/implementation.dart'; +part 'sync/sources.dart'; part 'utils/task.dart'; part 'widgets/browser_view.dart'; part 'widgets/file_preview.dart'; +part 'widgets/file_tile.dart'; part 'widgets/navigator.dart'; class FilesApp extends AppImplementation { @@ -94,6 +100,9 @@ class FilesApp extends AppImplementation { @override final Widget page = const FilesMainPage(); + @override + final FilesSync syncImplementation = const FilesSync(); + @override final RouteBase route = $filesAppRoute; } diff --git a/packages/neon/neon_files/lib/sync/implementation.dart b/packages/neon/neon_files/lib/sync/implementation.dart new file mode 100644 index 00000000..902c9727 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/implementation.dart @@ -0,0 +1,105 @@ +part of '../neon_files.dart'; + +@immutable +class FilesSync implements SyncImplementation { + const FilesSync(); + + @override + String get appId => AppIDs.files; + + @override + Future getSources(final Account account, final FilesSyncMapping mapping) async { + // This shouldn't be necessary, but it sadly is because of https://github.com/flutter/flutter/issues/25659. + // Alternative would be to use https://pub.dev/packages/shared_storage, + // but to be efficient we'd need https://github.com/alexrintt/shared-storage/issues/91 + // or copy the files to the app cache (which is also not optimal). + if (Platform.isAndroid && !await Permission.manageExternalStorage.request().isGranted) { + throw const MissingPermissionException(Permission.manageExternalStorage); + } + return FilesSyncSources( + account.client, + mapping.remotePath, + mapping.localPath, + ); + } + + @override + Map serializeMapping(final FilesSyncMapping mapping) => mapping.toJson(); + + @override + FilesSyncMapping deserializeMapping(final Map json) => FilesSyncMapping.fromJson(json); + + @override + Future addMapping(final BuildContext context, final Account account) async { + final accountsBloc = NeonProvider.of(context); + final appsBloc = accountsBloc.getAppsBlocFor(account); + final filesBloc = appsBloc.getAppBlocByID(AppIDs.files)! as FilesBloc; + final filesBrowserBloc = filesBloc.getNewFilesBrowserBloc(); + + final remotePath = await showDialog( + context: context, + builder: (final context) => FilesChooseFolderDialog( + bloc: filesBrowserBloc, + filesBloc: filesBloc, + originalPath: PathUri.cwd(), + ), + ); + filesBrowserBloc.dispose(); + if (remotePath == null) { + return null; + } + + final localPath = await FileUtils.pickDirectory(); + if (localPath == null) { + return null; + } + if (!context.mounted) { + return null; + } + + return FilesSyncMapping( + appId: AppIDs.files, + accountId: account.id, + remotePath: remotePath, + localPath: Directory(localPath), + journal: SyncJournal(), + ); + } + + @override + String getMappingDisplayTitle(final FilesSyncMapping mapping) => mapping.remotePath.toString(); + + @override + String getMappingDisplaySubtitle(final FilesSyncMapping mapping) => mapping.localPath.path; + + @override + String getMappingId(final FilesSyncMapping mapping) => + '${Uri.encodeComponent(mapping.remotePath.toString())}-${Uri.encodeComponent(mapping.localPath.path)}'; + + @override + Widget getConflictDetailsLocal(final BuildContext context, final FileSystemEntity object) { + final stat = object.statSync(); + return FilesFileTile( + showFullPath: true, + filesBloc: NeonProvider.of(context), + details: FileDetails( + uri: PathUri.parse(object.path), + size: stat.size, + etag: '', + mimeType: '', + lastModified: stat.modified, + hasPreview: false, + isFavorite: false, + ), + ); + } + + @override + Widget getConflictDetailsRemote(final BuildContext context, final WebDavFile object) => FilesFileTile( + showFullPath: true, + filesBloc: NeonProvider.of(context), + details: FileDetails.fromWebDav( + file: object, + ), + ); +} diff --git a/packages/neon/neon_files/lib/sync/mapping.dart b/packages/neon/neon_files/lib/sync/mapping.dart new file mode 100644 index 00000000..8d29a668 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/mapping.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:neon/sync.dart'; +import 'package:nextcloud/webdav.dart' as webdav; +import 'package:nextcloud/webdav.dart'; +import 'package:universal_io/io.dart'; +import 'package:watcher/watcher.dart'; + +part 'mapping.g.dart'; + +@JsonSerializable() +class FilesSyncMapping implements SyncMapping { + FilesSyncMapping({ + required this.accountId, + required this.appId, + required this.journal, + required this.remotePath, + required this.localPath, + }); + + factory FilesSyncMapping.fromJson(final Map json) => _$FilesSyncMappingFromJson(json); + Map toJson() => _$FilesSyncMappingToJson(this); + + @override + final String accountId; + + @override + final String appId; + + @override + final SyncJournal journal; + + final PathUri remotePath; + + @JsonKey( + fromJson: _directoryFromJson, + toJson: _directoryToJson, + ) + final Directory localPath; + + static Directory _directoryFromJson(final String value) => Directory(value); + static String _directoryToJson(final Directory value) => value.path; + + StreamSubscription? _subscription; + + @override + void watch(final void Function() onUpdated) { + debugPrint('Watching file changes: $localPath'); + _subscription ??= DirectoryWatcher(localPath.path).events.listen( + (final event) { + debugPrint('Registered file change: ${event.path} ${event.type}'); + onUpdated(); + }, + ); + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + } +} diff --git a/packages/neon/neon_files/lib/sync/mapping.g.dart b/packages/neon/neon_files/lib/sync/mapping.g.dart new file mode 100644 index 00000000..e29f1479 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/mapping.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mapping.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FilesSyncMapping _$FilesSyncMappingFromJson(Map json) => FilesSyncMapping( + accountId: json['accountId'] as String, + appId: json['appId'] as String, + journal: SyncJournal.fromJson(json['journal'] as Map), + remotePath: Uri.parse(json['remotePath'] as String), + localPath: FilesSyncMapping._directoryFromJson(json['localPath'] as String), + ); + +Map _$FilesSyncMappingToJson(FilesSyncMapping instance) => { + 'accountId': instance.accountId, + 'appId': instance.appId, + 'journal': instance.journal, + 'remotePath': instance.remotePath.toString(), + 'localPath': FilesSyncMapping._directoryToJson(instance.localPath), + }; diff --git a/packages/neon/neon_files/lib/sync/sources.dart b/packages/neon/neon_files/lib/sync/sources.dart new file mode 100644 index 00000000..37e37fb8 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/sources.dart @@ -0,0 +1,142 @@ +part of '../neon_files.dart'; + +class FilesSyncSources implements SyncSources { + FilesSyncSources( + final NextcloudClient client, + final PathUri webdavBaseDir, + final Directory ioBaseDir, + ) : sourceA = FilesSyncSourceWebDavFile(client, webdavBaseDir), + sourceB = FilesSyncSourceFileSystemEntity(client, ioBaseDir); + + @override + final SyncSource sourceA; + + @override + final SyncSource sourceB; + + @override + SyncConflictSolution? findSolution(final SyncObject objectA, final SyncObject objectB) { + if (objectA.data.isDirectory && objectB.data is Directory) { + return SyncConflictSolution.overwriteA; + } + + return null; + } +} + +class FilesSyncSourceWebDavFile implements SyncSource { + FilesSyncSourceWebDavFile( + this.client, + this.baseDir, + ); + + /// [NextcloudClient] used by the WebDAV part. + final NextcloudClient client; + + /// Base directory on the WebDAV server. + final PathUri baseDir; + + final props = WebDavPropWithoutValues.fromBools( + davgetetag: true, + davgetlastmodified: true, + nchaspreview: true, + ocsize: true, + ocfavorite: true, + ); + + PathUri _uri(final SyncObject object) => baseDir.join(PathUri.parse(object.id)); + + @override + Future>> listObjects() async => (await client.webdav.propfind( + baseDir, + prop: props, + depth: WebDavDepth.infinity, + )) + .toWebDavFiles() + .sublist(1) + .map( + (final file) => ( + id: file.path.pathSegments.sublist(baseDir.pathSegments.length).join('/'), + data: file, + ), + ) + .toList(); + + @override + Future getObjectETag(final SyncObject object) async => + object.data.isDirectory ? '' : object.data.etag!; + + @override + Future> writeObject(final SyncObject object) async { + if (object.data is File) { + final stat = await object.data.stat(); + await client.webdav.putFile( + object.data as File, + stat, + _uri(object), + lastModified: stat.modified, + ); + } else if (object.data is Directory) { + await client.webdav.mkcol(_uri(object)); + } else { + throw Exception('Unable to sync FileSystemEntity of type ${object.data.runtimeType}'); + } + return ( + id: object.id, + data: (await client.webdav.propfind( + _uri(object), + prop: props, + depth: WebDavDepth.zero, + )) + .toWebDavFiles() + .single, + ); + } + + @override + Future deleteObject(final SyncObject object) async => client.webdav.delete(_uri(object)); +} + +class FilesSyncSourceFileSystemEntity implements SyncSource { + FilesSyncSourceFileSystemEntity( + this.client, + this.baseDir, + ); + + /// [NextcloudClient] used by the WebDAV part. + final NextcloudClient client; + + /// Base directory on the local filesystem. + final Directory baseDir; + + @override + Future>> listObjects() async => baseDir.listSync(recursive: true).map( + (final e) { + var path = p.relative(e.path, from: baseDir.path); + if (path.endsWith('/')) { + path = path.substring(0, path.length - 1); + } + return (id: path, data: e); + }, + ).toList(); + + @override + Future getObjectETag(final SyncObject object) async => + object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString(); + + @override + Future> writeObject(final SyncObject object) async { + if (object.data.isDirectory) { + final dir = Directory(p.join(baseDir.path, object.id))..createSync(); + return (id: object.id, data: dir); + } else { + final file = File(p.join(baseDir.path, object.id)); + await client.webdav.getFile(object.data.path, file); + await file.setLastModified(object.data.lastModified!); + return (id: object.id, data: file); + } + } + + @override + Future deleteObject(final SyncObject object) async => object.data.delete(); +} diff --git a/packages/neon/neon_files/lib/widgets/actions.dart b/packages/neon/neon_files/lib/widgets/actions.dart index bbe1656b..a01a6c17 100644 --- a/packages/neon/neon_files/lib/widgets/actions.dart +++ b/packages/neon/neon_files/lib/widgets/actions.dart @@ -1,4 +1,3 @@ -import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:neon/platform.dart'; import 'package:neon/utils.dart'; @@ -16,7 +15,6 @@ class FileActions extends StatelessWidget { Future onSelected(final BuildContext context, final FilesFileAction action) async { final bloc = NeonProvider.of(context); - final browserBloc = bloc.browser; switch (action) { case FilesFileAction.share: bloc.shareFileNative(details.uri, details.etag!); @@ -85,23 +83,6 @@ class FileActions extends StatelessWidget { if (result != null) { bloc.copy(details.uri, result.join(PathUri.parse(details.name))); } - case FilesFileAction.sync: - if (!context.mounted) { - return; - } - final sizeWarning = browserBloc.options.downloadSizeWarning.value; - if (sizeWarning != null && details.size != null && details.size! > sizeWarning) { - if (!(await showConfirmationDialog( - context, - FilesLocalizations.of(context).downloadConfirmSizeWarning( - filesize(sizeWarning), - filesize(details.size), - ), - ))) { - return; - } - } - bloc.syncFile(details.uri); case FilesFileAction.delete: if (!context.mounted) { return; @@ -152,13 +133,6 @@ class FileActions extends StatelessWidget { value: FilesFileAction.copy, child: Text(FilesLocalizations.of(context).actionCopy), ), - // TODO: https://github.com/provokateurin/nextcloud-neon/issues/4 - if (!details.isDirectory) ...[ - PopupMenuItem( - value: FilesFileAction.sync, - child: Text(FilesLocalizations.of(context).actionSync), - ), - ], PopupMenuItem( value: FilesFileAction.delete, child: Text(FilesLocalizations.of(context).actionDelete), @@ -175,6 +149,5 @@ enum FilesFileAction { rename, move, copy, - sync, delete, } diff --git a/packages/neon/neon_files/lib/widgets/file_list_tile.dart b/packages/neon/neon_files/lib/widgets/file_list_tile.dart index 5d9a04b2..6b98bdfb 100644 --- a/packages/neon/neon_files/lib/widgets/file_list_tile.dart +++ b/packages/neon/neon_files/lib/widgets/file_list_tile.dart @@ -128,7 +128,7 @@ class _FileIcon extends StatelessWidget { child: Icon( Icons.star, size: smallIconSize, - color: Colors.yellow, + color: NcColors.starredColor, ), ), ], diff --git a/packages/neon/neon_files/lib/widgets/file_tile.dart b/packages/neon/neon_files/lib/widgets/file_tile.dart new file mode 100644 index 00000000..d988dfb0 --- /dev/null +++ b/packages/neon/neon_files/lib/widgets/file_tile.dart @@ -0,0 +1,94 @@ +part of '../neon_files.dart'; + +class FilesFileTile extends StatelessWidget { + const FilesFileTile({ + required this.filesBloc, + required this.details, + this.trailing, + this.onTap, + this.uploadProgress, + this.downloadProgress, + this.showFullPath = false, + super.key, + }); + + final FilesBloc filesBloc; + final FileDetails details; + final Widget? trailing; + final GestureTapCallback? onTap; + final int? uploadProgress; + final int? downloadProgress; + final bool showFullPath; + + @override + Widget build(final BuildContext context) { + Widget icon = Center( + child: uploadProgress != null || downloadProgress != null + ? Column( + children: [ + Icon( + uploadProgress != null ? MdiIcons.upload : MdiIcons.download, + color: Theme.of(context).colorScheme.primary, + ), + LinearProgressIndicator( + value: (uploadProgress ?? downloadProgress)! / 100, + ), + ], + ) + : FilePreview( + bloc: filesBloc, + details: details, + withBackground: true, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + ); + if (details.isFavorite ?? false) { + icon = Stack( + children: [ + icon, + const Align( + alignment: Alignment.bottomRight, + child: Icon( + Icons.star, + size: 14, + color: NcColors.starredColor, + ), + ), + ], + ); + } + + return ListTile( + onTap: onTap, + title: Text( + showFullPath ? details.uri.path : details.name, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + if (details.lastModified != null) ...[ + RelativeTime( + date: details.lastModified!, + ), + ], + if (details.size != null && details.size! > 0) ...[ + const SizedBox( + width: 10, + ), + Text( + filesize(details.size, 1), + style: DefaultTextStyle.of(context).style.copyWith( + color: Colors.grey, + ), + ), + ], + ], + ), + leading: SizedBox.square( + dimension: 40, + child: icon, + ), + trailing: trailing, + ); + } +} diff --git a/packages/neon/neon_files/pubspec.yaml b/packages/neon/neon_files/pubspec.yaml index 75cab273..2128981f 100644 --- a/packages/neon/neon_files/pubspec.yaml +++ b/packages/neon/neon_files/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: go_router: ^12.0.0 image_picker: ^1.0.0 intl: ^0.18.0 + json_annotation: ^4.8.1 neon: git: url: https://github.com/nextcloud/neon @@ -33,14 +34,17 @@ dependencies: open_file: ^3.0.0 path: ^1.0.0 path_provider: ^2.0.0 + permission_handler: ^11.0.0 queue: ^3.0.0 rxdart: ^0.27.0 share_plus: ^7.0.0 universal_io: ^2.0.0 + watcher: ^1.1.0 dev_dependencies: build_runner: ^2.4.6 go_router_builder: ^2.3.4 + json_serializable: ^6.7.1 neon_lints: git: url: https://github.com/nextcloud/neon