366 lines
13 KiB
Dart
366 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';
|
|
|
|
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<String> moveFolder({required String folderId, required String newParentId}) async {
|
|
final response = await http.patch(
|
|
_graph('/me/drive/${_itemSegment(folderId)}'),
|
|
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
|
body: jsonEncode({
|
|
'parentReference': {'id': newParentId},
|
|
}),
|
|
);
|
|
if (response.statusCode != 200) {
|
|
throw StateError('OneDrive API error (moveFolder): ${response.statusCode} ${response.body}');
|
|
}
|
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
|
return json['id'] as String? ?? folderId;
|
|
}
|
|
|
|
@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() {}
|
|
}
|