diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7a1f7d1..5797dde 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -150,5 +150,42 @@ "vaultFolderLabel": "Vault folder", "vaultNoneSelected": "No folder selected", "vaultChangeFolder": "Change vault folder", - "vaultUpdated": "Vault folder updated" + "vaultUpdated": "Vault folder updated", + "syncSection": "Sync (WebDAV)", + "syncServerUrl": "Server URL", + "syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me", + "syncUsername": "Username", + "syncPassword": "Password", + "syncRemoteFolder": "Remote folder", + "syncRemoteFolderHint": "BadNote", + "syncSave": "Save", + "syncSaved": "Sync settings saved", + "syncTestConnection": "Test connection", + "syncTestOk": "Connection OK", + "syncTestFailed": "Connection failed: {error}", + "@syncTestFailed": { + "placeholders": { "error": { "type": "String" } } + }, + "syncNow": "Sync now", + "syncRunning": "Syncing…", + "syncNeverRun": "Never synced", + "syncLastRun": "Last synced: {when}", + "@syncLastRun": { + "placeholders": { "when": { "type": "String" } } + }, + "syncResultSummary": "{uploaded} uploaded · {downloaded} downloaded · {conflicts} conflicts", + "@syncResultSummary": { + "placeholders": { + "uploaded": { "type": "int" }, + "downloaded": { "type": "int" }, + "conflicts": { "type": "int" } + } + }, + "syncFailed": "Sync failed: {error}", + "@syncFailed": { + "placeholders": { "error": { "type": "String" } } + }, + "syncAuto": "Sync automatically on launch", + "syncCredentialsNote": "Credentials are stored locally in plain text. Use a dedicated app password.", + "syncNotConfigured": "Enter a server URL to enable sync." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0832a04..0720ba8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -787,6 +787,132 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Vault folder updated'** String get vaultUpdated; + + /// No description provided for @syncSection. + /// + /// In en, this message translates to: + /// **'Sync (WebDAV)'** + String get syncSection; + + /// No description provided for @syncServerUrl. + /// + /// In en, this message translates to: + /// **'Server URL'** + String get syncServerUrl; + + /// No description provided for @syncServerUrlHint. + /// + /// In en, this message translates to: + /// **'https://dav.example.com/remote.php/dav/files/me'** + String get syncServerUrlHint; + + /// No description provided for @syncUsername. + /// + /// In en, this message translates to: + /// **'Username'** + String get syncUsername; + + /// No description provided for @syncPassword. + /// + /// In en, this message translates to: + /// **'Password'** + String get syncPassword; + + /// No description provided for @syncRemoteFolder. + /// + /// In en, this message translates to: + /// **'Remote folder'** + String get syncRemoteFolder; + + /// No description provided for @syncRemoteFolderHint. + /// + /// In en, this message translates to: + /// **'BadNote'** + String get syncRemoteFolderHint; + + /// No description provided for @syncSave. + /// + /// In en, this message translates to: + /// **'Save'** + String get syncSave; + + /// No description provided for @syncSaved. + /// + /// In en, this message translates to: + /// **'Sync settings saved'** + String get syncSaved; + + /// No description provided for @syncTestConnection. + /// + /// In en, this message translates to: + /// **'Test connection'** + String get syncTestConnection; + + /// No description provided for @syncTestOk. + /// + /// In en, this message translates to: + /// **'Connection OK'** + String get syncTestOk; + + /// No description provided for @syncTestFailed. + /// + /// In en, this message translates to: + /// **'Connection failed: {error}'** + String syncTestFailed(String error); + + /// No description provided for @syncNow. + /// + /// In en, this message translates to: + /// **'Sync now'** + String get syncNow; + + /// No description provided for @syncRunning. + /// + /// In en, this message translates to: + /// **'Syncing…'** + String get syncRunning; + + /// No description provided for @syncNeverRun. + /// + /// In en, this message translates to: + /// **'Never synced'** + String get syncNeverRun; + + /// No description provided for @syncLastRun. + /// + /// In en, this message translates to: + /// **'Last synced: {when}'** + String syncLastRun(String when); + + /// No description provided for @syncResultSummary. + /// + /// In en, this message translates to: + /// **'{uploaded} uploaded · {downloaded} downloaded · {conflicts} conflicts'** + String syncResultSummary(int uploaded, int downloaded, int conflicts); + + /// No description provided for @syncFailed. + /// + /// In en, this message translates to: + /// **'Sync failed: {error}'** + String syncFailed(String error); + + /// No description provided for @syncAuto. + /// + /// In en, this message translates to: + /// **'Sync automatically on launch'** + String get syncAuto; + + /// No description provided for @syncCredentialsNote. + /// + /// In en, this message translates to: + /// **'Credentials are stored locally in plain text. Use a dedicated app password.'** + String get syncCredentialsNote; + + /// No description provided for @syncNotConfigured. + /// + /// In en, this message translates to: + /// **'Enter a server URL to enable sync.'** + String get syncNotConfigured; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index afff8aa..c3bf99d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -379,4 +379,77 @@ class AppLocalizationsEn extends AppLocalizations { @override String get vaultUpdated => 'Vault folder updated'; + + @override + String get syncSection => 'Sync (WebDAV)'; + + @override + String get syncServerUrl => 'Server URL'; + + @override + String get syncServerUrlHint => + 'https://dav.example.com/remote.php/dav/files/me'; + + @override + String get syncUsername => 'Username'; + + @override + String get syncPassword => 'Password'; + + @override + String get syncRemoteFolder => 'Remote folder'; + + @override + String get syncRemoteFolderHint => 'BadNote'; + + @override + String get syncSave => 'Save'; + + @override + String get syncSaved => 'Sync settings saved'; + + @override + String get syncTestConnection => 'Test connection'; + + @override + String get syncTestOk => 'Connection OK'; + + @override + String syncTestFailed(String error) { + return 'Connection failed: $error'; + } + + @override + String get syncNow => 'Sync now'; + + @override + String get syncRunning => 'Syncing…'; + + @override + String get syncNeverRun => 'Never synced'; + + @override + String syncLastRun(String when) { + return 'Last synced: $when'; + } + + @override + String syncResultSummary(int uploaded, int downloaded, int conflicts) { + return '$uploaded uploaded · $downloaded downloaded · $conflicts conflicts'; + } + + @override + String syncFailed(String error) { + return 'Sync failed: $error'; + } + + @override + String get syncAuto => 'Sync automatically on launch'; + + @override + String get syncCredentialsNote => + 'Credentials are stored locally in plain text. Use a dedicated app password.'; + + @override + String get syncNotConfigured => 'Enter a server URL to enable sync.'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 962709b..77d6b4f 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -376,4 +376,76 @@ class AppLocalizationsZh extends AppLocalizations { @override String get vaultUpdated => '笔记库文件夹已更新'; + + @override + String get syncSection => '同步(WebDAV)'; + + @override + String get syncServerUrl => '服务器地址'; + + @override + String get syncServerUrlHint => + 'https://dav.example.com/remote.php/dav/files/me'; + + @override + String get syncUsername => '用户名'; + + @override + String get syncPassword => '密码'; + + @override + String get syncRemoteFolder => '远程文件夹'; + + @override + String get syncRemoteFolderHint => 'BadNote'; + + @override + String get syncSave => '保存'; + + @override + String get syncSaved => '同步设置已保存'; + + @override + String get syncTestConnection => '测试连接'; + + @override + String get syncTestOk => '连接成功'; + + @override + String syncTestFailed(String error) { + return '连接失败:$error'; + } + + @override + String get syncNow => '立即同步'; + + @override + String get syncRunning => '同步中…'; + + @override + String get syncNeverRun => '尚未同步'; + + @override + String syncLastRun(String when) { + return '上次同步:$when'; + } + + @override + String syncResultSummary(int uploaded, int downloaded, int conflicts) { + return '上传 $uploaded · 下载 $downloaded · 冲突 $conflicts'; + } + + @override + String syncFailed(String error) { + return '同步失败:$error'; + } + + @override + String get syncAuto => '启动时自动同步'; + + @override + String get syncCredentialsNote => '凭据以明文保存在本地,建议使用专用的应用密码。'; + + @override + String get syncNotConfigured => '请输入服务器地址以启用同步。'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 038043d..e382063 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -120,5 +120,42 @@ "vaultFolderLabel": "笔记库文件夹", "vaultNoneSelected": "尚未选择文件夹", "vaultChangeFolder": "更改笔记库文件夹", - "vaultUpdated": "笔记库文件夹已更新" + "vaultUpdated": "笔记库文件夹已更新", + "syncSection": "同步(WebDAV)", + "syncServerUrl": "服务器地址", + "syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me", + "syncUsername": "用户名", + "syncPassword": "密码", + "syncRemoteFolder": "远程文件夹", + "syncRemoteFolderHint": "BadNote", + "syncSave": "保存", + "syncSaved": "同步设置已保存", + "syncTestConnection": "测试连接", + "syncTestOk": "连接成功", + "syncTestFailed": "连接失败:{error}", + "@syncTestFailed": { + "placeholders": { "error": { "type": "String" } } + }, + "syncNow": "立即同步", + "syncRunning": "同步中…", + "syncNeverRun": "尚未同步", + "syncLastRun": "上次同步:{when}", + "@syncLastRun": { + "placeholders": { "when": { "type": "String" } } + }, + "syncResultSummary": "上传 {uploaded} · 下载 {downloaded} · 冲突 {conflicts}", + "@syncResultSummary": { + "placeholders": { + "uploaded": { "type": "int" }, + "downloaded": { "type": "int" }, + "conflicts": { "type": "int" } + } + }, + "syncFailed": "同步失败:{error}", + "@syncFailed": { + "placeholders": { "error": { "type": "String" } } + }, + "syncAuto": "启动时自动同步", + "syncCredentialsNote": "凭据以明文保存在本地,建议使用专用的应用密码。", + "syncNotConfigured": "请输入服务器地址以启用同步。" } diff --git a/lib/main.dart b/lib/main.dart index 4992331..367e3c4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'screens/home_screen.dart'; import 'screens/vault_setup_screen.dart'; import 'services/database_service.dart'; import 'services/vault_service.dart'; +import 'services/webdav_sync_service.dart'; import 'storage/sqlite_to_sidecar_migrator.dart'; Future main() async { @@ -141,7 +142,34 @@ class _VaultGateState extends State { _hadStoredPath = (vault.vaultRoot?.isNotEmpty ?? false); _loading = false; }); - if (valid) await _maybeMigrate(vault); + if (valid) { + await _maybeMigrate(vault); + _maybeAutoSync(vault); // fire-and-forget; never blocks the UI + } + } + + /// Optionally kick off a WebDAV sync on launch when the user has enabled + /// auto-sync (default OFF). Deliberately NON-blocking and failure-tolerant: a + /// bad config or offline server must never delay or crash startup. Results + /// are surfaced in Settings (last-synced time) rather than interrupting here. + Future _maybeAutoSync(VaultService vault) async { + try { + final prefs = await SharedPreferences.getInstance(); + final sync = WebDavSyncService(prefs); + final config = sync.config; + if (!config.autoSync || !config.isConfigured) return; + final root = vault.vaultRoot; + if (root == null || root.isEmpty) return; + final client = sync.buildClient(); + if (client == null) return; + try { + await sync.syncNow(vaultRoot: root, client: client); + } finally { + client.close(); + } + } catch (_) { + // Auto-sync is best-effort; swallow everything so launch is unaffected. + } } /// Run the one-time SQLite→sidecar migration ONCE per vault (Phase 5, §B). diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 14f249b..6e83c65 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,12 +2,14 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/app_localizations.dart'; import '../models/pen_tool.dart'; import '../models/pressure_curve.dart'; import '../providers/settings_provider.dart'; import '../services/vault_service.dart'; +import '../services/webdav_sync_service.dart'; import '../utils/stroke_stabilizer.dart'; /// Material 3 settings screen for BadNote. @@ -290,6 +292,15 @@ class SettingsScreen extends ConsumerWidget { child: _VaultSettings(), ), const Divider(), + _SectionHeader( + title: AppLocalizations.of(context).syncSection, + icon: Icons.cloud_sync, + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: _SyncSettings(), + ), + const Divider(), _SectionHeader(title: 'About', icon: Icons.info), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -415,6 +426,273 @@ class _VaultSettingsState extends State<_VaultSettings> { } } +/// WebDAV sync configuration + actions. Persists config through +/// [WebDavSyncService] (SharedPreferences-backed), exposes Test connection / +/// Sync now buttons, the last-synced time and last result, and an +/// "auto-sync on launch" toggle (default OFF). All network ops are +/// time-bounded inside the service and surface friendly errors here. +class _SyncSettings extends StatefulWidget { + const _SyncSettings(); + + @override + State<_SyncSettings> createState() => _SyncSettingsState(); +} + +class _SyncSettingsState extends State<_SyncSettings> { + WebDavSyncService? _sync; + final _urlCtrl = TextEditingController(); + final _userCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + final _folderCtrl = TextEditingController(); + bool _autoSync = false; + bool _busy = false; + bool _testing = false; + bool _obscure = true; + DateTime? _lastSync; + SyncResult? _lastResult; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _urlCtrl.dispose(); + _userCtrl.dispose(); + _passCtrl.dispose(); + _folderCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final sync = WebDavSyncService(prefs); + if (!mounted) return; + final c = sync.config; + setState(() { + _sync = sync; + _urlCtrl.text = c.baseUrl; + _userCtrl.text = c.username; + _passCtrl.text = c.password; + _folderCtrl.text = c.remoteRoot; + _autoSync = c.autoSync; + _lastSync = sync.lastSyncTime; + }); + } + + WebDavConfig _currentConfig() => WebDavConfig( + baseUrl: _urlCtrl.text, + username: _userCtrl.text, + password: _passCtrl.text, + remoteRoot: _folderCtrl.text, + autoSync: _autoSync, + ); + + Future _save() async { + final sync = _sync; + if (sync == null) return; + final l = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + await sync.saveConfig(_currentConfig()); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(l.syncSaved))); + } + + Future _testConnection() async { + final sync = _sync; + if (sync == null) return; + final l = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + await sync.saveConfig(_currentConfig()); + setState(() => _testing = true); + final client = sync.buildClient(); + try { + if (client == null) { + messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured))); + return; + } + await client.testConnection(); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(l.syncTestOk))); + } catch (e) { + if (!mounted) return; + messenger + .showSnackBar(SnackBar(content: Text(l.syncTestFailed(e.toString())))); + } finally { + client?.close(); + if (mounted) setState(() => _testing = false); + } + } + + Future _syncNow() async { + final sync = _sync; + if (sync == null) return; + final l = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + await sync.saveConfig(_currentConfig()); + final vault = await VaultService.getInstance(); + final root = vault.vaultRoot; + if (root == null || root.isEmpty) { + messenger.showSnackBar(SnackBar(content: Text(l.vaultNoneSelected))); + return; + } + setState(() => _busy = true); + final client = sync.buildClient(); + try { + if (client == null) { + messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured))); + return; + } + final result = await sync.syncNow(vaultRoot: root, client: client); + if (!mounted) return; + setState(() { + _lastResult = result; + _lastSync = sync.lastSyncTime; + }); + messenger.showSnackBar(SnackBar( + content: Text(result.ok + ? l.syncResultSummary( + result.uploaded, result.downloaded, result.conflicts) + : l.syncFailed(result.error ?? '')), + )); + } finally { + client?.close(); + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final colorScheme = Theme.of(context).colorScheme; + final configured = _urlCtrl.text.trim().isNotEmpty; + final lastResult = _lastResult; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _urlCtrl, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: InputDecoration( + labelText: l.syncServerUrl, + hintText: l.syncServerUrlHint, + border: const OutlineInputBorder(), + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + TextField( + controller: _userCtrl, + autocorrect: false, + decoration: InputDecoration( + labelText: l.syncUsername, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _passCtrl, + obscureText: _obscure, + autocorrect: false, + decoration: InputDecoration( + labelText: l.syncPassword, + border: const OutlineInputBorder(), + isDense: true, + suffixIcon: IconButton( + icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _folderCtrl, + autocorrect: false, + decoration: InputDecoration( + labelText: l.syncRemoteFolder, + hintText: l.syncRemoteFolderHint, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 8), + Text( + l.syncCredentialsNote, + style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l.syncAuto), + value: _autoSync, + onChanged: (v) { + setState(() => _autoSync = v); + _save(); + }, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: (_busy || _testing) ? null : _save, + icon: const Icon(Icons.save), + label: Text(l.syncSave), + ), + OutlinedButton.icon( + onPressed: (!configured || _busy || _testing) + ? null + : _testConnection, + icon: _testing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.wifi_tethering), + label: Text(l.syncTestConnection), + ), + FilledButton.icon( + onPressed: (!configured || _busy || _testing) ? null : _syncNow, + icon: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync), + label: Text(_busy ? l.syncRunning : l.syncNow), + ), + ], + ), + const SizedBox(height: 12), + Text( + _lastSync == null + ? l.syncNeverRun + : l.syncLastRun(_lastSync!.toLocal().toString()), + style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant), + ), + if (lastResult != null && lastResult.ok) ...[ + const SizedBox(height: 4), + Text( + l.syncResultSummary(lastResult.uploaded, lastResult.downloaded, + lastResult.conflicts), + style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant), + ), + ], + ], + ); + } +} + class _SectionHeader extends StatelessWidget { final String title; final IconData icon; diff --git a/lib/services/webdav_client.dart b/lib/services/webdav_client.dart new file mode 100644 index 0000000..545ffd4 --- /dev/null +++ b/lib/services/webdav_client.dart @@ -0,0 +1,348 @@ +// lib/services/webdav_client.dart +// +// A minimal WebDAV client abstraction for vault sync. The [WebDavClient] +// interface is intentionally tiny (the four verbs the sync algorithm needs: +// list / download / upload / mkcol) so that: +// * the sync ALGORITHM in WebDavSyncService can be unit-tested against a +// FAKE in-memory implementation (no real server), and +// * the real network adapter ([HttpWebDavClient]) stays a thin shim over +// `package:http` + `package:xml` (PROPFIND/GET/PUT/MKCOL). +// +// Paths handled here are REMOTE paths relative to the configured remote root, +// using forward slashes (e.g. `Lecture/Lecture.pdf`). Mapping vault file paths +// to/from these remote paths lives in WebDavSyncService. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:xml/xml.dart'; + +/// One remote resource returned by a directory listing (PROPFIND). +class RemoteEntry { + const RemoteEntry({ + required this.path, + required this.isDirectory, + this.modified, + this.size, + this.etag, + }); + + /// Remote path RELATIVE to the configured remote root, forward-slashed and + /// WITHOUT a leading slash, e.g. `Lecture/Lecture.pdf`. Directories carry no + /// trailing slash here (normalized by the client). + final String path; + + /// Whether this entry is a collection (directory) rather than a file. + final bool isDirectory; + + /// Server last-modified time (UTC) if the server reported one. + final DateTime? modified; + + /// Content length in bytes if reported (files only). + final int? size; + + /// Weak/strong ETag if reported (quotes stripped). + final String? etag; +} + +/// Thrown by [WebDavClient] implementations for any transport/protocol error. +/// Carries a human-readable [message] suitable for surfacing in the UI. +class WebDavException implements Exception { + WebDavException(this.message, {this.statusCode}); + + final String message; + final int? statusCode; + + @override + String toString() => 'WebDavException($message' + '${statusCode != null ? ', status: $statusCode' : ''})'; +} + +/// The four WebDAV operations the sync algorithm depends on. Inject a fake in +/// tests; inject [HttpWebDavClient] in production. +abstract class WebDavClient { + /// List the immediate-and-nested files under [remoteDir] (relative to the + /// remote root, `''` meaning the root itself). Returns every FILE found in + /// the subtree (directories are created on demand via [makeCollection], so + /// callers care about files). Implementations PROPFIND with Depth: infinity + /// and flatten the result. A missing remote dir yields an empty list. + Future> list(String remoteDir); + + /// Download the bytes of the remote file at [remotePath]. + Future download(String remotePath); + + /// Upload [bytes] to [remotePath], creating/overwriting the remote file. + /// Parent collections must already exist (use [makeCollection]). + Future upload(String remotePath, Uint8List bytes); + + /// Create the collection (directory) at [remotePath]. Idempotent: an + /// already-existing collection is not an error. + Future makeCollection(String remotePath); + + /// Probe connectivity + credentials cheaply (PROPFIND Depth:0 on the root). + /// Throws [WebDavException] on failure; returns normally on success. + Future testConnection(); +} + +/// Real WebDAV adapter over `package:http`. Thin by design — all the sync +/// decision logic lives in WebDavSyncService, NOT here. +/// +/// DEVICE/SERVER-VALIDATED ONLY: this class performs real network round-trips +/// and is not exercised in CI (no WebDAV server). The XML/path plumbing below +/// is best-effort against common servers (Nextcloud, Apache mod_dav). The sync +/// algorithm that consumes it is what the unit tests cover, via a fake client. +class HttpWebDavClient implements WebDavClient { + HttpWebDavClient({ + required String baseUrl, + required String username, + required String password, + String remoteRoot = '', + http.Client? httpClient, + this.timeout = const Duration(seconds: 30), + }) : _client = httpClient ?? http.Client(), + _ownsClient = httpClient == null, + _baseUri = _normalizeBase(baseUrl, remoteRoot), + _authHeader = + 'Basic ${base64Encode(utf8.encode('$username:$password'))}'; + + final http.Client _client; + final bool _ownsClient; + + /// Absolute base URI INCLUDING the remote root path, always ending in `/`. + final Uri _baseUri; + final String _authHeader; + final Duration timeout; + + /// Combine the server [baseUrl] with the [remoteRoot] folder into a single + /// absolute base URI ending in a slash. Tolerates trailing/leading slashes. + static Uri _normalizeBase(String baseUrl, String remoteRoot) { + var base = baseUrl.trim(); + if (!base.endsWith('/')) base = '$base/'; + var uri = Uri.parse(base); + final root = remoteRoot.trim().replaceAll(RegExp(r'^/+|/+$'), ''); + if (root.isNotEmpty) { + uri = uri.resolve('${Uri.encodeFull(root)}/'); + } + return uri; + } + + /// Resolve a remote-root-relative [remotePath] to an absolute URI. + Uri _resolve(String remotePath) { + final clean = remotePath.replaceAll(RegExp(r'^/+'), ''); + if (clean.isEmpty) return _baseUri; + // Encode each segment but keep the slashes. + final encoded = clean.split('/').map(Uri.encodeComponent).join('/'); + return _baseUri.resolve(encoded); + } + + Map get _headers => {'Authorization': _authHeader}; + + @override + Future testConnection() async { + final res = await _send('PROPFIND', _baseUri, headers: {'Depth': '0'}); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw WebDavException( + 'Server responded ${res.statusCode}', + statusCode: res.statusCode, + ); + } + } + + @override + Future> list(String remoteDir) async { + final uri = _resolve(remoteDir.endsWith('/') ? remoteDir : '$remoteDir/'); + final http.Response res; + try { + res = await _send('PROPFIND', uri, headers: {'Depth': 'infinity'}); + } on WebDavException { + rethrow; + } + if (res.statusCode == 404) return const []; + if (res.statusCode < 200 || res.statusCode >= 300) { + throw WebDavException( + 'PROPFIND failed (${res.statusCode})', + statusCode: res.statusCode, + ); + } + return _parseMultiStatus(res.body); + } + + @override + Future download(String remotePath) async { + final res = await _get(_resolve(remotePath)); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw WebDavException( + 'Download failed (${res.statusCode})', + statusCode: res.statusCode, + ); + } + return res.bodyBytes; + } + + @override + Future upload(String remotePath, Uint8List bytes) async { + final res = await _put(_resolve(remotePath), bytes); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw WebDavException( + 'Upload failed (${res.statusCode})', + statusCode: res.statusCode, + ); + } + } + + @override + Future makeCollection(String remotePath) async { + final uri = _resolve(remotePath.endsWith('/') ? remotePath : '$remotePath/'); + final res = await _send('MKCOL', uri); + // 201 created; 405 method-not-allowed means it already exists (fine). + if (res.statusCode == 201 || res.statusCode == 405) return; + if (res.statusCode < 200 || res.statusCode >= 300) { + throw WebDavException( + 'MKCOL failed (${res.statusCode})', + statusCode: res.statusCode, + ); + } + } + + /// Parse a WebDAV multistatus (PROPFIND) body into FILE entries, dropping + /// collections. Paths are made relative to [_baseUri]'s path and stripped of + /// a leading slash. + List _parseMultiStatus(String body) { + final doc = XmlDocument.parse(body); + final basePath = _baseUri.path; // ends with '/' + final entries = []; + + for (final response in doc.findAllElements('response', namespace: '*')) { + final href = response + .findElements('href', namespace: '*') + .map((e) => e.innerText.trim()) + .firstWhere((_) => true, orElse: () => ''); + if (href.isEmpty) continue; + + // href may be absolute (http://host/dav/Lecture/x.pdf) or root-relative + // (/dav/Lecture/x.pdf). Reduce to the server path, then strip basePath. + var hrefPath = Uri.parse(href).path; + hrefPath = Uri.decodeFull(hrefPath); + final decodedBase = Uri.decodeFull(basePath); + if (!hrefPath.startsWith(decodedBase)) { + // Some servers omit the app prefix; try a looser suffix match. + final idx = hrefPath.indexOf(decodedBase); + if (idx < 0) continue; + hrefPath = hrefPath.substring(idx); + } + var rel = hrefPath.substring(decodedBase.length); + final isDir = rel.endsWith('/'); + rel = rel.replaceAll(RegExp(r'^/+|/+$'), ''); + if (rel.isEmpty) continue; // the root collection itself + + final propstat = response.findElements('propstat', namespace: '*'); + DateTime? modified; + int? size; + String? etag; + var collection = isDir; + for (final ps in propstat) { + for (final prop in ps.findElements('prop', namespace: '*')) { + final lm = prop + .findElements('getlastmodified', namespace: '*') + .map((e) => e.innerText.trim()) + .firstWhere((_) => true, orElse: () => ''); + if (lm.isNotEmpty) modified = _parseHttpDate(lm); + final cl = prop + .findElements('getcontentlength', namespace: '*') + .map((e) => e.innerText.trim()) + .firstWhere((_) => true, orElse: () => ''); + if (cl.isNotEmpty) size = int.tryParse(cl); + final et = prop + .findElements('getetag', namespace: '*') + .map((e) => e.innerText.trim()) + .firstWhere((_) => true, orElse: () => ''); + if (et.isNotEmpty) etag = et.replaceAll('"', ''); + if (prop.findAllElements('collection', namespace: '*').isNotEmpty) { + collection = true; + } + } + } + if (collection) continue; // sync only cares about files + entries.add(RemoteEntry( + path: rel, + isDirectory: false, + modified: modified?.toUtc(), + size: size, + etag: etag, + )); + } + return entries; + } + + static DateTime? _parseHttpDate(String s) { + try { + return parseHttpDate(s); + } catch (_) { + return null; + } + } + + Future _send( + String method, + Uri uri, { + Map? headers, + }) async { + final req = http.Request(method, uri)..headers.addAll(_headers); + if (headers != null) req.headers.addAll(headers); + try { + final streamed = await _client.send(req).timeout(timeout); + return http.Response.fromStream(streamed); + } on WebDavException { + rethrow; + } catch (e) { + throw WebDavException('Network error: $e'); + } + } + + Future _get(Uri uri) async { + try { + return await _client.get(uri, headers: _headers).timeout(timeout); + } catch (e) { + throw WebDavException('Network error: $e'); + } + } + + Future _put(Uri uri, Uint8List bytes) async { + try { + return await _client + .put(uri, headers: _headers, body: bytes) + .timeout(timeout); + } catch (e) { + throw WebDavException('Network error: $e'); + } + } + + /// Release the underlying [http.Client] if this instance created it. + void close() { + if (_ownsClient) _client.close(); + } +} + +/// Parse an RFC 1123 / RFC 850 / asctime HTTP-date into UTC. Kept local (rather +/// than pulling `http_parser`) since only `getlastmodified` needs it. +DateTime? parseHttpDate(String input) { + final s = input.trim(); + // RFC 1123: "Sun, 06 Nov 1994 08:49:37 GMT" + final months = { + 'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6, + 'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12, + }; + final m = RegExp( + r'(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})', + ).firstMatch(s); + if (m == null) return null; + final day = int.parse(m.group(1)!); + final month = months[m.group(2)!]; + if (month == null) return null; + final year = int.parse(m.group(3)!); + final hour = int.parse(m.group(4)!); + final min = int.parse(m.group(5)!); + final sec = int.parse(m.group(6)!); + return DateTime.utc(year, month, day, hour, min, sec); +} diff --git a/lib/services/webdav_sync_service.dart b/lib/services/webdav_sync_service.dart new file mode 100644 index 0000000..119bf97 --- /dev/null +++ b/lib/services/webdav_sync_service.dart @@ -0,0 +1,573 @@ +// 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, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 15992cb..598d77c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -448,7 +448,7 @@ packages: source: hosted version: "2.0.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -1370,7 +1370,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index fce6251..db34492 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,14 @@ dependencies: # Settings persistence shared_preferences: ^2.3.0 + # WebDAV sync: a minimal client (PROPFIND/GET/PUT/MKCOL) is hand-rolled over + # `package:http` (HttpWebDavClient) — both were already transitive deps. We + # avoid the `webdav_client` package because it drags in `dio`; `http` works on + # Windows and keeps the real adapter thin so the sync algorithm stays testable + # against a fake client. + http: ^1.2.0 + xml: ^6.5.0 + # Cross-platform file picker file_picker: ^8.0.0 diff --git a/test/webdav_sync_service_test.dart b/test/webdav_sync_service_test.dart new file mode 100644 index 0000000..a3ffb4d --- /dev/null +++ b/test/webdav_sync_service_test.dart @@ -0,0 +1,427 @@ +// Proves the WebDAV sync ALGORITHM against a FAKE in-memory WebDavClient (no +// real server — the HttpWebDavClient adapter is device/server-validated only). +// +// Covers: the pure per-file decision (decide), vault<->remote path mapping, and +// the full syncNow orchestration — newer-local→upload, newer-remote→download, +// both-changed→.conflict kept (no data lost), new-local→created remotely. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:badnote/services/webdav_client.dart'; +import 'package:badnote/services/webdav_sync_service.dart'; + +/// In-memory fake WebDAV server. Stores bytes + mtimes by remote path +/// (forward-slashed, relative to the remote root). Records uploads so tests can +/// assert what was pushed. +class FakeWebDavClient implements WebDavClient { + final Map files = {}; + final Map mtimes = {}; + final Set collections = {}; + final List uploads = []; + bool testConnectionThrows = false; + + /// Seed a remote file with bytes + an mtime. + void seed(String remotePath, String content, DateTime mtime) { + files[remotePath] = Uint8List.fromList(utf8.encode(content)); + mtimes[remotePath] = mtime.toUtc(); + } + + @override + Future> list(String remoteDir) async { + return files.keys + .map((path) => RemoteEntry( + path: path, + isDirectory: false, + modified: mtimes[path], + size: files[path]!.length, + )) + .toList(); + } + + @override + Future download(String remotePath) async { + final bytes = files[remotePath]; + if (bytes == null) { + throw WebDavException('not found: $remotePath', statusCode: 404); + } + return bytes; + } + + @override + Future upload(String remotePath, Uint8List bytes) async { + files[remotePath] = bytes; + mtimes[remotePath] = DateTime.now().toUtc(); + uploads.add(remotePath); + } + + @override + Future makeCollection(String remotePath) async { + collections.add(remotePath); + } + + @override + Future testConnection() async { + if (testConnectionThrows) throw WebDavException('boom'); + } + + String? contentOf(String remotePath) { + final b = files[remotePath]; + return b == null ? null : utf8.decode(b); + } +} + +DateTime _utc(int y, int mo, int d, [int h = 0, int mi = 0, int s = 0]) => + DateTime.utc(y, mo, d, h, mi, s); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory vaultDir; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + vaultDir = await Directory.systemTemp.createTemp('webdav_sync_test'); + }); + + tearDown(() async { + if (await vaultDir.exists()) await vaultDir.delete(recursive: true); + }); + + Future makeService() async { + final prefs = await SharedPreferences.getInstance(); + return WebDavSyncService(prefs); + } + + Future writeLocal(String rel, String content, DateTime mtime) async { + final f = File(p.join(vaultDir.path, rel)); + await f.parent.create(recursive: true); + await f.writeAsString(content); + await f.setLastModified(mtime); + return f; + } + + String readLocal(String rel) => + File(p.join(vaultDir.path, rel)).readAsStringSync(); + + List localConflictCopies(String rel) { + final folder = Directory(p.join(vaultDir.path, p.dirname(rel))); + final base = p.basename(rel); + return folder + .listSync() + .whereType() + .map((f) => p.basename(f.path)) + .where((n) => n.startsWith('$base${WebDavSyncService.conflictMarker}')) + .toList(); + } + + group('decide (pure per-file decision)', () { + SyncBaseline both(DateTime l, DateTime r) => + SyncBaseline(localMtime: l, remoteMtime: r); + + test('local-only → upload', () { + final d = WebDavSyncService.decide(FileFacts( + relPath: 'a/x.pdf', + localMtime: _utc(2026, 1, 1), + remoteMtime: null, + baseline: null, + )); + expect(d.action, SyncAction.upload); + }); + + test('remote-only never-seen → download', () { + final d = WebDavSyncService.decide(FileFacts( + relPath: 'a/x.pdf', + localMtime: null, + remoteMtime: _utc(2026, 1, 1), + baseline: null, + )); + expect(d.action, SyncAction.download); + }); + + test('remote-only but known-before → download (restore, never delete)', () { + final d = WebDavSyncService.decide(FileFacts( + relPath: 'a/x.pdf', + localMtime: null, + remoteMtime: _utc(2026, 1, 2), + baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)), + )); + expect(d.action, SyncAction.download); + }); + + test('both unchanged since baseline → skip', () { + final t = _utc(2026, 1, 1, 10); + final d = WebDavSyncService.decide(FileFacts( + relPath: 'a/x.pdf', + localMtime: t, + remoteMtime: t, + baseline: both(t, t), + )); + expect(d.action, SyncAction.skip); + }); + + test('unchanged with DIFFERENT but-baselined mtimes per side → skip', () { + // After an upload the server stamps its own mtime; comparing each side to + // ITS baseline must still read as "no change". + final localT = _utc(2026, 1, 1, 10); + final remoteT = _utc(2026, 1, 1, 10, 0, 5); // server clock differs + final d = WebDavSyncService.decide(FileFacts( + relPath: 'a/x.pdf', + localMtime: localT, + remoteMtime: remoteT, + baseline: both(localT, remoteT), + )); + expect(d.action, SyncAction.skip); + }); + + test('only local changed → upload', () { + final d = WebDavSyncService.decide(FileFacts( + relPath: 'a/x.pdf', + localMtime: _utc(2026, 1, 2), + remoteMtime: _utc(2026, 1, 1), + baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)), + )); + expect(d.action, SyncAction.upload); + }); + + test('only remote changed → download', () { + final d = WebDavSyncService.decide(FileFacts( + relPath: 'a/x.pdf', + localMtime: _utc(2026, 1, 1), + remoteMtime: _utc(2026, 1, 2), + baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)), + )); + expect(d.action, SyncAction.download); + }); + + test('both changed → conflict, newer wins (local newer)', () { + final d = WebDavSyncService.decide(FileFacts( + relPath: 'a/x.pdf', + localMtime: _utc(2026, 1, 3), + remoteMtime: _utc(2026, 1, 2), + baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)), + )); + expect(d.action, SyncAction.conflict); + expect(d.conflictWinnerIsLocal, isTrue); + }); + + test('both changed → conflict, newer wins (remote newer)', () { + final d = WebDavSyncService.decide(FileFacts( + relPath: 'a/x.pdf', + localMtime: _utc(2026, 1, 2), + remoteMtime: _utc(2026, 1, 3), + baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)), + )); + expect(d.action, SyncAction.conflict); + expect(d.conflictWinnerIsLocal, isFalse); + }); + }); + + group('path mapping', () { + test('toRemotePath forward-slashes the relative path', () { + final rel = p.join('Lecture', 'Lecture.pdf'); + expect(WebDavSyncService.toRemotePath(rel), 'Lecture/Lecture.pdf'); + }); + + test('toLocalRelPath round-trips with OS separators', () { + const remote = 'Lecture/Lecture.pdf'; + final local = WebDavSyncService.toLocalRelPath(remote); + expect(local, p.join('Lecture', 'Lecture.pdf')); + expect(WebDavSyncService.toRemotePath(local), remote); + }); + + test('isSyncable filters tmp/bak/hidden', () { + expect(WebDavSyncService.isSyncable('a/x.pdf'), isTrue); + expect(WebDavSyncService.isSyncable('a/x.pdf.badnote.json'), isTrue); + expect(WebDavSyncService.isSyncable('a/x.pdf.tmp'), isFalse); + expect(WebDavSyncService.isSyncable('a/x.pdf.bak'), isFalse); + expect(WebDavSyncService.isSyncable('.hidden'), isFalse); + }); + }); + + group('syncNow orchestration (fake client)', () { + test('new-local → created remotely (upload + MKCOL)', () async { + final sync = await makeService(); + final fake = FakeWebDavClient(); + await writeLocal('Lecture/Lecture.pdf', 'pdf-bytes', _utc(2026, 1, 1)); + + final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + + expect(r.ok, isTrue); + expect(r.uploaded, 1); + expect(r.downloaded, 0); + expect(r.conflicts, 0); + expect(fake.contentOf('Lecture/Lecture.pdf'), 'pdf-bytes'); + // Parent collection was created before the upload. + expect(fake.collections, contains('Lecture')); + }); + + test('remote-only → downloaded into the vault', () async { + final sync = await makeService(); + final fake = FakeWebDavClient(); + fake.seed('Deck/Deck.pptx', 'remote-deck', _utc(2026, 2, 1)); + + final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + + expect(r.downloaded, 1); + expect(readLocal(p.join('Deck', 'Deck.pptx')), 'remote-deck'); + }); + + test('newer-local → upload overwrites remote', () async { + final sync = await makeService(); + final fake = FakeWebDavClient(); + // First sync establishes a shared baseline. + await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); + await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + expect(fake.contentOf('N/n.json'), 'v1'); + + // Local edited (newer mtime), remote untouched → upload. + await writeLocal('N/n.json', 'v2-local', _utc(2026, 1, 5)); + final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + + expect(r.uploaded, 1); + expect(r.conflicts, 0); + expect(fake.contentOf('N/n.json'), 'v2-local'); + }); + + test('newer-remote → download overwrites local', () async { + final sync = await makeService(); + final fake = FakeWebDavClient(); + await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); + await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + + // Remote edited (newer), local untouched → download. + fake.seed('N/n.json', 'v2-remote', _utc(2026, 1, 6)); + final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + + expect(r.downloaded, 1); + expect(r.conflicts, 0); + expect(readLocal(p.join('N', 'n.json')), 'v2-remote'); + }); + + test('both-changed → .conflict kept, no data lost (local wins)', () async { + final sync = await makeService(); + final fake = FakeWebDavClient(); + await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); + await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + + // BOTH sides change after the baseline; local is NEWER → local wins. + await writeLocal('N/n.json', 'local-edit', _utc(2026, 1, 10)); + fake.seed('N/n.json', 'remote-edit', _utc(2026, 1, 8)); + + final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + + expect(r.conflicts, 1); + // Canonical is the winner (local) on BOTH sides. + expect(readLocal(p.join('N', 'n.json')), 'local-edit'); + expect(fake.contentOf('N/n.json'), 'local-edit'); + + // The LOSER (remote bytes) is preserved as a local .conflict-* copy … + final copies = localConflictCopies(p.join('N', 'n.json')); + expect(copies, hasLength(1)); + expect(File(p.join(vaultDir.path, 'N', copies.single)).readAsStringSync(), + 'remote-edit'); + // … and pushed remotely too, so neither side loses the loser. + final remoteConflict = fake.files.keys.firstWhere( + (k) => k.contains(WebDavSyncService.conflictMarker), + orElse: () => '', + ); + expect(remoteConflict, isNotEmpty); + expect(fake.contentOf(remoteConflict), 'remote-edit'); + }); + + test('both-changed → .conflict kept, no data lost (remote wins)', () async { + final sync = await makeService(); + final fake = FakeWebDavClient(); + await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); + await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + + // BOTH change; remote is NEWER → remote wins. + await writeLocal('N/n.json', 'local-edit', _utc(2026, 1, 8)); + fake.seed('N/n.json', 'remote-edit', _utc(2026, 1, 12)); + + final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + + expect(r.conflicts, 1); + // Canonical is the remote winner on both sides. + expect(readLocal(p.join('N', 'n.json')), 'remote-edit'); + expect(fake.contentOf('N/n.json'), 'remote-edit'); + + // The LOSER (local bytes) is preserved as a .conflict-* copy locally. + final copies = localConflictCopies(p.join('N', 'n.json')); + expect(copies, hasLength(1)); + expect(File(p.join(vaultDir.path, 'N', copies.single)).readAsStringSync(), + 'local-edit'); + }); + + test('unchanged on both sides → skip, nothing re-uploaded', () async { + final sync = await makeService(); + final fake = FakeWebDavClient(); + await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); + await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + fake.uploads.clear(); + + final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + expect(r.skipped, greaterThanOrEqualTo(1)); + expect(r.uploaded, 0); + expect(r.downloaded, 0); + expect(fake.uploads, isEmpty); + }); + + test('missing vault folder → friendly error, no throw', () async { + final sync = await makeService(); + final fake = FakeWebDavClient(); + final r = await sync.syncNow( + vaultRoot: p.join(vaultDir.path, 'does-not-exist'), + client: fake, + ); + expect(r.ok, isFalse); + expect(r.error, isNotNull); + }); + + test('tmp/bak artifacts are not uploaded', () async { + final sync = await makeService(); + final fake = FakeWebDavClient(); + await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1)); + await writeLocal('N/n.json.tmp', 'partial', _utc(2026, 1, 1)); + await writeLocal('N/n.json.bak', 'backup', _utc(2026, 1, 1)); + + await sync.syncNow(vaultRoot: vaultDir.path, client: fake); + + expect(fake.files.keys, contains('N/n.json')); + expect(fake.files.keys.any((k) => k.endsWith('.tmp')), isFalse); + expect(fake.files.keys.any((k) => k.endsWith('.bak')), isFalse); + }); + }); + + group('config persistence', () { + test('saveConfig round-trips through SharedPreferences', () async { + final sync = await makeService(); + await sync.saveConfig(const WebDavConfig( + baseUrl: 'https://dav.example.com', + username: 'me', + password: 'secret', + remoteRoot: 'BadNote', + autoSync: true, + )); + final c = sync.config; + expect(c.baseUrl, 'https://dav.example.com'); + expect(c.username, 'me'); + expect(c.password, 'secret'); + expect(c.remoteRoot, 'BadNote'); + expect(c.autoSync, isTrue); + expect(c.isConfigured, isTrue); + }); + + test('buildClient returns null when unconfigured', () async { + final sync = await makeService(); + expect(sync.buildClient(), isNull); + expect(sync.config.isConfigured, isFalse); + }); + }); +}