Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
324
lib/services/cloud_sync_service.dart
Normal file
324
lib/services/cloud_sync_service.dart
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'cloud/cloud_storage_provider.dart';
|
||||
import 'database_service.dart';
|
||||
import 'db_schema.dart';
|
||||
import 'lock_coordinator.dart' as lock;
|
||||
|
||||
class SyncResult {
|
||||
final bool ranSync;
|
||||
final Object? error;
|
||||
|
||||
SyncResult.skipped()
|
||||
: ranSync = false,
|
||||
error = null;
|
||||
|
||||
SyncResult.success()
|
||||
: ranSync = true,
|
||||
error = null;
|
||||
|
||||
SyncResult.failure(this.error) : ranSync = false;
|
||||
}
|
||||
|
||||
/// Orchestrates one round of sync against the shared cloud folder — same
|
||||
/// behavior no matter which [CloudStorageProvider] it's wired to: acquires
|
||||
/// the cross-device lock, pulls + merges the remote database if it
|
||||
/// changed, uploads any pending receipt photos, pushes the local database
|
||||
/// back up, then releases the lock.
|
||||
///
|
||||
/// The merge itself runs as SQL directly against the local database with
|
||||
/// the downloaded remote copy `ATTACH`ed, rather than decoding records into
|
||||
/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's
|
||||
/// new to us or has a newer `updated_at` than our copy. Rows we haven't
|
||||
/// touched (including our own not-yet-pushed edits) are left alone by that
|
||||
/// statement, so no separate "keep local" step is needed — see the README
|
||||
/// for the full reasoning.
|
||||
class CloudSyncService {
|
||||
final CloudStorageProvider provider;
|
||||
final DatabaseService databaseService;
|
||||
|
||||
String? _appFolderId;
|
||||
String? _receiptsFolderId;
|
||||
final Map<String, String> _vinFolderIds = {};
|
||||
final Map<String, String> _monthFolderIds = {};
|
||||
String? _dataFileId;
|
||||
String? _lastKnownRemoteVersionTag;
|
||||
|
||||
CloudSyncService({required this.provider, required this.databaseService});
|
||||
|
||||
bool get isConfigured => _appFolderId != null;
|
||||
|
||||
/// Call once a cloud app folder has been chosen (or restored at launch).
|
||||
void configure(String appFolderId) {
|
||||
_appFolderId = appFolderId;
|
||||
_receiptsFolderId = null;
|
||||
_vinFolderIds.clear();
|
||||
_monthFolderIds.clear();
|
||||
_dataFileId = null;
|
||||
_lastKnownRemoteVersionTag = null;
|
||||
}
|
||||
|
||||
void clearConfiguration() {
|
||||
_appFolderId = null;
|
||||
_receiptsFolderId = null;
|
||||
_vinFolderIds.clear();
|
||||
_monthFolderIds.clear();
|
||||
_dataFileId = null;
|
||||
_lastKnownRemoteVersionTag = null;
|
||||
}
|
||||
|
||||
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a
|
||||
/// folder the user picked in the folder browser) and configures this
|
||||
/// service to use it. Returns the resulting folder ID.
|
||||
Future<String> selectAppFolder(String parentId) async {
|
||||
final session = provider.beginSession();
|
||||
try {
|
||||
final folderId =
|
||||
await session.findOrCreateFolder(parentId: parentId, name: appFolderName);
|
||||
configure(folderId);
|
||||
return folderId;
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// [keepLocalReceiptCopies] mirrors the Settings toggle: when true, a
|
||||
/// receipt photo's local copy is left in place after it's uploaded
|
||||
/// (useful for offline viewing / an on-device backup); when false
|
||||
/// (default), it's deleted once the cloud has it, matching the original
|
||||
/// "local is just a staging area" design.
|
||||
///
|
||||
/// [staleLockAge] mirrors the Settings "Advanced" stale-lock timeout:
|
||||
/// how old another device's lock file has to be before this device
|
||||
/// treats it as abandoned (e.g. that device crashed or went offline
|
||||
/// mid-sync) and deletes it rather than waiting forever. Defaults to 10
|
||||
/// minutes, matching [defaultStaleLockMinutes] in app_state.dart.
|
||||
Future<SyncResult> syncNow({
|
||||
bool keepLocalReceiptCopies = false,
|
||||
Duration staleLockAge = const Duration(minutes: 10),
|
||||
}) async {
|
||||
final appFolderId = _appFolderId;
|
||||
if (!provider.isSignedIn || appFolderId == null) {
|
||||
return SyncResult.skipped();
|
||||
}
|
||||
|
||||
CloudStorageSession? session;
|
||||
String? lockFileId;
|
||||
|
||||
try {
|
||||
session = provider.beginSession();
|
||||
|
||||
lockFileId = await _acquireLock(
|
||||
session,
|
||||
appFolderId: appFolderId,
|
||||
username: provider.accountLabel!,
|
||||
staleAge: staleLockAge,
|
||||
);
|
||||
|
||||
_receiptsFolderId ??=
|
||||
await session.findOrCreateFolder(parentId: appFolderId, name: receiptsFolderName);
|
||||
|
||||
final remoteInfo = await session.findFile(folderId: appFolderId, name: dataFileName);
|
||||
_dataFileId = remoteInfo?.id;
|
||||
if (remoteInfo != null && remoteInfo.versionTag != _lastKnownRemoteVersionTag) {
|
||||
await _pullAndMerge(session, remoteInfo.id);
|
||||
}
|
||||
|
||||
final allReceiptsUploaded =
|
||||
await _uploadPendingReceipts(session, keepLocalCopies: keepLocalReceiptCopies);
|
||||
|
||||
// Only push if every pending receipt made it up — otherwise we'd
|
||||
// either upload a row with a local-only file path (meaningless on
|
||||
// another device) or prematurely mark it clean.
|
||||
if (allReceiptsUploaded) {
|
||||
final pushedInfo = await _pushSnapshot(session, appFolderId);
|
||||
_lastKnownRemoteVersionTag = pushedInfo.versionTag;
|
||||
}
|
||||
|
||||
return SyncResult.success();
|
||||
} catch (e) {
|
||||
return SyncResult.failure(e);
|
||||
} finally {
|
||||
if (lockFileId != null && session != null) {
|
||||
try {
|
||||
await session.deleteFile(lockFileId);
|
||||
} catch (_) {
|
||||
// Best-effort: if this fails, the staleness reap on other
|
||||
// devices' next sync attempt will clean it up.
|
||||
}
|
||||
}
|
||||
session?.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pullAndMerge(CloudStorageSession session, String remoteFileId) async {
|
||||
final bytes = await session.downloadFileBytes(remoteFileId);
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath =
|
||||
p.join(tempDir.path, 'cloud_pull_${DateTime.now().microsecondsSinceEpoch}.db');
|
||||
final tempFile = File(tempPath);
|
||||
await tempFile.writeAsBytes(bytes, flush: true);
|
||||
|
||||
try {
|
||||
final db = databaseService.rawDb;
|
||||
await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db");
|
||||
try {
|
||||
await db.execute(mergeVehiclesSql);
|
||||
await db.execute(mergeFuelEntriesSql);
|
||||
} finally {
|
||||
try {
|
||||
await db.execute('DETACH DATABASE remote_db');
|
||||
} catch (_) {
|
||||
// Don't let a detach failure mask a real merge error above.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads any receipt images still needing it (a local path but no
|
||||
/// cloud file ID yet — see [FuelEntry.needsReceiptUpload]). Returns true
|
||||
/// only if every pending image made it up — see [syncNow] for why a
|
||||
/// partial failure here blocks the push step entirely rather than
|
||||
/// pushing something with a leftover local-only path.
|
||||
///
|
||||
/// Deliberately filters on `receipt_drive_file_id IS NULL` (see
|
||||
/// [pendingReceiptUploadWhereClause]), not just "has a local path": once
|
||||
/// [keepLocalCopies] is honored below, an already-uploaded row can still
|
||||
/// have a local path (kept on purpose), and re-matching it here would
|
||||
/// re-upload the same photo as a duplicate cloud file on every sync.
|
||||
Future<bool> _uploadPendingReceipts(
|
||||
CloudStorageSession session, {
|
||||
required bool keepLocalCopies,
|
||||
}) async {
|
||||
final db = databaseService.rawDb;
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
if (rows.isEmpty) return true;
|
||||
|
||||
final vehicleRows = await db.query('vehicles', columns: ['id', 'vin']);
|
||||
final vinByVehicleId = {for (final v in vehicleRows) v['id'] as String: v['vin'] as String};
|
||||
|
||||
var allSucceeded = true;
|
||||
for (final row in rows) {
|
||||
final id = row['id'] as String;
|
||||
final localPath = row['receipt_image_path'] as String;
|
||||
final localFile = File(localPath);
|
||||
|
||||
if (!await localFile.exists()) {
|
||||
// Nothing left to upload; clear the dangling reference.
|
||||
await db.update('fuel_entries', {'receipt_image_path': null},
|
||||
where: 'id = ?', whereArgs: [id]);
|
||||
continue;
|
||||
}
|
||||
|
||||
final vin = vinByVehicleId[row['vehicle_id']];
|
||||
if (vin == null) {
|
||||
// Vehicle row is missing outright (shouldn't normally happen);
|
||||
// nothing sane to file this under.
|
||||
allSucceeded = false;
|
||||
continue;
|
||||
}
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(row['date'] as int);
|
||||
|
||||
try {
|
||||
final folderId = await _receiptFolderFor(session, vin, date);
|
||||
final uploaded = await session.uploadFile(
|
||||
folderId: folderId,
|
||||
name: '$id${p.extension(localPath)}',
|
||||
localFile: localFile,
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
if (!keepLocalCopies) {
|
||||
await databaseService.deleteReceiptImageFile(localPath);
|
||||
}
|
||||
await db.update(
|
||||
'fuel_entries',
|
||||
{
|
||||
'receipt_drive_file_id': uploaded.id,
|
||||
'receipt_image_path': keepLocalCopies ? localPath : null,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
} catch (_) {
|
||||
allSucceeded = false;
|
||||
}
|
||||
}
|
||||
return allSucceeded;
|
||||
}
|
||||
|
||||
/// Finds-or-creates the `Receipts/<vin>/<yyyy.mm>` subfolder for [vin] and
|
||||
/// [date] (the fuel entry's purchase date, not upload time), caching both
|
||||
/// levels for the rest of this [CloudSyncService]'s lifetime (cleared by
|
||||
/// [configure]/[clearConfiguration]) so repeated uploads for the same
|
||||
/// vehicle/month in one sync — or across syncs — don't re-issue the
|
||||
/// lookup.
|
||||
Future<String> _receiptFolderFor(CloudStorageSession session, String vin, DateTime date) async {
|
||||
final vinFolderId = _vinFolderIds[vin] ??
|
||||
await session.findOrCreateFolder(
|
||||
parentId: _receiptsFolderId!,
|
||||
name: sanitizedPathSegment(vin),
|
||||
);
|
||||
_vinFolderIds[vin] = vinFolderId;
|
||||
|
||||
final month = monthFolderName(date);
|
||||
final monthCacheKey = '$vin/$month';
|
||||
final monthFolderId = _monthFolderIds[monthCacheKey] ??
|
||||
await session.findOrCreateFolder(parentId: vinFolderId, name: month);
|
||||
_monthFolderIds[monthCacheKey] = monthFolderId;
|
||||
|
||||
return monthFolderId;
|
||||
}
|
||||
|
||||
/// Uploads the current local database file as the new remote copy, then
|
||||
/// clears the dirty flag on every row now that local matches the cloud.
|
||||
///
|
||||
/// Reads the live database file directly rather than a `VACUUM INTO`
|
||||
/// snapshot: sqflite uses SQLite's default rollback-journal mode (not
|
||||
/// WAL) unless explicitly configured otherwise, so the main file is a
|
||||
/// complete, valid database as soon as the last write's Future
|
||||
/// completes. `wal_checkpoint` is run first anyway as cheap insurance in
|
||||
/// case that ever changes. This also sidesteps `VACUUM INTO` needing
|
||||
/// SQLite 3.27+, which isn't guaranteed on very old Android versions.
|
||||
Future<CloudFileInfo> _pushSnapshot(CloudStorageSession session, String appFolderId) async {
|
||||
final db = databaseService.rawDb;
|
||||
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
|
||||
final info = await session.uploadFile(
|
||||
folderId: appFolderId,
|
||||
name: dataFileName,
|
||||
existingFileId: _dataFileId,
|
||||
localFile: File(databaseService.databasePath),
|
||||
contentType: 'application/x-sqlite3',
|
||||
);
|
||||
_dataFileId = info.id;
|
||||
|
||||
await db.update('vehicles', {'dirty': 0});
|
||||
await db.update('fuel_entries', {'dirty': 0});
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
String _escapeSqlLiteral(String value) => value.replaceAll("'", "''");
|
||||
|
||||
Future<String> _acquireLock(
|
||||
CloudStorageSession session, {
|
||||
required String appFolderId,
|
||||
required String username,
|
||||
required Duration staleAge,
|
||||
}) {
|
||||
return lock.acquireLock(
|
||||
username: username,
|
||||
createLock: (name) => session.createLockFile(folderId: appFolderId, name: name),
|
||||
listLocks: () => session.listLockFiles(appFolderId),
|
||||
deleteLock: session.deleteFile,
|
||||
staleAge: staleAge,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue