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