Initial commit
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../core/contracts/trakt/trakt_token.dart';
|
||||
import '../core/infra/deep_link/deep_link_service.dart';
|
||||
import '../core/infra/trakt_api/trakt_credentials.dart';
|
||||
import '../core/infra/trakt_api/trakt_token_store.dart';
|
||||
import '../shared/utils/app_logger.dart';
|
||||
import 'di_providers.dart';
|
||||
import 'shared/settings_persistence.dart';
|
||||
|
||||
const _tag = 'Trakt';
|
||||
|
||||
|
||||
const _connectTimeout = Duration(minutes: 5);
|
||||
|
||||
|
||||
class TraktState {
|
||||
final bool isConnected;
|
||||
final String username;
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresIn;
|
||||
final int createdAt;
|
||||
|
||||
const TraktState({
|
||||
this.isConnected = false,
|
||||
this.username = '',
|
||||
this.accessToken = '',
|
||||
this.refreshToken = '',
|
||||
this.expiresIn = 0,
|
||||
this.createdAt = 0,
|
||||
});
|
||||
|
||||
TraktState copyWith({
|
||||
bool? isConnected,
|
||||
String? username,
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
int? expiresIn,
|
||||
int? createdAt,
|
||||
}) => TraktState(
|
||||
isConnected: isConnected ?? this.isConnected,
|
||||
username: username ?? this.username,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
expiresIn: expiresIn ?? this.expiresIn,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
class TraktConnectException implements Exception {
|
||||
final String message;
|
||||
const TraktConnectException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'TraktConnectException: $message';
|
||||
}
|
||||
|
||||
class TraktSettingsNotifier extends AsyncNotifier<TraktState>
|
||||
with SettingsPersistence {
|
||||
static const _keyAccessToken = TraktTokenStore.keyAccessToken;
|
||||
static const _keyRefreshToken = TraktTokenStore.keyRefreshToken;
|
||||
static const _keyExpiresIn = TraktTokenStore.keyExpiresIn;
|
||||
static const _keyCreatedAt = TraktTokenStore.keyCreatedAt;
|
||||
static const _keyUsername = 'smplayer.trakt_username';
|
||||
|
||||
@override
|
||||
Future<TraktState> build() async {
|
||||
ref.read(traktAuthProvider).onCleared = () {
|
||||
final s = state.value;
|
||||
if (s != null && s.isConnected) {
|
||||
state = const AsyncValue.data(TraktState());
|
||||
}
|
||||
};
|
||||
|
||||
final prefs = await getPrefs();
|
||||
final accessToken = prefs.getString(_keyAccessToken)?.trim() ?? '';
|
||||
return TraktState(
|
||||
isConnected: accessToken.isNotEmpty,
|
||||
username: prefs.getString(_keyUsername) ?? '',
|
||||
accessToken: accessToken,
|
||||
refreshToken: prefs.getString(_keyRefreshToken)?.trim() ?? '',
|
||||
expiresIn: prefs.getInt(_keyExpiresIn) ?? 0,
|
||||
createdAt: prefs.getInt(_keyCreatedAt) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> connect() async {
|
||||
final gateway = ref.read(traktGatewayProvider);
|
||||
|
||||
final csrfState = _generateState();
|
||||
|
||||
final redirectFuture = DeepLinkService.instance.uriStream
|
||||
.firstWhere((uri) => uri.queryParameters['state'] == csrfState)
|
||||
.timeout(_connectTimeout);
|
||||
|
||||
final authorizeUrl = TraktCredentials.authorizeUrl(csrfState);
|
||||
final launched = await launchUrl(
|
||||
authorizeUrl,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched) {
|
||||
throw const TraktConnectException('无法打开浏览器');
|
||||
}
|
||||
|
||||
final Uri redirect;
|
||||
try {
|
||||
redirect = await redirectFuture;
|
||||
} on TimeoutException {
|
||||
throw const TraktConnectException('连接超时,请重试');
|
||||
}
|
||||
|
||||
final error = redirect.queryParameters['error'];
|
||||
if (error != null && error.isNotEmpty) {
|
||||
AppLogger.warn(_tag, 'authorization denied: $error');
|
||||
throw const TraktConnectException('已取消授权');
|
||||
}
|
||||
|
||||
final returnedState = redirect.queryParameters['state'];
|
||||
if (returnedState != csrfState) {
|
||||
AppLogger.warn(_tag, 'state mismatch');
|
||||
throw const TraktConnectException('授权校验失败');
|
||||
}
|
||||
|
||||
final code = redirect.queryParameters['code'];
|
||||
if (code == null || code.isEmpty) {
|
||||
throw const TraktConnectException('未收到授权码');
|
||||
}
|
||||
|
||||
final TraktToken token;
|
||||
try {
|
||||
token = await gateway.exchangeCode(code);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'exchangeCode failed', e);
|
||||
throw const TraktConnectException('令牌交换失败');
|
||||
}
|
||||
|
||||
await _persistToken(token);
|
||||
|
||||
var username = '';
|
||||
try {
|
||||
final user = await gateway.getMe(token.accessToken);
|
||||
username = user.username;
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'getMe failed (non-fatal)', e);
|
||||
}
|
||||
await persistField(_keyUsername, username);
|
||||
|
||||
state = AsyncValue.data(
|
||||
TraktState(
|
||||
isConnected: true,
|
||||
username: username,
|
||||
accessToken: token.accessToken,
|
||||
refreshToken: token.refreshToken,
|
||||
expiresIn: token.expiresIn,
|
||||
createdAt: token.createdAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await ref.read(traktAuthProvider).clear();
|
||||
await persistField(_keyUsername, null);
|
||||
state = const AsyncValue.data(TraktState());
|
||||
}
|
||||
|
||||
|
||||
Future<String?> ensureValidToken() {
|
||||
return ref.read(traktAuthProvider).validAccessToken();
|
||||
}
|
||||
|
||||
Future<void> _persistToken(TraktToken token) async {
|
||||
await persistFields(<String, dynamic>{
|
||||
_keyAccessToken: token.accessToken,
|
||||
_keyRefreshToken: token.refreshToken,
|
||||
_keyExpiresIn: token.expiresIn,
|
||||
_keyCreatedAt: token.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
static String _generateState() {
|
||||
final rnd = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
}
|
||||
|
||||
final traktSettingsProvider =
|
||||
AsyncNotifierProvider<TraktSettingsNotifier, TraktState>(
|
||||
TraktSettingsNotifier.new,
|
||||
);
|
||||
Reference in New Issue
Block a user