Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
350
lib/services/cloud/onedrive_provider.dart
Normal file
350
lib/services/cloud/onedrive_provider.dart
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
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';
|
||||
|
||||
const _graphScopes = 'offline_access Files.ReadWrite.All';
|
||||
|
||||
/// OneDrive implementation of [CloudStorageProvider], via Microsoft Graph.
|
||||
/// Unlike Dropbox, Graph is ID-addressed like Drive, so it fits the
|
||||
/// interface directly with no path-based workaround.
|
||||
class OneDriveProvider implements CloudStorageProvider {
|
||||
String? _accessToken;
|
||||
String? _refreshToken;
|
||||
DateTime? _accessTokenExpiry;
|
||||
String? _accountLabel;
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.oneDrive;
|
||||
|
||||
@override
|
||||
String get displayName => 'OneDrive';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => _refreshToken != null;
|
||||
|
||||
@override
|
||||
String? get accountLabel => _accountLabel;
|
||||
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
// As with Dropbox, the refresh token is kept in memory only in this
|
||||
// first pass — see README's Known limitations.
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signIn() async {
|
||||
final clientId = CloudOAuthConfig.oneDriveClientId;
|
||||
final redirectUri = CloudOAuthConfig.oneDriveRedirectUri;
|
||||
if (clientId == null || redirectUri == null) {
|
||||
throw StateError('OneDrive OAuth is not configured yet (see cloud_oauth_config.dart).');
|
||||
}
|
||||
|
||||
final pkce = PkcePair.generate();
|
||||
final authUrl = Uri.https(
|
||||
'login.microsoftonline.com',
|
||||
'/common/oauth2/v2.0/authorize',
|
||||
{
|
||||
'client_id': clientId,
|
||||
'response_type': 'code',
|
||||
'redirect_uri': redirectUri,
|
||||
'response_mode': 'query',
|
||||
'scope': _graphScopes,
|
||||
'code_challenge': pkce.codeChallenge,
|
||||
'code_challenge_method': 'S256',
|
||||
},
|
||||
);
|
||||
|
||||
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('OneDrive sign-in did not return an authorization code.');
|
||||
}
|
||||
|
||||
await _exchangeCodeForTokens(
|
||||
code: code,
|
||||
codeVerifier: pkce.codeVerifier,
|
||||
clientId: clientId,
|
||||
redirectUri: redirectUri,
|
||||
);
|
||||
_accountLabel = await _fetchAccountEmail();
|
||||
return _accountLabel!;
|
||||
}
|
||||
|
||||
Future<void> _exchangeCodeForTokens({
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String clientId,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'),
|
||||
body: {
|
||||
'client_id': clientId,
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': redirectUri,
|
||||
'code_verifier': codeVerifier,
|
||||
'scope': _graphScopes,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive 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? ?? 3600));
|
||||
}
|
||||
|
||||
Future<String> _fetchAccountEmail() async {
|
||||
final response = await http.get(
|
||||
Uri.https('graph.microsoft.com', '/v1.0/me'),
|
||||
headers: {'Authorization': 'Bearer $_accessToken'},
|
||||
);
|
||||
if (response.statusCode != 200) return 'OneDrive account';
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (json['mail'] as String?) ??
|
||||
(json['userPrincipalName'] as String?) ??
|
||||
'OneDrive account';
|
||||
}
|
||||
|
||||
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 clientId = CloudOAuthConfig.oneDriveClientId;
|
||||
if (refreshToken == null || clientId == null) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'),
|
||||
body: {
|
||||
'client_id': clientId,
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
'scope': _graphScopes,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_refreshToken = json['refresh_token'] as String? ?? _refreshToken;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600));
|
||||
return _accessToken!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
_accessTokenExpiry = null;
|
||||
_accountLabel = null;
|
||||
}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() {
|
||||
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
|
||||
return OneDriveSession(this);
|
||||
}
|
||||
}
|
||||
|
||||
class OneDriveSession implements CloudStorageSession {
|
||||
final OneDriveProvider _provider;
|
||||
OneDriveSession(this._provider);
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => true;
|
||||
|
||||
Future<Map<String, String>> _authHeader() async =>
|
||||
{'Authorization': 'Bearer ${await _provider._freshAccessToken()}'};
|
||||
|
||||
Uri _graph(String path) => Uri.parse('https://graph.microsoft.com/v1.0$path');
|
||||
|
||||
/// The interface's `'root'` sentinel (matching Google's convention) maps
|
||||
/// to Graph's own `/me/drive/root` special item.
|
||||
String _itemSegment(String id) => id == 'root' ? 'root' : 'items/$id';
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final uri = sharedWithMe
|
||||
? _graph('/me/drive/sharedWithMe')
|
||||
: _graph('/me/drive/${_itemSegment(parentId ?? 'root')}/children');
|
||||
|
||||
final response = await http.get(uri, headers: await _authHeader());
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive API error (listFolders): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final entries = (json['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
|
||||
|
||||
final folders = <CloudFolder>[];
|
||||
for (final entry in entries) {
|
||||
if (entry['folder'] == null) continue;
|
||||
// "Shared with me" items carry the shared item's own id under
|
||||
// `remoteItem`, not the top-level entry id.
|
||||
final remoteItem = entry['remoteItem'] as Map<String, dynamic>?;
|
||||
final id = (remoteItem?['id'] ?? entry['id']) as String?;
|
||||
final name = entry['name'] as String?;
|
||||
if (id != null && name != null) {
|
||||
folders.add(CloudFolder(id: id, name: name));
|
||||
}
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
final childrenUri = _graph('/me/drive/${_itemSegment(parentId)}/children');
|
||||
final listResponse = await http.get(childrenUri, headers: await _authHeader());
|
||||
if (listResponse.statusCode != 200) {
|
||||
throw StateError(
|
||||
'OneDrive API error (findOrCreateFolder list): ${listResponse.statusCode} ${listResponse.body}');
|
||||
}
|
||||
final listJson = jsonDecode(listResponse.body) as Map<String, dynamic>;
|
||||
final entries = (listJson['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
|
||||
for (final entry in entries) {
|
||||
if (entry['folder'] != null && entry['name'] == name) {
|
||||
return entry['id'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
final createResponse = await http.post(
|
||||
childrenUri,
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'folder': <String, dynamic>{},
|
||||
'@microsoft.graph.conflictBehavior': 'fail',
|
||||
}),
|
||||
);
|
||||
if (createResponse.statusCode != 201) {
|
||||
throw StateError(
|
||||
'OneDrive API error (findOrCreateFolder create): ${createResponse.statusCode} ${createResponse.body}');
|
||||
}
|
||||
final created = jsonDecode(createResponse.body) as Map<String, dynamic>;
|
||||
return created['id'] as String;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name');
|
||||
final response = await http.get(uri, headers: await _authHeader());
|
||||
if (response.statusCode == 404) return null;
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive API error (findFile): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final response =
|
||||
await http.get(_graph('/me/drive/items/$fileId/content'), headers: await _authHeader());
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive 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 bytes = await localFile.readAsBytes();
|
||||
// Graph's simple upload endpoint (content < 4MB, which every receipt
|
||||
// photo and the sqlite data file comfortably are) — no upload session
|
||||
// needed.
|
||||
final uri = existingFileId != null
|
||||
? _graph('/me/drive/items/$existingFileId/content')
|
||||
: _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content');
|
||||
|
||||
final response = await http.put(
|
||||
uri,
|
||||
headers: {...await _authHeader(), 'Content-Type': contentType},
|
||||
body: bytes,
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw StateError('OneDrive upload failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
final response =
|
||||
await http.delete(_graph('/me/drive/items/$fileId'), headers: await _authHeader());
|
||||
if (response.statusCode != 204 && response.statusCode != 404) {
|
||||
throw StateError('OneDrive delete failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content');
|
||||
final response = await http.put(
|
||||
uri,
|
||||
headers: {...await _authHeader(), 'Content-Type': 'text/plain'},
|
||||
body: const <int>[],
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw StateError('OneDrive lock creation failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return json['id'] as String;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
|
||||
final response = await http.get(
|
||||
_graph('/me/drive/${_itemSegment(folderId)}/children'),
|
||||
headers: await _authHeader(),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive API error (listLockFiles): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final entries = (json['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
|
||||
|
||||
final locks = <CloudLockFile>[];
|
||||
for (final entry in entries) {
|
||||
final parsed = parseLockFileName(entry['name'] as String?);
|
||||
if (parsed != null) {
|
||||
locks.add(CloudLockFile(
|
||||
id: entry['id'] as String,
|
||||
username: parsed.$1,
|
||||
createdAtUtc: parsed.$2,
|
||||
));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue