diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index 27de6945..a62e6426 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -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? _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 onActiveProfileChanged(String? newUserUuid) async { @@ -83,7 +85,11 @@ class TrackersProvider extends ChangeNotifier { Future connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( 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 connectAnilist({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( 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 connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect( 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(); notifyListeners(); try { return await runConnectPipeline( @@ -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(); } diff --git a/lib/providers/trakt_account_provider.dart b/lib/providers/trakt_account_provider.dart index 64515ff0..57623ad7 100644 --- a/lib/providers/trakt_account_provider.dart +++ b/lib/providers/trakt_account_provider.dart @@ -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? _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 connect({required void Function(DeviceCode code) onCodeReady}) async { if (_isConnecting || isConnected) return false; _isConnecting = true; - _cancelRequested = false; + _cancelCompleter = Completer(); notifyListeners(); try { return await runConnectPipeline( 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(); } diff --git a/lib/services/trackers/anilist/anilist_auth_service.dart b/lib/services/trackers/anilist/anilist_auth_service.dart index b08e60d4..f61df151 100644 --- a/lib/services/trackers/anilist/anilist_auth_service.dart +++ b/lib/services/trackers/anilist/anilist_auth_service.dart @@ -19,10 +19,11 @@ class AnilistAuthService { Future authorize({ required void Function(OAuthProxyStart) onCodeReady, bool Function()? shouldCancel, + Future? 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); } diff --git a/lib/services/trackers/device_code_auth_service.dart b/lib/services/trackers/device_code_auth_service.dart index c0709abf..2a05a6c2 100644 --- a/lib/services/trackers/device_code_auth_service.dart +++ b/lib/services/trackers/device_code_auth_service.dart @@ -32,10 +32,19 @@ abstract class DeviceCodeAuthServiceBase { /// 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 authorize({required void Function(DeviceCode code) onCodeReady, bool Function()? shouldCancel}) async { + Future authorize({ + required void Function(DeviceCode code) onCodeReady, + bool Function()? shouldCancel, + Future? 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; } diff --git a/lib/services/trackers/device_code_poller.dart b/lib/services/trackers/device_code_poller.dart index 3ae26ea8..b46df67a 100644 --- a/lib/services/trackers/device_code_poller.dart +++ b/lib/services/trackers/device_code_poller.dart @@ -14,13 +14,18 @@ Stream pollDeviceCode( DeviceCode code, { required Future Function() probe, bool Function()? shouldCancel, + Future? 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.delayed(interval); + if (onCancel != null) { + await Future.any([Future.delayed(interval), onCancel]); + } else { + await Future.delayed(interval); + } if (shouldCancel != null && shouldCancel()) return; final event = await probe(); diff --git a/lib/services/trackers/mal/mal_auth_service.dart b/lib/services/trackers/mal/mal_auth_service.dart index 7c0ec800..e27a5bf7 100644 --- a/lib/services/trackers/mal/mal_auth_service.dart +++ b/lib/services/trackers/mal/mal_auth_service.dart @@ -34,10 +34,11 @@ class MalAuthService { Future authorize({ required void Function(OAuthProxyStart) onCodeReady, bool Function()? shouldCancel, + Future? 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); } diff --git a/lib/services/trackers/oauth_proxy_client.dart b/lib/services/trackers/oauth_proxy_client.dart index 9bd52d64..06b62105 100644 --- a/lib/services/trackers/oauth_proxy_client.dart +++ b/lib/services/trackers/oauth_proxy_client.dart @@ -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 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 poll(String session, {bool Function()? shouldCancel, Future? 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([_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');