// lib/services/webdav_sync_service.dart // // Two-way sync of the BadNote vault folder <-> a user-configured WebDAV remote. // The vault is a flat folder of notebook subfolders, each holding a source file // plus its `.badnote.json` sidecar; sync operates on the FILES only and // never touches editors or storage formats. // // DESIGN: all sync DECISION logic (per-file winner, conflict handling, path // mapping, last-synced bookkeeping) lives here and is injected with a // [WebDavClient]. Tests drive it with an in-memory FAKE client; production wires // an [HttpWebDavClient]. The real network round-trip is device/server-validated // only (no WebDAV server in CI). // // ALGORITHM (per file, keyed by its vault-root-relative path): // Let L = local state (exists? mtime), R = remote state (exists? mtime), // and B = the per-file LAST-SYNCED baseline we stored after the previous sync // (the mtime we last reconciled to, or absent for never-synced files). // // * local-only (L, !R) -> upload L (create remote dirs) // * remote-only (!L, R): // - known-before (B present) -> remote was DELETED by peer? We // do NOT delete locally (conservative); // we re-UPLOAD to restore. [see note] // - never-seen (B absent) -> download R // * both exist (L, R): // - localChanged = L.mtime != B.mtime (or B absent) // - remoteChanged = R.mtime != B.mtime (or B absent) // - neither changed -> skip // - only local changed -> upload L // - only remote changed -> download R // - BOTH changed (true conflict) -> last-write-wins by mtime: // keep the WINNER as the canonical file, write the LOSER's bytes to // `.conflict-` on BOTH sides so NO data is // lost, then converge canonical (upload or download as needed). // After acting, record the converged mtime as the new baseline B. // // Deletes are handled CONSERVATIVELY: we never delete a file on either side as a // result of sync. A file vanishing on one side is treated as "restore from the // other side" rather than "propagate the delete", because we cannot distinguish // an intentional delete from a half-finished transfer without a tombstone log // (a deliberate TODO — see report). import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; import 'webdav_client.dart'; /// Persisted WebDAV connection + sync configuration. class WebDavConfig { const WebDavConfig({ required this.baseUrl, required this.username, required this.password, this.remoteRoot = '', this.autoSync = false, }); final String baseUrl; final String username; final String password; /// Folder under the server's WebDAV root to sync into, e.g. `BadNote`. final String remoteRoot; /// When true, sync runs on launch/resume (non-blocking, failure-tolerant). final bool autoSync; /// True once enough is set to attempt a sync (URL present). bool get isConfigured => baseUrl.trim().isNotEmpty; WebDavConfig copyWith({ String? baseUrl, String? username, String? password, String? remoteRoot, bool? autoSync, }) { return WebDavConfig( baseUrl: baseUrl ?? this.baseUrl, username: username ?? this.username, password: password ?? this.password, remoteRoot: remoteRoot ?? this.remoteRoot, autoSync: autoSync ?? this.autoSync, ); } } /// Outcome of a [WebDavSyncService.syncNow] run, surfaced to the UI. class SyncResult { const SyncResult({ this.uploaded = 0, this.downloaded = 0, this.conflicts = 0, this.skipped = 0, this.finishedAt, this.error, }); final int uploaded; final int downloaded; final int conflicts; final int skipped; final DateTime? finishedAt; /// Friendly error message when the run failed wholesale; null on success. final String? error; bool get ok => error == null; } /// What to do with one file after comparing local/remote/baseline. @visibleForTesting enum SyncAction { skip, upload, download, conflict } /// A single planned per-file decision (exposed for testing the algorithm). @visibleForTesting class SyncDecision { const SyncDecision(this.relPath, this.action, {this.conflictWinnerIsLocal}); final String relPath; final SyncAction action; /// For [SyncAction.conflict]: true if the LOCAL copy won (newer) and the /// remote copy is the loser kept as `.conflict-*`; false if remote won. final bool? conflictWinnerIsLocal; } /// Per-file last-synced baseline. After each sync we record BOTH sides' /// observed mtimes, because an upload makes the server stamp its OWN mtime (≠ /// the local one) — a single shared timestamp would then look "changed" on the /// next run. Comparing each side to its own baseline avoids that false conflict. @visibleForTesting class SyncBaseline { const SyncBaseline({this.localMtime, this.remoteMtime}); final DateTime? localMtime; final DateTime? remoteMtime; } /// Compact local/remote snapshot of a file for the decision function. @visibleForTesting class FileFacts { const FileFacts({ required this.relPath, required this.localMtime, required this.remoteMtime, this.baseline, }); final String relPath; /// Local file mtime (UTC, whole-second), or null if the file is absent. final DateTime? localMtime; /// Remote file mtime (UTC, whole-second), or null if absent. final DateTime? remoteMtime; /// Per-side mtimes recorded after the last successful sync, or null if never. final SyncBaseline? baseline; } class WebDavSyncService { WebDavSyncService(this._prefs); final SharedPreferences _prefs; static const String _kBaseUrl = 'webdav.baseUrl'; static const String _kUsername = 'webdav.username'; static const String _kPassword = 'webdav.password'; static const String _kRemoteRoot = 'webdav.remoteRoot'; static const String _kAutoSync = 'webdav.autoSync'; static const String _kLastSyncMillis = 'webdav.lastSyncMillis'; /// JSON map { relPath: {l: localMillis, r: remoteMillis} } persisted across /// runs — the per-file, per-side baseline that lets us detect which side /// changed since the last successful sync. static const String _kBaselineJson = 'webdav.baseline'; /// Suffix marking a kept conflict loser. The trailing timestamp keeps repeated /// conflicts from clobbering each other. static const String conflictMarker = '.conflict-'; // ---- Configuration (SharedPreferences-backed) ------------------------- WebDavConfig get config => WebDavConfig( baseUrl: _prefs.getString(_kBaseUrl) ?? '', username: _prefs.getString(_kUsername) ?? '', password: _prefs.getString(_kPassword) ?? '', remoteRoot: _prefs.getString(_kRemoteRoot) ?? '', autoSync: _prefs.getBool(_kAutoSync) ?? false, ); Future saveConfig(WebDavConfig c) async { await _prefs.setString(_kBaseUrl, c.baseUrl.trim()); await _prefs.setString(_kUsername, c.username); await _prefs.setString(_kPassword, c.password); await _prefs.setString(_kRemoteRoot, c.remoteRoot.trim()); await _prefs.setBool(_kAutoSync, c.autoSync); } DateTime? get lastSyncTime { final millis = _prefs.getInt(_kLastSyncMillis); return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis); } // ---- Baseline map ----------------------------------------------------- Map _readBaseline() { final raw = _prefs.getString(_kBaselineJson); if (raw == null || raw.isEmpty) return {}; try { final decoded = jsonDecode(raw) as Map; return decoded.map((k, v) { final m = v as Map; final lm = m['l'] as int?; final rm = m['r'] as int?; return MapEntry( k, SyncBaseline( localMtime: lm == null ? null : DateTime.fromMillisecondsSinceEpoch(lm), remoteMtime: rm == null ? null : DateTime.fromMillisecondsSinceEpoch(rm), ), ); }); } catch (_) { return {}; } } Future _writeBaseline(Map baseline) async { final encoded = jsonEncode(baseline.map((k, v) => MapEntry(k, { 'l': v.localMtime?.millisecondsSinceEpoch, 'r': v.remoteMtime?.millisecondsSinceEpoch, }))); await _prefs.setString(_kBaselineJson, encoded); } // ---- The pure decision function (unit-tested) ------------------------- /// Decide what to do for one file from its [FileFacts]. Pure: no I/O. This is /// the heart of the algorithm and is exercised directly by the tests. /// /// Mtimes are compared at whole-second granularity (WebDAV `getlastmodified` /// has 1-second resolution); callers must truncate before passing facts in. @visibleForTesting static SyncDecision decide(FileFacts f) { final l = f.localMtime; final r = f.remoteMtime; final bl = f.baseline?.localMtime; final br = f.baseline?.remoteMtime; if (l != null && r == null) { // Local-only: either brand new locally, or remote vanished. Either way we // (re)upload — never delete the local file. return SyncDecision(f.relPath, SyncAction.upload); } if (l == null && r != null) { // Never synced OR previously known but now gone locally — in both cases we // conservatively pull from remote rather than propagating a delete. return SyncDecision(f.relPath, SyncAction.download); } if (l == null && r == null) { return SyncDecision(f.relPath, SyncAction.skip); } // Both sides have the file. Compare each side to ITS OWN baseline so an // upload-stamped remote mtime isn't mistaken for a remote edit. final localChanged = bl == null || !_sameSecond(l!, bl); final remoteChanged = br == null || !_sameSecond(r!, br); if (!localChanged && !remoteChanged) { return SyncDecision(f.relPath, SyncAction.skip); } if (localChanged && !remoteChanged) { return SyncDecision(f.relPath, SyncAction.upload); } if (!localChanged && remoteChanged) { return SyncDecision(f.relPath, SyncAction.download); } // Both changed since baseline -> true conflict. Newer mtime wins. final localWins = !l!.isBefore(r!); // ties resolve to local (keep working copy) return SyncDecision( f.relPath, SyncAction.conflict, conflictWinnerIsLocal: localWins, ); } static bool _sameSecond(DateTime a, DateTime b) => a.toUtc().millisecondsSinceEpoch ~/ 1000 == b.toUtc().millisecondsSinceEpoch ~/ 1000; // ---- Path mapping (vault <-> remote), unit-tested --------------------- /// Map a vault-root-relative path (OS separators) to a forward-slashed remote /// path. e.g. on Windows `Lecture\Lecture.pdf` -> `Lecture/Lecture.pdf`. @visibleForTesting static String toRemotePath(String relPath) => p.split(relPath).where((s) => s.isNotEmpty).join('/'); /// Map a forward-slashed remote path back to a vault-root-relative path using /// OS separators. @visibleForTesting static String toLocalRelPath(String remotePath) => p.joinAll(remotePath.split('/').where((s) => s.isNotEmpty)); /// True for files sync must IGNORE: sidecar temp/backup artifacts and our own /// conflict copies (conflict copies stay local; they are not re-synced as if /// canonical, but ARE uploaded as plain new files if the user keeps them). @visibleForTesting static bool isSyncable(String relPath) { final name = p.basename(relPath); if (name.startsWith('.')) return false; // hidden / .badnote metadata if (name.endsWith('.tmp') || name.endsWith('.bak')) return false; return true; } // ---- The orchestrator (I/O; device/server-validated) ------------------ /// Run a full two-way sync of [vaultRoot] against [client]. Pure decisions /// from [decide] drive uploads/downloads; conflicts keep the loser as a /// `.conflict-*` copy on both sides. Returns counts for the UI; on a wholesale /// failure returns a [SyncResult] with [SyncResult.error] set (never throws). Future syncNow({ required String vaultRoot, required WebDavClient client, }) async { try { final root = Directory(vaultRoot); if (!await root.exists()) { return const SyncResult(error: 'Vault folder not found'); } // 1. Snapshot both sides keyed by vault-relative path. final localFiles = await _scanLocal(root); final remoteList = await client.list(''); final remoteFiles = {}; for (final e in remoteList) { final rel = toLocalRelPath(e.path); if (isSyncable(rel)) remoteFiles[rel] = e; } final baseline = _readBaseline(); var uploaded = 0; var downloaded = 0; var conflicts = 0; var skipped = 0; // Rel paths that ended up converged (and so deserve a fresh baseline). final converged = {}; final allPaths = {...localFiles.keys, ...remoteFiles.keys}; for (final rel in allPaths) { final localMtime = localFiles[rel]; final remoteEntry = remoteFiles[rel]; final facts = FileFacts( relPath: rel, localMtime: localMtime == null ? null : _truncate(localMtime), remoteMtime: remoteEntry?.modified == null ? null : _truncate(remoteEntry!.modified!), baseline: baseline[rel], ); final decision = decide(facts); switch (decision.action) { case SyncAction.skip: skipped++; converged.add(rel); break; case SyncAction.upload: await _doUpload(root, client, rel); uploaded++; converged.add(rel); break; case SyncAction.download: await _doDownload(root, client, rel, remoteEntry!.modified); downloaded++; converged.add(rel); break; case SyncAction.conflict: await _doConflict( root, client, rel, localWins: decision.conflictWinnerIsLocal == true, localMtime: localMtime!, remoteMtime: remoteEntry!.modified, ); conflicts++; converged.add(rel); break; } } // Re-snapshot both sides so the new baseline records each side's ACTUAL // post-sync mtime (uploads make the server stamp its own mtime). Comparing // each side to its own baseline next time avoids a false "remote changed". final finalLocal = await _scanLocal(root); final finalRemote = {}; for (final e in await client.list('')) { final r = toLocalRelPath(e.path); if (isSyncable(r)) finalRemote[r] = e.modified; } final newBaseline = {}; for (final rel in converged) { final lm = finalLocal[rel]; final rm = finalRemote[rel]; // Only keep a baseline once a file exists on BOTH sides; a one-sided // file (mid-restore) stays "new" so the next run finishes converging it. if (lm != null && rm != null) { newBaseline[rel] = SyncBaseline( localMtime: _truncate(lm), remoteMtime: _truncate(rm), ); } } await _writeBaseline(newBaseline); final finishedAt = DateTime.now(); await _prefs.setInt(_kLastSyncMillis, finishedAt.millisecondsSinceEpoch); return SyncResult( uploaded: uploaded, downloaded: downloaded, conflicts: conflicts, skipped: skipped, finishedAt: finishedAt, ); } on WebDavException catch (e) { return SyncResult(error: e.message); } catch (e) { return SyncResult(error: e.toString()); } } /// Recursively collect every syncable file under [root], keyed by its /// vault-root-relative path, mapped to its mtime. Future> _scanLocal(Directory root) async { final out = {}; await for (final entity in root.list(recursive: true, followLinks: false)) { if (entity is! File) continue; final rel = p.relative(entity.path, from: root.path); // Skip anything inside a hidden folder (e.g. .badnote) or hidden file. if (p.split(rel).any((seg) => seg.startsWith('.'))) continue; if (!isSyncable(rel)) continue; final stat = await entity.stat(); out[rel] = stat.modified; } return out; } /// Upload the local file at [rel], creating remote parent collections. Future _doUpload(Directory root, WebDavClient client, String rel) async { final file = File(p.join(root.path, rel)); final bytes = await file.readAsBytes(); await _ensureRemoteDirs(client, rel); await client.upload(toRemotePath(rel), bytes); } /// Download the remote file at [rel] into the vault, creating local parent /// dirs. Sets the local mtime to the remote's so the next run sees no drift. Future _doDownload( Directory root, WebDavClient client, String rel, DateTime? remoteMtime, ) async { final bytes = await client.download(toRemotePath(rel)); final file = File(p.join(root.path, rel)); await file.parent.create(recursive: true); await file.writeAsBytes(bytes, flush: true); if (remoteMtime != null) { try { await file.setLastModified(remoteMtime); } catch (_) { // Some filesystems reject setLastModified; the post-sync re-snapshot // captures whatever mtime landed, so convergence still holds. } } } /// Resolve a true conflict: keep the loser as `.conflict-` /// on BOTH sides (no data lost), then converge the canonical to the winner. Future _doConflict( Directory root, WebDavClient client, String rel, { required bool localWins, required DateTime localMtime, required DateTime? remoteMtime, }) async { final winnerMtime = localWins ? localMtime : (remoteMtime ?? localMtime); final stamp = _truncate(winnerMtime).millisecondsSinceEpoch; final conflictRel = '$rel$conflictMarker$stamp'; final localFile = File(p.join(root.path, rel)); final remoteBytes = await client.download(toRemotePath(rel)); if (localWins) { // Local is canonical. Save the REMOTE bytes as the local conflict copy, // upload that conflict copy remotely too, then push local up as canonical. final conflictFile = File(p.join(root.path, conflictRel)); await conflictFile.parent.create(recursive: true); await conflictFile.writeAsBytes(remoteBytes, flush: true); await _ensureRemoteDirs(client, conflictRel); await client.upload(toRemotePath(conflictRel), remoteBytes); final localBytes = await localFile.readAsBytes(); await _ensureRemoteDirs(client, rel); await client.upload(toRemotePath(rel), localBytes); } else { // Remote is canonical. Save the LOCAL bytes as the local conflict copy // and push it remotely, then overwrite local with the remote (winner). final localBytes = await localFile.readAsBytes(); final conflictFile = File(p.join(root.path, conflictRel)); await conflictFile.parent.create(recursive: true); await conflictFile.writeAsBytes(localBytes, flush: true); await _ensureRemoteDirs(client, conflictRel); await client.upload(toRemotePath(conflictRel), localBytes); await localFile.writeAsBytes(remoteBytes, flush: true); if (remoteMtime != null) { try { await localFile.setLastModified(remoteMtime); } catch (_) {} } } } /// Create each remote parent collection of [rel] from the root down (MKCOL is /// idempotent), so an upload never 409s on a missing directory. Future _ensureRemoteDirs(WebDavClient client, String rel) async { final segments = p.split(rel).where((s) => s.isNotEmpty).toList(); if (segments.length <= 1) return; // file at root, no dirs needed var acc = ''; for (var i = 0; i < segments.length - 1; i++) { acc = acc.isEmpty ? segments[i] : '$acc/${segments[i]}'; await client.makeCollection(acc); } } static DateTime _truncate(DateTime t) => DateTime.fromMillisecondsSinceEpoch( (t.toUtc().millisecondsSinceEpoch ~/ 1000) * 1000, isUtc: true, ); // ---- Convenience: build a real client from the saved config ----------- /// Construct an [HttpWebDavClient] from the persisted [config], or null when /// unconfigured (caller disables the sync button). Caller owns close(). HttpWebDavClient? buildClient() { final c = config; if (!c.isConfigured) return null; return HttpWebDavClient( baseUrl: c.baseUrl, username: c.username, password: c.password, remoteRoot: c.remoteRoot, ); } }