Light theme default + yt-dlp search fallback
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
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<GoogleSession?> session =
|
||||
ValueNotifier<GoogleSession?>(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<void> 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<bool> 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<void> saveWindowsConfiguration({
|
||||
required String desktopClientId,
|
||||
required String desktopClientSecret,
|
||||
}) async {
|
||||
await GoogleOauthConfigService.instance.saveWindows(
|
||||
desktopClientId: desktopClientId,
|
||||
desktopClientSecret: desktopClientSecret,
|
||||
);
|
||||
}
|
||||
|
||||
Future<GoogleSession> 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<String> 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<void> 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<void> _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<GoogleSession> _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<GoogleSession> _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 = <String>[
|
||||
'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(
|
||||
'<!doctype html><html><head>'
|
||||
'<meta charset="utf-8">'
|
||||
'<title>YouTube Personal</title></head>'
|
||||
'<body style="font-family:Segoe UI,Arial;'
|
||||
'padding:40px;background:#111;color:#fff">'
|
||||
'<h2>Connexion terminée</h2>'
|
||||
'<p>Tu peux fermer cet onglet et revenir '
|
||||
'dans YouTube Personal.</p></body></html>',
|
||||
);
|
||||
|
||||
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 = <String, String>{
|
||||
'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<void> _refreshWindowsAccessToken() async {
|
||||
final refreshToken = _windowsRefreshToken;
|
||||
|
||||
if (refreshToken == null ||
|
||||
refreshToken.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final body = <String, String>{
|
||||
'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;
|
||||
}
|
||||
Reference in New Issue
Block a user