Remote sync of webdav

This commit is contained in:
Courtney Arnold 2026-08-13 17:05:10 -05:00
parent f01186df79
commit 483fbeafb6
44 changed files with 4842 additions and 975 deletions

View file

@ -0,0 +1,364 @@
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<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<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() {}
}