241 lines
7.9 KiB
Dart
241 lines
7.9 KiB
Dart
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;
|
|
}
|