334 lines
12 KiB
Dart
334 lines
12 KiB
Dart
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 `Show Me The Fuel Refund` folder under [parentId]
|
|
/// (a folder the user picked in the folder browser) and configures this
|
|
/// service to use it. Returns the resulting folder ID.
|
|
///
|
|
/// If the user picked a folder that's *already* named
|
|
/// `Show Me The Fuel Refund` — [currentFolderName] carries that name up
|
|
/// from the browser — [parentId] is used directly instead of nesting
|
|
/// another same-named folder inside it. Otherwise a folder picked at
|
|
/// "My Files" root would end up as `/Show Me The Fuel Refund` on first
|
|
/// setup, but re-picking that same folder later (e.g. after reinstalling)
|
|
/// would double it up as `/Show Me The Fuel Refund/Show Me The Fuel
|
|
/// Refund`.
|
|
Future<String> selectAppFolder(String parentId, {String? currentFolderName}) async {
|
|
final session = provider.beginSession();
|
|
try {
|
|
final folderId = currentFolderName == appFolderName
|
|
? parentId
|
|
: 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,
|
|
);
|
|
}
|
|
}
|