Add device sync: mirror folders/files locally, background WorkManager engine
Build APK / build (push) Successful in 7m33s

- New native sync engine (SyncEngine/SyncWorker/ConflictResolveWorker,
  WorkManager-based) that mirrors selected Nextcloud folders or files to
  app-private local storage, periodically and on-demand ("Sync now"),
  with never-auto-resolved conflict notifications.
- Single files (not just folders) can now be marked "Sync to device".
- Live sync status (syncing/synced/conflicts) pushes from native to Dart
  over a new EventChannel; the persistent header chip/panel now reflects
  device-sync status instead of the WebDAV-refresh loading state, with a
  cloud_off/cloud_sync/cloud_done/cloud_alert icon set and an expandable
  conflicts list with in-app "Keep local"/"Use server" resolution.
  Per-item cloud_done/sync badges show on Files tiles.
- Share/Download short-circuit to the local copy for already-synced
  files instead of a fresh network fetch.
- Settings gained a Device Sync section (per-folder list, "Sync
  everything", Wi-Fi-only toggle, manual "Sync now").
- Fixed two release-only bugs found via on-device testing: Android's
  HttpURLConnection silently rejects the PROPFIND method (switched to
  OkHttp), and R8 was stripping WorkManager's reflection-instantiated
  internals (broadened proguard-rules.pro to keep androidx.work.**
  wholesale rather than chasing individual classes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 00:52:56 -04:00
co-authored by Claude Sonnet 5
parent 722b66f7b1
commit faaec5365f
21 changed files with 2023 additions and 42 deletions
+180
View File
@@ -14,11 +14,13 @@ import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
import '../models/pick_request.dart';
import '../models/saved_account.dart';
import '../models/sync_status.dart';
import '../services/account_store.dart';
import '../services/app_lock_service.dart';
import '../services/login_flow_service.dart';
import '../services/nextcloud_service.dart';
import '../services/pick_intent_service.dart';
import '../services/sync_service.dart';
import '../theme/app_theme.dart';
enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
@@ -97,6 +99,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
static const _prefLoginLockEnabled = 'ui_login_lock_enabled';
static const _prefLockAccountSwitching = 'ui_lock_account_switching';
static const _prefLockHiddenFiles = 'ui_lock_hidden_files';
static const _prefSyncedFolders = 'ui_synced_folders';
static const _prefSyncEverything = 'ui_sync_everything';
static const _prefSyncOnCellular = 'ui_sync_on_cellular';
final Future<SharedPreferences> _prefsFuture =
SharedPreferences.getInstance();
@@ -127,6 +132,23 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
double _bottomBarBlur = 28;
bool _tapTabToScrollTop = true;
// Device sync - which remote paths (files or folders, per account) get
// mirrored locally by the native SyncWorker, and whether its periodic
// background runs are allowed on cellular (default Wi-Fi-only). See
// SyncEngine.kt/server.md.
List<String> _syncedPaths = [];
bool _syncEverything = false;
bool _syncOnCellular = false;
// Live device-sync status, pushed from SyncStatusBus.kt via
// SyncService.statusStream (see _subscribeToSyncStatus) - account-wide,
// not scoped to whatever folder is currently browsed.
bool _isSyncingNow = false;
Set<String> _syncingFileIds = {};
Set<String> _syncedFileIds = {};
List<SyncConflictInfo> _syncConflicts = [];
StreamSubscription<SyncStatusSnapshot>? _syncStatusSub;
// App lock (PIN/biometric via the device's own credential, not our own
// storage - see AppLockService). Global, not per-account: it guards
// access to the app/its accounts, not any one account's content.
@@ -255,6 +277,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
ServerProvider() {
WidgetsBinding.instance.addObserver(this);
_init();
_subscribeToSyncStatus();
}
Future<void> _init() async {
@@ -265,6 +288,29 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
await Future.wait([_loadPreferences(), _restoreSession()]);
}
/// Live device-sync status for the rest of the app's lifetime (not
/// re-subscribed per account switch - each event carries its own
/// `accountId`, so events for an account that isn't currently active are
/// just ignored below). Seeded once via a one-shot snapshot right after
/// every successful login/account-switch instead (see
/// `_applyCredentialsForAccount`), since events only arrive on change.
void _subscribeToSyncStatus() {
_syncStatusSub = SyncService.statusStream.listen((snapshot) {
_applySyncStatus(snapshot);
});
}
void _applySyncStatus(SyncStatusSnapshot snapshot) {
if (snapshot.accountId != null && snapshot.accountId != _activeAccountId) {
return;
}
_isSyncingNow = snapshot.syncing;
_syncingFileIds = snapshot.syncingFileIds;
_syncedFileIds = snapshot.syncedFileIds;
_syncConflicts = snapshot.conflicts;
notifyListeners();
}
/// Starts/stops the periodic folder-listing refresh as the app leaves and
/// returns to the foreground - only relevant under [CachePolicy.interval].
/// Also re-locks the app on backgrounding when login lock is set up - a
@@ -323,6 +369,29 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
bool get lockHiddenFiles => _lockHiddenFiles;
bool get needsUnlock => _loginLockEnabled && !_isUnlocked;
List<String> get syncedPaths => List.unmodifiable(_syncedPaths);
bool get syncEverything => _syncEverything;
bool get syncOnCellular => _syncOnCellular;
bool get isSyncingNow => _isSyncingNow;
List<SyncConflictInfo> get syncConflicts => List.unmodifiable(_syncConflicts);
SyncHeaderStatus get syncHeaderStatus {
if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert;
if (_isSyncingNow) return SyncHeaderStatus.syncing;
if (!_syncEverything && _syncedPaths.isEmpty) return SyncHeaderStatus.off;
return SyncHeaderStatus.done;
}
SyncItemStatus syncStatusFor(NextcloudItem item) {
if (_syncingFileIds.contains(item.id)) return SyncItemStatus.syncing;
if (_syncConflicts.any((c) => c.fileId == item.id)) {
return SyncItemStatus.conflict;
}
if (_syncedFileIds.contains(item.id)) return SyncItemStatus.synced;
return SyncItemStatus.none;
}
String get currentFolderPath => _currentFolderPath;
AppTab? get requestedTab => _requestedTab;
@@ -621,6 +690,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching;
_lockHiddenFiles =
prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles;
_syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular;
_applyAccountPrefs(prefs, _activeAccountId);
final savedOrderNames = prefs.getStringList(_prefTabOrder);
@@ -709,6 +779,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_photosSortAscending = true;
_cachePolicy = CachePolicy.never;
_cacheIntervalMinutes = 5;
_syncedPaths = [];
_syncEverything = false;
return;
}
@@ -767,6 +839,19 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
orElse: () => CachePolicy.never,
);
_cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5;
final syncedFoldersJson = prefs.getString(k(_prefSyncedFolders));
if (syncedFoldersJson != null) {
try {
_syncedPaths = (jsonDecode(syncedFoldersJson) as List).cast<String>();
} catch (e) {
debugPrint('[ServerProvider] Synced folders restore failed: $e');
_syncedPaths = [];
}
} else {
_syncedPaths = [];
}
_syncEverything = prefs.getBool(k(_prefSyncEverything)) ?? false;
}
/// Verifies [appPassword] for [account] and, on success, makes it the
@@ -806,6 +891,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (gen != _sessionGeneration) return false;
_isLoading = false;
notifyListeners();
unawaited(SyncService.reschedule(this));
unawaited(SyncService.getStatus().then(_applySyncStatus));
return true;
}
} catch (e) {
@@ -1131,7 +1218,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
final prefs = await _prefsFuture;
await _accountStore.saveActiveAccountId(prefs, null);
_applyAccountPrefs(prefs, null);
_isSyncingNow = false;
_syncingFileIds = {};
_syncedFileIds = {};
_syncConflicts = [];
notifyListeners();
unawaited(SyncService.cancel());
}
/// Ends the active session but keeps this account saved - unlike
@@ -1473,6 +1565,93 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_persistFolderSort();
}
void _persistSyncedPaths() {
_persistAccountPref(
_prefSyncedFolders,
(p, key) => p.setString(key, jsonEncode(_syncedPaths)),
);
}
bool isPathSynced(String path) => _syncedPaths.contains(path);
void addSyncedPath(String path) {
if (_syncedPaths.contains(path)) return;
_syncedPaths = [..._syncedPaths, path];
notifyListeners();
_persistSyncedPaths();
unawaited(SyncService.reschedule(this));
}
void removeSyncedPath(String path) {
if (!_syncedPaths.contains(path)) return;
_syncedPaths = _syncedPaths.where((f) => f != path).toList();
notifyListeners();
_persistSyncedPaths();
unawaited(SyncService.reschedule(this));
}
void setSyncEverything(bool value) {
if (_syncEverything == value) return;
_syncEverything = value;
notifyListeners();
_persistAccountPref(_prefSyncEverything, (p, key) => p.setBool(key, value));
unawaited(SyncService.reschedule(this));
}
void setSyncOnCellular(bool value) {
if (_syncOnCellular == value) return;
_syncOnCellular = value;
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefSyncOnCellular, value));
unawaited(SyncService.reschedule(this));
}
/// In-app conflict resolution (the sync header's "Keep local"/"Use
/// server" buttons) - see `SyncService.resolveConflict`'s doc comment
/// for why this shares the exact same native path the notification
/// actions use.
Future<void> resolveSyncConflict(
SyncConflictInfo conflict, {
required bool useLocal,
}) {
return SyncService.resolveConflict(
this,
conflict,
useLocal ? 'local' : 'server',
);
}
/// The local mirror path for [item] if it falls under a synced folder
/// *and* has actually been synced down already - a pure function of the
/// remote path once a folder's marked synced (mirrors
/// `SyncEngine.kt#syncRoot`'s `<externalFilesDir>/sync/<accountId>/...`
/// layout exactly), so this never needs to read the native sync-state
/// SharedPreferences from Dart. Callers (Share/Download) use this to
/// skip a fresh WebDAV fetch when a local copy already exists.
Future<String?> localSyncedFilePath(NextcloudItem item) async {
final id = activeAccountId;
if (id == null) return null;
final itemPath = item.path.endsWith('/')
? item.path.substring(0, item.path.length - 1)
: item.path;
final isSynced =
_syncEverything ||
_syncedPaths.any((folder) {
final f = folder.endsWith('/')
? folder.substring(0, folder.length - 1)
: folder;
return itemPath == f || itemPath.startsWith('$f/');
});
if (!isSynced) return null;
final base = await getExternalStorageDirectory();
if (base == null) return null;
final relPath = item.path.startsWith('/')
? item.path.substring(1)
: item.path;
final file = File(p.join(base.path, 'sync', id, relPath));
return file.existsSync() ? file.path : null;
}
void setTabOrder(List<AppTab> order) {
_tabOrder = order;
notifyListeners();
@@ -2154,6 +2333,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_pollTimer?.cancel();
_pollTimeoutTimer?.cancel();
_cacheRefreshTimer?.cancel();
_syncStatusSub?.cancel();
super.dispose();
}
}