fix(trackers): cancel connect immediately

This commit is contained in:
edde746
2026-04-24 20:54:38 +02:00
parent 1de91a598c
commit df94e83592
7 changed files with 77 additions and 26 deletions
+26 -9
View File
@@ -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();
}
+16 -5
View File
@@ -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);
}
+14 -7
View File
@@ -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');