199 lines
5.6 KiB
Dart
199 lines
5.6 KiB
Dart
|
|
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,
|
||
|
|
);
|