Remote sync of webdav

This commit is contained in:
Courtney Arnold 2026-08-13 17:05:10 -05:00
parent f01186df79
commit 483fbeafb6
44 changed files with 4842 additions and 975 deletions

View 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));
}

View 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() {}
}

View 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();
}

View 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();
}
}

View 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() {}
}

View 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() {}
}