MO-Fuel-Tax-Back/lib/services/cloud_sync_service.dart

428 lines
17 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;
/// True when pull/merge completed but [CloudSyncService.syncNow]'s
/// `beforeUpload` gate (the app's ad-watch requirement — see
/// [AdService.showGateAd]) came back closed, so nothing local was
/// uploaded. Remote changes, if any, were still merged in.
final bool adGateBlocked;
SyncResult.skipped()
: ranSync = false,
error = null,
adGateBlocked = false;
SyncResult.success()
: ranSync = true,
error = null,
adGateBlocked = false;
SyncResult.failure(this.error)
: ranSync = false,
adGateBlocked = false;
SyncResult.adGateBlocked()
: ranSync = false,
error = null,
adGateBlocked = true;
}
/// 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;
}
/// Points this service at the `Show Me The Fuel Refund` folder under
/// [parentId] (a folder the user picked in the folder browser, or —
/// see [AppState.connectProvider] — the assumed root location a fresh
/// connection defaults to) 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`.
///
/// Otherwise, three cases:
/// - A same-named folder already exists directly under [parentId] (e.g.
/// one someone else already set up there to share) — adopt it as-is,
/// the same convergence behavior as before, so two people pointed at
/// the same shared parent end up sharing one app folder either way.
/// - This service already has an app folder configured somewhere else
/// (from a previous [selectAppFolder] call, or the root default a
/// fresh connection starts with) and [parentId] has no folder of its
/// own yet — *move* the existing one there via
/// [CloudStorageSession.moveFolder], preserving its contents, rather
/// than creating a fresh empty folder and abandoning the old one with
/// all its synced data still in it.
/// - Neither of the above (nothing configured yet, nothing at the
/// destination) — create a fresh one, same as always.
Future<String> selectAppFolder(String parentId, {String? currentFolderName}) async {
final session = provider.beginSession();
try {
if (currentFolderName == appFolderName) {
configure(parentId);
return parentId;
}
CloudFolder? existingAtDestination;
for (final folder in await session.listFolders(parentId: parentId)) {
if (folder.name == appFolderName) {
existingAtDestination = folder;
break;
}
}
final String folderId;
if (existingAtDestination != null) {
folderId = existingAtDestination.id;
} else if (_appFolderId != null) {
folderId = await session.moveFolder(folderId: _appFolderId!, newParentId: parentId);
} else {
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.
///
/// [beforeUpload], if given, is awaited once — after any pull/merge of
/// remote changes has finished, but before anything local gets uploaded
/// (pending receipt photos or the database snapshot itself) — and its
/// result decides whether the upload happens at all. This is the hook
/// [AppState] uses to gate uploads behind the app's ad placement: a
/// `false` return means the gate is closed (see [AdService.showGateAd])
/// and this call returns [SyncResult.adGateBlocked] without uploading
/// anything, having still pulled/merged whatever the remote side had.
/// `everSyncedBefore` tells the caller whether a remote data file already
/// existed — `false` means this is the very first sync this shared cloud
/// folder has ever seen, which [AppState] uses to let that one upload
/// through for free, with no ad required.
Future<SyncResult> syncNow({
bool keepLocalReceiptCopies = false,
Duration staleLockAge = const Duration(minutes: 10),
Future<bool> Function({required bool everSyncedBefore})? beforeUpload,
}) 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);
}
if (beforeUpload != null &&
!await beforeUpload(everSyncedBefore: remoteInfo != null)) {
return SyncResult.adGateBlocked();
}
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);
// ad_free_entitlement is a newer table than vehicles/fuel_entries —
// unlike those two (present since the very first schema version, so
// any remote snapshot ever pushed already has them), a remote
// snapshot pushed before this table existed genuinely won't have
// it. Merging against it unconditionally would throw ("no such
// table") on exactly that snapshot; skipping when absent just means
// "that older snapshot has no entitlement info to contribute",
// which is correct — local's own value (if any) is left as-is.
final remoteHasEntitlementTable = (await db.rawQuery(
"SELECT 1 FROM remote_db.sqlite_master WHERE type = 'table' AND name = 'ad_free_entitlement'",
)).isNotEmpty;
if (remoteHasEntitlementTable) {
await db.execute(mergeAdFreeEntitlementSql);
}
// Same reasoning as ad_free_entitlement above — app_usage is newer
// than vehicles/fuel_entries, so an older remote snapshot genuinely
// won't have it.
final remoteHasAppUsageTable = (await db.rawQuery(
"SELECT 1 FROM remote_db.sqlite_master WHERE type = 'table' AND name = 'app_usage'",
)).isNotEmpty;
if (remoteHasAppUsageTable) {
await db.execute(mergeAppUsageSql);
}
} 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,
);
}
}