From f3f82c9963e9be7b39398e15922ddfc71dbc3e54 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 + .../neon_storage/pubspec_overrides.yaml | 2 +- .../packages/synchronize/LICENSE | 1 + .../packages/synchronize/README.md | 3 + .../synchronize/analysis_options.yaml | 5 + .../packages/synchronize/lib/src/action.dart | 60 ++ .../synchronize/lib/src/conflict.dart | 61 ++ .../packages/synchronize/lib/src/journal.dart | 33 ++ .../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 + .../synchronize/pubspec_overrides.yaml | 4 + .../packages/synchronize/test/sync_test.dart | 540 ++++++++++++++++++ 18 files changed, 1118 insertions(+), 1 deletion(-) create mode 120000 packages/neon_framework/packages/synchronize/LICENSE create mode 100644 packages/neon_framework/packages/synchronize/README.md create mode 100644 packages/neon_framework/packages/synchronize/analysis_options.yaml create mode 100644 packages/neon_framework/packages/synchronize/lib/src/action.dart create mode 100644 packages/neon_framework/packages/synchronize/lib/src/conflict.dart create mode 100644 packages/neon_framework/packages/synchronize/lib/src/journal.dart create mode 100644 packages/neon_framework/packages/synchronize/lib/src/journal.g.dart create mode 100644 packages/neon_framework/packages/synchronize/lib/src/journal_entry.dart create mode 100644 packages/neon_framework/packages/synchronize/lib/src/journal_entry.g.dart create mode 100644 packages/neon_framework/packages/synchronize/lib/src/object.dart create mode 100644 packages/neon_framework/packages/synchronize/lib/src/sources.dart create mode 100644 packages/neon_framework/packages/synchronize/lib/src/sync.dart create mode 100644 packages/neon_framework/packages/synchronize/lib/synchronize.dart create mode 100644 packages/neon_framework/packages/synchronize/pubspec.yaml create mode 100644 packages/neon_framework/packages/synchronize/pubspec_overrides.yaml create mode 100644 packages/neon_framework/packages/synchronize/test/sync_test.dart diff --git a/commitlint.yaml b/commitlint.yaml index 6618e1c9b04..c3dec43c572 100644 --- a/commitlint.yaml +++ b/commitlint.yaml @@ -35,5 +35,6 @@ rules: - notifications_push_repository - release - sort_box + - synchronize - talk_app - tool diff --git a/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml b/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml index d4b2d2ff99a..fbf277d4fc5 100644 --- a/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: neon_lints,cookie_store,cookie_store_conformance_tests,dynamite_runtime,nextcloud +# melos_managed_dependency_overrides: cookie_store,cookie_store_conformance_tests,dynamite_runtime,neon_lints,nextcloud dependency_overrides: cookie_store: path: ../../../cookie_store diff --git a/packages/neon_framework/packages/synchronize/LICENSE b/packages/neon_framework/packages/synchronize/LICENSE new file mode 120000 index 00000000000..dc0786b028f --- /dev/null +++ b/packages/neon_framework/packages/synchronize/LICENSE @@ -0,0 +1 @@ +../../assets/BSD-3-Clause.txt \ No newline at end of file diff --git a/packages/neon_framework/packages/synchronize/README.md b/packages/neon_framework/packages/synchronize/README.md new file mode 100644 index 00000000000..97032ba0f56 --- /dev/null +++ b/packages/neon_framework/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/neon_framework/packages/synchronize/analysis_options.yaml b/packages/neon_framework/packages/synchronize/analysis_options.yaml new file mode 100644 index 00000000000..bff1b129f3c --- /dev/null +++ b/packages/neon_framework/packages/synchronize/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:neon_lints/dart.yaml + +custom_lint: + rules: + - avoid_exports: false diff --git a/packages/neon_framework/packages/synchronize/lib/src/action.dart b/packages/neon_framework/packages/synchronize/lib/src/action.dart new file mode 100644 index 00000000000..31f5ca03bef --- /dev/null +++ b/packages/neon_framework/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/neon_framework/packages/synchronize/lib/src/conflict.dart b/packages/neon_framework/packages/synchronize/lib/src/conflict.dart new file mode 100644 index 00000000000..d957d2c86a0 --- /dev/null +++ b/packages/neon_framework/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 ==(Object 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/neon_framework/packages/synchronize/lib/src/journal.dart b/packages/neon_framework/packages/synchronize/lib/src/journal.dart new file mode 100644 index 00000000000..710007cd33d --- /dev/null +++ b/packages/neon_framework/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([Set? entries]) : entries = entries ?? {}; + + /// Deserializes a journal from [json]. + factory SyncJournal.fromJson(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(SyncJournalEntry entry) { + entries + ..remove(entry) + ..add(entry); + } + + @override + String toString() => 'SyncJournal(entries: $entries)'; +} diff --git a/packages/neon_framework/packages/synchronize/lib/src/journal.g.dart b/packages/neon_framework/packages/synchronize/lib/src/journal.g.dart new file mode 100644 index 00000000000..87a172721ae --- /dev/null +++ b/packages/neon_framework/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/neon_framework/packages/synchronize/lib/src/journal_entry.dart b/packages/neon_framework/packages/synchronize/lib/src/journal_entry.dart new file mode 100644 index 00000000000..c07821c4742 --- /dev/null +++ b/packages/neon_framework/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(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 ==(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(String id) => firstWhereOrNull((entry) => entry.id == id); +} diff --git a/packages/neon_framework/packages/synchronize/lib/src/journal_entry.g.dart b/packages/neon_framework/packages/synchronize/lib/src/journal_entry.g.dart new file mode 100644 index 00000000000..67e59deceec --- /dev/null +++ b/packages/neon_framework/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/neon_framework/packages/synchronize/lib/src/object.dart b/packages/neon_framework/packages/synchronize/lib/src/object.dart new file mode 100644 index 00000000000..781affe81a5 --- /dev/null +++ b/packages/neon_framework/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(String id) => firstWhereOrNull((object) => object.id == id); +} diff --git a/packages/neon_framework/packages/synchronize/lib/src/sources.dart b/packages/neon_framework/packages/synchronize/lib/src/sources.dart new file mode 100644 index 00000000000..37ab2186c74 --- /dev/null +++ b/packages/neon_framework/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(SyncObject object); + + /// Writes the given [object]. + FutureOr> writeObject(SyncObject object); + + /// Deletes the given [object]. + FutureOr deleteObject(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(SyncObject objectA, SyncObject objectB); + + @override + String toString() => 'SyncSources<$T1, $T2>(sourceA: $sourceA, sourceB: $sourceB)'; +} diff --git a/packages/neon_framework/packages/synchronize/lib/src/sync.dart b/packages/neon_framework/packages/synchronize/lib/src/sync.dart new file mode 100644 index 00000000000..26ebbe4d1dd --- /dev/null +++ b/packages/neon_framework/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( + SyncSources sources, + SyncJournal journal, { + Map? conflictSolutions, + 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( + SyncSources sources, + SyncJournal journal, + SyncDiff diff, +) async { + for (final action in diff.actions) { + switch (action) { + case SyncActionDeleteFromA(): + await sources.sourceA.deleteObject(action.object as SyncObject); + journal.entries.removeWhere((entry) => entry.id == action.object.id); + case SyncActionDeleteFromB(): + await sources.sourceB.deleteObject(action.object as SyncObject); + journal.entries.removeWhere((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( + SyncSources sources, + SyncJournal journal, { + Map? conflictSolutions, + 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((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(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(List> actions) => + actions..sort((a, b) => a.object.id.compareTo(b.object.id)); diff --git a/packages/neon_framework/packages/synchronize/lib/synchronize.dart b/packages/neon_framework/packages/synchronize/lib/synchronize.dart new file mode 100644 index 00000000000..5b448e17676 --- /dev/null +++ b/packages/neon_framework/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/neon_framework/packages/synchronize/pubspec.yaml b/packages/neon_framework/packages/synchronize/pubspec.yaml new file mode 100644 index 00000000000..bb4606606cb --- /dev/null +++ b/packages/neon_framework/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.9.0 + meta: ^1.0.0 + +dev_dependencies: + build_runner: ^2.4.13 + crypto: ^3.0.5 + json_serializable: ^6.8.0 + neon_lints: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_lints + test: ^1.25.8 diff --git a/packages/neon_framework/packages/synchronize/pubspec_overrides.yaml b/packages/neon_framework/packages/synchronize/pubspec_overrides.yaml new file mode 100644 index 00000000000..0e5d8123538 --- /dev/null +++ b/packages/neon_framework/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/neon_framework/packages/synchronize/test/sync_test.dart b/packages/neon_framework/packages/synchronize/test/sync_test.dart new file mode 100644 index 00000000000..7b563b335b7 --- /dev/null +++ b/packages/neon_framework/packages/synchronize/test/sync_test.dart @@ -0,0 +1,540 @@ +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((key) => (id: key, data: state[key]!)).toList(); + + @override + Future getObjectETag(SyncObject object) async => etagA(object.data.content); + + @override + Future> writeObject(SyncObject object) async { + final wrap = WrapA(object.data.content); + state[object.id] = wrap; + return (id: object.id, data: wrap); + } + + @override + Future deleteObject(SyncObject object) async => state.remove(object.id); +} + +class TestSyncSourceB implements SyncSource { + TestSyncSourceB(this.state); + + final Map state; + + @override + Future>> listObjects() async => state.keys.map((key) => (id: key, data: state[key]!)).toList(); + + @override + Future getObjectETag(SyncObject object) async => etagB(object.data.content); + + @override + Future> writeObject(SyncObject object) async { + final wrap = WrapB(object.data.content); + state[object.id] = wrap; + return (id: object.id, data: wrap); + } + + @override + Future deleteObject(SyncObject object) async => state.remove(object.id); +} + +class TestSyncSources implements SyncSources { + TestSyncSources( + this.sourceA, + this.sourceB, + ); + + factory TestSyncSources.fromState(TestSyncState state) => TestSyncSources( + TestSyncSourceA(state.stateA), + TestSyncSourceB(state.stateB), + ); + + @override + final SyncSource sourceA; + + @override + final SyncSource sourceB; + + @override + SyncConflictSolution? findSolution(SyncObject objectA, SyncObject objectB) => null; +} + +String etagA(String content) => sha1.convert(utf8.encode('A$content')).toString(); + +String etagB(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 26d710d6b8854819cc5a71e357a5bb23d0966eae Mon Sep 17 00:00:00 2001 From: jld3103 Date: Tue, 8 Aug 2023 18:05:51 +0200 Subject: [PATCH 2/3] feat(neon_framework): Implement syncing Signed-off-by: jld3103 --- packages/neon_framework/example/pubspec.lock | 7 + .../example/pubspec_overrides.yaml | 4 +- packages/neon_framework/lib/l10n/en.arb | 42 ++- .../lib/l10n/localizations.dart | 96 ++++++ .../lib/l10n/localizations_en.dart | 54 ++++ packages/neon_framework/lib/neon.dart | 6 + .../neon_framework/lib/src/blocs/apps.dart | 9 + .../neon_framework/lib/src/blocs/sync.dart | 290 ++++++++++++++++++ .../lib/src/models/app_implementation.dart | 5 + .../neon_framework/lib/src/pages/sync.dart | 132 ++++++++ .../lib/src/pages/sync_mapping_settings.dart | 73 +++++ packages/neon_framework/lib/src/router.dart | 16 + packages/neon_framework/lib/src/router.g.dart | 20 ++ .../widgets/custom_settings_tile.dart | 3 + .../neon_framework/lib/src/storage/keys.dart | 3 + .../lib/src/sync/models/conflicts.dart | 18 ++ .../lib/src/sync/models/implementation.dart | 37 +++ .../lib/src/sync/models/mapping.dart | 23 ++ .../resolve_sync_conflicts_dialog.dart | 115 +++++++ .../src/sync/widgets/sync_conflict_card.dart | 49 +++ .../lib/src/utils/file_utils.dart | 21 ++ .../lib/src/utils/global_popups.dart | 40 ++- .../lib/src/utils/sync_mapping_options.dart | 31 ++ .../widgets/adaptive_widgets/list_tile.dart | 14 + .../lib/src/widgets/drawer.dart | 27 ++ .../lib/src/widgets/sync_status_icon.dart | 44 +++ packages/neon_framework/lib/sync.dart | 4 + packages/neon_framework/lib/utils.dart | 1 + .../account_repository/pubspec_overrides.yaml | 4 +- .../dashboard_app/pubspec_overrides.yaml | 4 +- .../packages/files_app/pubspec_overrides.yaml | 4 +- .../packages/news_app/pubspec_overrides.yaml | 4 +- .../packages/notes_app/pubspec_overrides.yaml | 4 +- .../notifications_app/pubspec_overrides.yaml | 4 +- .../pubspec_overrides.yaml | 4 +- .../packages/talk_app/pubspec_overrides.yaml | 4 +- packages/neon_framework/pubspec.yaml | 4 + .../neon_framework/pubspec_overrides.yaml | 4 +- 38 files changed, 1212 insertions(+), 12 deletions(-) create mode 100644 packages/neon_framework/lib/src/blocs/sync.dart create mode 100644 packages/neon_framework/lib/src/pages/sync.dart create mode 100644 packages/neon_framework/lib/src/pages/sync_mapping_settings.dart create mode 100644 packages/neon_framework/lib/src/sync/models/conflicts.dart create mode 100644 packages/neon_framework/lib/src/sync/models/implementation.dart create mode 100644 packages/neon_framework/lib/src/sync/models/mapping.dart create mode 100644 packages/neon_framework/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart create mode 100644 packages/neon_framework/lib/src/sync/widgets/sync_conflict_card.dart create mode 100644 packages/neon_framework/lib/src/utils/file_utils.dart create mode 100644 packages/neon_framework/lib/src/utils/sync_mapping_options.dart create mode 100644 packages/neon_framework/lib/src/widgets/sync_status_icon.dart create mode 100644 packages/neon_framework/lib/sync.dart diff --git a/packages/neon_framework/example/pubspec.lock b/packages/neon_framework/example/pubspec.lock index 75f65622b86..3f3eaeed665 100644 --- a/packages/neon_framework/example/pubspec.lock +++ b/packages/neon_framework/example/pubspec.lock @@ -1409,6 +1409,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronize: + dependency: "direct overridden" + description: + path: "../packages/synchronize" + relative: true + source: path + version: "1.0.0" synchronized: dependency: transitive description: diff --git a/packages/neon_framework/example/pubspec_overrides.yaml b/packages/neon_framework/example/pubspec_overrides.yaml index 3839f558d53..6c9b32c9718 100644 --- a/packages/neon_framework/example/pubspec_overrides.yaml +++ b/packages/neon_framework/example/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dashboard_app,dynamite_runtime,files_app,interceptor_http_client,neon_framework,neon_http_client,neon_lints,news_app,nextcloud,notes_app,notifications_app,sort_box,talk_app +# melos_managed_dependency_overrides: account_repository,cookie_store,dashboard_app,dynamite_runtime,files_app,interceptor_http_client,neon_framework,neon_http_client,neon_lints,news_app,nextcloud,notes_app,notifications_app,sort_box,synchronize,talk_app dependency_overrides: account_repository: path: ../packages/account_repository @@ -28,5 +28,7 @@ dependency_overrides: path: ../packages/notifications_app sort_box: path: ../packages/sort_box + synchronize: + path: ../packages/synchronize talk_app: path: ../packages/talk_app diff --git a/packages/neon_framework/lib/l10n/en.arb b/packages/neon_framework/lib/l10n/en.arb index bb49cb49ea3..6b921730ca3 100644 --- a/packages/neon_framework/lib/l10n/en.arb +++ b/packages/neon_framework/lib/l10n/en.arb @@ -104,6 +104,9 @@ "actionContinue": "Continue", "actionCancel": "Cancel", "actionDone": "Done", + "actionPrevious": "Previous", + "actionNext": "Next", + "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.", @@ -294,5 +297,42 @@ "userStatusClearAtThisWeek": "This week", "userStatusActionClear": "Clear status", "userStatusStatusMessage": "Status message", - "userStatusOnlineStatus": "Online status" + "userStatusOnlineStatus": "Online status", + "sync": "Synchronization", + "syncOptionsNoSynchronizations": "No {type} synchronizations", + "@syncOptionsNoSynchronizations": { + "placeholders": { + "type": { + "type": "String" + } + } + }, + "syncOptionsAdd": "Add {type} synchronization", + "@syncOptionsAdd": { + "placeholders": { + "type": { + "type": "String" + } + } + }, + "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_framework/lib/l10n/localizations.dart b/packages/neon_framework/lib/l10n/localizations.dart index 0b6dafc8335..764cd304ad4 100644 --- a/packages/neon_framework/lib/l10n/localizations.dart +++ b/packages/neon_framework/lib/l10n/localizations.dart @@ -337,6 +337,24 @@ abstract class NeonLocalizations { /// **'Done'** String get actionDone; + /// 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 @actionFinish. + /// + /// In en, this message translates to: + /// **'Finish'** + String get actionFinish; + /// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications. /// /// In en, this message translates to: @@ -870,6 +888,84 @@ abstract class NeonLocalizations { /// In en, this message translates to: /// **'Online status'** String get userStatusOnlineStatus; + + /// No description provided for @sync. + /// + /// In en, this message translates to: + /// **'Synchronization'** + String get sync; + + /// No description provided for @syncOptionsNoSynchronizations. + /// + /// In en, this message translates to: + /// **'No {type} synchronizations'** + String syncOptionsNoSynchronizations(String type); + + /// No description provided for @syncOptionsAdd. + /// + /// In en, this message translates to: + /// **'Add {type} synchronization'** + String syncOptionsAdd(String type); + + /// 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_framework/lib/l10n/localizations_en.dart b/packages/neon_framework/lib/l10n/localizations_en.dart index d8dae1a560f..57d4daaa279 100644 --- a/packages/neon_framework/lib/l10n/localizations_en.dart +++ b/packages/neon_framework/lib/l10n/localizations_en.dart @@ -162,6 +162,15 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get actionDone => 'Done'; + @override + String get actionPrevious => 'Previous'; + + @override + String get actionNext => 'Next'; + + @override + String get actionFinish => 'Finish'; + @override String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; @@ -507,4 +516,49 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get userStatusOnlineStatus => 'Online status'; + + @override + String get sync => 'Synchronization'; + + @override + String syncOptionsNoSynchronizations(String type) { + return 'No $type synchronizations'; + } + + @override + String syncOptionsAdd(String type) { + return 'Add $type 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_framework/lib/neon.dart b/packages/neon_framework/lib/neon.dart index c141bd6a412..afd753cd11b 100644 --- a/packages/neon_framework/lib/neon.dart +++ b/packages/neon_framework/lib/neon.dart @@ -13,6 +13,7 @@ import 'package:neon_framework/src/blocs/accounts.dart'; import 'package:neon_framework/src/blocs/first_launch.dart'; import 'package:neon_framework/src/blocs/next_push.dart'; import 'package:neon_framework/src/blocs/push_notifications.dart'; +import 'package:neon_framework/src/blocs/sync.dart'; import 'package:neon_framework/src/models/app_implementation.dart'; import 'package:neon_framework/src/models/disposable.dart'; import 'package:neon_framework/src/platform/platform.dart'; @@ -91,6 +92,10 @@ Future runNeon({ accountsSubject: accountsBloc.accounts, globalOptions: globalOptions, ); + final syncBloc = SyncBloc( + accountsBloc, + appImplementations, + ); runApp( MultiProvider( @@ -100,6 +105,7 @@ Future runNeon({ NeonProvider.value(value: accountsBloc), NeonProvider.value(value: firstLaunchBloc), NeonProvider.value(value: nextPushBloc), + NeonProvider.value(value: syncBloc), Provider>( create: (_) => appImplementations, dispose: (_, appImplementations) => appImplementations.disposeAll(), diff --git a/packages/neon_framework/lib/src/blocs/apps.dart b/packages/neon_framework/lib/src/blocs/apps.dart index 6244bc64678..e2e244d5b3a 100644 --- a/packages/neon_framework/lib/src/blocs/apps.dart +++ b/packages/neon_framework/lib/src/blocs/apps.dart @@ -54,6 +54,12 @@ abstract class AppsBloc implements InteractiveBloc { /// If no bloc exists yet a new one will be instantiated and cached in [AppImplementation.blocsCache]. T getAppBloc(AppImplementation appImplementation); + /// 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(String appId); + /// Returns the active [Bloc] for every registered [AppImplementation] wrapped in a Provider. List> get appBlocProviders; } @@ -280,6 +286,9 @@ class _AppsBloc extends InteractiveBloc implements AppsBloc { @override T getAppBloc(AppImplementation appImplementation) => appImplementation.getBloc(account); + @override + T getAppBlocByID(String appId) => allAppImplementations.find(appId).getBloc(account) as T; + @override List> get appBlocProviders => allAppImplementations.map((appImplementation) => appImplementation.blocProvider).toList(); diff --git a/packages/neon_framework/lib/src/blocs/sync.dart b/packages/neon_framework/lib/src/blocs/sync.dart new file mode 100644 index 00000000000..fcc37969b74 --- /dev/null +++ b/packages/neon_framework/lib/src/blocs/sync.dart @@ -0,0 +1,290 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:account_repository/account_repository.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/src/blocs/accounts.dart'; +import 'package:neon_framework/src/models/app_implementation.dart'; +import 'package:neon_framework/src/storage/keys.dart'; +import 'package:neon_framework/src/storage/storage_manager.dart'; +import 'package:neon_framework/src/sync/models/conflicts.dart'; +import 'package:neon_framework/src/sync/models/implementation.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; +import 'package:neon_framework/src/utils/findable.dart'; +import 'package:neon_framework/src/utils/sync_mapping_options.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:synchronize/synchronize.dart'; + +sealed class SyncBloc implements InteractiveBloc { + factory SyncBloc( + AccountsBloc accountsBloc, + Iterable appImplementations, + ) => + _SyncBloc( + accountsBloc, + appImplementations, + ); + + /// Adds a new [mapping] that will be synced. + Future addMapping(SyncMapping mapping); + + /// Removes an existing [mapping] that will no longer be synced. + Future removeMapping(SyncMapping mapping); + + /// Explicitly trigger a sync for the [mapping]. + /// [solutions] can be use to apply solutions for conflicts. + Future syncMapping( + SyncMapping mapping, { + Map solutions = const {}, + }); + + /// Map of [SyncMapping]s and their [SyncMappingStatus]es + BehaviorSubject, SyncMappingStatus>> get mappingStatuses; + + /// Stream of conflicts that have arisen during syncing. + Stream> get conflicts; + + SyncMappingOptions getSyncMappingOptionsFor(SyncMapping mapping); +} + +class _SyncBloc extends InteractiveBloc implements SyncBloc { + _SyncBloc( + this.accountsBloc, + Iterable appImplementations, + ) { + _syncImplementations = appImplementations.map((app) => app.syncImplementation).whereNotNull(); + _timer = TimerBloc().registerTimer(const Duration(minutes: 1), refresh); + + _loadMappings(); + mappingStatuses.value.keys.forEach(_watchMapping); + unawaited(refresh()); + } + + @override + final log = Logger('SyncBloc'); + + final AccountsBloc accountsBloc; + static final _storage = NeonStorage().singleValueStore(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(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(SyncMapping mapping) async { + debugPrint('Removing mapping: $mapping'); + mappingStatuses.add(Map.fromEntries(mappingStatuses.value.entries.where((m) => m.key != mapping))); + mapping.dispose(); + await _saveMappings(); + } + + @override + Future syncMapping( + SyncMapping mapping, { + Map solutions = const {}, + }) async { + debugPrint('Syncing mapping: $mapping'); + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: SyncMappingStatus.incomplete, + }); + + final account = accountsBloc.accountByID(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((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(SyncMapping mapping) async { + final account = accountsBloc.accountByID(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( + Account account, + 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 = >[]; + + final value = _storage.getString(); + if (value != null && value.isNotEmpty) { + final serializedMappings = (json.decode(value) as Map) + .map((key, value) => MapEntry(key, (value as List).map((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(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((_) async { + await _updateMapping(mapping); + }); + + _watchControllers[syncImplementation.getMappingId(mapping)] = controller; + + mapping.watch(() { + controller.add(null); + }); + } + + @override + SyncMappingOptions getSyncMappingOptionsFor(SyncMapping mapping) { + final syncImplementation = _syncImplementations.find(mapping.appId); + final id = syncImplementation.getGlobalUniqueMappingId(mapping); + return _syncMappingOptions[id] ??= SyncMappingOptions( + NeonStorage().settingsStore(StorageKeys.sync, id), + ); + } +} diff --git a/packages/neon_framework/lib/src/models/app_implementation.dart b/packages/neon_framework/lib/src/models/app_implementation.dart index c9f7cf1af34..1b18bbf066b 100644 --- a/packages/neon_framework/lib/src/models/app_implementation.dart +++ b/packages/neon_framework/lib/src/models/app_implementation.dart @@ -11,6 +11,8 @@ import 'package:neon_framework/src/models/account_cache.dart'; import 'package:neon_framework/src/models/disposable.dart'; import 'package:neon_framework/src/settings/models/options_collection.dart'; import 'package:neon_framework/src/storage/keys.dart'; +import 'package:neon_framework/src/sync/models/implementation.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; import 'package:neon_framework/src/utils/findable.dart'; import 'package:neon_framework/src/utils/provider.dart'; import 'package:neon_framework/src/widgets/drawer_destination.dart'; @@ -93,6 +95,9 @@ abstract class AppImplementation, 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_framework/lib/src/pages/sync.dart b/packages/neon_framework/lib/src/pages/sync.dart new file mode 100644 index 00000000000..d00a45a918d --- /dev/null +++ b/packages/neon_framework/lib/src/pages/sync.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/src/blocs/accounts.dart'; +import 'package:neon_framework/src/blocs/sync.dart'; +import 'package:neon_framework/src/pages/sync_mapping_settings.dart'; +import 'package:neon_framework/src/settings/widgets/custom_settings_tile.dart'; +import 'package:neon_framework/src/settings/widgets/settings_category.dart'; +import 'package:neon_framework/src/theme/dialog.dart'; +import 'package:neon_framework/src/utils/provider.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; +import 'package:neon_framework/src/widgets/sync_status_icon.dart'; +import 'package:neon_framework/src/widgets/user_avatar.dart'; + +class SyncPage extends StatelessWidget { + const SyncPage({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final accountsBloc = NeonProvider.of(context); + final syncBloc = NeonProvider.of(context); + final appImplementations = + NeonProvider.of>(context).where((app) => app.syncImplementation != null); + + final body = StreamBuilder( + stream: syncBloc.mappingStatuses, + builder: (context, mappingStatuses) => !mappingStatuses.hasData + ? const SizedBox.shrink() + : ListView( + children: appImplementations.map( + (appImplementation) { + final appName = NeonLocalizations.of(context).appImplementationName(appImplementation.id); + final appMappingStatuses = mappingStatuses.requireData.entries + .where((mappingStatus) => mappingStatus.key.appId == appImplementation.id); + + return SettingsCategory( + title: Row( + children: [ + appImplementation.buildIcon(), + const SizedBox( + width: 5, + ), + Text(appName), + ], + ), + tiles: [ + if (appMappingStatuses.isEmpty) + CustomSettingsTile( + title: Text(NeonLocalizations.of(context).syncOptionsNoSynchronizations(appName)), + ), + for (final mappingStatus in appMappingStatuses) ...[ + CustomSettingsTile( + title: Text(appImplementation.syncImplementation!.getMappingDisplayTitle(mappingStatus.key)), + subtitle: + Text(appImplementation.syncImplementation!.getMappingDisplaySubtitle(mappingStatus.key)), + leading: NeonUserAvatar( + account: accountsBloc.accountByID(mappingStatus.key.accountId)!, + userStatusBloc: null, + ), + 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: (context) => SyncMappingSettingsPage( + mapping: mappingStatus.key, + ), + ), + ); + }, + ), + ], + CustomSettingsTile( + title: ElevatedButton.icon( + onPressed: () async { + final account = await showDialog( + context: context, + builder: (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(appName)), + ), + ), + ], + ); + }, + ).toList(), + ), + ); + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text(NeonLocalizations.of(context).sync), + ), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: NeonDialogTheme.of(context).constraints, + child: body, + ), + ), + ), + ); + } +} diff --git a/packages/neon_framework/lib/src/pages/sync_mapping_settings.dart b/packages/neon_framework/lib/src/pages/sync_mapping_settings.dart new file mode 100644 index 00000000000..18bea2381ad --- /dev/null +++ b/packages/neon_framework/lib/src/pages/sync_mapping_settings.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/src/blocs/sync.dart'; +import 'package:neon_framework/src/settings/widgets/option_settings_tile.dart'; +import 'package:neon_framework/src/settings/widgets/settings_category.dart'; +import 'package:neon_framework/src/settings/widgets/settings_list.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; +import 'package:neon_framework/src/utils/dialog.dart'; +import 'package:provider/provider.dart'; + +class SyncMappingSettingsPage extends StatelessWidget { + const SyncMappingSettingsPage({ + required this.mapping, + super.key, + }); + + final SyncMapping mapping; + + @override + Widget build(BuildContext context) { + final syncBloc = Provider.of(context, listen: false); + final options = syncBloc.getSyncMappingOptionsFor(mapping); + + return Scaffold( + appBar: AppBar( + title: Text(NeonLocalizations.of(context).sync), + actions: [ + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context: context, + title: 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: context, + title: 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_framework/lib/src/router.dart b/packages/neon_framework/lib/src/router.dart index 4498adc2465..715bd3749bf 100644 --- a/packages/neon_framework/lib/src/router.dart +++ b/packages/neon_framework/lib/src/router.dart @@ -16,6 +16,7 @@ import 'package:neon_framework/src/pages/app_implementation_settings.dart'; import 'package:neon_framework/src/pages/home.dart'; import 'package:neon_framework/src/pages/route_not_found.dart'; import 'package:neon_framework/src/pages/settings.dart'; +import 'package:neon_framework/src/pages/sync.dart'; import 'package:neon_framework/src/utils/findable.dart'; import 'package:neon_framework/src/utils/provider.dart'; @@ -100,6 +101,9 @@ class AccountSettingsRoute extends GoRouteData { ), ], ), + TypedGoRoute( + path: 'sync', + ), ], ) @immutable @@ -202,3 +206,15 @@ class SettingsRoute extends GoRouteData { ); } } + +/// {@template AppRoutes.SyncRoute} +/// Route for the the [SyncPage]. +/// {@endtemplate} +@immutable +class SyncRoute extends GoRouteData { + /// {@macro AppRoutes.SyncRoute} + const SyncRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => const SyncPage(); +} diff --git a/packages/neon_framework/lib/src/router.g.dart b/packages/neon_framework/lib/src/router.g.dart index b3a63e0220b..58a98d25092 100644 --- a/packages/neon_framework/lib/src/router.g.dart +++ b/packages/neon_framework/lib/src/router.g.dart @@ -33,6 +33,10 @@ RouteBase get $homeRoute => GoRouteData.$route( ), ], ), + GoRouteData.$route( + path: 'sync', + factory: $SyncRouteExtension._fromState, + ), ], ); @@ -120,6 +124,22 @@ extension $AccountSettingsRouteExtension on AccountSettingsRoute { void replace(BuildContext context) => context.replace(location); } +extension $SyncRouteExtension on SyncRoute { + static SyncRoute _fromState(GoRouterState state) => const SyncRoute(); + + String get location => GoRouteData.$location( + '/sync', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + T? _$convertMapValue( String key, Map map, diff --git a/packages/neon_framework/lib/src/settings/widgets/custom_settings_tile.dart b/packages/neon_framework/lib/src/settings/widgets/custom_settings_tile.dart index e15fd6513f0..a59e69dbf47 100644 --- a/packages/neon_framework/lib/src/settings/widgets/custom_settings_tile.dart +++ b/packages/neon_framework/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(BuildContext context) { @@ -30,6 +32,7 @@ class CustomSettingsTile extends SettingsTile { leading: leading, trailing: trailing, onTap: onTap, + onLongPress: onLongPress, ); } } diff --git a/packages/neon_framework/lib/src/storage/keys.dart b/packages/neon_framework/lib/src/storage/keys.dart index cf80c4c48bd..dd9deb9876c 100644 --- a/packages/neon_framework/lib/src/storage/keys.dart +++ b/packages/neon_framework/lib/src/storage/keys.dart @@ -22,6 +22,9 @@ enum StorageKeys implements Storable { /// The key for the list of logged in `Account`s. accounts._('accounts-accounts'), + /// The key for the `SyncImplementation`s. + sync._('sync'), + /// The key for the `GlobalOptions`. global._('global'), diff --git a/packages/neon_framework/lib/src/sync/models/conflicts.dart b/packages/neon_framework/lib/src/sync/models/conflicts.dart new file mode 100644 index 00000000000..30889f7ddbc --- /dev/null +++ b/packages/neon_framework/lib/src/sync/models/conflicts.dart @@ -0,0 +1,18 @@ +import 'package:account_repository/account_repository.dart'; +import 'package:neon_framework/src/sync/models/implementation.dart'; +import 'package:neon_framework/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_framework/lib/src/sync/models/implementation.dart b/packages/neon_framework/lib/src/sync/models/implementation.dart new file mode 100644 index 00000000000..16f77b397be --- /dev/null +++ b/packages/neon_framework/lib/src/sync/models/implementation.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:account_repository/account_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; +import 'package:neon_framework/src/utils/findable.dart'; +import 'package:synchronize/synchronize.dart'; + +@immutable +abstract interface class SyncImplementation, T1, T2> implements Findable { + @override + String get id; + + FutureOr> getSources(Account account, S mapping); + + Map serializeMapping(S mapping); + + S deserializeMapping(Map json); + + FutureOr addMapping(BuildContext context, Account account); + + String getMappingDisplayTitle(S mapping); + + String getMappingDisplaySubtitle(S mapping); + + String getMappingId(S mapping); + + Widget getConflictDetailsLocal(BuildContext context, T2 object); + + Widget getConflictDetailsRemote(BuildContext context, T1 object); +} + +extension SyncImplementationGlobalUniqueMappingId + on SyncImplementation, dynamic, dynamic> { + String getGlobalUniqueMappingId(SyncMapping mapping) => + '${mapping.accountId}-${mapping.appId}-${getMappingId(mapping)}'; +} diff --git a/packages/neon_framework/lib/src/sync/models/mapping.dart b/packages/neon_framework/lib/src/sync/models/mapping.dart new file mode 100644 index 00000000000..f95ef7abdb8 --- /dev/null +++ b/packages/neon_framework/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(void Function() onUpdated) {} + + @mustBeOverridden + void dispose() {} + + @override + String toString() => 'SyncMapping(accountId: $accountId, appId: $appId)'; +} + +enum SyncMappingStatus { + unknown, + incomplete, + complete, +} diff --git a/packages/neon_framework/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart b/packages/neon_framework/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart new file mode 100644 index 00000000000..65cdabe2b0e --- /dev/null +++ b/packages/neon_framework/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/src/sync/models/conflicts.dart'; +import 'package:neon_framework/src/sync/widgets/sync_conflict_card.dart'; +import 'package:neon_framework/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(SyncConflictSolution solution) { + setState(() { + _solutions[conflict.id] = solution; + }); + } + + bool get isFirst => _index == 0; + bool get isLast => _index == widget.conflicts.conflicts.length - 1; + + @override + Widget build(BuildContext context) { + final body = Column( + children: [ + Text( + NeonLocalizations.of(context).syncResolveConflictsTitle( + widget.conflicts.conflicts.length, + NeonLocalizations.of(context).appImplementationName(widget.conflicts.implementation.id), + ), + 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_framework/lib/src/sync/widgets/sync_conflict_card.dart b/packages/neon_framework/lib/src/sync/widgets/sync_conflict_card.dart new file mode 100644 index 00000000000..6b42e720519 --- /dev/null +++ b/packages/neon_framework/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(BuildContext context) => Card( + shape: selected + ? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).colorScheme.onSurface, + ), + ) + : 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_framework/lib/src/utils/file_utils.dart b/packages/neon_framework/lib/src/utils/file_utils.dart new file mode 100644 index 00000000000..a71a1b5bee6 --- /dev/null +++ b/packages/neon_framework/lib/src/utils/file_utils.dart @@ -0,0 +1,21 @@ +import 'package:file_picker/file_picker.dart'; + +class FileUtils { + FileUtils._(); + + static Future loadFileWithPickDialog({ + bool withData = false, + bool allowMultiple = false, + 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_framework/lib/src/utils/global_popups.dart b/packages/neon_framework/lib/src/utils/global_popups.dart index 3ed8068e32a..671f7bf336f 100644 --- a/packages/neon_framework/lib/src/utils/global_popups.dart +++ b/packages/neon_framework/lib/src/utils/global_popups.dart @@ -3,14 +3,20 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/src/blocs/accounts.dart'; import 'package:neon_framework/src/blocs/first_launch.dart'; import 'package:neon_framework/src/blocs/next_push.dart'; +import 'package:neon_framework/src/blocs/sync.dart'; import 'package:neon_framework/src/pages/settings.dart'; import 'package:neon_framework/src/platform/platform.dart'; import 'package:neon_framework/src/router.dart'; +import 'package:neon_framework/src/sync/widgets/resolve_sync_conflicts_dialog.dart'; import 'package:neon_framework/src/utils/global_options.dart'; import 'package:neon_framework/src/utils/provider.dart'; import 'package:neon_framework/src/widgets/dialog.dart'; +import 'package:neon_framework/src/widgets/error.dart'; +import 'package:provider/provider.dart'; +import 'package:synchronize/synchronize.dart'; /// Singleton class managing global popups. @internal @@ -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((_) { - assert(context.mounted, 'Context should be mounted'); + assert(_context.mounted, 'Context should be mounted'); if (!globalOptions.pushNotificationsEnabled.enabled) { return; } @@ -95,5 +102,36 @@ class GlobalPopups { }), ]); } + _subscriptions.addAll([ + syncBloc.errors.listen((error) { + if (!_context.mounted) { + return; + } + + NeonError.showSnackbar(_context, error); + }), + syncBloc.conflicts.listen((conflicts) async { + if (!_context.mounted) { + return; + } + + final providers = NeonProvider.of(_context).getAppsBlocFor(conflicts.account).appBlocProviders; + final result = await showDialog>( + context: _context, + builder: (context) => MultiProvider( + providers: providers, + child: NeonResolveSyncConflictsDialog(conflicts: conflicts), + ), + ); + if (result == null) { + return; + } + + await syncBloc.syncMapping( + conflicts.mapping, + solutions: result, + ); + }), + ]); } } diff --git a/packages/neon_framework/lib/src/utils/sync_mapping_options.dart b/packages/neon_framework/lib/src/utils/sync_mapping_options.dart new file mode 100644 index 00000000000..56ab710e034 --- /dev/null +++ b/packages/neon_framework/lib/src/utils/sync_mapping_options.dart @@ -0,0 +1,31 @@ +import 'package:meta/meta.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/settings.dart'; +import 'package:neon_framework/src/storage/keys.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: (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_framework/lib/src/widgets/adaptive_widgets/list_tile.dart b/packages/neon_framework/lib/src/widgets/adaptive_widgets/list_tile.dart index f84183689a3..480111e9cfd 100644 --- a/packages/neon_framework/lib/src/widgets/adaptive_widgets/list_tile.dart +++ b/packages/neon_framework/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_framework.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_framework.AdaptiveListTile.enabled} /// Whether this list tile is interactive. /// diff --git a/packages/neon_framework/lib/src/widgets/drawer.dart b/packages/neon_framework/lib/src/widgets/drawer.dart index d66f42652aa..9600066fa0b 100644 --- a/packages/neon_framework/lib/src/widgets/drawer.dart +++ b/packages/neon_framework/lib/src/widgets/drawer.dart @@ -2,9 +2,13 @@ import 'dart:async'; import 'package:built_collection/built_collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/l10n/localizations.dart'; import 'package:neon_framework/models.dart'; +import 'package:neon_framework/src/router.dart'; +import 'package:neon_framework/src/theme/icons.dart'; import 'package:neon_framework/src/utils/provider.dart'; import 'package:neon_framework/src/widgets/drawer_destination.dart'; import 'package:neon_framework/src/widgets/error.dart'; @@ -32,6 +36,21 @@ class _NeonDrawerState extends State { int? _activeApp; late final StreamSubscription>> appImplementationsSubscription; + final _extraDestinations = { + NavigationDrawerDestination( + icon: const Icon(MdiIcons.cloudSync), + label: Builder( + builder: (context) => Text(NeonLocalizations.of(context).sync), + ), + ): (context) => const SyncRoute().go(context), + NavigationDrawerDestination( + icon: Icon(AdaptiveIcons.settings), + label: Builder( + builder: (context) => Text(NeonLocalizations.of(context).settings), + ), + ): (context) => const SettingsRoute().go(context), + }; + @override void initState() { super.initState(); @@ -56,6 +75,13 @@ class _NeonDrawerState extends State { void onAppChange(int index) { Scaffold.maybeOf(context)?.closeDrawer(); + // selected item is not a registered app like the SettingsPage + final appsCount = _apps?.length ?? 0; + if (index >= appsCount) { + _extraDestinations.values.elementAt(index - appsCount)(context); + return; + } + setState(() { _activeApp = index; }); @@ -78,6 +104,7 @@ class _NeonDrawerState extends State { children: [ const NeonDrawerHeader(), ...?appDestinations, + ..._extraDestinations.keys, ], ); diff --git a/packages/neon_framework/lib/src/widgets/sync_status_icon.dart b/packages/neon_framework/lib/src/widgets/sync_status_icon.dart new file mode 100644 index 00000000000..492e4c4b07c --- /dev/null +++ b/packages/neon_framework/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_framework/l10n/localizations.dart'; +import 'package:neon_framework/src/sync/models/mapping.dart'; +import 'package:neon_framework/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(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_framework/lib/sync.dart b/packages/neon_framework/lib/sync.dart new file mode 100644 index 00000000000..dbd946cb05f --- /dev/null +++ b/packages/neon_framework/lib/sync.dart @@ -0,0 +1,4 @@ +export 'package:neon_framework/src/sync/models/conflicts.dart'; +export 'package:neon_framework/src/sync/models/implementation.dart'; +export 'package:neon_framework/src/sync/models/mapping.dart'; +export 'package:synchronize/synchronize.dart'; diff --git a/packages/neon_framework/lib/utils.dart b/packages/neon_framework/lib/utils.dart index b52590aa02b..4f1d1a4f33a 100644 --- a/packages/neon_framework/lib/utils.dart +++ b/packages/neon_framework/lib/utils.dart @@ -3,6 +3,7 @@ export 'package:neon_framework/src/utils/adaptive.dart'; export 'package:neon_framework/src/utils/app_route.dart'; export 'package:neon_framework/src/utils/dialog.dart'; export 'package:neon_framework/src/utils/exceptions.dart'; +export 'package:neon_framework/src/utils/file_utils.dart'; export 'package:neon_framework/src/utils/findable.dart'; export 'package:neon_framework/src/utils/hex_color.dart'; export 'package:neon_framework/src/utils/launch_url.dart'; diff --git a/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml b/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml index 5c2f161f46d..17f0ec1ed09 100644 --- a/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/account_repository/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: cookie_store: path: ../../../cookie_store @@ -16,3 +16,5 @@ dependency_overrides: path: ../../../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon_framework/packages/dashboard_app/pubspec_overrides.yaml b/packages/neon_framework/packages/dashboard_app/pubspec_overrides.yaml index 0abe46d2bdd..da5df148a1d 100644 --- a/packages/neon_framework/packages/dashboard_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/dashboard_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: account_repository: path: ../account_repository @@ -18,3 +18,5 @@ dependency_overrides: path: ../../../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon_framework/packages/files_app/pubspec_overrides.yaml b/packages/neon_framework/packages/files_app/pubspec_overrides.yaml index 0abe46d2bdd..da5df148a1d 100644 --- a/packages/neon_framework/packages/files_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/files_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: account_repository: path: ../account_repository @@ -18,3 +18,5 @@ dependency_overrides: path: ../../../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon_framework/packages/news_app/pubspec_overrides.yaml b/packages/neon_framework/packages/news_app/pubspec_overrides.yaml index 0abe46d2bdd..da5df148a1d 100644 --- a/packages/neon_framework/packages/news_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/news_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: account_repository: path: ../account_repository @@ -18,3 +18,5 @@ dependency_overrides: path: ../../../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon_framework/packages/notes_app/pubspec_overrides.yaml b/packages/neon_framework/packages/notes_app/pubspec_overrides.yaml index 0abe46d2bdd..da5df148a1d 100644 --- a/packages/neon_framework/packages/notes_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/notes_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: account_repository: path: ../account_repository @@ -18,3 +18,5 @@ dependency_overrides: path: ../../../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon_framework/packages/notifications_app/pubspec_overrides.yaml b/packages/neon_framework/packages/notifications_app/pubspec_overrides.yaml index 0abe46d2bdd..da5df148a1d 100644 --- a/packages/neon_framework/packages/notifications_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/notifications_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: account_repository: path: ../account_repository @@ -18,3 +18,5 @@ dependency_overrides: path: ../../../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml b/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml index 0abe46d2bdd..da5df148a1d 100644 --- a/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/notifications_push_repository/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: account_repository: path: ../account_repository @@ -18,3 +18,5 @@ dependency_overrides: path: ../../../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon_framework/packages/talk_app/pubspec_overrides.yaml b/packages/neon_framework/packages/talk_app/pubspec_overrides.yaml index 0abe46d2bdd..da5df148a1d 100644 --- a/packages/neon_framework/packages/talk_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/talk_app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: account_repository: path: ../account_repository @@ -18,3 +18,5 @@ dependency_overrides: path: ../../../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon_framework/pubspec.yaml b/packages/neon_framework/pubspec.yaml index 8536e246e03..08b00e27d63 100644 --- a/packages/neon_framework/pubspec.yaml +++ b/packages/neon_framework/pubspec.yaml @@ -68,6 +68,10 @@ dependencies: sqflite: ^2.3.0 sqflite_common_ffi: ^2.3.2 sqflite_common_ffi_web: ^0.4.2+3 + synchronize: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework/packages/synchronize timezone: ^0.9.4 unifiedpush: ^5.0.0 unifiedpush_android: ^2.0.0 diff --git a/packages/neon_framework/pubspec_overrides.yaml b/packages/neon_framework/pubspec_overrides.yaml index 73b6a2ad440..764f7a38863 100644 --- a/packages/neon_framework/pubspec_overrides.yaml +++ b/packages/neon_framework/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,cookie_store_conformance_tests,dynamite_runtime,interceptor_http_client,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookie_store,cookie_store_conformance_tests,dynamite_runtime,interceptor_http_client,neon_http_client,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: account_repository: path: packages/account_repository @@ -18,3 +18,5 @@ dependency_overrides: path: ../nextcloud sort_box: path: packages/sort_box + synchronize: + path: packages/synchronize From 27b0d2ed25f8ee8342cc0499a9ad36ebf31a8efd 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/files_app/lib/files_app.dart | 4 + .../lib/src/sync/implementation.dart | 109 +++++++++++++ .../files_app/lib/src/sync/mapping.dart | 69 +++++++++ .../files_app/lib/src/sync/mapping.g.dart | 23 +++ .../files_app/lib/src/sync/sources.dart | 145 ++++++++++++++++++ .../files_app/lib/src/utils/dialog.dart | 6 +- .../lib/src/widgets/file_list_tile.dart | 2 +- .../files_app/lib/src/widgets/file_tile.dart | 99 ++++++++++++ .../packages/files_app/pubspec.yaml | 4 + 9 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 packages/neon_framework/packages/files_app/lib/src/sync/implementation.dart create mode 100644 packages/neon_framework/packages/files_app/lib/src/sync/mapping.dart create mode 100644 packages/neon_framework/packages/files_app/lib/src/sync/mapping.g.dart create mode 100644 packages/neon_framework/packages/files_app/lib/src/sync/sources.dart create mode 100644 packages/neon_framework/packages/files_app/lib/src/widgets/file_tile.dart diff --git a/packages/neon_framework/packages/files_app/lib/files_app.dart b/packages/neon_framework/packages/files_app/lib/files_app.dart index 91f79eee655..6a6a8ea57da 100644 --- a/packages/neon_framework/packages/files_app/lib/files_app.dart +++ b/packages/neon_framework/packages/files_app/lib/files_app.dart @@ -8,6 +8,7 @@ import 'package:files_app/src/blocs/files.dart'; import 'package:files_app/src/options.dart'; import 'package:files_app/src/pages/main.dart'; import 'package:files_app/src/routes.dart'; +import 'package:files_app/src/sync/implementation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:neon_framework/models.dart'; @@ -37,6 +38,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_framework/packages/files_app/lib/src/sync/implementation.dart b/packages/neon_framework/packages/files_app/lib/src/sync/implementation.dart new file mode 100644 index 00000000000..2c9414d9b2e --- /dev/null +++ b/packages/neon_framework/packages/files_app/lib/src/sync/implementation.dart @@ -0,0 +1,109 @@ +import 'package:files_app/src/blocs/files.dart'; +import 'package:files_app/src/models/file_details.dart'; +import 'package:files_app/src/sync/mapping.dart'; +import 'package:files_app/src/sync/sources.dart'; +import 'package:files_app/src/utils/dialog.dart'; +import 'package:files_app/src/widgets/file_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/sync.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:nextcloud/files.dart' as files; +import 'package:nextcloud/webdav.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'package:universal_io/io.dart'; + +@immutable +class FilesSync implements SyncImplementation { + const FilesSync(); + + @override + String get id => files.appID; + + @override + Future getSources(Account account, FilesSyncMapping mapping) async { + // This shouldn't be necessary, but it sadly is because of https://github.com/flutter/flutter/issues/25659. + // Alternative would be to use https://pub.dev/packages/shared_storage, + // but to be efficient we'd need https://github.com/alexrintt/shared-storage/issues/91 + // or copy the files to the app cache (which is also not optimal). + if (Platform.isAndroid && !await Permission.manageExternalStorage.request().isGranted) { + throw const MissingPermissionException(Permission.manageExternalStorage); + } + return FilesSyncSources( + account.client, + mapping.remotePath, + mapping.localPath, + ); + } + + @override + Map serializeMapping(FilesSyncMapping mapping) => mapping.toJson(); + + @override + FilesSyncMapping deserializeMapping(Map json) => FilesSyncMapping.fromJson(json); + + @override + Future addMapping(BuildContext context, Account account) async { + final remotePath = await showChooseFolderDialog(context, null); + if (remotePath == null) { + return null; + } + + final localPath = await FileUtils.pickDirectory(); + if (localPath == null) { + return null; + } + if (!context.mounted) { + return null; + } + + return FilesSyncMapping( + appId: files.appID, + accountId: account.id, + remotePath: remotePath, + localPath: Directory(localPath), + journal: SyncJournal(), + ); + } + + @override + String getMappingDisplayTitle(FilesSyncMapping mapping) { + final path = mapping.remotePath.toString(); + return path.substring(0, path.length - 1); + } + + @override + String getMappingDisplaySubtitle(FilesSyncMapping mapping) => mapping.localPath.path; + + @override + String getMappingId(FilesSyncMapping mapping) => + '${Uri.encodeComponent(mapping.remotePath.toString())}-${Uri.encodeComponent(mapping.localPath.path)}'; + + @override + Widget getConflictDetailsLocal(BuildContext context, FileSystemEntity object) { + final stat = object.statSync(); + return FilesFileTile( + showFullPath: true, + filesBloc: NeonProvider.of(context), + details: FileDetails( + uri: PathUri.parse(object.path), + size: stat.size, + etag: '', + mimeType: '', + lastModified: tz.TZDateTime.from(stat.modified.toUtc(), tz.UTC), + isFavorite: false, + blurHash: null, + ), + ); + } + + @override + Widget getConflictDetailsRemote(BuildContext context, WebDavFile object) => FilesFileTile( + showFullPath: true, + filesBloc: NeonProvider.of(context), + details: FileDetails.fromWebDav( + file: object, + ), + ); +} diff --git a/packages/neon_framework/packages/files_app/lib/src/sync/mapping.dart b/packages/neon_framework/packages/files_app/lib/src/sync/mapping.dart new file mode 100644 index 00000000000..14a2a0b3665 --- /dev/null +++ b/packages/neon_framework/packages/files_app/lib/src/sync/mapping.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:neon_framework/sync.dart'; +import 'package:nextcloud/webdav.dart' as webdav; +import 'package:nextcloud/webdav.dart'; +import 'package:universal_io/io.dart'; +import 'package:watcher/watcher.dart'; + +part 'mapping.g.dart'; + +@JsonSerializable() +class FilesSyncMapping implements SyncMapping { + FilesSyncMapping({ + required this.accountId, + required this.appId, + required this.journal, + required this.remotePath, + required this.localPath, + }); + + factory FilesSyncMapping.fromJson(Map json) => _$FilesSyncMappingFromJson(json); + Map toJson() => _$FilesSyncMappingToJson(this); + + @override + final String accountId; + + @override + final String appId; + + @override + final SyncJournal journal; + + @JsonKey( + fromJson: PathUri.parse, + toJson: _pathUriToJson, + ) + final PathUri remotePath; + + static String _pathUriToJson(PathUri uri) => uri.toString(); + + @JsonKey( + fromJson: _directoryFromJson, + toJson: _directoryToJson, + ) + final Directory localPath; + + static Directory _directoryFromJson(String value) => Directory(value); + static String _directoryToJson(Directory value) => value.path; + + StreamSubscription? _subscription; + + @override + void watch(void Function() onUpdated) { + debugPrint('Watching file changes: $localPath'); + _subscription ??= DirectoryWatcher(localPath.path).events.listen( + (event) { + debugPrint('Registered file change: ${event.path} ${event.type}'); + onUpdated(); + }, + ); + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + } +} diff --git a/packages/neon_framework/packages/files_app/lib/src/sync/mapping.g.dart b/packages/neon_framework/packages/files_app/lib/src/sync/mapping.g.dart new file mode 100644 index 00000000000..e8d95d4b3b8 --- /dev/null +++ b/packages/neon_framework/packages/files_app/lib/src/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: PathUri.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': FilesSyncMapping._pathUriToJson(instance.remotePath), + 'localPath': FilesSyncMapping._directoryToJson(instance.localPath), + }; diff --git a/packages/neon_framework/packages/files_app/lib/src/sync/sources.dart b/packages/neon_framework/packages/files_app/lib/src/sync/sources.dart new file mode 100644 index 00000000000..60b8dd78a8a --- /dev/null +++ b/packages/neon_framework/packages/files_app/lib/src/sync/sources.dart @@ -0,0 +1,145 @@ +import 'package:neon_framework/sync.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:nextcloud/webdav.dart'; +import 'package:path/path.dart' as p; +import 'package:universal_io/io.dart'; + +class FilesSyncSources implements SyncSources { + FilesSyncSources( + NextcloudClient client, + PathUri webdavBaseDir, + Directory ioBaseDir, + ) : sourceA = FilesSyncSourceWebDavFile(client, webdavBaseDir), + sourceB = FilesSyncSourceFileSystemEntity(client, ioBaseDir); + + @override + final SyncSource sourceA; + + @override + final SyncSource sourceB; + + @override + SyncConflictSolution? findSolution(SyncObject objectA, 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 = const WebDavPropWithoutValues.fromBools( + davGetetag: true, + davGetlastmodified: true, + ncHasPreview: true, + ocSize: true, + ocFavorite: true, + ); + + PathUri _uri(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( + (file) => ( + id: file.path.pathSegments.sublist(baseDir.pathSegments.length).join('/'), + data: file, + ), + ) + .toList(); + + @override + Future getObjectETag(SyncObject object) async => object.data.isDirectory ? '' : object.data.etag!; + + @override + Future> writeObject(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(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( + (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(SyncObject object) async => + object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString(); + + @override + Future> writeObject(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(SyncObject object) async => object.data.delete(); +} diff --git a/packages/neon_framework/packages/files_app/lib/src/utils/dialog.dart b/packages/neon_framework/packages/files_app/lib/src/utils/dialog.dart index d68105eaa08..1fb6a5ff0ba 100644 --- a/packages/neon_framework/packages/files_app/lib/src/utils/dialog.dart +++ b/packages/neon_framework/packages/files_app/lib/src/utils/dialog.dart @@ -69,15 +69,15 @@ Future showUploadConfirmationDialog( /// Displays a [FilesChooseFolderDialog] to choose a new location for a file with the given [details]. /// /// Returns a future with the new location. -Future showChooseFolderDialog(BuildContext context, FileDetails details) async { +Future showChooseFolderDialog(BuildContext context, FileDetails? details) async { final filesBloc = NeonProvider.of(context); final result = await showDialog( context: context, builder: (context) => FilesChooseFolderDialog( bloc: filesBloc, - uri: details.uri.parent!, - hideUri: details.uri, + uri: details?.uri.parent ?? webdav.PathUri.cwd(), + hideUri: details?.uri, ), ); diff --git a/packages/neon_framework/packages/files_app/lib/src/widgets/file_list_tile.dart b/packages/neon_framework/packages/files_app/lib/src/widgets/file_list_tile.dart index d1b049bef56..0505df1d153 100644 --- a/packages/neon_framework/packages/files_app/lib/src/widgets/file_list_tile.dart +++ b/packages/neon_framework/packages/files_app/lib/src/widgets/file_list_tile.dart @@ -130,7 +130,7 @@ class _FileIcon extends StatelessWidget { child: Icon( AdaptiveIcons.star, size: smallIconSize, - color: Colors.yellow, + color: NcColors.starredColor, ), ), ], diff --git a/packages/neon_framework/packages/files_app/lib/src/widgets/file_tile.dart b/packages/neon_framework/packages/files_app/lib/src/widgets/file_tile.dart new file mode 100644 index 00000000000..e84530ac9dc --- /dev/null +++ b/packages/neon_framework/packages/files_app/lib/src/widgets/file_tile.dart @@ -0,0 +1,99 @@ +import 'package:files_app/src/blocs/files.dart'; +import 'package:files_app/src/models/file_details.dart'; +import 'package:files_app/src/widgets/file_preview.dart'; +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon_framework/theme.dart'; +import 'package:neon_framework/widgets.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(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, + ), + ); + 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_framework/packages/files_app/pubspec.yaml b/packages/neon_framework/packages/files_app/pubspec.yaml index 8b02a7b513e..9e7ecf1995a 100644 --- a/packages/neon_framework/packages/files_app/pubspec.yaml +++ b/packages/neon_framework/packages/files_app/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: go_router: ^14.0.0 image_picker: ^1.0.0 intl: ^0.19.0 + json_annotation: ^4.8.1 logging: ^1.0.0 meta: ^1.0.0 neon_framework: @@ -30,16 +31,19 @@ dependencies: open_filex: ^4.4.0 path: ^1.0.0 path_provider: ^2.0.0 + permission_handler: ^11.0.0 queue: ^3.0.0 rxdart: ^0.28.0 share_plus: ^10.0.0 timezone: ^0.9.4 universal_io: ^2.0.0 + watcher: ^1.1.0 dev_dependencies: build_runner: ^2.4.13 custom_lint: ^0.6.8 go_router_builder: ^2.7.1 + json_serializable: ^6.8.0 neon_lints: git: url: https://github.com/nextcloud/neon