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 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 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 _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; _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 _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; return (json['mail'] as String?) ?? (json['userPrincipalName'] as String?) ?? 'OneDrive account'; } 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 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; _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 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> _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> 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; final entries = (json['value'] as List? ?? []).cast>(); final folders = []; 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?; 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 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; final entries = (listJson['value'] as List? ?? []).cast>(); 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': {}, '@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; return created['id'] as String; } @override Future 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; return json['id'] as String? ?? folderId; } @override Future 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; return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?); } @override Future> 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 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; return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?); } @override Future 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 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 [], ); if (response.statusCode != 200 && response.statusCode != 201) { throw StateError('OneDrive lock creation failed: ${response.statusCode} ${response.body}'); } final json = jsonDecode(response.body) as Map; return json['id'] as String; } @override Future> 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; final entries = (json['value'] as List? ?? []).cast>(); final locks = []; 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() {} }