import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; import 'google_oauth_config_service.dart'; class GoogleSession { const GoogleSession({ required this.accessToken, required this.displayName, this.email, this.photoUrl, }); final String accessToken; final String displayName; final String? email; final String? photoUrl; GoogleSession copyWith({ String? accessToken, String? displayName, String? email, String? photoUrl, }) { return GoogleSession( accessToken: accessToken ?? this.accessToken, displayName: displayName ?? this.displayName, email: email ?? this.email, photoUrl: photoUrl ?? this.photoUrl, ); } } class GoogleAuthConfigurationException implements Exception { const GoogleAuthConfigurationException(this.message); final String message; @override String toString() => message; } class GoogleAuthService { GoogleAuthService._(); static final GoogleAuthService instance = GoogleAuthService._(); static const String youtubeReadonlyScope = 'https://www.googleapis.com/auth/youtube.readonly'; static const String _envWebClientId = String.fromEnvironment( 'GOOGLE_WEB_CLIENT_ID', ); static const String _envDesktopClientId = String.fromEnvironment( 'GOOGLE_DESKTOP_CLIENT_ID', ); static const String _envDesktopClientSecret = String.fromEnvironment( 'GOOGLE_DESKTOP_CLIENT_SECRET', ); final ValueNotifier session = ValueNotifier(null); bool _googleSignInInitialized = false; String? _initializedAndroidWebClientId; GoogleSignInAccount? _androidAccount; String? _windowsRefreshToken; DateTime? _windowsAccessTokenExpiry; GoogleOauthConfig get _savedConfig => GoogleOauthConfigService.instance.config.value; String get _webClientId { final environment = _envWebClientId.trim(); if (environment.isNotEmpty) { return environment; } return _savedConfig.webClientId.trim(); } String get _desktopClientId { final environment = _envDesktopClientId.trim(); if (environment.isNotEmpty) { return environment; } return _savedConfig.desktopClientId.trim(); } String get _desktopClientSecret { final environment = _envDesktopClientSecret.trim(); if (environment.isNotEmpty) { return environment; } return _savedConfig.desktopClientSecret.trim(); } bool get isConfigured { if (Platform.isAndroid) { return _webClientId.isNotEmpty; } if (Platform.isWindows) { return _desktopClientId.isNotEmpty; } return false; } String get configurationHint { if (Platform.isAndroid) { return 'Client ID Web OAuth Google manquant'; } if (Platform.isWindows) { return 'Client ID OAuth "Desktop app" manquant'; } return 'Plateforme non prise en charge'; } Future initialize() async { await GoogleOauthConfigService.instance.initialize(); if (!Platform.isAndroid || !isConfigured) { return; } await _initializeAndroidGoogleSignIn(); try { final future = GoogleSignIn.instance .attemptLightweightAuthentication(); if (future == null) { return; } final account = await future; if (account == null) { return; } _androidAccount = account; final authorization = await account.authorizationClient .authorizationForScopes( const [youtubeReadonlyScope], ); if (authorization == null) { return; } session.value = GoogleSession( accessToken: authorization.accessToken, displayName: account.displayName ?? account.email, email: account.email, photoUrl: account.photoUrl, ); } catch (error) { debugPrint( '[GOOGLE-AUTH] Restauration ignorée: $error', ); } } Future saveAndroidConfiguration( String webClientId, ) async { final previous = _webClientId; await GoogleOauthConfigService.instance.saveAndroid( webClientId: webClientId, ); if (!Platform.isAndroid) { return true; } final next = _webClientId; if (next.isEmpty) { return true; } if (!_googleSignInInitialized) { await _initializeAndroidGoogleSignIn(); return true; } return previous == next; } Future saveWindowsConfiguration({ required String desktopClientId, required String desktopClientSecret, }) async { await GoogleOauthConfigService.instance.saveWindows( desktopClientId: desktopClientId, desktopClientSecret: desktopClientSecret, ); } Future signIn() async { if (!isConfigured) { throw GoogleAuthConfigurationException( configurationHint, ); } if (Platform.isAndroid) { return _signInAndroid(); } if (Platform.isWindows) { return _signInWindows(); } throw const GoogleAuthConfigurationException( 'Connexion Google non prise en charge ' 'sur cette plateforme.', ); } Future getAccessToken() async { final current = session.value; if (current == null) { throw StateError( 'Aucune session Google active.', ); } if (Platform.isAndroid) { final account = _androidAccount; if (account != null) { final authorization = await account.authorizationClient .authorizationForScopes( const [youtubeReadonlyScope], ); if (authorization != null) { final nextAccessToken = authorization.accessToken; // Ne notifie les listeners que si le jeton a réellement // changé. Avant, chaque simple lecture du jeton réémettait // session.value et pouvait relancer le chargement des // abonnements en boucle. if (nextAccessToken != current.accessToken) { session.value = current.copyWith( accessToken: nextAccessToken, ); } return nextAccessToken; } } return current.accessToken; } if (Platform.isWindows) { final expiry = _windowsAccessTokenExpiry; if (expiry != null && DateTime.now().isAfter( expiry.subtract( const Duration(minutes: 2), ), ) && _windowsRefreshToken != null) { await _refreshWindowsAccessToken(); } return session.value?.accessToken ?? current.accessToken; } return current.accessToken; } Future signOut() async { if (Platform.isAndroid && _googleSignInInitialized) { try { await GoogleSignIn.instance.signOut(); } catch (error) { debugPrint( '[GOOGLE-AUTH] signOut Android: $error', ); } } _androidAccount = null; _windowsRefreshToken = null; _windowsAccessTokenExpiry = null; session.value = null; } Future _initializeAndroidGoogleSignIn() async { if (_googleSignInInitialized) { return; } final clientId = _webClientId; if (clientId.isEmpty) { throw const GoogleAuthConfigurationException( 'Client ID Web OAuth Google manquant.', ); } await GoogleSignIn.instance.initialize( serverClientId: clientId, ); _googleSignInInitialized = true; _initializedAndroidWebClientId = clientId; } Future _signInAndroid() async { await _initializeAndroidGoogleSignIn(); if (_initializedAndroidWebClientId != _webClientId) { throw const GoogleAuthConfigurationException( 'Le Client ID Android a été modifié. ' 'Redémarre l’application avant de te connecter.', ); } if (!GoogleSignIn.instance.supportsAuthenticate()) { throw StateError( 'Google Sign-In interactif ' 'n’est pas disponible.', ); } final account = await GoogleSignIn.instance.authenticate( scopeHint: const [youtubeReadonlyScope], ); _androidAccount = account; var authorization = await account.authorizationClient .authorizationForScopes( const [youtubeReadonlyScope], ); authorization ??= await account.authorizationClient .authorizeScopes( const [youtubeReadonlyScope], ); final result = GoogleSession( accessToken: authorization.accessToken, displayName: account.displayName ?? account.email, email: account.email, photoUrl: account.photoUrl, ); session.value = result; return result; } Future _signInWindows() async { final server = await HttpServer.bind( InternetAddress.loopbackIPv4, 0, ); final redirectUri = Uri.parse( 'http://127.0.0.1:${server.port}/', ); final verifier = _randomUrlSafeString(96); final state = _randomUrlSafeString(32); final challenge = base64UrlEncode( sha256.convert(ascii.encode(verifier)).bytes, ).replaceAll('=', ''); const windowsScopes = [ 'openid', 'email', 'profile', youtubeReadonlyScope, ]; final authorizationUri = Uri.https( 'accounts.google.com', '/o/oauth2/v2/auth', { 'client_id': _desktopClientId, 'redirect_uri': redirectUri.toString(), 'response_type': 'code', 'scope': windowsScopes.join(' '), 'code_challenge': challenge, 'code_challenge_method': 'S256', 'state': state, 'access_type': 'offline', 'prompt': 'consent', }, ); final launched = await launchUrl( authorizationUri, mode: LaunchMode.externalApplication, ); if (!launched) { await server.close(force: true); throw StateError( 'Impossible d’ouvrir le navigateur ' 'pour Google.', ); } HttpRequest request; try { request = await server.first.timeout( const Duration(minutes: 3), ); } on TimeoutException { await server.close(force: true); throw StateError( 'La connexion Google a expiré.', ); } final params = request.uri.queryParameters; request.response ..statusCode = HttpStatus.ok ..headers.contentType = ContentType.html ..write( '' '' 'YouTube Personal' '' '

Connexion terminée

' '

Tu peux fermer cet onglet et revenir ' 'dans YouTube Personal.

', ); await request.response.close(); await server.close(force: true); if (params['state'] != state) { throw StateError( 'Réponse OAuth Google invalide ' '(state incorrect).', ); } final error = params['error']; if (error != null) { throw StateError( 'Connexion Google refusée: $error', ); } final code = params['code']; if (code == null || code.isEmpty) { throw StateError( 'Google n’a pas renvoyé de ' 'code d’autorisation.', ); } final tokenBody = { 'client_id': _desktopClientId, 'code': code, 'code_verifier': verifier, 'grant_type': 'authorization_code', 'redirect_uri': redirectUri.toString(), }; if (_desktopClientSecret.isNotEmpty) { tokenBody['client_secret'] = _desktopClientSecret; } final tokenResponse = await http.post( Uri.parse( 'https://oauth2.googleapis.com/token', ), headers: const { 'Content-Type': 'application/x-www-form-urlencoded', }, body: tokenBody, ); if (tokenResponse.statusCode < 200 || tokenResponse.statusCode >= 300) { throw StateError( 'Échange OAuth Google impossible ' '(${tokenResponse.statusCode}): ' '${tokenResponse.body}', ); } final decoded = jsonDecode( tokenResponse.body, ); if (decoded is! Map) { throw StateError( 'Réponse OAuth Google invalide.', ); } final accessToken = decoded['access_token']; if (accessToken is! String || accessToken.isEmpty) { throw StateError( 'Aucun jeton d’accès Google reçu.', ); } final refreshToken = decoded['refresh_token']; if (refreshToken is String && refreshToken.isNotEmpty) { _windowsRefreshToken = refreshToken; } final expiresIn = decoded['expires_in']; if (expiresIn is num) { _windowsAccessTokenExpiry = DateTime.now().add( Duration( seconds: expiresIn.toInt(), ), ); } final profile = await _fetchGoogleProfile( accessToken, ); final result = GoogleSession( accessToken: accessToken, displayName: profile.name ?? profile.email ?? 'Compte Google', email: profile.email, photoUrl: profile.photoUrl, ); session.value = result; return result; } Future _refreshWindowsAccessToken() async { final refreshToken = _windowsRefreshToken; if (refreshToken == null || refreshToken.isEmpty) { return; } final body = { 'client_id': _desktopClientId, 'refresh_token': refreshToken, 'grant_type': 'refresh_token', }; if (_desktopClientSecret.isNotEmpty) { body['client_secret'] = _desktopClientSecret; } final response = await http.post( Uri.parse( 'https://oauth2.googleapis.com/token', ), headers: const { 'Content-Type': 'application/x-www-form-urlencoded', }, body: body, ); if (response.statusCode < 200 || response.statusCode >= 300) { throw StateError( 'Impossible de renouveler ' 'la session Google.', ); } final decoded = jsonDecode(response.body); if (decoded is! Map || decoded['access_token'] is! String) { throw StateError( 'Réponse de renouvellement ' 'Google invalide.', ); } final token = decoded['access_token'] as String; final expiresIn = decoded['expires_in']; if (expiresIn is num) { _windowsAccessTokenExpiry = DateTime.now().add( Duration( seconds: expiresIn.toInt(), ), ); } final current = session.value; if (current != null) { session.value = current.copyWith( accessToken: token, ); } } Future<_GoogleProfile> _fetchGoogleProfile( String accessToken, ) async { try { final response = await http.get( Uri.parse( 'https://www.googleapis.com/oauth2/v3/userinfo', ), headers: { 'Authorization': 'Bearer $accessToken', 'Accept': 'application/json', }, ); if (response.statusCode < 200 || response.statusCode >= 300) { return const _GoogleProfile(); } final decoded = jsonDecode(response.body); if (decoded is! Map) { return const _GoogleProfile(); } return _GoogleProfile( name: decoded['name'] is String ? decoded['name'] as String : null, email: decoded['email'] is String ? decoded['email'] as String : null, photoUrl: decoded['picture'] is String ? decoded['picture'] as String : null, ); } catch (_) { return const _GoogleProfile(); } } String _randomUrlSafeString(int length) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz' '0123456789-._~'; final random = Random.secure(); return List.generate( length, (_) => chars[ random.nextInt(chars.length)], ).join(); } } class _GoogleProfile { const _GoogleProfile({ this.name, this.email, this.photoUrl, }); final String? name; final String? email; final String? photoUrl; }