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 = ['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 _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 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 signIn() async { await _ensureInitialized(); final account = await GoogleSignIn.instance.authenticate(scopeHint: _driveScopes); _account = account; await account.authorizationClient.authorizeScopes(_driveScopes); return account.email; } @override Future 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 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> 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 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 ?? []; 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 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 ?? []; if (files.isEmpty || files.first.id == null) return null; return CloudFileInfo(id: files.first.id!, versionTag: files.first.md5Checksum); } @override Future> downloadFileBytes(String fileId) async { final media = await _api.files.get( fileId, downloadOptions: drive.DownloadOptions.fullMedia, ) as drive.Media; return _collectBytes(media.stream); } @override Future 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 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 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> 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 = []; for (final f in result.files ?? []) { 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> _collectBytes(Stream> stream) async { final bytes = []; await for (final chunk in stream) { bytes.addAll(chunk); } return bytes; } @override void close() => _client.close(); }