251 lines
8.2 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|