import 'dart:io'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'database_service.dart'; import 'db_schema.dart'; import 'drive_auth_service.dart'; import 'drive_service.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 Drive folder: /// 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 DriveSyncService { final DriveAuthService authService; final DatabaseService databaseService; String? _appFolderId; String? _receiptsFolderId; String? _dataFileId; String? _lastKnownRemoteMd5; DriveSyncService({required this.authService, required this.databaseService}); bool get isConfigured => _appFolderId != null; /// Call once a Drive app folder has been chosen (or restored at launch). void configure(String appFolderId) { _appFolderId = appFolderId; _receiptsFolderId = null; _dataFileId = null; _lastKnownRemoteMd5 = null; } void clearConfiguration() { _appFolderId = null; _receiptsFolderId = null; _dataFileId = null; _lastKnownRemoteMd5 = null; } /// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a /// folder the user picked in the Drive folder browser) and configures /// this service to use it. Returns the resulting folder ID. Future selectAppFolder(String parentId) async { final client = authService.authenticatedHttpClient(); try { final drive = DriveService(client); final folderId = await drive.findOrCreateAppFolder(parentId); configure(folderId); return folderId; } finally { client.close(); } } Future syncNow() async { final appFolderId = _appFolderId; if (!authService.isSignedIn || appFolderId == null) { return SyncResult.skipped(); } http.Client? client; DriveService? drive; String? lockFileId; try { client = authService.authenticatedHttpClient(); drive = DriveService(client); lockFileId = await _acquireLock( drive, appFolderId: appFolderId, username: authService.currentAccountEmail!, ); _receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId); final remoteInfo = await drive.findDataFile(appFolderId); _dataFileId = remoteInfo?.id; if (remoteInfo != null && remoteInfo.md5Checksum != _lastKnownRemoteMd5) { await _pullAndMerge(drive, remoteInfo.id); } final allReceiptsUploaded = await _uploadPendingReceipts(drive); // 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(drive, appFolderId); _lastKnownRemoteMd5 = pushedInfo.md5Checksum; } return SyncResult.success(); } catch (e) { return SyncResult.failure(e); } finally { if (lockFileId != null && drive != null) { try { await drive.deleteFile(lockFileId); } catch (_) { // Best-effort: if this fails, the 10-minute staleness reap on // other devices' next sync attempt will clean it up. } } client?.close(); } } Future _pullAndMerge(DriveService drive, String remoteFileId) async { final bytes = await drive.downloadFileBytes(remoteFileId); final tempDir = await getTemporaryDirectory(); final tempPath = p.join(tempDir.path, 'drive_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 locally-pending receipt images (dirty fuel entries that /// still have a local path, not yet a Drive file ID). 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 path. Future _uploadPendingReceipts(DriveService drive) async { final db = databaseService.rawDb; final rows = await db.query( 'fuel_entries', where: 'dirty = 1 AND receipt_image_path IS NOT NULL AND deleted_at IS NULL', ); 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; } try { final driveFileId = await drive.uploadReceiptImage( receiptsFolderId: _receiptsFolderId!, fileName: '$id${p.extension(localPath)}', imageFile: localFile, ); await databaseService.deleteReceiptImageFile(localPath); await db.update( 'fuel_entries', {'receipt_drive_file_id': driveFileId, 'receipt_image_path': null}, where: 'id = ?', whereArgs: [id], ); } catch (_) { allSucceeded = false; } } return allSucceeded; } /// Uploads the current local database file as the new remote copy, then /// clears the dirty flag on every row now that local matches Drive. /// /// 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(DriveService drive, String appFolderId) async { final db = databaseService.rawDb; await db.execute('PRAGMA wal_checkpoint(TRUNCATE)'); final info = await drive.uploadDataFile( appFolderId: appFolderId, existingFileId: _dataFileId, localSnapshotFile: File(databaseService.databasePath), ); _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( DriveService drive, { required String appFolderId, required String username, }) { return lock.acquireLock( username: username, createLock: (name) => drive.createLockFile(appFolderId: appFolderId, name: name), listLocks: () => drive.listLockFiles(appFolderId), deleteLock: drive.deleteFile, ); } }