Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue