Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
364
lib/services/cloud/dropbox_provider.dart
Normal file
364
lib/services/cloud/dropbox_provider.dart
Normal 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() {}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue