diff --git a/lib/screens/auth/plex_pin_auth_flow.dart b/lib/screens/auth/plex_pin_auth_flow.dart index 25f423fc..6844e629 100644 --- a/lib/screens/auth/plex_pin_auth_flow.dart +++ b/lib/screens/auth/plex_pin_auth_flow.dart @@ -12,6 +12,16 @@ import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; import '../../utils/platform_detector.dart'; +/// Whether the Plex PIN hand-off must stay in-app as a QR code instead of +/// opening the auth URL. +/// +/// Android Automotive OS head units ship no browser: the system hands an +/// app-launched `https` URL to the car link viewer, which only renders it as +/// a QR code of its own while this flow sits on a "sign in from your browser" +/// spinner until the PIN expires two minutes later. Owning the QR keeps the +/// scan target, the retry action and the error copy inside the app. +bool plexSignInRequiresInAppQr({required bool isAutomotive}) => isAutomotive; + /// Self-contained Plex PIN/QR auth flow. /// /// Renders the polling UI (QR code or browser-waiting spinner) once an @@ -59,6 +69,10 @@ class PlexPinAuthFlow extends StatefulWidget { final Widget Function(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy)? initialButtonsBuilder; + /// Test seam: builds the auth service this flow drives. Defaults to the + /// production [PlexAuthService.create]. + final Future Function()? serviceFactory; + const PlexPinAuthFlow({ super.key, required this.onTokenReceived, @@ -68,6 +82,7 @@ class PlexPinAuthFlow extends StatefulWidget { this.initializeService = true, this.initialUseQr, this.initialButtonsBuilder, + this.serviceFactory, }); @override @@ -85,12 +100,14 @@ class _PlexPinAuthFlowState extends State { @override void initState() { super.initState(); - _useQr = widget.initialUseQr ?? PlatformDetector.isTV(); + _useQr = + widget.initialUseQr ?? + (PlatformDetector.isTV() || plexSignInRequiresInAppQr(isAutomotive: PlatformDetector.isAutomotive())); if (widget.initializeService) unawaited(_initService()); } Future _initService() async { - final svc = await PlexAuthService.create(); + final svc = await (widget.serviceFactory?.call() ?? PlexAuthService.create()); if (!mounted) { svc.dispose(); return; @@ -113,9 +130,13 @@ class _PlexPinAuthFlowState extends State { Future _start({required bool useQr}) async { final svc = _authService; if (svc == null) return; + // A car has no browser to hand the PIN URL to, so the browser action + // resolves to the in-app QR there instead of a spinner that can only time + // out. Every other platform honours what the user pressed. + final resolvedUseQr = useQr || plexSignInRequiresInAppQr(isAutomotive: PlatformDetector.isAutomotive()); final attemptId = ++_attemptId; setState(() { - _useQr = useQr; + _useQr = resolvedUseQr; _isPolling = true; _errorMessage = null; _qrAuthUrl = null; @@ -129,7 +150,7 @@ class _PlexPinAuthFlowState extends State { final url = svc.getAuthUrl(pinCode); if (!_isCurrentAttempt(attemptId)) return; - if (useQr) { + if (resolvedUseQr) { setState(() => _qrAuthUrl = url); } else { final uri = Uri.parse(url); @@ -157,7 +178,7 @@ class _PlexPinAuthFlowState extends State { // Auto-close the in-app browser on mobile (no-op on desktop / when // already closed). - if (!useQr) { + if (!resolvedUseQr) { try { await closeInAppWebView(); } catch (_) {} diff --git a/test/screens/auth/plex_pin_auth_flow_test.dart b/test/screens/auth/plex_pin_auth_flow_test.dart new file mode 100644 index 00000000..adaea6d6 --- /dev/null +++ b/test/screens/auth/plex_pin_auth_flow_test.dart @@ -0,0 +1,133 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; +import 'package:http/http.dart' as http; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/screens/auth/plex_pin_auth_flow.dart'; +import 'package:plezy/services/plex_auth_service.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../../test_helpers/theme.dart'; + +/// Serves a PIN without touching plex.tv, and never resolves the claim poll so +/// the flow parks on whichever waiting UI it chose. +class _StalledPlexAuthService extends PlexAuthService { + _StalledPlexAuthService() + : super.forTesting(http: MediaServerHttpClient(client: MockClient((_) async => http.Response('{}', 200)))); + + @override + Future> createPin() async => {'id': 1, 'code': 'ABCD'}; + + @override + String getAuthUrl(String pinCode) => 'https://app.plex.tv/auth#?code=$pinCode'; + + @override + Future pollPinUntilClaimed( + int pinId, { + Duration timeout = const Duration(minutes: 2), + bool Function()? shouldCancel, + }) => Completer().future; +} + +/// The channel every `url_launcher` platform implementation talks to; a +/// recorded call means the flow tried to leave the app for a browser. +const _urlLauncherChannel = MethodChannel('plugins.flutter.io/url_launcher'); + +void main() { + final launched = []; + + setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + setUp(() { + launched.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(_urlLauncherChannel, ( + call, + ) async { + switch (call.method) { + case 'launch': + launched.add((call.arguments as Map)['url'] as String); + return true; + case 'canLaunch': + return true; + default: + return null; + } + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + _urlLauncherChannel, + null, + ); + TvDetectionService.debugSetAutomotiveOverride(null); + TvDetectionService.debugReset(); + }); + + Future pumpFlow(WidgetTester tester) async { + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + theme: ThemeData(extensions: const [testMonoTokens]), + home: Scaffold( + body: PlexPinAuthFlow( + onTokenReceived: (_) async {}, + autoStartQrOnTV: false, + serviceFactory: () async => _StalledPlexAuthService(), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + } + + testWidgets('a car keeps the Plex hand-off in-app as a QR code', (tester) async { + // A head unit has no browser: Android Automotive gives an app-launched URL + // to the car link viewer, so launching one strands the flow on a spinner + // until the PIN expires. + TvDetectionService.debugSetAutomotiveOverride(true); + await pumpFlow(tester); + + await tester.tap(find.text(t.auth.signInWithPlex)); + await tester.pump(); + await tester.pump(); + + expect(find.byType(QrImageView), findsOneWidget); + expect(find.text(t.auth.scanQRToSignIn), findsOneWidget); + expect(find.text(t.auth.waitingForAuth), findsNothing); + expect(launched, isEmpty); + }); + + testWidgets('every other device still hands Plex sign-in to a browser', (tester) async { + TvDetectionService.debugSetAutomotiveOverride(false); + await pumpFlow(tester); + + await tester.tap(find.text(t.auth.signInWithPlex)); + await tester.pump(); + await tester.pump(); + + expect(launched, hasLength(1)); + expect(launched.single, startsWith('https://app.plex.tv/auth')); + expect(find.text(t.auth.waitingForAuth), findsOneWidget); + expect(find.byType(QrImageView), findsNothing); + }); + + testWidgets('the QR button is unaffected off a car', (tester) async { + TvDetectionService.debugSetAutomotiveOverride(false); + await pumpFlow(tester); + + await tester.tap(find.text(t.auth.showQRCode)); + await tester.pump(); + await tester.pump(); + + expect(find.byType(QrImageView), findsOneWidget); + expect(launched, isEmpty); + }); +}