375 lines
13 KiB
Dart
375 lines
13 KiB
Dart
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. "/Show Me The Fuel Refund") 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<String> moveFolder({required String folderId, required String newParentId}) async {
|
|
final name = folderId.substring(folderId.lastIndexOf('/') + 1);
|
|
final toPath = _childPath(newParentId, name);
|
|
if (toPath == folderId) return folderId;
|
|
|
|
final json = await _post('/2/files/move_v2', {'from_path': folderId, 'to_path': toPath});
|
|
final metadata = json['metadata'] as Map<String, dynamic>?;
|
|
return metadata?['path_display'] as String? ?? toPath;
|
|
}
|
|
|
|
@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() {}
|
|
}
|