MO-Fuel-Tax-Back/lib/services/cloud/onedrive_provider.dart

350 lines
12 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<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() {}
}