Added sqlite
This commit is contained in:
parent
73a7c93e64
commit
f01186df79
14 changed files with 784 additions and 382 deletions
|
|
@ -2,50 +2,51 @@ 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 '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import 'database_service.dart';
|
||||
import 'db_schema.dart';
|
||||
import 'drive_auth_service.dart';
|
||||
import 'drive_service.dart';
|
||||
import 'lock_coordinator.dart' as lock;
|
||||
import 'merge_utils.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
class SyncResult {
|
||||
final bool ranSync;
|
||||
final List<Vehicle>? vehicles;
|
||||
final List<FuelEntry>? fuelEntries;
|
||||
final Object? error;
|
||||
|
||||
SyncResult.skipped()
|
||||
: ranSync = false,
|
||||
vehicles = null,
|
||||
fuelEntries = null,
|
||||
error = null;
|
||||
|
||||
SyncResult.success({required this.vehicles, required this.fuelEntries})
|
||||
SyncResult.success()
|
||||
: ranSync = true,
|
||||
error = null;
|
||||
|
||||
SyncResult.failure(this.error)
|
||||
: ranSync = false,
|
||||
vehicles = null,
|
||||
fuelEntries = null;
|
||||
SyncResult.failure(this.error) : ranSync = false;
|
||||
}
|
||||
|
||||
/// Orchestrates one round of push-local/pull-remote sync against the shared
|
||||
/// Drive folder: acquires the cross-device file lock, downloads and merges
|
||||
/// the remote data file with local changes, uploads any pending receipt
|
||||
/// photos, writes the merged data file back, then releases the lock.
|
||||
/// 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 StorageService storageService;
|
||||
final DatabaseService databaseService;
|
||||
|
||||
String? _appFolderId;
|
||||
String? _receiptsFolderId;
|
||||
String? _dataFileId;
|
||||
String? _lastKnownRemoteMd5;
|
||||
|
||||
DriveSyncService({required this.authService, required this.storageService});
|
||||
DriveSyncService({required this.authService, required this.databaseService});
|
||||
|
||||
bool get isConfigured => _appFolderId != null;
|
||||
|
||||
|
|
@ -54,12 +55,14 @@ class DriveSyncService {
|
|||
_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
|
||||
|
|
@ -77,10 +80,7 @@ class DriveSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
Future<SyncResult> syncNow({
|
||||
required List<Vehicle> localVehicles,
|
||||
required List<FuelEntry> localFuelEntries,
|
||||
}) async {
|
||||
Future<SyncResult> syncNow() async {
|
||||
final appFolderId = _appFolderId;
|
||||
if (!authService.isSignedIn || appFolderId == null) {
|
||||
return SyncResult.skipped();
|
||||
|
|
@ -101,42 +101,24 @@ class DriveSyncService {
|
|||
);
|
||||
|
||||
_receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId);
|
||||
_dataFileId ??= await drive.findDataFileId(appFolderId);
|
||||
|
||||
var remoteVehicles = <Vehicle>[];
|
||||
var remoteFuelEntries = <FuelEntry>[];
|
||||
if (_dataFileId != null) {
|
||||
final text = await drive.downloadTextFile(_dataFileId!);
|
||||
if (text.trim().isNotEmpty) {
|
||||
final decoded = storageService.decodeData(text);
|
||||
remoteVehicles = decoded.vehicles;
|
||||
remoteFuelEntries = decoded.entries;
|
||||
}
|
||||
final remoteInfo = await drive.findDataFile(appFolderId);
|
||||
_dataFileId = remoteInfo?.id;
|
||||
if (remoteInfo != null && remoteInfo.md5Checksum != _lastKnownRemoteMd5) {
|
||||
await _pullAndMerge(drive, remoteInfo.id);
|
||||
}
|
||||
|
||||
final mergedVehicles = mergeById(
|
||||
remoteVehicles,
|
||||
localVehicles,
|
||||
(v) => v.id,
|
||||
(v) => v.updatedAt,
|
||||
);
|
||||
final mergedFuelEntries = await _uploadPendingReceiptsAndMerge(
|
||||
drive: drive,
|
||||
remoteFuelEntries: remoteFuelEntries,
|
||||
localFuelEntries: localFuelEntries,
|
||||
);
|
||||
final allReceiptsUploaded = await _uploadPendingReceipts(drive);
|
||||
|
||||
final jsonContent = storageService.encodeData(
|
||||
vehicles: mergedVehicles,
|
||||
entries: mergedFuelEntries,
|
||||
);
|
||||
_dataFileId = await drive.uploadDataFile(
|
||||
appFolderId: appFolderId,
|
||||
existingFileId: _dataFileId,
|
||||
jsonContent: jsonContent,
|
||||
);
|
||||
// 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(vehicles: mergedVehicles, fuelEntries: mergedFuelEntries);
|
||||
return SyncResult.success();
|
||||
} catch (e) {
|
||||
return SyncResult.failure(e);
|
||||
} finally {
|
||||
|
|
@ -152,43 +134,108 @@ class DriveSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
Future<List<FuelEntry>> _uploadPendingReceiptsAndMerge({
|
||||
required DriveService drive,
|
||||
required List<FuelEntry> remoteFuelEntries,
|
||||
required List<FuelEntry> localFuelEntries,
|
||||
}) async {
|
||||
final merged = mergeById(
|
||||
remoteFuelEntries,
|
||||
localFuelEntries,
|
||||
(e) => e.id,
|
||||
(e) => e.updatedAt,
|
||||
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',
|
||||
);
|
||||
|
||||
final result = <FuelEntry>[];
|
||||
for (final entry in merged) {
|
||||
if (!entry.hasPendingLocalReceipt) {
|
||||
result.add(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
final localPath = entry.receiptImagePath!;
|
||||
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()) {
|
||||
result.add(entry);
|
||||
// Nothing left to upload; clear the dangling reference.
|
||||
await db.update('fuel_entries', {'receipt_image_path': null},
|
||||
where: 'id = ?', whereArgs: [id]);
|
||||
continue;
|
||||
}
|
||||
|
||||
final driveFileId = await drive.uploadReceiptImage(
|
||||
receiptsFolderId: _receiptsFolderId!,
|
||||
fileName: '${entry.id}${p.extension(localPath)}',
|
||||
imageFile: localFile,
|
||||
);
|
||||
await storageService.deleteReceiptImage(localPath);
|
||||
result.add(entry.copyWith(receiptDriveFileId: driveFileId, clearReceiptImagePath: true));
|
||||
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 result;
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue