Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
|
|
@ -8,19 +8,42 @@ import 'package:uuid/uuid.dart';
|
|||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import 'cloud/cloud_storage_provider.dart';
|
||||
import 'cloud/dropbox_provider.dart';
|
||||
import 'cloud/google_drive_provider.dart';
|
||||
import 'cloud/onedrive_provider.dart';
|
||||
import 'cloud/webdav_provider.dart';
|
||||
import 'cloud_sync_service.dart';
|
||||
import 'database_service.dart';
|
||||
import 'drive_auth_service.dart';
|
||||
import 'drive_sync_service.dart';
|
||||
|
||||
const _prefsKeyDriveFolderId = 'drive_app_folder_id';
|
||||
const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
|
||||
const _prefsKeyActiveProviderId = 'active_cloud_provider_id';
|
||||
const _prefsKeyCloudFolderId = 'cloud_folder_id';
|
||||
const _prefsKeyCloudFolderPath = 'cloud_folder_path';
|
||||
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
|
||||
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
||||
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
||||
|
||||
const defaultStaleLockMinutes = 10;
|
||||
const minStaleLockMinutes = 1;
|
||||
const maxStaleLockMinutes = 60;
|
||||
|
||||
/// Thrown by [AppState.addVehicle] when the given VIN already belongs to
|
||||
/// an active vehicle — VIN is the primary/unique identifier, so adding a
|
||||
/// duplicate should be rejected rather than silently overwriting it.
|
||||
class DuplicateVinException implements Exception {
|
||||
final String vin;
|
||||
DuplicateVinException(this.vin);
|
||||
|
||||
@override
|
||||
String toString() => 'A vehicle with VIN "$vin" already exists.';
|
||||
}
|
||||
|
||||
/// Single source of truth for the app's in-memory data (vehicles + fuel
|
||||
/// entries), backed by [DatabaseService] (local SQLite) for persistence and
|
||||
/// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens
|
||||
/// read from this via Provider and call its mutating methods, which write
|
||||
/// through to the local database immediately and kick off a best-effort
|
||||
/// background sync to Drive.
|
||||
/// a [CloudSyncService] for pushing/pulling the shared copy on whichever
|
||||
/// [CloudStorageProvider] the user has connected. Screens read from this
|
||||
/// via Provider and call its mutating methods, which write through to the
|
||||
/// local database immediately and kick off a best-effort background sync.
|
||||
///
|
||||
/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the
|
||||
/// database, refreshed after every mutation and after every sync (since
|
||||
|
|
@ -28,27 +51,71 @@ const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
|
|||
/// handing back updated Dart objects).
|
||||
class AppState extends ChangeNotifier {
|
||||
final DatabaseService database = DatabaseService();
|
||||
final DriveAuthService driveAuth = DriveAuthService();
|
||||
late final DriveSyncService driveSync =
|
||||
DriveSyncService(authService: driveAuth, databaseService: database);
|
||||
|
||||
/// Every storage backend the user can choose from in Settings.
|
||||
final List<CloudStorageProvider> availableProviders = [
|
||||
GoogleDriveProvider(),
|
||||
DropboxProvider(),
|
||||
OneDriveProvider(),
|
||||
WebDavProvider(),
|
||||
];
|
||||
|
||||
CloudStorageProvider? activeProvider;
|
||||
CloudSyncService? cloudSync;
|
||||
|
||||
final _uuid = const Uuid();
|
||||
|
||||
List<Vehicle> vehicles = [];
|
||||
List<FuelEntry> fuelEntries = [];
|
||||
bool isLoading = true;
|
||||
|
||||
bool isDriveConnected = false;
|
||||
String? driveAccountEmail;
|
||||
String? driveFolderPath;
|
||||
String? cloudFolderPath;
|
||||
bool isSyncing = false;
|
||||
DateTime? lastSyncedAt;
|
||||
Object? lastSyncError;
|
||||
|
||||
bool keepReceiptPhotosLocally = false;
|
||||
int staleLockMinutes = defaultStaleLockMinutes;
|
||||
|
||||
/// When false (default), a newly captured receipt photo is downscaled
|
||||
/// and re-compressed to [receiptImageMaxDimension]/[receiptImageQuality]
|
||||
/// before it's stored — receipts are just photos of small printed text,
|
||||
/// so a full-resolution original (often several MB on a modern phone
|
||||
/// camera) buys nothing but cloud storage and sync bandwidth. When true,
|
||||
/// the original camera/gallery image is kept as-is.
|
||||
bool keepMaxQualityReceiptPhotos = false;
|
||||
|
||||
/// Set if [init] fails. The UI shows this (with a retry option) instead
|
||||
/// of spinning forever — an unhandled exception here previously left
|
||||
/// `isLoading` stuck at true with no feedback at all.
|
||||
Object? initError;
|
||||
|
||||
bool get isCloudConnected => activeProvider?.isSignedIn ?? false;
|
||||
String? get cloudAccountLabel => activeProvider?.accountLabel;
|
||||
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
|
||||
Future<void> init() async {
|
||||
await database.init();
|
||||
await _refreshFromDatabase();
|
||||
isLoading = true;
|
||||
initError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await database.init();
|
||||
await _refreshFromDatabase();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false;
|
||||
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
|
||||
keepMaxQualityReceiptPhotos =
|
||||
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
|
||||
} catch (e) {
|
||||
initError = e;
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
|
|
@ -58,7 +125,7 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
});
|
||||
|
||||
unawaited(_restoreDriveConnection());
|
||||
unawaited(_restoreCloudConnection());
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -72,18 +139,34 @@ class AppState extends ChangeNotifier {
|
|||
fuelEntries = await database.getFuelEntries();
|
||||
}
|
||||
|
||||
Future<void> _restoreDriveConnection() async {
|
||||
final signedIn = await driveAuth.attemptSilentSignIn();
|
||||
CloudStorageProvider? _providerById(CloudProviderId id) {
|
||||
for (final provider in availableProviders) {
|
||||
if (provider.id == id) return provider;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _restoreCloudConnection() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storedProviderName = prefs.getString(_prefsKeyActiveProviderId);
|
||||
if (storedProviderName == null) return;
|
||||
|
||||
final matchingId = CloudProviderId.values
|
||||
.where((id) => id.name == storedProviderName)
|
||||
.firstOrNull;
|
||||
final provider = matchingId == null ? null : _providerById(matchingId);
|
||||
if (provider == null) return;
|
||||
|
||||
final signedIn = await provider.attemptSilentSignIn();
|
||||
if (!signedIn) return;
|
||||
|
||||
isDriveConnected = true;
|
||||
driveAccountEmail = driveAuth.currentAccountEmail;
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final folderId = prefs.getString(_prefsKeyDriveFolderId);
|
||||
driveFolderPath = prefs.getString(_prefsKeyDriveFolderPath);
|
||||
final folderId = prefs.getString(_prefsKeyCloudFolderId);
|
||||
cloudFolderPath = prefs.getString(_prefsKeyCloudFolderPath);
|
||||
if (folderId != null) {
|
||||
driveSync.configure(folderId);
|
||||
cloudSync!.configure(folderId);
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
|
|
@ -92,48 +175,91 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> connectDrive() async {
|
||||
final email = await driveAuth.signIn();
|
||||
isDriveConnected = true;
|
||||
driveAccountEmail = email;
|
||||
notifyListeners();
|
||||
}
|
||||
Future<void> connectProvider(CloudProviderId id) async {
|
||||
final provider = _providerById(id);
|
||||
if (provider == null) return;
|
||||
|
||||
Future<void> disconnectDrive() async {
|
||||
await driveAuth.signOut();
|
||||
driveSync.clearConfiguration();
|
||||
isDriveConnected = false;
|
||||
driveAccountEmail = null;
|
||||
driveFolderPath = null;
|
||||
await provider.signIn();
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefsKeyDriveFolderId);
|
||||
await prefs.remove(_prefsKeyDriveFolderPath);
|
||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> chooseDriveFolder({
|
||||
/// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider]
|
||||
/// (currently just WebDAV) that needs a server URL/username/password
|
||||
/// instead of an OAuth browser flow. The caller (Settings) is responsible
|
||||
/// for collecting those from the user first.
|
||||
Future<void> connectProviderWithCredentials(
|
||||
CloudProviderId id, {
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final provider = _providerById(id);
|
||||
if (provider == null || provider is! ManualCredentialCloudStorageProvider) return;
|
||||
|
||||
await (provider as ManualCredentialCloudStorageProvider).signInWithCredentials(
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> disconnectCloud() async {
|
||||
final provider = activeProvider;
|
||||
if (provider == null) return;
|
||||
|
||||
await provider.signOut();
|
||||
cloudSync?.clearConfiguration();
|
||||
activeProvider = null;
|
||||
cloudSync = null;
|
||||
cloudFolderPath = null;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefsKeyActiveProviderId);
|
||||
await prefs.remove(_prefsKeyCloudFolderId);
|
||||
await prefs.remove(_prefsKeyCloudFolderPath);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> chooseCloudFolder({
|
||||
required String parentId,
|
||||
required String breadcrumbPath,
|
||||
}) async {
|
||||
final folderId = await driveSync.selectAppFolder(parentId);
|
||||
driveFolderPath = breadcrumbPath;
|
||||
final sync = cloudSync;
|
||||
if (sync == null) return;
|
||||
|
||||
final folderId = await sync.selectAppFolder(parentId);
|
||||
cloudFolderPath = breadcrumbPath;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKeyDriveFolderId, folderId);
|
||||
await prefs.setString(_prefsKeyDriveFolderPath, breadcrumbPath);
|
||||
await prefs.setString(_prefsKeyCloudFolderId, folderId);
|
||||
await prefs.setString(_prefsKeyCloudFolderPath, breadcrumbPath);
|
||||
notifyListeners();
|
||||
|
||||
unawaited(syncNow());
|
||||
}
|
||||
|
||||
Future<void> syncNow() async {
|
||||
if (isSyncing || !driveSync.isConfigured) return;
|
||||
final sync = cloudSync;
|
||||
if (isSyncing || sync == null || !sync.isConfigured) return;
|
||||
|
||||
isSyncing = true;
|
||||
notifyListeners();
|
||||
|
||||
final result = await driveSync.syncNow();
|
||||
final result = await sync.syncNow(
|
||||
keepLocalReceiptCopies: keepReceiptPhotosLocally,
|
||||
staleLockAge: Duration(minutes: staleLockMinutes),
|
||||
);
|
||||
|
||||
if (result.ranSync) {
|
||||
await _refreshFromDatabase();
|
||||
|
|
@ -147,36 +273,71 @@ class AppState extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setKeepReceiptPhotosLocally(bool value) async {
|
||||
keepReceiptPhotosLocally = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyKeepReceiptPhotosLocally, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setKeepMaxQualityReceiptPhotos(bool value) async {
|
||||
keepMaxQualityReceiptPhotos = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyKeepMaxQualityReceiptPhotos, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
|
||||
/// Settings "Advanced" section, which restricts the picker to that range
|
||||
/// anyway; this is a defensive backstop for any other caller.
|
||||
Future<void> setStaleLockMinutes(int minutes) async {
|
||||
staleLockMinutes = minutes.clamp(minStaleLockMinutes, maxStaleLockMinutes);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_prefsKeyStaleLockMinutes, staleLockMinutes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Throws [DuplicateVinException] if [vin] already belongs to another
|
||||
/// active vehicle — VIN must stay unique even though it's editable, so
|
||||
/// silently letting a duplicate through would be a real correctness bug,
|
||||
/// not just a UX wrinkle.
|
||||
Future<void> addVehicle({
|
||||
required String make,
|
||||
required String model,
|
||||
required String color,
|
||||
required String licensePlate,
|
||||
required String vin,
|
||||
String? nickname,
|
||||
}) async {
|
||||
if (await database.vinExists(vin)) {
|
||||
throw DuplicateVinException(vin);
|
||||
}
|
||||
final vehicle = Vehicle(
|
||||
id: _uuid.v4(),
|
||||
make: make,
|
||||
model: model,
|
||||
color: color,
|
||||
licensePlate: licensePlate,
|
||||
vin: vin,
|
||||
nickname: nickname,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
await database.saveVehicle(vehicle);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
/// Throws [DuplicateVinException] if [updated]'s VIN now collides with
|
||||
/// another active vehicle's — this is the check [addVehicle] does for a
|
||||
/// new vehicle, but here it also has to exclude the vehicle being edited
|
||||
/// itself (its VIN obviously still matches its own prior value if it
|
||||
/// wasn't changed).
|
||||
Future<void> updateVehicle(Vehicle updated) async {
|
||||
if (await database.vinExists(updated.vin, excludeId: updated.id)) {
|
||||
throw DuplicateVinException(updated.vin);
|
||||
}
|
||||
await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> deleteVehicle(String vehicleId) async {
|
||||
Future<void> deleteVehicle(String id) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(vehicleId, now);
|
||||
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now);
|
||||
for (final path in orphanedLocalPaths) {
|
||||
await database.deleteReceiptImageFile(path);
|
||||
}
|
||||
await database.softDeleteVehicle(vehicleId, now);
|
||||
await database.softDeleteVehicle(id, now);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +352,8 @@ class AppState extends ChangeNotifier {
|
|||
final id = _uuid.v4();
|
||||
String? storedImagePath;
|
||||
if (receiptImage != null) {
|
||||
storedImagePath = await database.storeReceiptImage(receiptImage, id);
|
||||
final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin;
|
||||
storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date);
|
||||
}
|
||||
|
||||
final entry = FuelEntry(
|
||||
|
|
@ -243,3 +405,7 @@ class AppState extends ChangeNotifier {
|
|||
unawaited(syncNow());
|
||||
}
|
||||
}
|
||||
|
||||
extension _FirstOrNull<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
|
|
|
|||
153
lib/services/cloud/cloud_storage_provider.dart
Normal file
153
lib/services/cloud/cloud_storage_provider.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import 'dart:io';
|
||||
|
||||
/// Shared naming convention across all providers: whichever cloud storage
|
||||
/// backend is active, the app looks for (or creates) a folder with this
|
||||
/// exact name wherever the user points it, so two devices pointed at the
|
||||
/// same shared parent location converge on the same data regardless of
|
||||
/// which provider they're using.
|
||||
const appFolderName = 'MO-Fuel-Tax-Back';
|
||||
const receiptsFolderName = 'receipts';
|
||||
const dataFileName = 'fuel_tax_tracker.db';
|
||||
|
||||
enum CloudProviderId { googleDrive, dropbox, oneDrive, webdav }
|
||||
|
||||
class CloudFolder {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
CloudFolder({required this.id, required this.name});
|
||||
}
|
||||
|
||||
class CloudFileInfo {
|
||||
final String id;
|
||||
|
||||
/// Opaque change-detection signal — Drive's md5Checksum, Dropbox's
|
||||
/// content_hash, OneDrive's cTag all satisfy "did this change since I
|
||||
/// last looked", which is the only thing callers need from it.
|
||||
final String? versionTag;
|
||||
|
||||
CloudFileInfo({required this.id, required this.versionTag});
|
||||
}
|
||||
|
||||
class CloudLockFile {
|
||||
final String id;
|
||||
final String username;
|
||||
final DateTime createdAtUtc;
|
||||
|
||||
CloudLockFile({required this.id, required this.username, required this.createdAtUtc});
|
||||
}
|
||||
|
||||
/// Thrown when an operation needs authorization that isn't currently
|
||||
/// available without prompting the user, e.g. during a background sync
|
||||
/// with an expired/revoked token.
|
||||
class CloudNotAuthorizedException implements Exception {
|
||||
final String providerName;
|
||||
CloudNotAuthorizedException(this.providerName);
|
||||
|
||||
@override
|
||||
String toString() => '$providerName access is not currently authorized.';
|
||||
}
|
||||
|
||||
/// One connected cloud storage backend (Google Drive, Dropbox, OneDrive).
|
||||
/// Owns account-level identity/auth; per-sync operations go through a
|
||||
/// [CloudStorageSession] obtained via [beginSession].
|
||||
abstract class CloudStorageProvider {
|
||||
CloudProviderId get id;
|
||||
String get displayName;
|
||||
|
||||
bool get isSignedIn;
|
||||
String? get accountLabel;
|
||||
|
||||
/// Attempts to restore a previous sign-in without any UI. Returns true
|
||||
/// if signed in and authorized.
|
||||
Future<bool> attemptSilentSignIn();
|
||||
|
||||
/// Interactive sign-in. Must be called from a user-initiated action
|
||||
/// (e.g. a button press). Returns a label to display (email/username).
|
||||
Future<String> signIn();
|
||||
|
||||
Future<void> signOut();
|
||||
|
||||
/// Starts one session's worth of operations (roughly: one sync round,
|
||||
/// or one folder-browsing screen visit). The caller owns its lifecycle —
|
||||
/// call [CloudStorageSession.close] when done with it.
|
||||
CloudStorageSession beginSession();
|
||||
}
|
||||
|
||||
/// Raw operations against one cloud storage backend, scoped to a single
|
||||
/// authenticated session (e.g. one HTTP client). `folderId`/`fileId` are
|
||||
/// opaque per-provider — for most providers a real ID, but for a
|
||||
/// path-addressed API (Dropbox) a session may internally treat the path
|
||||
/// itself as the "id". Callers never need to know which.
|
||||
abstract class CloudStorageSession {
|
||||
/// Lists folders under [parentId], or (if [sharedWithMe] is true and the
|
||||
/// provider supports it) top-level folders shared with the signed-in
|
||||
/// account regardless of parent. Providers that don't have a meaningful
|
||||
/// separate "shared with me" concept may just ignore [sharedWithMe] and
|
||||
/// always list under [parentId].
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false});
|
||||
|
||||
/// True if this provider has a distinct "Shared with me" browsing mode
|
||||
/// worth showing as a separate tab in the folder picker UI.
|
||||
bool get supportsSharedWithMe;
|
||||
|
||||
/// Finds a folder named [name] directly under [parentId], or creates one
|
||||
/// if none exists. If duplicates exist, the earliest-created one wins.
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name});
|
||||
|
||||
/// Looks up a file's ID + versionTag by name within [folderId] without
|
||||
/// downloading its content, or null if no such file exists yet.
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name});
|
||||
|
||||
Future<List<int>> downloadFileBytes(String fileId);
|
||||
|
||||
/// Creates the file if [existingFileId] is null, otherwise overwrites
|
||||
/// its content. Returns the (possibly new) file ID and fresh versionTag.
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
});
|
||||
|
||||
Future<void> deleteFile(String fileId);
|
||||
|
||||
Future<String> createLockFile({required String folderId, required String name});
|
||||
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId);
|
||||
|
||||
/// Releases any resources (e.g. closes an underlying HTTP client).
|
||||
void close();
|
||||
}
|
||||
|
||||
/// Implemented by providers that need the user to type in connection
|
||||
/// details (server URL, username, password) instead of completing an
|
||||
/// OAuth browser flow — namely a self-hosted WebDAV server, which has no
|
||||
/// central authorization server to redirect to. The Settings screen checks
|
||||
/// `provider is ManualCredentialCloudStorageProvider` to decide whether
|
||||
/// "Connect" opens a small credentials form instead of calling
|
||||
/// [CloudStorageProvider.signIn] directly.
|
||||
abstract class ManualCredentialCloudStorageProvider {
|
||||
Future<String> signInWithCredentials({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
});
|
||||
}
|
||||
|
||||
/// Parses a lock file named `{username}-{utcEpochMillis}.lock` — shared by
|
||||
/// every provider's [CloudStorageSession.listLockFiles] implementation
|
||||
/// rather than duplicated, since the lock file naming convention itself
|
||||
/// (owned by `lock_coordinator.dart`) is provider-agnostic.
|
||||
(String, DateTime)? parseLockFileName(String? name) {
|
||||
if (name == null || !name.endsWith('.lock')) return null;
|
||||
final withoutExt = name.substring(0, name.length - '.lock'.length);
|
||||
final lastDash = withoutExt.lastIndexOf('-');
|
||||
if (lastDash == -1) return null;
|
||||
final username = withoutExt.substring(0, lastDash);
|
||||
final epochStr = withoutExt.substring(lastDash + 1);
|
||||
final epoch = int.tryParse(epochStr);
|
||||
if (epoch == null) return null;
|
||||
return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true));
|
||||
}
|
||||
364
lib/services/cloud/dropbox_provider.dart
Normal file
364
lib/services/cloud/dropbox_provider.dart
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../cloud_oauth_config.dart';
|
||||
import 'cloud_storage_provider.dart';
|
||||
import 'oauth_pkce.dart';
|
||||
|
||||
/// Dropbox implementation of [CloudStorageProvider].
|
||||
///
|
||||
/// Unlike Google Drive, Dropbox's API is fundamentally *path*-addressed,
|
||||
/// not ID-addressed. Rather than fight that, [DropboxSession] treats a
|
||||
/// folder's own Dropbox path (e.g. "/MO-Fuel-Tax-Back") as its "id" for
|
||||
/// purposes of the generic [CloudStorageSession] interface — an
|
||||
/// implementation detail entirely inside this file, invisible to
|
||||
/// [CloudSyncService].
|
||||
class DropboxProvider implements CloudStorageProvider {
|
||||
String? _accessToken;
|
||||
String? _refreshToken;
|
||||
DateTime? _accessTokenExpiry;
|
||||
String? _accountLabel;
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.dropbox;
|
||||
|
||||
@override
|
||||
String get displayName => 'Dropbox';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => _refreshToken != null;
|
||||
|
||||
@override
|
||||
String? get accountLabel => _accountLabel;
|
||||
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
// The refresh token isn't persisted across app launches in this first
|
||||
// pass (kept in memory only) — see README's Known limitations. Silent
|
||||
// restore always fails; the user reconnects once per cold start until
|
||||
// that's added.
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signIn() async {
|
||||
final appKey = CloudOAuthConfig.dropboxAppKey;
|
||||
final redirectUri = CloudOAuthConfig.dropboxRedirectUri;
|
||||
if (appKey == null || redirectUri == null) {
|
||||
throw StateError('Dropbox OAuth is not configured yet (see cloud_oauth_config.dart).');
|
||||
}
|
||||
|
||||
final pkce = PkcePair.generate();
|
||||
final authUrl = Uri.https('www.dropbox.com', '/oauth2/authorize', {
|
||||
'client_id': appKey,
|
||||
'response_type': 'code',
|
||||
'code_challenge': pkce.codeChallenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'redirect_uri': redirectUri,
|
||||
'token_access_type': 'offline',
|
||||
});
|
||||
|
||||
final callbackUrlScheme = Uri.parse(redirectUri).scheme;
|
||||
final resultUrl = await FlutterWebAuth2.authenticate(
|
||||
url: authUrl.toString(),
|
||||
callbackUrlScheme: callbackUrlScheme,
|
||||
);
|
||||
|
||||
final code = Uri.parse(resultUrl).queryParameters['code'];
|
||||
if (code == null) {
|
||||
throw StateError('Dropbox sign-in did not return an authorization code.');
|
||||
}
|
||||
|
||||
await _exchangeCodeForTokens(
|
||||
code: code,
|
||||
codeVerifier: pkce.codeVerifier,
|
||||
appKey: appKey,
|
||||
redirectUri: redirectUri,
|
||||
);
|
||||
_accountLabel = await _fetchAccountEmail();
|
||||
return _accountLabel!;
|
||||
}
|
||||
|
||||
Future<void> _exchangeCodeForTokens({
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String appKey,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/oauth2/token'),
|
||||
body: {
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': appKey,
|
||||
'code_verifier': codeVerifier,
|
||||
'redirect_uri': redirectUri,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox token exchange failed: ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_refreshToken = json['refresh_token'] as String?;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400));
|
||||
}
|
||||
|
||||
Future<String> _fetchAccountEmail() async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/users/get_current_account'),
|
||||
headers: {'Authorization': 'Bearer $_accessToken'},
|
||||
);
|
||||
if (response.statusCode != 200) return 'Dropbox account';
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return json['email'] as String? ?? 'Dropbox account';
|
||||
}
|
||||
|
||||
/// Refreshes the access token if it's missing or close to expiring.
|
||||
/// Never prompts for UI — suitable for background sync — so throws
|
||||
/// [CloudNotAuthorizedException] if there's no refresh token to use.
|
||||
Future<String> _freshAccessToken() async {
|
||||
final stillValid = _accessToken != null &&
|
||||
_accessTokenExpiry != null &&
|
||||
DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1)));
|
||||
if (stillValid) return _accessToken!;
|
||||
|
||||
final refreshToken = _refreshToken;
|
||||
final appKey = CloudOAuthConfig.dropboxAppKey;
|
||||
if (refreshToken == null || appKey == null) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/oauth2/token'),
|
||||
body: {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
'client_id': appKey,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400));
|
||||
return _accessToken!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
_accessTokenExpiry = null;
|
||||
_accountLabel = null;
|
||||
}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() {
|
||||
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
|
||||
return DropboxSession(this);
|
||||
}
|
||||
}
|
||||
|
||||
class DropboxSession implements CloudStorageSession {
|
||||
final DropboxProvider _provider;
|
||||
DropboxSession(this._provider);
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => false;
|
||||
|
||||
Future<Map<String, String>> _authHeader() async =>
|
||||
{'Authorization': 'Bearer ${await _provider._freshAccessToken()}'};
|
||||
|
||||
/// Dropbox's root path is `""`, not `"/"` — our generic interface uses
|
||||
/// the literal string `'root'` for "the top of the tree" (matching
|
||||
/// Google Drive's convention), so translate that here.
|
||||
String _normalizePath(String id) => id == 'root' ? '' : id;
|
||||
|
||||
String _childPath(String parentId, String name) {
|
||||
final parent = _normalizePath(parentId);
|
||||
return '$parent/$name';
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _post(String path, Map<String, dynamic> body) async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', path),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox API error ($path): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// True if a Dropbox API error response's `.tag` chain indicates "the
|
||||
/// path doesn't exist" — Dropbox reports this as a normal 409 response
|
||||
/// with a structured error body, not a 404, so it needs its own check
|
||||
/// rather than a status-code check.
|
||||
bool _isPathNotFoundError(http.Response response) {
|
||||
if (response.statusCode != 409) return false;
|
||||
try {
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return jsonEncode(body['error']).contains('not_found');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final path = _normalizePath(parentId ?? 'root');
|
||||
final json = await _post('/2/files/list_folder', {'path': path});
|
||||
final entries = (json['entries'] as List<dynamic>? ?? []);
|
||||
return entries
|
||||
.cast<Map<String, dynamic>>()
|
||||
.where((e) => e['.tag'] == 'folder')
|
||||
.map((e) => CloudFolder(id: e['path_display'] as String, name: e['name'] as String))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
final childPath = _childPath(parentId, name);
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/files/get_metadata'),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'path': childPath}),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (json['.tag'] == 'folder') return childPath;
|
||||
} else if (!_isPathNotFoundError(response)) {
|
||||
throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
await _post('/2/files/create_folder_v2', {'path': childPath});
|
||||
return childPath;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
final filePath = _childPath(folderId, name);
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/files/get_metadata'),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'path': filePath}),
|
||||
);
|
||||
if (_isPathNotFoundError(response)) return null;
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (json['.tag'] != 'file') return null;
|
||||
return CloudFileInfo(id: filePath, versionTag: json['content_hash'] as String?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final response = await http.post(
|
||||
Uri.https('content.dropboxapi.com', '/2/files/download'),
|
||||
headers: {
|
||||
...await _authHeader(),
|
||||
'Dropbox-API-Arg': jsonEncode({'path': fileId}),
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox download failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final targetPath = existingFileId ?? _childPath(folderId, name);
|
||||
final bytes = await localFile.readAsBytes();
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('content.dropboxapi.com', '/2/files/upload'),
|
||||
headers: {
|
||||
...await _authHeader(),
|
||||
'Dropbox-API-Arg': jsonEncode({'path': targetPath, 'mode': 'overwrite'}),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: bytes,
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox upload failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return CloudFileInfo(
|
||||
id: json['path_display'] as String? ?? targetPath,
|
||||
versionTag: json['content_hash'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/files/delete_v2'),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'path': fileId}),
|
||||
);
|
||||
if (response.statusCode != 200 && !_isPathNotFoundError(response)) {
|
||||
throw StateError('Dropbox delete failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
final path = _childPath(folderId, name);
|
||||
final response = await http.post(
|
||||
Uri.https('content.dropboxapi.com', '/2/files/upload'),
|
||||
headers: {
|
||||
...await _authHeader(),
|
||||
'Dropbox-API-Arg': jsonEncode({'path': path, 'mode': 'overwrite'}),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: const <int>[],
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox lock creation failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
|
||||
final json = await _post('/2/files/list_folder', {'path': _normalizePath(folderId)});
|
||||
final entries = (json['entries'] as List<dynamic>? ?? []);
|
||||
|
||||
final locks = <CloudLockFile>[];
|
||||
for (final entry in entries.cast<Map<String, dynamic>>()) {
|
||||
if (entry['.tag'] != 'file') continue;
|
||||
final parsed = parseLockFileName(entry['name'] as String?);
|
||||
if (parsed != null) {
|
||||
locks.add(CloudLockFile(
|
||||
id: entry['path_display'] as String,
|
||||
username: parsed.$1,
|
||||
createdAtUtc: parsed.$2,
|
||||
));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
263
lib/services/cloud/google_drive_provider.dart
Normal file
263
lib/services/cloud/google_drive_provider.dart
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io' show File, Platform;
|
||||
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:googleapis/drive/v3.dart' as drive;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../cloud_oauth_config.dart';
|
||||
import 'cloud_storage_provider.dart';
|
||||
|
||||
/// Full Drive access is required (not the narrower `drive.file` scope)
|
||||
/// because users need to browse to and reuse folders that someone else
|
||||
/// created and shared with them, not just folders/files this app itself
|
||||
/// created. See the plan doc for the tradeoffs (this requires Google
|
||||
/// Cloud Console "Testing" mode with explicit test users, to avoid needing
|
||||
/// a full OAuth verification review).
|
||||
const _driveScopes = <String>['https://www.googleapis.com/auth/drive'];
|
||||
|
||||
const _folderMimeType = 'application/vnd.google-apps.folder';
|
||||
|
||||
/// Google Drive implementation of [CloudStorageProvider], via
|
||||
/// `google_sign_in` for auth and the `googleapis` `DriveApi` client for
|
||||
/// everything else.
|
||||
class GoogleDriveProvider implements CloudStorageProvider {
|
||||
bool _initialized = false;
|
||||
GoogleSignInAccount? _account;
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.googleDrive;
|
||||
|
||||
@override
|
||||
String get displayName => 'Google Drive';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => _account != null;
|
||||
|
||||
@override
|
||||
String? get accountLabel => _account?.email;
|
||||
|
||||
Future<void> _ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
await GoogleSignIn.instance.initialize(
|
||||
clientId: Platform.isIOS ? CloudOAuthConfig.googleIosClientId : null,
|
||||
serverClientId: Platform.isAndroid ? CloudOAuthConfig.googleAndroidServerClientId : null,
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
await _ensureInitialized();
|
||||
final account = await GoogleSignIn.instance.attemptLightweightAuthentication();
|
||||
_account = account;
|
||||
if (account == null) return false;
|
||||
|
||||
final authorization =
|
||||
await account.authorizationClient.authorizationForScopes(_driveScopes);
|
||||
return authorization != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signIn() async {
|
||||
await _ensureInitialized();
|
||||
final account = await GoogleSignIn.instance.authenticate(scopeHint: _driveScopes);
|
||||
_account = account;
|
||||
await account.authorizationClient.authorizeScopes(_driveScopes);
|
||||
return account.email;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
await GoogleSignIn.instance.signOut();
|
||||
_account = null;
|
||||
}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() {
|
||||
final account = _account;
|
||||
if (account == null) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
final client = _GoogleAuthHttpClient(account.authorizationClient);
|
||||
return GoogleDriveSession(client);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoogleAuthHttpClient extends http.BaseClient {
|
||||
final GoogleSignInAuthorizationClient _authClient;
|
||||
final http.Client _inner = http.Client();
|
||||
|
||||
_GoogleAuthHttpClient(this._authClient);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final headers =
|
||||
await _authClient.authorizationHeaders(_driveScopes, promptIfNecessary: false);
|
||||
if (headers == null) {
|
||||
throw CloudNotAuthorizedException('Google Drive');
|
||||
}
|
||||
request.headers.addAll(headers);
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
|
||||
class GoogleDriveSession implements CloudStorageSession {
|
||||
final http.Client _client;
|
||||
final drive.DriveApi _api;
|
||||
|
||||
GoogleDriveSession(this._client) : _api = drive.DriveApi(_client);
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => true;
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final query = sharedWithMe
|
||||
? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false"
|
||||
: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false";
|
||||
|
||||
final result = await _api.files.list(
|
||||
q: query,
|
||||
orderBy: 'name',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
return (result.files ?? [])
|
||||
.where((f) => f.id != null && f.name != null)
|
||||
.map((f) => CloudFolder(id: f.id!, name: f.name!))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
final existing = await _api.files.list(
|
||||
q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'",
|
||||
orderBy: 'createdTime',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
final files = existing.files ?? <drive.File>[];
|
||||
if (files.isNotEmpty && files.first.id != null) return files.first.id!;
|
||||
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..mimeType = _folderMimeType
|
||||
..parents = [parentId],
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$folderId' in parents and trashed=false and name='$name'",
|
||||
orderBy: 'modifiedTime desc',
|
||||
$fields: 'files(id,name,md5Checksum)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
final files = result.files ?? <drive.File>[];
|
||||
if (files.isEmpty || files.first.id == null) return null;
|
||||
return CloudFileInfo(id: files.first.id!, versionTag: files.first.md5Checksum);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final media = await _api.files.get(
|
||||
fileId,
|
||||
downloadOptions: drive.DownloadOptions.fullMedia,
|
||||
) as drive.Media;
|
||||
return _collectBytes(media.stream);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final length = await localFile.length();
|
||||
final media = drive.Media(localFile.openRead(), length, contentType: contentType);
|
||||
|
||||
if (existingFileId != null) {
|
||||
final updated = await _api.files.update(
|
||||
drive.File(),
|
||||
existingFileId,
|
||||
uploadMedia: media,
|
||||
$fields: 'id,md5Checksum',
|
||||
);
|
||||
return CloudFileInfo(id: updated.id ?? existingFileId, versionTag: updated.md5Checksum);
|
||||
}
|
||||
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..parents = [folderId],
|
||||
uploadMedia: media,
|
||||
$fields: 'id,md5Checksum',
|
||||
);
|
||||
return CloudFileInfo(id: created.id!, versionTag: created.md5Checksum);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
try {
|
||||
await _api.files.delete(fileId);
|
||||
} on drive.DetailedApiRequestError catch (e) {
|
||||
// Already gone (e.g. deleted by another device) — not an error for
|
||||
// our purposes.
|
||||
if (e.status != 404) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..parents = [folderId],
|
||||
uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'),
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$folderId' in parents and trashed=false and name contains '.lock'",
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
final locks = <CloudLockFile>[];
|
||||
for (final f in result.files ?? <drive.File>[]) {
|
||||
final parsed = parseLockFileName(f.name);
|
||||
if (f.id != null && parsed != null) {
|
||||
locks.add(CloudLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
Future<List<int>> _collectBytes(Stream<List<int>> stream) async {
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in stream) {
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() => _client.close();
|
||||
}
|
||||
31
lib/services/cloud/oauth_pkce.dart
Normal file
31
lib/services/cloud/oauth_pkce.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// PKCE (RFC 7636) verifier/challenge pair for Dropbox's and OneDrive's
|
||||
/// OAuth2 Authorization Code flow — proves to the token endpoint that the
|
||||
/// app completing the exchange is the same one that started the browser
|
||||
/// redirect, without needing an embedded client secret (appropriate for a
|
||||
/// public/mobile client, since a secret can't actually be kept secret in
|
||||
/// a distributed app binary).
|
||||
class PkcePair {
|
||||
final String codeVerifier;
|
||||
final String codeChallenge;
|
||||
|
||||
PkcePair._(this.codeVerifier, this.codeChallenge);
|
||||
|
||||
factory PkcePair.generate() {
|
||||
final verifier = _randomUrlSafeString(64);
|
||||
final challenge =
|
||||
base64Url.encode(sha256.convert(utf8.encode(verifier)).bytes).replaceAll('=', '');
|
||||
return PkcePair._(verifier, challenge);
|
||||
}
|
||||
|
||||
static String _randomUrlSafeString(int length) {
|
||||
// RFC 7636's unreserved character set for a code_verifier.
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
final random = Random.secure();
|
||||
return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join();
|
||||
}
|
||||
}
|
||||
350
lib/services/cloud/onedrive_provider.dart
Normal file
350
lib/services/cloud/onedrive_provider.dart
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../cloud_oauth_config.dart';
|
||||
import 'cloud_storage_provider.dart';
|
||||
import 'oauth_pkce.dart';
|
||||
|
||||
const _graphScopes = 'offline_access Files.ReadWrite.All';
|
||||
|
||||
/// OneDrive implementation of [CloudStorageProvider], via Microsoft Graph.
|
||||
/// Unlike Dropbox, Graph is ID-addressed like Drive, so it fits the
|
||||
/// interface directly with no path-based workaround.
|
||||
class OneDriveProvider implements CloudStorageProvider {
|
||||
String? _accessToken;
|
||||
String? _refreshToken;
|
||||
DateTime? _accessTokenExpiry;
|
||||
String? _accountLabel;
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.oneDrive;
|
||||
|
||||
@override
|
||||
String get displayName => 'OneDrive';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => _refreshToken != null;
|
||||
|
||||
@override
|
||||
String? get accountLabel => _accountLabel;
|
||||
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
// As with Dropbox, the refresh token is kept in memory only in this
|
||||
// first pass — see README's Known limitations.
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signIn() async {
|
||||
final clientId = CloudOAuthConfig.oneDriveClientId;
|
||||
final redirectUri = CloudOAuthConfig.oneDriveRedirectUri;
|
||||
if (clientId == null || redirectUri == null) {
|
||||
throw StateError('OneDrive OAuth is not configured yet (see cloud_oauth_config.dart).');
|
||||
}
|
||||
|
||||
final pkce = PkcePair.generate();
|
||||
final authUrl = Uri.https(
|
||||
'login.microsoftonline.com',
|
||||
'/common/oauth2/v2.0/authorize',
|
||||
{
|
||||
'client_id': clientId,
|
||||
'response_type': 'code',
|
||||
'redirect_uri': redirectUri,
|
||||
'response_mode': 'query',
|
||||
'scope': _graphScopes,
|
||||
'code_challenge': pkce.codeChallenge,
|
||||
'code_challenge_method': 'S256',
|
||||
},
|
||||
);
|
||||
|
||||
final callbackUrlScheme = Uri.parse(redirectUri).scheme;
|
||||
final resultUrl = await FlutterWebAuth2.authenticate(
|
||||
url: authUrl.toString(),
|
||||
callbackUrlScheme: callbackUrlScheme,
|
||||
);
|
||||
|
||||
final code = Uri.parse(resultUrl).queryParameters['code'];
|
||||
if (code == null) {
|
||||
throw StateError('OneDrive sign-in did not return an authorization code.');
|
||||
}
|
||||
|
||||
await _exchangeCodeForTokens(
|
||||
code: code,
|
||||
codeVerifier: pkce.codeVerifier,
|
||||
clientId: clientId,
|
||||
redirectUri: redirectUri,
|
||||
);
|
||||
_accountLabel = await _fetchAccountEmail();
|
||||
return _accountLabel!;
|
||||
}
|
||||
|
||||
Future<void> _exchangeCodeForTokens({
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String clientId,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'),
|
||||
body: {
|
||||
'client_id': clientId,
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': redirectUri,
|
||||
'code_verifier': codeVerifier,
|
||||
'scope': _graphScopes,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive token exchange failed: ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_refreshToken = json['refresh_token'] as String?;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600));
|
||||
}
|
||||
|
||||
Future<String> _fetchAccountEmail() async {
|
||||
final response = await http.get(
|
||||
Uri.https('graph.microsoft.com', '/v1.0/me'),
|
||||
headers: {'Authorization': 'Bearer $_accessToken'},
|
||||
);
|
||||
if (response.statusCode != 200) return 'OneDrive account';
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (json['mail'] as String?) ??
|
||||
(json['userPrincipalName'] as String?) ??
|
||||
'OneDrive account';
|
||||
}
|
||||
|
||||
Future<String> _freshAccessToken() async {
|
||||
final stillValid = _accessToken != null &&
|
||||
_accessTokenExpiry != null &&
|
||||
DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1)));
|
||||
if (stillValid) return _accessToken!;
|
||||
|
||||
final refreshToken = _refreshToken;
|
||||
final clientId = CloudOAuthConfig.oneDriveClientId;
|
||||
if (refreshToken == null || clientId == null) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'),
|
||||
body: {
|
||||
'client_id': clientId,
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
'scope': _graphScopes,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_refreshToken = json['refresh_token'] as String? ?? _refreshToken;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600));
|
||||
return _accessToken!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
_accessTokenExpiry = null;
|
||||
_accountLabel = null;
|
||||
}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() {
|
||||
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
|
||||
return OneDriveSession(this);
|
||||
}
|
||||
}
|
||||
|
||||
class OneDriveSession implements CloudStorageSession {
|
||||
final OneDriveProvider _provider;
|
||||
OneDriveSession(this._provider);
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => true;
|
||||
|
||||
Future<Map<String, String>> _authHeader() async =>
|
||||
{'Authorization': 'Bearer ${await _provider._freshAccessToken()}'};
|
||||
|
||||
Uri _graph(String path) => Uri.parse('https://graph.microsoft.com/v1.0$path');
|
||||
|
||||
/// The interface's `'root'` sentinel (matching Google's convention) maps
|
||||
/// to Graph's own `/me/drive/root` special item.
|
||||
String _itemSegment(String id) => id == 'root' ? 'root' : 'items/$id';
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final uri = sharedWithMe
|
||||
? _graph('/me/drive/sharedWithMe')
|
||||
: _graph('/me/drive/${_itemSegment(parentId ?? 'root')}/children');
|
||||
|
||||
final response = await http.get(uri, headers: await _authHeader());
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive API error (listFolders): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final entries = (json['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
|
||||
|
||||
final folders = <CloudFolder>[];
|
||||
for (final entry in entries) {
|
||||
if (entry['folder'] == null) continue;
|
||||
// "Shared with me" items carry the shared item's own id under
|
||||
// `remoteItem`, not the top-level entry id.
|
||||
final remoteItem = entry['remoteItem'] as Map<String, dynamic>?;
|
||||
final id = (remoteItem?['id'] ?? entry['id']) as String?;
|
||||
final name = entry['name'] as String?;
|
||||
if (id != null && name != null) {
|
||||
folders.add(CloudFolder(id: id, name: name));
|
||||
}
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
final childrenUri = _graph('/me/drive/${_itemSegment(parentId)}/children');
|
||||
final listResponse = await http.get(childrenUri, headers: await _authHeader());
|
||||
if (listResponse.statusCode != 200) {
|
||||
throw StateError(
|
||||
'OneDrive API error (findOrCreateFolder list): ${listResponse.statusCode} ${listResponse.body}');
|
||||
}
|
||||
final listJson = jsonDecode(listResponse.body) as Map<String, dynamic>;
|
||||
final entries = (listJson['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
|
||||
for (final entry in entries) {
|
||||
if (entry['folder'] != null && entry['name'] == name) {
|
||||
return entry['id'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
final createResponse = await http.post(
|
||||
childrenUri,
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'folder': <String, dynamic>{},
|
||||
'@microsoft.graph.conflictBehavior': 'fail',
|
||||
}),
|
||||
);
|
||||
if (createResponse.statusCode != 201) {
|
||||
throw StateError(
|
||||
'OneDrive API error (findOrCreateFolder create): ${createResponse.statusCode} ${createResponse.body}');
|
||||
}
|
||||
final created = jsonDecode(createResponse.body) as Map<String, dynamic>;
|
||||
return created['id'] as String;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name');
|
||||
final response = await http.get(uri, headers: await _authHeader());
|
||||
if (response.statusCode == 404) return null;
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive API error (findFile): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final response =
|
||||
await http.get(_graph('/me/drive/items/$fileId/content'), headers: await _authHeader());
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive download failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final bytes = await localFile.readAsBytes();
|
||||
// Graph's simple upload endpoint (content < 4MB, which every receipt
|
||||
// photo and the sqlite data file comfortably are) — no upload session
|
||||
// needed.
|
||||
final uri = existingFileId != null
|
||||
? _graph('/me/drive/items/$existingFileId/content')
|
||||
: _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content');
|
||||
|
||||
final response = await http.put(
|
||||
uri,
|
||||
headers: {...await _authHeader(), 'Content-Type': contentType},
|
||||
body: bytes,
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw StateError('OneDrive upload failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
final response =
|
||||
await http.delete(_graph('/me/drive/items/$fileId'), headers: await _authHeader());
|
||||
if (response.statusCode != 204 && response.statusCode != 404) {
|
||||
throw StateError('OneDrive delete failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content');
|
||||
final response = await http.put(
|
||||
uri,
|
||||
headers: {...await _authHeader(), 'Content-Type': 'text/plain'},
|
||||
body: const <int>[],
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw StateError('OneDrive lock creation failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return json['id'] as String;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
|
||||
final response = await http.get(
|
||||
_graph('/me/drive/${_itemSegment(folderId)}/children'),
|
||||
headers: await _authHeader(),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive API error (listLockFiles): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final entries = (json['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
|
||||
|
||||
final locks = <CloudLockFile>[];
|
||||
for (final entry in entries) {
|
||||
final parsed = parseLockFileName(entry['name'] as String?);
|
||||
if (parsed != null) {
|
||||
locks.add(CloudLockFile(
|
||||
id: entry['id'] as String,
|
||||
username: parsed.$1,
|
||||
createdAtUtc: parsed.$2,
|
||||
));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
390
lib/services/cloud/webdav_provider.dart
Normal file
390
lib/services/cloud/webdav_provider.dart
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import 'cloud_storage_provider.dart';
|
||||
|
||||
const _secureStorageKeyServerUrl = 'webdav_server_url';
|
||||
const _secureStorageKeyUsername = 'webdav_username';
|
||||
const _secureStorageKeyPassword = 'webdav_password';
|
||||
|
||||
const _propfindRequestBody = '''<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getetag/>
|
||||
</D:prop>
|
||||
</D:propfind>''';
|
||||
|
||||
/// One `<D:response>` entry from a WebDAV PROPFIND multistatus response.
|
||||
/// Not private, and [parseWebDavMultistatus] is a free function, purely so
|
||||
/// the XML parsing can be unit-tested directly against sample responses
|
||||
/// from different server implementations — real WebDAV servers vary in
|
||||
/// namespace prefix (Nextcloud uses `d:`, others `D:` or none at all),
|
||||
/// which is exactly the kind of real-world format variance this app has
|
||||
/// been burned by before with format-specific assumptions.
|
||||
class WebDavEntry {
|
||||
final String path;
|
||||
final bool isCollection;
|
||||
final String? etag;
|
||||
WebDavEntry({required this.path, required this.isCollection, required this.etag});
|
||||
}
|
||||
|
||||
/// Parses a WebDAV PROPFIND multistatus XML body into [WebDavEntry]s, with
|
||||
/// each entry's href resolved to an absolute path against [baseUrl].
|
||||
/// Matches elements by local name only (ignoring namespace prefix), since
|
||||
/// that's the part that varies across server implementations.
|
||||
List<WebDavEntry> parseWebDavMultistatus(String xmlBody, Uri baseUrl) {
|
||||
final document = XmlDocument.parse(xmlBody);
|
||||
return _byLocalName(document, 'response').map((responseEl) {
|
||||
final href = _byLocalName(responseEl, 'href').first.innerText;
|
||||
final isCollection = _byLocalName(responseEl, 'collection').isNotEmpty;
|
||||
final etagEls = _byLocalName(responseEl, 'getetag').toList();
|
||||
return WebDavEntry(
|
||||
path: baseUrl.resolve(href).path,
|
||||
isCollection: isCollection,
|
||||
etag: etagEls.isEmpty ? null : etagEls.first.innerText,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Iterable<XmlElement> _byLocalName(XmlNode node, String localName) =>
|
||||
node.descendants.whereType<XmlElement>().where((e) => e.name.local == localName);
|
||||
|
||||
class _RawResponse {
|
||||
final int statusCode;
|
||||
final String body;
|
||||
final Map<String, String> headers;
|
||||
_RawResponse({required this.statusCode, required this.body, required this.headers});
|
||||
}
|
||||
|
||||
/// Sends a request with an arbitrary HTTP method (PROPFIND, MKCOL) that
|
||||
/// `package:http`'s GET/PUT/DELETE convenience functions don't support.
|
||||
Future<_RawResponse> _send(String method, Uri uri, {Map<String, String>? headers, Object? body}) async {
|
||||
final client = http.Client();
|
||||
try {
|
||||
final request = http.Request(method, uri);
|
||||
if (headers != null) request.headers.addAll(headers);
|
||||
if (body is String) request.body = body;
|
||||
if (body is List<int>) request.bodyBytes = body;
|
||||
final streamed = await client.send(request);
|
||||
final responseBody = await streamed.stream.bytesToString();
|
||||
return _RawResponse(statusCode: streamed.statusCode, body: responseBody, headers: streamed.headers);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
String _basicAuthHeader(String username, String password) =>
|
||||
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
|
||||
|
||||
String _normalizedPath(String path) => path.endsWith('/') ? path.substring(0, path.length - 1) : path;
|
||||
|
||||
String _nameFromPath(String path) {
|
||||
final segments = _normalizedPath(path).split('/').where((s) => s.isNotEmpty);
|
||||
return segments.isEmpty ? '' : Uri.decodeComponent(segments.last);
|
||||
}
|
||||
|
||||
/// WebDAV implementation of [CloudStorageProvider], for self-hosted
|
||||
/// personal cloud servers (Nextcloud, ownCloud, a Synology NAS, or any
|
||||
/// generic WebDAV server) rather than a named commercial provider. Unlike
|
||||
/// the OAuth-based providers, there's no browser sign-in flow and no
|
||||
/// developer-console app to register ahead of time — the user supplies a
|
||||
/// server URL, username, and password (an app-specific password is
|
||||
/// recommended on servers that support one, e.g. Nextcloud's Security
|
||||
/// settings) directly via [signInWithCredentials].
|
||||
class WebDavProvider implements CloudStorageProvider, ManualCredentialCloudStorageProvider {
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
|
||||
Uri? _baseUrl;
|
||||
String? _username;
|
||||
String? _password;
|
||||
|
||||
WebDavProvider({FlutterSecureStorage? secureStorage})
|
||||
: _secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.webdav;
|
||||
|
||||
@override
|
||||
String get displayName => 'WebDAV';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => _baseUrl != null;
|
||||
|
||||
@override
|
||||
String? get accountLabel => isSignedIn ? '$_username@${_baseUrl!.host}' : null;
|
||||
|
||||
/// Restores a session from credentials saved on a previous
|
||||
/// [signInWithCredentials] call (OS-encrypted storage — Keystore on
|
||||
/// Android, Keychain on iOS), re-verifying them with the same PROPFIND
|
||||
/// check rather than trusting them blindly, since the server config or
|
||||
/// password could have changed since. Any failure here — wrong/expired
|
||||
/// credentials, no network, nothing stored yet — just means "not signed
|
||||
/// in"; this never throws; a real error from a user-initiated attempt
|
||||
/// belongs to [signInWithCredentials], not this silent path.
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
try {
|
||||
final serverUrl = await _secureStorage.read(key: _secureStorageKeyServerUrl);
|
||||
final username = await _secureStorage.read(key: _secureStorageKeyUsername);
|
||||
final password = await _secureStorage.read(key: _secureStorageKeyPassword);
|
||||
if (serverUrl == null || username == null || password == null) return false;
|
||||
|
||||
await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signIn() {
|
||||
throw UnsupportedError(
|
||||
'WebDAV needs a server URL and credentials — use signInWithCredentials instead.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signInWithCredentials({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final label =
|
||||
await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password);
|
||||
|
||||
await _secureStorage.write(key: _secureStorageKeyServerUrl, value: _baseUrl!.toString());
|
||||
await _secureStorage.write(key: _secureStorageKeyUsername, value: username);
|
||||
await _secureStorage.write(key: _secureStorageKeyPassword, value: password);
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
/// Shared by [signInWithCredentials] and [attemptSilentSignIn]: normalizes
|
||||
/// the URL, does a side-effect-free PROPFIND on the root to confirm the
|
||||
/// URL and credentials actually work, and — only once that's confirmed —
|
||||
/// sets this instance's session fields.
|
||||
Future<String> _verifiedSignIn({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
var normalized = serverUrl.trim();
|
||||
if (!normalized.contains('://')) normalized = 'https://$normalized';
|
||||
if (!normalized.endsWith('/')) normalized += '/';
|
||||
final baseUrl = Uri.parse(normalized);
|
||||
|
||||
final response = await _send('PROPFIND', baseUrl, headers: {
|
||||
'Authorization': _basicAuthHeader(username, password),
|
||||
'Depth': '0',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
}, body: _propfindRequestBody);
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
throw StateError('WebDAV sign-in failed: invalid username or password.');
|
||||
}
|
||||
if (response.statusCode != 207 && response.statusCode != 200) {
|
||||
throw StateError('WebDAV sign-in failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
_baseUrl = baseUrl;
|
||||
_username = username;
|
||||
_password = password;
|
||||
return accountLabel!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
_baseUrl = null;
|
||||
_username = null;
|
||||
_password = null;
|
||||
await _secureStorage.delete(key: _secureStorageKeyServerUrl);
|
||||
await _secureStorage.delete(key: _secureStorageKeyUsername);
|
||||
await _secureStorage.delete(key: _secureStorageKeyPassword);
|
||||
}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() {
|
||||
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
|
||||
return WebDavSession(this);
|
||||
}
|
||||
|
||||
String get _authHeader => _basicAuthHeader(_username!, _password!);
|
||||
}
|
||||
|
||||
class _WebDavNotFoundException implements Exception {}
|
||||
|
||||
class WebDavSession implements CloudStorageSession {
|
||||
final WebDavProvider _provider;
|
||||
WebDavSession(this._provider);
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => false;
|
||||
|
||||
Uri _uriFor(String id) => id == 'root' ? _provider._baseUrl! : _provider._baseUrl!.replace(path: id);
|
||||
|
||||
Uri _childUri(Uri parent, String name) {
|
||||
final parentUri = parent.path.endsWith('/') ? parent : parent.replace(path: '${parent.path}/');
|
||||
return parentUri.resolve(Uri.encodeComponent(name));
|
||||
}
|
||||
|
||||
Future<List<WebDavEntry>> _propfind(Uri uri, {required String depth}) async {
|
||||
final response = await _send('PROPFIND', uri, headers: {
|
||||
'Authorization': _provider._authHeader,
|
||||
'Depth': depth,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
}, body: _propfindRequestBody);
|
||||
|
||||
if (response.statusCode == 404) throw _WebDavNotFoundException();
|
||||
if (response.statusCode != 207) {
|
||||
throw StateError('WebDAV PROPFIND failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return parseWebDavMultistatus(response.body, _provider._baseUrl!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final uri = _uriFor(parentId ?? 'root');
|
||||
final selfPath = _normalizedPath(uri.path);
|
||||
|
||||
List<WebDavEntry> entries;
|
||||
try {
|
||||
entries = await _propfind(uri, depth: '1');
|
||||
} on _WebDavNotFoundException {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries
|
||||
.where((e) => e.isCollection && _normalizedPath(e.path) != selfPath)
|
||||
.map((e) => CloudFolder(id: e.path, name: _nameFromPath(e.path)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
final childUri = _childUri(_uriFor(parentId), name);
|
||||
|
||||
try {
|
||||
final entries = await _propfind(childUri, depth: '0');
|
||||
if (entries.isNotEmpty && entries.first.isCollection) return childUri.path;
|
||||
} on _WebDavNotFoundException {
|
||||
// Falls through to create it below.
|
||||
}
|
||||
|
||||
final response =
|
||||
await _send('MKCOL', childUri, headers: {'Authorization': _provider._authHeader});
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw StateError('WebDAV MKCOL failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return childUri.path;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
final fileUri = _childUri(_uriFor(folderId), name);
|
||||
List<WebDavEntry> entries;
|
||||
try {
|
||||
entries = await _propfind(fileUri, depth: '0');
|
||||
} on _WebDavNotFoundException {
|
||||
return null;
|
||||
}
|
||||
if (entries.isEmpty) return null;
|
||||
return CloudFileInfo(id: fileUri.path, versionTag: entries.first.etag);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final response =
|
||||
await http.get(_uriFor(fileId), headers: {'Authorization': _provider._authHeader});
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('WebDAV download failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final uri = existingFileId != null ? _uriFor(existingFileId) : _childUri(_uriFor(folderId), name);
|
||||
final bytes = await localFile.readAsBytes();
|
||||
|
||||
final response = await http.put(
|
||||
uri,
|
||||
headers: {'Authorization': _provider._authHeader, 'Content-Type': contentType},
|
||||
body: bytes,
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) {
|
||||
throw StateError('WebDAV upload failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
// Many servers return the new ETag directly on the PUT response; fall
|
||||
// back to a follow-up PROPFIND only if it's missing.
|
||||
var etag = response.headers['etag'];
|
||||
if (etag == null) {
|
||||
try {
|
||||
final entries = await _propfind(uri, depth: '0');
|
||||
etag = entries.isEmpty ? null : entries.first.etag;
|
||||
} on _WebDavNotFoundException {
|
||||
etag = null;
|
||||
}
|
||||
}
|
||||
return CloudFileInfo(id: uri.path, versionTag: etag);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
final response =
|
||||
await http.delete(_uriFor(fileId), headers: {'Authorization': _provider._authHeader});
|
||||
if (response.statusCode != 200 && response.statusCode != 204 && response.statusCode != 404) {
|
||||
throw StateError('WebDAV delete failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
final uri = _childUri(_uriFor(folderId), name);
|
||||
final response = await http.put(
|
||||
uri,
|
||||
headers: {'Authorization': _provider._authHeader, 'Content-Type': 'text/plain'},
|
||||
body: const <int>[],
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) {
|
||||
throw StateError('WebDAV lock creation failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return uri.path;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
|
||||
final uri = _uriFor(folderId);
|
||||
final selfPath = _normalizedPath(uri.path);
|
||||
|
||||
List<WebDavEntry> entries;
|
||||
try {
|
||||
entries = await _propfind(uri, depth: '1');
|
||||
} on _WebDavNotFoundException {
|
||||
return [];
|
||||
}
|
||||
|
||||
final locks = <CloudLockFile>[];
|
||||
for (final entry in entries) {
|
||||
if (_normalizedPath(entry.path) == selfPath) continue;
|
||||
final parsed = parseLockFileName(_nameFromPath(entry.path));
|
||||
if (parsed != null) {
|
||||
locks.add(CloudLockFile(id: entry.path, username: parsed.$1, createdAtUtc: parsed.$2));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
52
lib/services/cloud_oauth_config.dart
Normal file
52
lib/services/cloud_oauth_config.dart
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/// Fill these in once the corresponding OAuth apps/registrations exist —
|
||||
/// see README.md "Manual setup required" for exact steps per provider.
|
||||
class CloudOAuthConfig {
|
||||
// --- Google Drive ---
|
||||
//
|
||||
// - Android sign-in (Credential Manager-based, as of google_sign_in v7)
|
||||
// authenticates using a *Web application* type OAuth client's ID, not
|
||||
// the Android client's own ID. The separate Android OAuth client
|
||||
// (registered with the package name + debug/release SHA-1) is still
|
||||
// required, but only to let Credential Manager verify this specific
|
||||
// signed app — its client ID itself is never referenced here.
|
||||
// - iOS uses its own iOS-type OAuth client ID directly.
|
||||
|
||||
/// The Web application OAuth client ID. Required for sign-in to work on
|
||||
/// Android.
|
||||
static const String? googleAndroidServerClientId = null; // TODO: fill in
|
||||
|
||||
/// The iOS OAuth client ID. Leave null if GIDClientID is instead set
|
||||
/// directly in ios/Runner/Info.plist.
|
||||
static const String? googleIosClientId = null; // TODO: fill in
|
||||
|
||||
// --- Dropbox ---
|
||||
//
|
||||
// From a "Full Dropbox" access app at dropbox.com/developers/apps.
|
||||
|
||||
/// The app's key (client ID for OAuth2 PKCE — no secret needed).
|
||||
static const String? dropboxAppKey = null; // TODO: fill in
|
||||
|
||||
/// Custom URL scheme redirect registered in the Dropbox app console.
|
||||
/// The scheme "mofueltaxback-dropbox" is already wired up in
|
||||
/// AndroidManifest.xml / Info.plist, so unless you have a reason to pick
|
||||
/// a different scheme, use exactly:
|
||||
/// "mofueltaxback-dropbox://oauth2redirect"
|
||||
static const String? dropboxRedirectUri = null; // TODO: fill in
|
||||
|
||||
// --- OneDrive (Microsoft Graph) ---
|
||||
//
|
||||
// From an app registration in Azure Portal → Entra ID → App
|
||||
// registrations, with Graph delegated permissions Files.ReadWrite.All +
|
||||
// offline_access, redirect URI registered as a "Mobile and desktop
|
||||
// application" platform.
|
||||
|
||||
/// The Application (client) ID from the Azure app registration.
|
||||
static const String? oneDriveClientId = null; // TODO: fill in
|
||||
|
||||
/// Custom URL scheme redirect registered in Azure. The scheme
|
||||
/// "mofueltaxback-onedrive" is already wired up in AndroidManifest.xml /
|
||||
/// Info.plist, so unless you have a reason to pick a different scheme,
|
||||
/// register and use exactly:
|
||||
/// "mofueltaxback-onedrive://auth"
|
||||
static const String? oneDriveRedirectUri = null; // TODO: fill in
|
||||
}
|
||||
324
lib/services/cloud_sync_service.dart
Normal file
324
lib/services/cloud_sync_service.dart
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
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<String, String> _vinFolderIds = {};
|
||||
final Map<String, String> _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;
|
||||
}
|
||||
|
||||
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a
|
||||
/// folder the user picked in the folder browser) and configures this
|
||||
/// service to use it. Returns the resulting folder ID.
|
||||
Future<String> selectAppFolder(String parentId) async {
|
||||
final session = provider.beginSession();
|
||||
try {
|
||||
final 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.
|
||||
Future<SyncResult> syncNow({
|
||||
bool keepLocalReceiptCopies = false,
|
||||
Duration staleLockAge = const Duration(minutes: 10),
|
||||
}) 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);
|
||||
}
|
||||
|
||||
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<void> _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);
|
||||
} 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<bool> _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/<vin>/<yyyy.mm>` 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<String> _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<CloudFileInfo> _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<String> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,33 @@ import 'dart:io';
|
|||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import 'db_schema.dart';
|
||||
|
||||
const dbFileName = 'fuel_tax_tracker.db';
|
||||
const receiptsFolderName = 'receipts';
|
||||
|
||||
/// OCR-scanned VINs are a fixed alphanumeric charset, but manual entry in
|
||||
/// the vehicle form doesn't enforce that — so anything outside
|
||||
/// `[A-Za-z0-9_-]` is replaced before a VIN is used as a local directory
|
||||
/// name or cloud folder name, to keep it a safe single path segment (no
|
||||
/// `/`, `..`, etc.).
|
||||
String sanitizedPathSegment(String value) => value.trim().replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
|
||||
|
||||
/// `yyyy.mm` for the given (local) date — the subfolder a receipt is filed
|
||||
/// under within its vehicle's folder, e.g. `2026.08`. Based on the fuel
|
||||
/// entry's purchase date ([FuelEntry.date]), not when the photo happened to
|
||||
/// be taken or synced.
|
||||
String monthFolderName(DateTime date) =>
|
||||
'${date.year.toString().padLeft(4, '0')}.${date.month.toString().padLeft(2, '0')}';
|
||||
|
||||
/// Name of the *local* on-device staging folder for not-yet-uploaded
|
||||
/// receipt photos — distinct from (though coincidentally the same string
|
||||
/// as) `receiptsFolderName` in `cloud/cloud_storage_provider.dart`, which
|
||||
/// names the corresponding subfolder inside the shared cloud app folder.
|
||||
const localReceiptsFolderName = 'receipts';
|
||||
|
||||
/// Owns the local SQLite database (vehicles + fuel_entries) and the
|
||||
/// `receipts/` folder of not-yet-uploaded receipt photos, both under
|
||||
|
|
@ -18,9 +38,9 @@ const receiptsFolderName = 'receipts';
|
|||
/// Every row carries `updated_at` (for newest-wins merging), `deleted_at`
|
||||
/// (a soft-delete tombstone — see [Vehicle.deletedAt]/[FuelEntry.deletedAt]
|
||||
/// for why deletes aren't real `DELETE`s), and `dirty` (local-only: "not
|
||||
/// yet pushed to Drive"). `dirty` is intentionally not exposed on the
|
||||
/// yet pushed to the cloud"). `dirty` is intentionally not exposed on the
|
||||
/// domain model classes — it's sync bookkeeping the UI layer never needs to
|
||||
/// know about; only [DriveSyncService] reads/clears it.
|
||||
/// know about; only [CloudSyncService] reads/clears it.
|
||||
class DatabaseService {
|
||||
late Directory _rootDirectory;
|
||||
late Database _db;
|
||||
|
|
@ -28,9 +48,9 @@ class DatabaseService {
|
|||
Directory get rootDirectory => _rootDirectory;
|
||||
|
||||
Directory get receiptsDirectory =>
|
||||
Directory(p.join(_rootDirectory.path, receiptsFolderName));
|
||||
Directory(p.join(_rootDirectory.path, localReceiptsFolderName));
|
||||
|
||||
/// Raw handle for [DriveSyncService], which needs to run ATTACH-based
|
||||
/// Raw handle for [CloudSyncService], which needs to run ATTACH-based
|
||||
/// merge SQL and VACUUM INTO snapshots that go beyond simple CRUD.
|
||||
Database get rawDb => _db;
|
||||
|
||||
|
|
@ -43,7 +63,12 @@ class DatabaseService {
|
|||
await receiptsDirectory.create(recursive: true);
|
||||
|
||||
final dbPath = p.join(_rootDirectory.path, dbFileName);
|
||||
_db = await openDatabase(dbPath, version: 1, onCreate: _onCreate);
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
version: 4,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
|
|
@ -52,6 +77,76 @@ class DatabaseService {
|
|||
await db.execute(createFuelEntriesIndexSql);
|
||||
}
|
||||
|
||||
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
|
||||
/// rebuilt fresh on upgrade rather than migrated, since at the time that
|
||||
/// only affected local, not-yet-synced test data. Version 4 (VIN becomes
|
||||
/// editable, with a new hidden `id` taking over as the actual primary
|
||||
/// key/merge key) is the first schema change made after real usage had
|
||||
/// likely accumulated, so this one preserves existing rows instead of
|
||||
/// dropping them: each vehicle gets a freshly generated `id`, and
|
||||
/// `fuel_entries.vehicle_id` is repointed from the old `vehicle_vin` via
|
||||
/// that mapping.
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
if (oldVersion < 4) {
|
||||
await _migrateToVehicleIdSchema(db);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateToVehicleIdSchema(Database db) async {
|
||||
const uuid = Uuid();
|
||||
|
||||
final oldVehicles = await db.query('vehicles');
|
||||
final vinToId = <String, String>{};
|
||||
|
||||
await db.execute('ALTER TABLE vehicles RENAME TO vehicles_old');
|
||||
await db.execute(createVehiclesTableSql);
|
||||
for (final row in oldVehicles) {
|
||||
final vin = row['vin'] as String;
|
||||
// An already-upgraded-then-reverted-then-upgraded-again edge case
|
||||
// could in theory produce duplicate vin rows pre-migration; keep the
|
||||
// first id assigned per vin so fuel_entries below has a single,
|
||||
// unambiguous target.
|
||||
final id = vinToId.putIfAbsent(vin, uuid.v4);
|
||||
await db.insert('vehicles', {
|
||||
'id': id,
|
||||
'vin': vin,
|
||||
'nickname': row['nickname'],
|
||||
'updated_at': row['updated_at'],
|
||||
'deleted_at': row['deleted_at'],
|
||||
// Force a re-push under the new schema — the shape of what's on
|
||||
// the cloud (if anything's been synced yet) still reflects the old
|
||||
// schema and needs to be overwritten with this one.
|
||||
'dirty': 1,
|
||||
});
|
||||
}
|
||||
await db.execute('DROP TABLE vehicles_old');
|
||||
|
||||
final oldFuelEntries = await db.query('fuel_entries');
|
||||
await db.execute('ALTER TABLE fuel_entries RENAME TO fuel_entries_old');
|
||||
await db.execute(createFuelEntriesTableSql);
|
||||
await db.execute(createFuelEntriesIndexSql);
|
||||
for (final row in oldFuelEntries) {
|
||||
final vehicleId = vinToId[row['vehicle_vin'] as String];
|
||||
// No matching vehicle row (shouldn't normally happen) — drop rather
|
||||
// than insert a fuel entry with a dangling reference.
|
||||
if (vehicleId == null) continue;
|
||||
await db.insert('fuel_entries', {
|
||||
'id': row['id'],
|
||||
'vehicle_id': vehicleId,
|
||||
'date': row['date'],
|
||||
'gallons': row['gallons'],
|
||||
'price_per_gallon': row['price_per_gallon'],
|
||||
'total_cost': row['total_cost'],
|
||||
'receipt_image_path': row['receipt_image_path'],
|
||||
'receipt_drive_file_id': row['receipt_drive_file_id'],
|
||||
'updated_at': row['updated_at'],
|
||||
'deleted_at': row['deleted_at'],
|
||||
'dirty': 1,
|
||||
});
|
||||
}
|
||||
await db.execute('DROP TABLE fuel_entries_old');
|
||||
}
|
||||
|
||||
Future<List<Vehicle>> getVehicles() async {
|
||||
final rows = await _db.query('vehicles', where: 'deleted_at IS NULL');
|
||||
return rows.map(Vehicle.fromMap).toList();
|
||||
|
|
@ -62,6 +157,27 @@ class DatabaseService {
|
|||
return rows.map(FuelEntry.fromMap).toList();
|
||||
}
|
||||
|
||||
/// True if an active (non-deleted) vehicle other than [excludeId] already
|
||||
/// has this VIN. VIN must stay unique even though it's editable, so
|
||||
/// callers adding a new vehicle (no [excludeId]) or changing an existing
|
||||
/// one's VIN (passing its own [id][Vehicle.id] as [excludeId], so it
|
||||
/// doesn't collide with itself) should check this first.
|
||||
Future<bool> vinExists(String vin, {String? excludeId}) async {
|
||||
final where = StringBuffer('vin = ? AND deleted_at IS NULL');
|
||||
final whereArgs = <Object?>[vin];
|
||||
if (excludeId != null) {
|
||||
where.write(' AND id != ?');
|
||||
whereArgs.add(excludeId);
|
||||
}
|
||||
final rows = await _db.query(
|
||||
'vehicles',
|
||||
where: where.toString(),
|
||||
whereArgs: whereArgs,
|
||||
limit: 1,
|
||||
);
|
||||
return rows.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Inserts or fully overwrites a vehicle row and marks it dirty (pending
|
||||
/// push to Drive).
|
||||
Future<void> saveVehicle(Vehicle vehicle) async {
|
||||
|
|
@ -129,9 +245,24 @@ class DatabaseService {
|
|||
return localPaths;
|
||||
}
|
||||
|
||||
Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
|
||||
/// Stores under `receipts/<vin>/<yyyy.mm>/`, mirroring the per-vehicle,
|
||||
/// per-month folder structure used on the cloud side (see
|
||||
/// [CloudSyncService]'s `_receiptFolderFor`), so a receipt's local
|
||||
/// staging path already shows which vehicle and month it belongs to.
|
||||
Future<String> storeReceiptImage(
|
||||
File sourceImage,
|
||||
String fuelEntryId,
|
||||
String vin,
|
||||
DateTime date,
|
||||
) async {
|
||||
final ext = p.extension(sourceImage.path);
|
||||
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
|
||||
final entryDir = Directory(p.join(
|
||||
receiptsDirectory.path,
|
||||
sanitizedPathSegment(vin),
|
||||
monthFolderName(date),
|
||||
));
|
||||
await entryDir.create(recursive: true);
|
||||
final destPath = p.join(entryDir.path, '$fuelEntryId$ext');
|
||||
final copied = await sourceImage.copy(destPath);
|
||||
return copied.path;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
const createVehiclesTableSql = '''
|
||||
CREATE TABLE vehicles (
|
||||
id TEXT PRIMARY KEY,
|
||||
make TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
license_plate TEXT NOT NULL,
|
||||
vin TEXT NOT NULL,
|
||||
nickname TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
dirty INTEGER NOT NULL DEFAULT 1
|
||||
|
|
@ -30,15 +28,33 @@ const createFuelEntriesTableSql = '''
|
|||
const createFuelEntriesIndexSql =
|
||||
'CREATE INDEX idx_fuel_entries_vehicle_id ON fuel_entries(vehicle_id)';
|
||||
|
||||
/// Selects fuel entries whose receipt photo still needs uploading to
|
||||
/// Drive. Deliberately requires `receipt_drive_file_id IS NULL`, not just
|
||||
/// "has a local path": once a row is uploaded, its local copy may still be
|
||||
/// kept around (see the "keep photos on this phone" setting) — matching on
|
||||
/// the local path alone would re-upload that same photo as a duplicate
|
||||
/// Drive file on every subsequent sync.
|
||||
const pendingReceiptUploadWhereClause = 'dirty = 1 AND receipt_image_path IS NOT NULL '
|
||||
'AND receipt_drive_file_id IS NULL AND deleted_at IS NULL';
|
||||
|
||||
/// Merges attached `remote_db` rows into `main` (the live local database):
|
||||
/// any remote row that's new to us, or newer than our copy, replaces ours.
|
||||
/// Rows this doesn't touch — including our own not-yet-pushed edits — are
|
||||
/// left alone, since the WHERE clause only matches rows remote should win;
|
||||
/// no separate "keep local" statement is needed.
|
||||
///
|
||||
/// Merges on `id` (the hidden, immutable identifier), not `vin` — VIN is
|
||||
/// user-editable, so it can't be relied on as a stable merge key. VIN
|
||||
/// uniqueness among active vehicles is enforced at the app layer instead
|
||||
/// (see `DatabaseService.vinExists`), not by a database constraint here,
|
||||
/// since two devices could in principle each independently add a vehicle
|
||||
/// with the same VIN while offline; that's an accepted rare-conflict edge
|
||||
/// case (see the "field-level conflicts aren't merged" caveat in the
|
||||
/// README) rather than something this merge statement tries to resolve.
|
||||
const mergeVehiclesSql = '''
|
||||
INSERT OR REPLACE INTO main.vehicles
|
||||
(id, make, model, color, license_plate, updated_at, deleted_at, dirty)
|
||||
SELECT r.id, r.make, r.model, r.color, r.license_plate, r.updated_at, r.deleted_at, 0
|
||||
(id, vin, nickname, updated_at, deleted_at, dirty)
|
||||
SELECT r.id, r.vin, r.nickname, r.updated_at, r.deleted_at, 0
|
||||
FROM remote_db.vehicles r
|
||||
LEFT JOIN main.vehicles l ON l.id = r.id
|
||||
WHERE l.id IS NULL OR r.updated_at > l.updated_at
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'drive_oauth_config.dart';
|
||||
|
||||
/// Full Drive access is required (not the narrower `drive.file` scope)
|
||||
/// because users need to browse to and reuse folders that someone else
|
||||
/// created and shared with them, not just folders/files this app itself
|
||||
/// created. See the plan doc for the tradeoffs (this requires Google
|
||||
/// Cloud Console "Testing" mode with explicit test users, to avoid needing
|
||||
/// a full OAuth verification review).
|
||||
const driveScopes = <String>['https://www.googleapis.com/auth/drive'];
|
||||
|
||||
/// Thrown when a Drive API call needs authorization that isn't currently
|
||||
/// available without prompting the user, e.g. during a background sync.
|
||||
class DriveNotAuthorizedException implements Exception {
|
||||
@override
|
||||
String toString() => 'Drive access is not currently authorized.';
|
||||
}
|
||||
|
||||
/// Wraps `google_sign_in` for authenticating with Google and producing an
|
||||
/// authenticated [http.Client] for the Drive API.
|
||||
class DriveAuthService {
|
||||
bool _initialized = false;
|
||||
GoogleSignInAccount? _account;
|
||||
|
||||
bool get isSignedIn => _account != null;
|
||||
|
||||
String? get currentAccountEmail => _account?.email;
|
||||
|
||||
Future<void> _ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
await GoogleSignIn.instance.initialize(
|
||||
clientId: Platform.isIOS ? DriveOAuthConfig.iosClientId : null,
|
||||
serverClientId: Platform.isAndroid ? DriveOAuthConfig.androidServerClientId : null,
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Attempts to restore a previous sign-in without any UI. Returns true if
|
||||
/// the user is signed in and Drive access is already authorized.
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
await _ensureInitialized();
|
||||
final account = await GoogleSignIn.instance.attemptLightweightAuthentication();
|
||||
_account = account;
|
||||
if (account == null) return false;
|
||||
|
||||
final authorization =
|
||||
await account.authorizationClient.authorizationForScopes(driveScopes);
|
||||
return authorization != null;
|
||||
}
|
||||
|
||||
/// Interactive sign-in + Drive scope authorization. Must be called from a
|
||||
/// user-initiated action (e.g. a button press).
|
||||
Future<String> signIn() async {
|
||||
await _ensureInitialized();
|
||||
final account = await GoogleSignIn.instance.authenticate(scopeHint: driveScopes);
|
||||
_account = account;
|
||||
await account.authorizationClient.authorizeScopes(driveScopes);
|
||||
return account.email;
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
await GoogleSignIn.instance.signOut();
|
||||
_account = null;
|
||||
}
|
||||
|
||||
/// Builds an [http.Client] that attaches a fresh Drive authorization
|
||||
/// header to every request. Fetches headers per-request (rather than
|
||||
/// once) so a client that lives across a long sync doesn't use a stale,
|
||||
/// expired token. Never prompts for UI — suitable for background sync —
|
||||
/// so throws [DriveNotAuthorizedException] if authorization isn't already
|
||||
/// in place (the caller should treat that as "Drive is disconnected").
|
||||
http.Client authenticatedHttpClient() {
|
||||
final account = _account;
|
||||
if (account == null) {
|
||||
throw DriveNotAuthorizedException();
|
||||
}
|
||||
return _DriveHttpClient(account.authorizationClient);
|
||||
}
|
||||
}
|
||||
|
||||
class _DriveHttpClient extends http.BaseClient {
|
||||
final GoogleSignInAuthorizationClient _authClient;
|
||||
final http.Client _inner = http.Client();
|
||||
|
||||
_DriveHttpClient(this._authClient);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final headers =
|
||||
await _authClient.authorizationHeaders(driveScopes, promptIfNecessary: false);
|
||||
if (headers == null) {
|
||||
throw DriveNotAuthorizedException();
|
||||
}
|
||||
request.headers.addAll(headers);
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
/// Fill these in once the corresponding OAuth clients exist in Google Cloud
|
||||
/// Console (see README.md "Manual setup required"). Both are needed even
|
||||
/// though only one app runs on each platform:
|
||||
///
|
||||
/// - Android sign-in (Credential Manager-based, as of google_sign_in v7)
|
||||
/// authenticates using a *Web application* type OAuth client's ID, not the
|
||||
/// Android client's own ID. The separate Android OAuth client (registered
|
||||
/// with the package name + debug/release SHA-1) is still required, but
|
||||
/// only to let Credential Manager verify this specific signed app — its
|
||||
/// client ID itself is never referenced here.
|
||||
/// - iOS uses its own iOS-type OAuth client ID directly.
|
||||
class DriveOAuthConfig {
|
||||
/// The Web application OAuth client ID. Required for sign-in to work on
|
||||
/// Android.
|
||||
static const String? androidServerClientId = null; // TODO: fill in
|
||||
|
||||
/// The iOS OAuth client ID. Leave null if GIDClientID is instead set
|
||||
/// directly in ios/Runner/Info.plist.
|
||||
static const String? iosClientId = null; // TODO: fill in
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:googleapis/drive/v3.dart' as drive;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
const appFolderName = 'MO-Fuel-Tax-Back';
|
||||
const receiptsFolderName = 'receipts';
|
||||
const dataFileName = 'fuel_tax_tracker.db';
|
||||
|
||||
const _folderMimeType = 'application/vnd.google-apps.folder';
|
||||
|
||||
class DriveFolder {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
DriveFolder({required this.id, required this.name});
|
||||
}
|
||||
|
||||
class DriveDataFileInfo {
|
||||
final String id;
|
||||
|
||||
/// Cheap change-detection signal: compare against the last value seen to
|
||||
/// decide whether a pull is actually needed, without downloading content.
|
||||
final String? md5Checksum;
|
||||
|
||||
DriveDataFileInfo({required this.id, required this.md5Checksum});
|
||||
}
|
||||
|
||||
class DriveLockFile {
|
||||
final String id;
|
||||
final String username;
|
||||
final DateTime createdAtUtc;
|
||||
|
||||
DriveLockFile({required this.id, required this.username, required this.createdAtUtc});
|
||||
}
|
||||
|
||||
/// Raw Google Drive API operations, built on top of an already-authenticated
|
||||
/// [http.Client] (see [DriveAuthService.authenticatedHttpClient]). Callers
|
||||
/// own the client's lifecycle (create it, use a [DriveService] instance for
|
||||
/// the duration of one sync, then close it).
|
||||
class DriveService {
|
||||
final drive.DriveApi _api;
|
||||
|
||||
DriveService(http.Client authenticatedClient) : _api = drive.DriveApi(authenticatedClient);
|
||||
|
||||
/// Lists folders under [parentId], or (if [sharedWithMe] is true) the
|
||||
/// top-level folders that other users have shared with the signed-in
|
||||
/// account, regardless of parent.
|
||||
Future<List<DriveFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final query = sharedWithMe
|
||||
? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false"
|
||||
: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false";
|
||||
|
||||
final result = await _api.files.list(
|
||||
q: query,
|
||||
orderBy: 'name',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
return (result.files ?? [])
|
||||
.where((f) => f.id != null && f.name != null)
|
||||
.map((f) => DriveFolder(id: f.id!, name: f.name!))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Finds a folder named [appFolderName] under [parentId], or creates one
|
||||
/// if none exists. Multiple devices pointed at the same shared parent
|
||||
/// converge on the same folder this way. If duplicates exist (Drive
|
||||
/// allows same-named folders), the earliest-created one wins.
|
||||
Future<String> findOrCreateAppFolder(String parentId) {
|
||||
return _findOrCreateFolder(name: appFolderName, parentId: parentId);
|
||||
}
|
||||
|
||||
Future<String> findOrCreateReceiptsFolder(String appFolderId) {
|
||||
return _findOrCreateFolder(name: receiptsFolderName, parentId: appFolderId);
|
||||
}
|
||||
|
||||
Future<String> _findOrCreateFolder({required String name, required String parentId}) async {
|
||||
final existing = await _api.files.list(
|
||||
q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'",
|
||||
orderBy: 'createdTime',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
final firstMatch = (existing.files ?? []).firstOrNullWithId();
|
||||
if (firstMatch != null) return firstMatch;
|
||||
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..mimeType = _folderMimeType
|
||||
..parents = [parentId],
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
/// Looks up the shared data file's ID and md5 checksum without
|
||||
/// downloading its content, so sync can cheaply decide whether a pull is
|
||||
/// actually needed.
|
||||
Future<DriveDataFileInfo?> findDataFile(String appFolderId) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'",
|
||||
orderBy: 'modifiedTime desc',
|
||||
$fields: 'files(id,name,md5Checksum)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
final files = result.files ?? <drive.File>[];
|
||||
if (files.isEmpty) return null;
|
||||
final first = files.first;
|
||||
if (first.id == null) return null;
|
||||
return DriveDataFileInfo(id: first.id!, md5Checksum: first.md5Checksum);
|
||||
}
|
||||
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final media = await _api.files.get(
|
||||
fileId,
|
||||
downloadOptions: drive.DownloadOptions.fullMedia,
|
||||
) as drive.Media;
|
||||
return _collectBytes(media.stream);
|
||||
}
|
||||
|
||||
/// Creates the data file if [existingFileId] is null, otherwise
|
||||
/// overwrites its content with the bytes of the local sqlite database
|
||||
/// file (see [DriveSyncService]). Returns the (possibly new) file ID and
|
||||
/// its fresh md5 checksum, so the caller can remember it for
|
||||
/// change-detection on the next sync without an extra round-trip.
|
||||
Future<DriveDataFileInfo> uploadDataFile({
|
||||
required String appFolderId,
|
||||
required String? existingFileId,
|
||||
required File localSnapshotFile,
|
||||
}) async {
|
||||
final length = await localSnapshotFile.length();
|
||||
final media = drive.Media(
|
||||
localSnapshotFile.openRead(),
|
||||
length,
|
||||
contentType: 'application/x-sqlite3',
|
||||
);
|
||||
|
||||
if (existingFileId != null) {
|
||||
final updated = await _api.files.update(
|
||||
drive.File(),
|
||||
existingFileId,
|
||||
uploadMedia: media,
|
||||
$fields: 'id,md5Checksum',
|
||||
);
|
||||
return DriveDataFileInfo(id: updated.id ?? existingFileId, md5Checksum: updated.md5Checksum);
|
||||
}
|
||||
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = dataFileName
|
||||
..parents = [appFolderId],
|
||||
uploadMedia: media,
|
||||
$fields: 'id,md5Checksum',
|
||||
);
|
||||
return DriveDataFileInfo(id: created.id!, md5Checksum: created.md5Checksum);
|
||||
}
|
||||
|
||||
Future<String> uploadReceiptImage({
|
||||
required String receiptsFolderId,
|
||||
required String fileName,
|
||||
required File imageFile,
|
||||
}) async {
|
||||
final length = await imageFile.length();
|
||||
final media = drive.Media(
|
||||
imageFile.openRead(),
|
||||
length,
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = fileName
|
||||
..parents = [receiptsFolderId],
|
||||
uploadMedia: media,
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
try {
|
||||
await _api.files.delete(fileId);
|
||||
} on drive.DetailedApiRequestError catch (e) {
|
||||
// Already gone (e.g. deleted by another device) — not an error for
|
||||
// our purposes.
|
||||
if (e.status != 404) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> createLockFile({required String appFolderId, required String name}) async {
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..parents = [appFolderId],
|
||||
uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'),
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
Future<List<DriveLockFile>> listLockFiles(String appFolderId) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$appFolderId' in parents and trashed=false and name contains '.lock'",
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
final locks = <DriveLockFile>[];
|
||||
for (final f in result.files ?? <drive.File>[]) {
|
||||
final parsed = _parseLockFileName(f.name);
|
||||
if (f.id != null && parsed != null) {
|
||||
locks.add(DriveLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
static (String, DateTime)? _parseLockFileName(String? name) {
|
||||
if (name == null || !name.endsWith('.lock')) return null;
|
||||
final withoutExt = name.substring(0, name.length - '.lock'.length);
|
||||
final lastDash = withoutExt.lastIndexOf('-');
|
||||
if (lastDash == -1) return null;
|
||||
final username = withoutExt.substring(0, lastDash);
|
||||
final epochStr = withoutExt.substring(lastDash + 1);
|
||||
final epoch = int.tryParse(epochStr);
|
||||
if (epoch == null) return null;
|
||||
return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true));
|
||||
}
|
||||
|
||||
Future<List<int>> _collectBytes(Stream<List<int>> stream) async {
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in stream) {
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
extension _FirstMatchExtension on List<drive.File> {
|
||||
String? firstOrNullWithId() => isEmpty ? null : first.id;
|
||||
}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'drive_service.dart' show DriveLockFile;
|
||||
import 'cloud/cloud_storage_provider.dart' show CloudLockFile;
|
||||
|
||||
/// Ticket-based mutex using timestamped lock files as the coordination
|
||||
/// point: create a lock named after our own timestamp, then wait until no
|
||||
|
|
@ -7,12 +7,13 @@ import 'drive_service.dart' show DriveLockFile;
|
|||
/// device that crashed/went offline before releasing its own lock.
|
||||
///
|
||||
/// Pure orchestration over injected operations (rather than a concrete
|
||||
/// Drive dependency) so the state machine is unit-testable without a real
|
||||
/// network connection.
|
||||
/// cloud storage dependency) so the state machine is unit-testable without
|
||||
/// a real network connection, and works identically no matter which
|
||||
/// [CloudStorageProvider] it's wired to.
|
||||
Future<String> acquireLock({
|
||||
required String username,
|
||||
required Future<String> Function(String lockName) createLock,
|
||||
required Future<List<DriveLockFile>> Function() listLocks,
|
||||
required Future<List<CloudLockFile>> Function() listLocks,
|
||||
required Future<void> Function(String lockId) deleteLock,
|
||||
DateTime Function()? nowUtc,
|
||||
Future<void> Function(Duration)? delay,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,25 @@
|
|||
/// Two-letter USPS abbreviation to full name, for the 50 states + DC —
|
||||
/// deliberately excludes territories (PR, VI, GU, ...): "VI" in particular
|
||||
/// shows up on real receipts as a "Visa" abbreviation, and including it as
|
||||
/// a valid state code would misfire on that.
|
||||
const usStateNames = <String, String>{
|
||||
'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas',
|
||||
'CA': 'California', 'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware',
|
||||
'FL': 'Florida', 'GA': 'Georgia', 'HI': 'Hawaii', 'ID': 'Idaho',
|
||||
'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa', 'KS': 'Kansas',
|
||||
'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland',
|
||||
'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota',
|
||||
'MS': 'Mississippi', 'MO': 'Missouri', 'MT': 'Montana', 'NE': 'Nebraska',
|
||||
'NV': 'Nevada', 'NH': 'New Hampshire', 'NJ': 'New Jersey',
|
||||
'NM': 'New Mexico', 'NY': 'New York', 'NC': 'North Carolina',
|
||||
'ND': 'North Dakota', 'OH': 'Ohio', 'OK': 'Oklahoma', 'OR': 'Oregon',
|
||||
'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina',
|
||||
'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah',
|
||||
'VT': 'Vermont', 'VA': 'Virginia', 'WA': 'Washington',
|
||||
'WV': 'West Virginia', 'WI': 'Wisconsin', 'WY': 'Wyoming',
|
||||
'DC': 'District of Columbia',
|
||||
};
|
||||
|
||||
/// Best-effort values pulled out of OCR'd receipt text. Any field can be
|
||||
/// null if it couldn't be found, and the confirm screen lets the user fill
|
||||
/// in or correct whatever the parser got wrong.
|
||||
|
|
@ -5,12 +27,25 @@ class ParsedReceipt {
|
|||
final double? gallons;
|
||||
final double? pricePerGallon;
|
||||
final double? totalCost;
|
||||
|
||||
/// The transaction date/time printed on the receipt, if found. Null
|
||||
/// means the confirm screen should fall back to manual entry (it
|
||||
/// defaults to "now" and lets the user pick a different date/time).
|
||||
final DateTime? date;
|
||||
|
||||
/// Two-letter state abbreviation of the station's address, if found
|
||||
/// (e.g. "MO", "TX"). Null means no state could be confidently
|
||||
/// identified — callers should treat that as "unknown", not "Missouri".
|
||||
final String? state;
|
||||
|
||||
final String rawText;
|
||||
|
||||
ParsedReceipt({
|
||||
this.gallons,
|
||||
this.pricePerGallon,
|
||||
this.totalCost,
|
||||
this.date,
|
||||
this.state,
|
||||
required this.rawText,
|
||||
});
|
||||
}
|
||||
|
|
@ -23,36 +58,91 @@ class ParsedReceipt {
|
|||
/// to deriving a missing value from the other two when exactly one is
|
||||
/// missing (total = gallons * price, etc).
|
||||
class ReceiptParser {
|
||||
// These use [ \t]* rather than \s* between a label and its value: \s
|
||||
// matches newlines too, which let a number on one line match a
|
||||
// completely unrelated label several lines further down (e.g. a price
|
||||
// value followed, many lines later, by an incidental "Gallons" heading
|
||||
// for a different column) — a real bug this surfaced against an actual
|
||||
// receipt where "$1.999" ended up matching "...Gallons" two lines below
|
||||
// it. A label and its own value are always on the same line.
|
||||
static final _gallonsPatterns = [
|
||||
RegExp(r'GALLONS?\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'\bGAL\b\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'(\d+\.\d{2,3})\s*GAL(?:LONS)?\b', caseSensitive: false),
|
||||
RegExp(r'GALLONS?[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'\bGAL\b[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'(\d+\.\d{2,3})[ \t]*GAL(?:LONS)?\b', caseSensitive: false),
|
||||
];
|
||||
|
||||
static final _pricePerGallonPatterns = [
|
||||
RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
|
||||
RegExp(r'PRICE[ \t]*/?[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
|
||||
caseSensitive: false),
|
||||
RegExp(r'\bPPG\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'PER\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
|
||||
RegExp(r'\bPPG\b[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'PER[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
|
||||
caseSensitive: false),
|
||||
RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
|
||||
RegExp(r'\$[ \t]*/[ \t]*GAL[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
|
||||
caseSensitive: false),
|
||||
];
|
||||
|
||||
static final _totalPatterns = [
|
||||
RegExp(r'FUEL\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
|
||||
RegExp(r'SALE\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
|
||||
RegExp(r'AMOUNT\s*DUE\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
|
||||
RegExp(r'(?<!SUB)\bTOTAL\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2})',
|
||||
caseSensitive: false),
|
||||
RegExp(r'AMOUNT\s*DUE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
|
||||
RegExp(r'FUEL\s*SALE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
|
||||
// Allows for words between TOTAL and the amount ("Total Sale $120.72",
|
||||
// "Fuel Total: $44.44"), not just a bare colon/dash.
|
||||
RegExp(r'(?<!SUB)\bTOTAL\b[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
|
||||
];
|
||||
|
||||
/// Matches a bare or $-prefixed 3-decimal number anywhere in the text.
|
||||
/// US fuel receipts overwhelmingly print both gallons pumped and price
|
||||
/// per gallon with exactly 3 decimal places, and only the price gets a
|
||||
/// currency symbol — a much more reliable signal than the label text
|
||||
/// itself, which varies a lot and is often split from its value onto a
|
||||
/// different line by a tabular "Pump / Gallons / Price" header row (in
|
||||
/// which case the label-based patterns above never match at all).
|
||||
///
|
||||
/// Uses `(?!\d)` rather than `\b` after the number: some receipts print
|
||||
/// the unit directly attached with no space ("17.364G"), and a digit
|
||||
/// followed by a letter is *not* a `\b` boundary, so `\b` would miss it.
|
||||
/// `(?<!-)` excludes negative amounts (e.g. a per-gallon discount line
|
||||
/// like "Debit Di/GAL $-0.100"), which are never a real gallons/price
|
||||
/// value and would otherwise get matched as one.
|
||||
static final _threeDecimalNumberPattern = RegExp(r'(?<!-)(\$)?\s*(\d+\.\d{3})(?!\d)');
|
||||
|
||||
// Matches MM/DD/YYYY, MM-DD-YY, and similar — US gas receipts are
|
||||
// consistent about digit-separator-digit-separator-digit ordering even
|
||||
// though the separator (/ or -) and year length (2 or 4 digits) vary.
|
||||
static final _datePattern = RegExp(r'\b(\d{1,2})[/-](\d{1,2})[/-](\d{2}|\d{4})\b');
|
||||
|
||||
// Optional AM/PM, optional seconds — covers "02:21", "10:11:00 AM", and
|
||||
// "02:11PM" (no space before the meridiem) all at once.
|
||||
static final _timePattern = RegExp(r'\b(\d{1,2}):(\d{2})(?::\d{2})?\s*([AaPp][Mm])?\b');
|
||||
|
||||
// A two-letter code directly followed by a ZIP is by far the strongest
|
||||
// signal an address block gives us (very low false-positive rate); a
|
||||
// code right after a comma is a weaker but still decent fallback for
|
||||
// receipts that print "City, ST" without a visible ZIP alongside it.
|
||||
// Both require the candidate to be checked against [usStateNames] before
|
||||
// being trusted — a bare 2-letter scan alone would misfire constantly
|
||||
// (e.g. "VI" for Visa, "IN" as the word "in", "OR" as the word "or").
|
||||
static final _stateBeforeZipPattern = RegExp(r'\b([A-Z]{2})\s+\d{5}(?:-\d{4})?\b');
|
||||
static final _stateAfterCommaPattern = RegExp(r',\s*([A-Z]{2})\b');
|
||||
|
||||
static ParsedReceipt parse(String text) {
|
||||
final normalized = text.replaceAll(',', '');
|
||||
|
||||
final gallons = _firstMatch(_gallonsPatterns, normalized);
|
||||
final pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
|
||||
var totalCost = _firstMatch(_totalPatterns, normalized);
|
||||
var gallons = _firstMatch(_gallonsPatterns, normalized);
|
||||
var pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
|
||||
final totalCost = _firstMatch(_totalPatterns, normalized);
|
||||
|
||||
if (gallons == null || pricePerGallon == null) {
|
||||
for (final match in _threeDecimalNumberPattern.allMatches(normalized)) {
|
||||
final value = double.tryParse(match.group(2)!);
|
||||
if (value == null) continue;
|
||||
final hasDollarSign = match.group(1) != null;
|
||||
if (hasDollarSign) {
|
||||
pricePerGallon ??= value;
|
||||
} else {
|
||||
gallons ??= value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final derivedTotal = _deriveMissingValue(
|
||||
gallons: gallons,
|
||||
|
|
@ -64,10 +154,76 @@ class ReceiptParser {
|
|||
gallons: derivedTotal.gallons,
|
||||
pricePerGallon: derivedTotal.pricePerGallon,
|
||||
totalCost: derivedTotal.totalCost,
|
||||
date: _parseDate(normalized),
|
||||
// Uses the original text, not the comma-stripped `normalized` copy —
|
||||
// the comma-adjacency fallback pattern needs commas intact.
|
||||
state: _parseState(text),
|
||||
rawText: text,
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks for a US state abbreviation in the station's address block. Only
|
||||
/// trusts a candidate that's both a plausible address position (right
|
||||
/// before a ZIP, or right after a comma) *and* a real state code — see
|
||||
/// [usStateNames] and the patterns above for why both checks matter.
|
||||
static String? _parseState(String text) {
|
||||
final zipMatch = _stateBeforeZipPattern.firstMatch(text);
|
||||
if (zipMatch != null) {
|
||||
final code = zipMatch.group(1)!.toUpperCase();
|
||||
if (usStateNames.containsKey(code)) return code;
|
||||
}
|
||||
|
||||
final commaMatch = _stateAfterCommaPattern.firstMatch(text);
|
||||
if (commaMatch != null) {
|
||||
final code = commaMatch.group(1)!.toUpperCase();
|
||||
if (usStateNames.containsKey(code)) return code;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Finds a date on the receipt and, if a time is printed nearby (same
|
||||
/// line — within a short character window right after the date, since
|
||||
/// receipts almost always print them adjacent, e.g. "DATE 3/26/22
|
||||
/// 18:12" or "Date: ...\nTime: ..."), combines them. Falls back to
|
||||
/// midnight if no time is found, and to null (manual entry) if no date
|
||||
/// is found at all.
|
||||
static DateTime? _parseDate(String text) {
|
||||
final dateMatch = _datePattern.firstMatch(text);
|
||||
if (dateMatch == null) return null;
|
||||
|
||||
final month = int.tryParse(dateMatch.group(1)!);
|
||||
final day = int.tryParse(dateMatch.group(2)!);
|
||||
var year = int.tryParse(dateMatch.group(3)!);
|
||||
if (month == null || day == null || year == null) return null;
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||
if (year < 100) year += 2000;
|
||||
|
||||
final windowEnd = (dateMatch.end + 40).clamp(0, text.length);
|
||||
final nearbyText = text.substring(dateMatch.end, windowEnd);
|
||||
final timeMatch = _timePattern.firstMatch(nearbyText);
|
||||
|
||||
var hour = 0;
|
||||
var minute = 0;
|
||||
if (timeMatch != null) {
|
||||
hour = int.tryParse(timeMatch.group(1)!) ?? 0;
|
||||
minute = int.tryParse(timeMatch.group(2)!) ?? 0;
|
||||
final meridiem = timeMatch.group(3)?.toUpperCase();
|
||||
if (meridiem == 'PM' && hour != 12) hour += 12;
|
||||
if (meridiem == 'AM' && hour == 12) hour = 0;
|
||||
if (hour > 23 || minute > 59) {
|
||||
hour = 0;
|
||||
minute = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return DateTime(year, month, day, hour, minute);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static double? _firstMatch(List<RegExp> patterns, String text) {
|
||||
for (final pattern in patterns) {
|
||||
final match = pattern.firstMatch(text);
|
||||
|
|
|
|||
31
lib/services/vin_parser.dart
Normal file
31
lib/services/vin_parser.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/// Extracts a 17-character VIN from OCR'd text (a photo of a door-jamb
|
||||
/// sticker, dashboard plate, title, etc).
|
||||
///
|
||||
/// Real VINs never contain the letters I, O, or Q (they're excluded from
|
||||
/// the standard specifically so they can't be confused with 1 and 0), so
|
||||
/// requiring that charset both matches genuine VINs and rules out a lot of
|
||||
/// incidental 17-character noise elsewhere on the sticker (barcodes text,
|
||||
/// weight ratings, date codes, etc).
|
||||
class VinParser {
|
||||
static final _labeledVinPattern =
|
||||
RegExp(r'VIN[:\s]*([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
|
||||
|
||||
// \b on both sides matters: since digits and letters are both "word"
|
||||
// characters, this only matches a maximal run of exactly 17 eligible
|
||||
// characters — not a 17-character slice out of an 18+ character run.
|
||||
static final _bareVinPattern = RegExp(r'\b([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
|
||||
|
||||
/// Returns the VIN in uppercase, or null if nothing matching the VIN
|
||||
/// charset/length was found. A labeled "VIN: ..." match is preferred
|
||||
/// over a bare 17-character token, in case a busy sticker has more than
|
||||
/// one candidate (e.g. also a 17-digit tire/parts barcode number).
|
||||
static String? parse(String text) {
|
||||
final labeled = _labeledVinPattern.firstMatch(text);
|
||||
if (labeled != null) return labeled.group(1)!.toUpperCase();
|
||||
|
||||
final bare = _bareVinPattern.firstMatch(text);
|
||||
if (bare != null) return bare.group(1)!.toUpperCase();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue