277 lines
8.3 KiB
Dart
277 lines
8.3 KiB
Dart
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<String> moveFolder({required String folderId, required String newParentId}) async {
|
|
final current = await _api.files.get(folderId, $fields: 'parents') as drive.File;
|
|
final oldParents = (current.parents ?? []).join(',');
|
|
final updated = await _api.files.update(
|
|
drive.File(),
|
|
folderId,
|
|
addParents: newParentId,
|
|
removeParents: oldParents.isEmpty ? null : oldParents,
|
|
$fields: 'id',
|
|
);
|
|
return updated.id ?? folderId;
|
|
}
|
|
|
|
@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();
|
|
}
|