MO-Fuel-Tax-Back/lib/services/drive_sync_service.dart
2026-08-08 16:55:40 -05:00

251 lines
8.2 KiB
Dart

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<String> 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<SyncResult> 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<void> _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<bool> _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<DriveDataFileInfo> _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<String> _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,
);
}
}