433 lines
11 KiB
Dart
433 lines
11 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../core/domain/ports/sm_account_gateway.dart';
|
|
import '../core/infra/sm_account_api/sm_account_client.dart';
|
|
import 'shared/settings_persistence.dart';
|
|
import 'tmdb_settings_provider.dart';
|
|
|
|
|
|
final smAccountGatewayProvider = Provider<SmAccountGateway>(
|
|
(ref) => SmAccountClient(),
|
|
);
|
|
|
|
|
|
enum SmAuthStatus {
|
|
|
|
unauthenticated,
|
|
|
|
|
|
restoring,
|
|
|
|
|
|
authenticated,
|
|
|
|
|
|
expired,
|
|
|
|
|
|
degraded,
|
|
}
|
|
|
|
|
|
class SmAccountState {
|
|
|
|
final SmAuthStatus status;
|
|
|
|
|
|
final String email;
|
|
|
|
|
|
final String baseUrl;
|
|
|
|
|
|
final String dataRefreshToken;
|
|
|
|
|
|
final bool isVip;
|
|
|
|
|
|
final int? vipExpiresAt;
|
|
|
|
|
|
final bool vipPermanent;
|
|
|
|
const SmAccountState({
|
|
this.status = SmAuthStatus.unauthenticated,
|
|
this.email = '',
|
|
this.baseUrl = defaultBaseUrl,
|
|
this.dataRefreshToken = '',
|
|
this.isVip = false,
|
|
this.vipExpiresAt,
|
|
this.vipPermanent = false,
|
|
});
|
|
|
|
|
|
static const defaultBaseUrl = 'https://your-server.example';
|
|
|
|
|
|
String get tmdbApiBaseUrl => '${baseUrl.replaceAll(RegExp(r'/+$'), '')}/3';
|
|
|
|
|
|
bool get loggedIn => status == SmAuthStatus.authenticated;
|
|
|
|
|
|
bool get canUseDataPlane => status == SmAuthStatus.authenticated;
|
|
|
|
|
|
bool get hasActiveSession =>
|
|
status == SmAuthStatus.authenticated || status == SmAuthStatus.restoring;
|
|
|
|
|
|
bool get needsReauth =>
|
|
status == SmAuthStatus.expired || status == SmAuthStatus.degraded;
|
|
|
|
|
|
bool get hasVip => status == SmAuthStatus.authenticated && isVip;
|
|
|
|
SmAccountState copyWith({
|
|
SmAuthStatus? status,
|
|
String? email,
|
|
String? baseUrl,
|
|
String? dataRefreshToken,
|
|
bool? isVip,
|
|
int? vipExpiresAt,
|
|
bool? vipPermanent,
|
|
bool clearVipExpiresAt = false,
|
|
}) => SmAccountState(
|
|
status: status ?? this.status,
|
|
email: email ?? this.email,
|
|
baseUrl: baseUrl ?? this.baseUrl,
|
|
dataRefreshToken: dataRefreshToken ?? this.dataRefreshToken,
|
|
isVip: isVip ?? this.isVip,
|
|
vipExpiresAt: clearVipExpiresAt
|
|
? null
|
|
: (vipExpiresAt ?? this.vipExpiresAt),
|
|
vipPermanent: vipPermanent ?? this.vipPermanent,
|
|
);
|
|
}
|
|
|
|
class SmAccountNotifier extends AsyncNotifier<SmAccountState>
|
|
with SettingsPersistence {
|
|
static const _keyBaseUrl = 'smplayer.sm_account_base_url';
|
|
static const _keyEmail = 'smplayer.sm_account_email';
|
|
|
|
|
|
static const _keyCookie = 'smplayer.sm_account_cookie';
|
|
static const _keyRefreshToken = 'smplayer.sm_account_refresh_token';
|
|
static const _keyVip = 'smplayer.sm_account_vip';
|
|
static const _keyVipExpiresAt = 'smplayer.sm_account_vip_expires_at';
|
|
static const _keyVipPermanent = 'smplayer.sm_account_vip_permanent';
|
|
|
|
|
|
String? _accessToken;
|
|
DateTime? _accessTokenExpiry;
|
|
Future<String?>? _refreshInFlight;
|
|
|
|
@override
|
|
Future<SmAccountState> build() async {
|
|
final prefs = await getPrefs();
|
|
final refreshToken = prefs.getString(_keyRefreshToken)?.trim() ?? '';
|
|
final legacyCookie = prefs.getString(_keyCookie)?.trim() ?? '';
|
|
final rawBaseUrl = prefs.getString(_keyBaseUrl)?.trim();
|
|
final baseUrl = (rawBaseUrl == null || rawBaseUrl.isEmpty)
|
|
? SmAccountState.defaultBaseUrl
|
|
: rawBaseUrl;
|
|
final email = prefs.getString(_keyEmail)?.trim() ?? '';
|
|
final isVip = prefs.getBool(_keyVip) ?? false;
|
|
final vipExpiresAt = prefs.getInt(_keyVipExpiresAt);
|
|
final vipPermanent = prefs.getBool(_keyVipPermanent) ?? false;
|
|
|
|
if (legacyCookie.isNotEmpty) {
|
|
await persistField(_keyCookie, null);
|
|
}
|
|
|
|
if (refreshToken.isNotEmpty) {
|
|
Future.microtask(_restoreSession);
|
|
return SmAccountState(
|
|
status: SmAuthStatus.restoring,
|
|
email: email,
|
|
baseUrl: baseUrl,
|
|
dataRefreshToken: refreshToken,
|
|
isVip: isVip,
|
|
vipExpiresAt: vipExpiresAt,
|
|
vipPermanent: vipPermanent,
|
|
);
|
|
}
|
|
|
|
if (legacyCookie.isNotEmpty) {
|
|
return SmAccountState(
|
|
status: SmAuthStatus.degraded,
|
|
email: email,
|
|
baseUrl: baseUrl,
|
|
);
|
|
}
|
|
|
|
return SmAccountState(
|
|
status: SmAuthStatus.unauthenticated,
|
|
baseUrl: baseUrl,
|
|
);
|
|
}
|
|
|
|
|
|
Future<void> _restoreSession() async {
|
|
final current = state.value;
|
|
if (current == null ||
|
|
current.status != SmAuthStatus.restoring ||
|
|
current.dataRefreshToken.isEmpty) {
|
|
return;
|
|
}
|
|
final token = await getAccessToken(force: true);
|
|
if (token != null) {
|
|
return;
|
|
}
|
|
final after = state.value;
|
|
if (after != null &&
|
|
after.status == SmAuthStatus.restoring &&
|
|
after.dataRefreshToken.isNotEmpty) {
|
|
_setStatus(SmAuthStatus.authenticated);
|
|
}
|
|
}
|
|
|
|
void _setStatus(SmAuthStatus status) {
|
|
final current = state.value;
|
|
if (current == null || current.status == status) return;
|
|
state = AsyncValue.data(current.copyWith(status: status));
|
|
}
|
|
|
|
|
|
Future<String?> getAccessToken({bool force = false}) async {
|
|
final current = state.value;
|
|
if (current == null || current.dataRefreshToken.isEmpty) return null;
|
|
|
|
if (!force &&
|
|
_accessToken != null &&
|
|
_accessTokenExpiry != null &&
|
|
_accessTokenExpiry!.isAfter(
|
|
DateTime.now().add(const Duration(seconds: 60)),
|
|
)) {
|
|
return _accessToken;
|
|
}
|
|
|
|
final inFlight = _refreshInFlight;
|
|
if (inFlight != null) return inFlight;
|
|
|
|
final refresh = _refreshAccessToken(current);
|
|
_refreshInFlight = refresh;
|
|
try {
|
|
return await refresh;
|
|
} finally {
|
|
if (identical(_refreshInFlight, refresh)) {
|
|
_refreshInFlight = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<String?> _refreshAccessToken(SmAccountState current) async {
|
|
final result = await ref
|
|
.read(smAccountGatewayProvider)
|
|
.refreshAccessToken(
|
|
baseUrl: current.baseUrl,
|
|
refreshToken: current.dataRefreshToken,
|
|
);
|
|
|
|
if (!result.ok) {
|
|
_accessToken = null;
|
|
_accessTokenExpiry = null;
|
|
if (result.invalidRefreshToken) {
|
|
await _markExpired();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_accessToken = result.accessToken;
|
|
_accessTokenExpiry = DateTime.now().add(
|
|
Duration(seconds: result.expiresIn > 0 ? result.expiresIn : 900),
|
|
);
|
|
|
|
final latest = state.value ?? current;
|
|
final rotatedRefreshToken = result.refreshToken.trim();
|
|
final nextRefreshToken =
|
|
(rotatedRefreshToken.isNotEmpty &&
|
|
rotatedRefreshToken != latest.dataRefreshToken)
|
|
? rotatedRefreshToken
|
|
: latest.dataRefreshToken;
|
|
if (nextRefreshToken != latest.dataRefreshToken) {
|
|
await persistField(_keyRefreshToken, nextRefreshToken);
|
|
}
|
|
if (result.hasVipInfo) {
|
|
await persistFields(<String, dynamic>{
|
|
_keyVip: result.isVip,
|
|
_keyVipExpiresAt: result.vipExpiresAt,
|
|
_keyVipPermanent: result.vipPermanent,
|
|
});
|
|
}
|
|
state = AsyncValue.data(
|
|
latest.copyWith(
|
|
status: SmAuthStatus.authenticated,
|
|
dataRefreshToken: nextRefreshToken,
|
|
isVip: result.hasVipInfo ? result.isVip : null,
|
|
vipExpiresAt: result.hasVipInfo ? result.vipExpiresAt : null,
|
|
clearVipExpiresAt: result.hasVipInfo && result.vipExpiresAt == null,
|
|
vipPermanent: result.hasVipInfo ? result.vipPermanent : null,
|
|
),
|
|
);
|
|
return _accessToken;
|
|
}
|
|
|
|
|
|
Future<SmLoginResult> login(String email, String password) async {
|
|
final current = state.value ?? const SmAccountState();
|
|
final baseUrl = current.baseUrl.trim().isEmpty
|
|
? SmAccountState.defaultBaseUrl
|
|
: current.baseUrl.trim();
|
|
|
|
final result = await ref
|
|
.read(smAccountGatewayProvider)
|
|
.login(baseUrl: baseUrl, email: email.trim(), password: password);
|
|
if (!result.ok) return result;
|
|
|
|
await _applySession(baseUrl, result);
|
|
return result;
|
|
}
|
|
|
|
|
|
Future<SmActionResult> registerStart(String email) async {
|
|
final baseUrl = _effectiveBaseUrl();
|
|
final trimmedEmail = email.trim();
|
|
final result = await ref
|
|
.read(smAccountGatewayProvider)
|
|
.registerStart(baseUrl: baseUrl, email: trimmedEmail);
|
|
if (result.ok) {
|
|
await persistField(_keyBaseUrl, baseUrl);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
|
|
Future<SmLoginResult> registerVerify(
|
|
String email,
|
|
String password,
|
|
String code,
|
|
) async {
|
|
final baseUrl = _effectiveBaseUrl();
|
|
final result = await ref
|
|
.read(smAccountGatewayProvider)
|
|
.registerVerify(
|
|
baseUrl: baseUrl,
|
|
email: email.trim(),
|
|
password: password,
|
|
code: code.trim(),
|
|
);
|
|
if (!result.ok) return result;
|
|
|
|
await _applySession(baseUrl, result);
|
|
return result;
|
|
}
|
|
|
|
|
|
Future<SmActionResult> resendRegisterCode(String email) async {
|
|
return ref
|
|
.read(smAccountGatewayProvider)
|
|
.resendCode(baseUrl: _effectiveBaseUrl(), email: email.trim());
|
|
}
|
|
|
|
|
|
Future<void> logout() async {
|
|
final current = state.value ?? const SmAccountState();
|
|
_accessToken = null;
|
|
_accessTokenExpiry = null;
|
|
_refreshInFlight = null;
|
|
if (current.dataRefreshToken.isNotEmpty) {
|
|
await ref
|
|
.read(smAccountGatewayProvider)
|
|
.logout(
|
|
baseUrl: current.baseUrl,
|
|
refreshToken: current.dataRefreshToken,
|
|
);
|
|
}
|
|
await persistFields(<String, dynamic>{
|
|
_keyEmail: null,
|
|
_keyCookie: null,
|
|
_keyRefreshToken: null,
|
|
_keyVip: null,
|
|
_keyVipExpiresAt: null,
|
|
_keyVipPermanent: null,
|
|
});
|
|
state = AsyncValue.data(
|
|
SmAccountState(
|
|
status: SmAuthStatus.unauthenticated,
|
|
baseUrl: current.baseUrl,
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
Future<void> _applySession(String baseUrl, SmLoginResult result) async {
|
|
_accessToken = result.accessToken.isNotEmpty ? result.accessToken : null;
|
|
_accessTokenExpiry = result.expiresIn > 0
|
|
? DateTime.now().add(Duration(seconds: result.expiresIn))
|
|
: null;
|
|
_refreshInFlight = null;
|
|
|
|
await persistFields(<String, dynamic>{
|
|
_keyBaseUrl: baseUrl,
|
|
_keyEmail: result.email,
|
|
_keyRefreshToken: result.refreshToken,
|
|
_keyVip: result.isVip,
|
|
_keyVipExpiresAt: result.vipExpiresAt,
|
|
_keyVipPermanent: result.vipPermanent,
|
|
_keyCookie: null,
|
|
});
|
|
final current = state.value ?? const SmAccountState();
|
|
state = AsyncValue.data(
|
|
current.copyWith(
|
|
status: SmAuthStatus.authenticated,
|
|
email: result.email,
|
|
baseUrl: baseUrl,
|
|
dataRefreshToken: result.refreshToken,
|
|
isVip: result.isVip,
|
|
vipExpiresAt: result.vipExpiresAt,
|
|
clearVipExpiresAt: result.vipExpiresAt == null,
|
|
vipPermanent: result.vipPermanent,
|
|
),
|
|
);
|
|
if (result.isVip) {
|
|
await ref.read(tmdbSettingsProvider.notifier).enableSmAccountProxy();
|
|
}
|
|
}
|
|
|
|
|
|
Future<void> _markExpired() async {
|
|
_accessToken = null;
|
|
_accessTokenExpiry = null;
|
|
_refreshInFlight = null;
|
|
await persistFields(<String, dynamic>{
|
|
_keyRefreshToken: null,
|
|
_keyVip: null,
|
|
_keyVipExpiresAt: null,
|
|
_keyVipPermanent: null,
|
|
});
|
|
final current = state.value ?? const SmAccountState();
|
|
state = AsyncValue.data(
|
|
current.copyWith(
|
|
status: SmAuthStatus.expired,
|
|
dataRefreshToken: '',
|
|
isVip: false,
|
|
clearVipExpiresAt: true,
|
|
vipPermanent: false,
|
|
),
|
|
);
|
|
}
|
|
|
|
String _effectiveBaseUrl() {
|
|
final current = state.value ?? const SmAccountState();
|
|
final v = current.baseUrl.trim();
|
|
return v.isEmpty ? SmAccountState.defaultBaseUrl : v;
|
|
}
|
|
}
|
|
|
|
final smAccountProvider =
|
|
AsyncNotifierProvider<SmAccountNotifier, SmAccountState>(
|
|
SmAccountNotifier.new,
|
|
);
|