fix(trackers): cancel connect immediately
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/trackers/device_code.dart';
|
||||
@@ -37,7 +39,7 @@ class TrackersProvider extends ChangeNotifier {
|
||||
|
||||
String _activeUserUuid = '';
|
||||
TrackerService? _connecting;
|
||||
bool _cancelRequested = false;
|
||||
Completer<void>? _cancelCompleter;
|
||||
|
||||
MalSession? get mal => _mal;
|
||||
AnilistSession? get anilist => _anilist;
|
||||
@@ -53,11 +55,11 @@ class TrackersProvider extends ChangeNotifier {
|
||||
|
||||
bool isConnecting(TrackerService service) => _connecting == service;
|
||||
|
||||
/// Cancel an in-flight connect. Supported for all three services — Simkl's
|
||||
/// device-code poll and MAL/AniList's OAuth-proxy long-poll both honor the
|
||||
/// flag on their next tick.
|
||||
/// Cancel an in-flight connect. Completing the completer both wakes the
|
||||
/// blocking `Future.any` race and flips `isCompleted` for the next sync check.
|
||||
void cancelConnect() {
|
||||
_cancelRequested = true;
|
||||
final c = _cancelCompleter;
|
||||
if (c != null && !c.isCompleted) c.complete();
|
||||
}
|
||||
|
||||
Future<void> onActiveProfileChanged(String? newUserUuid) async {
|
||||
@@ -83,7 +85,11 @@ class TrackersProvider extends ChangeNotifier {
|
||||
Future<bool> connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect<MalSession>(
|
||||
service: TrackerService.mal,
|
||||
alreadyConnected: isMalConnected,
|
||||
authorize: () => _malAuth.authorize(onCodeReady: onCodeReady, shouldCancel: () => _cancelRequested),
|
||||
authorize: () => _malAuth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: () => _cancelCompleter?.isCompleted ?? false,
|
||||
onCancel: _cancelCompleter!.future,
|
||||
),
|
||||
enrich: _enrichMal,
|
||||
store: malAccountStore,
|
||||
assign: (s) {
|
||||
@@ -100,7 +106,11 @@ class TrackersProvider extends ChangeNotifier {
|
||||
Future<bool> connectAnilist({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect<AnilistSession>(
|
||||
service: TrackerService.anilist,
|
||||
alreadyConnected: isAnilistConnected,
|
||||
authorize: () => _anilistAuth.authorize(onCodeReady: onCodeReady, shouldCancel: () => _cancelRequested),
|
||||
authorize: () => _anilistAuth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: () => _cancelCompleter?.isCompleted ?? false,
|
||||
onCancel: _cancelCompleter!.future,
|
||||
),
|
||||
enrich: _enrichAnilist,
|
||||
store: anilistAccountStore,
|
||||
assign: (s) {
|
||||
@@ -117,7 +127,11 @@ class TrackersProvider extends ChangeNotifier {
|
||||
Future<bool> connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect<SimklSession>(
|
||||
service: TrackerService.simkl,
|
||||
alreadyConnected: isSimklConnected,
|
||||
authorize: () => _simklAuth.authorize(onCodeReady: onCodeReady, shouldCancel: () => _cancelRequested),
|
||||
authorize: () => _simklAuth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: () => _cancelCompleter?.isCompleted ?? false,
|
||||
onCancel: _cancelCompleter!.future,
|
||||
),
|
||||
enrich: _enrichSimkl,
|
||||
store: simklAccountStore,
|
||||
assign: (s) {
|
||||
@@ -144,7 +158,7 @@ class TrackersProvider extends ChangeNotifier {
|
||||
}) async {
|
||||
if (_connecting != null || alreadyConnected) return false;
|
||||
_connecting = service;
|
||||
_cancelRequested = false;
|
||||
_cancelCompleter = Completer<void>();
|
||||
notifyListeners();
|
||||
try {
|
||||
return await runConnectPipeline<T>(
|
||||
@@ -155,6 +169,9 @@ class TrackersProvider extends ChangeNotifier {
|
||||
assign: assign,
|
||||
);
|
||||
} finally {
|
||||
final c = _cancelCompleter;
|
||||
if (c != null && !c.isCompleted) c.complete();
|
||||
_cancelCompleter = null;
|
||||
_connecting = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/trackers/device_code.dart';
|
||||
@@ -21,7 +23,7 @@ class TraktAccountProvider extends ChangeNotifier {
|
||||
TraktSession? _session;
|
||||
String _activeUserUuid = '';
|
||||
bool _isConnecting = false;
|
||||
bool _cancelRequested = false;
|
||||
Completer<void>? _cancelCompleter;
|
||||
|
||||
TraktSession? get session => _session;
|
||||
bool get isConnected => _session != null;
|
||||
@@ -29,9 +31,11 @@ class TraktAccountProvider extends ChangeNotifier {
|
||||
bool get isConnecting => _isConnecting;
|
||||
|
||||
/// Cancel an in-flight `connect()` (e.g. user dismissed the device-code
|
||||
/// dialog). The poll loop checks this flag between iterations.
|
||||
/// dialog). Completing the completer both wakes the blocking `Future.any`
|
||||
/// race and flips `isCompleted` for the next sync check.
|
||||
void cancelConnect() {
|
||||
_cancelRequested = true;
|
||||
final c = _cancelCompleter;
|
||||
if (c != null && !c.isCompleted) c.complete();
|
||||
}
|
||||
|
||||
/// Called whenever the active Plex profile changes (or on initial load).
|
||||
@@ -48,17 +52,24 @@ class TraktAccountProvider extends ChangeNotifier {
|
||||
Future<bool> connect({required void Function(DeviceCode code) onCodeReady}) async {
|
||||
if (_isConnecting || isConnected) return false;
|
||||
_isConnecting = true;
|
||||
_cancelRequested = false;
|
||||
_cancelCompleter = Completer<void>();
|
||||
notifyListeners();
|
||||
try {
|
||||
return await runConnectPipeline<TraktSession>(
|
||||
logLabel: 'Trakt',
|
||||
authorize: () => _auth.authorize(onCodeReady: onCodeReady, shouldCancel: () => _cancelRequested),
|
||||
authorize: () => _auth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: () => _cancelCompleter?.isCompleted ?? false,
|
||||
onCancel: _cancelCompleter!.future,
|
||||
),
|
||||
enrich: _enrichUsername,
|
||||
save: (s) => _store.save(_activeUserUuid, s),
|
||||
assign: _setSessionAndRebind,
|
||||
);
|
||||
} finally {
|
||||
final c = _cancelCompleter;
|
||||
if (c != null && !c.isCompleted) c.complete();
|
||||
_cancelCompleter = null;
|
||||
_isConnecting = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -19,10 +19,11 @@ class AnilistAuthService {
|
||||
Future<AnilistSession?> authorize({
|
||||
required void Function(OAuthProxyStart) onCodeReady,
|
||||
bool Function()? shouldCancel,
|
||||
Future<void>? onCancel,
|
||||
}) async {
|
||||
final start = await _proxy.start('anilist');
|
||||
onCodeReady(start);
|
||||
final result = await _proxy.poll(start.session, shouldCancel: shouldCancel);
|
||||
final result = await _proxy.poll(start.session, shouldCancel: shouldCancel, onCancel: onCancel);
|
||||
if (result == null) return null;
|
||||
return AnilistSession.fromProxyResult(result);
|
||||
}
|
||||
|
||||
@@ -32,10 +32,19 @@ abstract class DeviceCodeAuthServiceBase<T> {
|
||||
/// Drive the full flow. Invokes [onCodeReady] once with the code so the UI
|
||||
/// can render the dialog, then polls until the user authorizes, denies, or
|
||||
/// the code expires. Returns null on denied/expired/cancel.
|
||||
Future<T?> authorize({required void Function(DeviceCode code) onCodeReady, bool Function()? shouldCancel}) async {
|
||||
Future<T?> authorize({
|
||||
required void Function(DeviceCode code) onCodeReady,
|
||||
bool Function()? shouldCancel,
|
||||
Future<void>? onCancel,
|
||||
}) async {
|
||||
final code = await createDeviceCode();
|
||||
onCodeReady(code);
|
||||
await for (final event in poller.pollDeviceCode(code, shouldCancel: shouldCancel, probe: () => probe(code))) {
|
||||
await for (final event in poller.pollDeviceCode(
|
||||
code,
|
||||
shouldCancel: shouldCancel,
|
||||
onCancel: onCancel,
|
||||
probe: () => probe(code),
|
||||
)) {
|
||||
if (event is DevicePollSuccess) return buildSession(event.tokenResponse);
|
||||
if (event is DevicePollDenied || event is DevicePollExpired) return null;
|
||||
}
|
||||
|
||||
@@ -14,13 +14,18 @@ Stream<DevicePollEvent> pollDeviceCode(
|
||||
DeviceCode code, {
|
||||
required Future<DevicePollEvent> Function() probe,
|
||||
bool Function()? shouldCancel,
|
||||
Future<void>? onCancel,
|
||||
}) async* {
|
||||
var interval = Duration(seconds: code.interval);
|
||||
final deadline = DateTime.now().add(Duration(seconds: code.expiresIn));
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (shouldCancel != null && shouldCancel()) return;
|
||||
await Future<void>.delayed(interval);
|
||||
if (onCancel != null) {
|
||||
await Future.any<void>([Future<void>.delayed(interval), onCancel]);
|
||||
} else {
|
||||
await Future<void>.delayed(interval);
|
||||
}
|
||||
if (shouldCancel != null && shouldCancel()) return;
|
||||
|
||||
final event = await probe();
|
||||
|
||||
@@ -34,10 +34,11 @@ class MalAuthService {
|
||||
Future<MalSession?> authorize({
|
||||
required void Function(OAuthProxyStart) onCodeReady,
|
||||
bool Function()? shouldCancel,
|
||||
Future<void>? onCancel,
|
||||
}) async {
|
||||
final start = await _proxy.start('mal');
|
||||
onCodeReady(start);
|
||||
final result = await _proxy.poll(start.session, shouldCancel: shouldCancel);
|
||||
final result = await _proxy.poll(start.session, shouldCancel: shouldCancel, onCancel: onCancel);
|
||||
if (result == null) return null;
|
||||
return MalSession.fromProxyResult(result);
|
||||
}
|
||||
|
||||
@@ -48,18 +48,22 @@ class OAuthProxyClient {
|
||||
|
||||
/// Long-poll /auth/result?session=X until a completion event arrives.
|
||||
///
|
||||
/// Returns null if [shouldCancel] flips true before a result arrives. Throws
|
||||
/// [OAuthProxyException] on unrecoverable errors (session gone, upstream
|
||||
/// failure). The server holds each request for up to 50 s; 204 responses are
|
||||
/// retried transparently.
|
||||
Future<OAuthProxyResult?> poll(String session, {bool Function()? shouldCancel}) async {
|
||||
/// Returns null if [shouldCancel] flips true between iterations or [onCancel]
|
||||
/// completes mid-request. Throws [OAuthProxyException] on unrecoverable errors
|
||||
/// (session gone, upstream failure). The server holds each request for up to
|
||||
/// 50 s; 204 responses are retried transparently.
|
||||
Future<OAuthProxyResult?> poll(String session, {bool Function()? shouldCancel, Future<void>? onCancel}) async {
|
||||
final uri = Uri.parse('$baseUrl/auth/result').replace(queryParameters: {'session': session});
|
||||
final cancelSentinel = Object();
|
||||
// Subscribe to onCancel once; reusing this derived future avoids
|
||||
// accumulating a fresh listener per loop iteration.
|
||||
final cancelFuture = onCancel?.then((_) => cancelSentinel);
|
||||
while (true) {
|
||||
if (shouldCancel?.call() ?? false) return null;
|
||||
|
||||
final http.Response res;
|
||||
final Object? raced;
|
||||
try {
|
||||
res = await _http.get(uri).timeout(const Duration(seconds: 65));
|
||||
raced = await Future.any<Object?>([_http.get(uri).timeout(const Duration(seconds: 65)), ?cancelFuture]);
|
||||
} on TimeoutException {
|
||||
continue;
|
||||
} catch (e) {
|
||||
@@ -68,6 +72,9 @@ class OAuthProxyClient {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (identical(raced, cancelSentinel)) return null;
|
||||
final res = raced as http.Response;
|
||||
|
||||
if (res.statusCode == 204) continue; // server-side timeout, retry
|
||||
if (res.statusCode == 410) {
|
||||
throw const OAuthProxyException('Session expired or already used');
|
||||
|
||||
Reference in New Issue
Block a user