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