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. "/MO-Fuel-Tax-Back") 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 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 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 _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; _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 _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; 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 _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; _accessToken = json['access_token'] as String; _accessTokenExpiry = DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400)); return _accessToken!; } @override Future 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> _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> _post(String path, Map 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; } /// 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; return jsonEncode(body['error']).contains('not_found'); } catch (_) { return false; } } @override Future> 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? ?? []); return entries .cast>() .where((e) => e['.tag'] == 'folder') .map((e) => CloudFolder(id: e['path_display'] as String, name: e['name'] as String)) .toList(); } @override Future 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; 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 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; if (json['.tag'] != 'file') return null; return CloudFileInfo(id: filePath, versionTag: json['content_hash'] as String?); } @override Future> 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 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; return CloudFileInfo( id: json['path_display'] as String? ?? targetPath, versionTag: json['content_hash'] as String?, ); } @override Future 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 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 [], ); if (response.statusCode != 200) { throw StateError('Dropbox lock creation failed: ${response.statusCode} ${response.body}'); } return path; } @override Future> listLockFiles(String folderId) async { final json = await _post('/2/files/list_folder', {'path': _normalizePath(folderId)}); final entries = (json['entries'] as List? ?? []); final locks = []; for (final entry in entries.cast>()) { 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() {} }