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 _vinFolderIds = {}; final Map _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 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). This is the /// one hook [AppState] uses to show the app's single ad placement, since /// it's meant to run before *pushing* local data up, not before *pulling* /// remote data down. Future syncNow({ bool keepLocalReceiptCopies = false, Duration staleLockAge = const Duration(minutes: 10), Future Function()? 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(); } 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 _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); } } 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 _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//` 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 _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 _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 _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, ); } }