fix(auth): show the Plex sign-in QR in the app on a car
Signing in opened plex.tv in a browser, and a head unit has none: the user was left staring at a launcher error with no way to link the account. The QR code and the linking code are now rendered in the app on a car, so the pairing happens on a phone while the vehicle shows what to scan.
This commit is contained in:
@@ -12,6 +12,16 @@ import '../../theme/mono_tokens.dart';
|
|||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../../utils/platform_detector.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.
|
/// Self-contained Plex PIN/QR auth flow.
|
||||||
///
|
///
|
||||||
/// Renders the polling UI (QR code or browser-waiting spinner) once an
|
/// 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)?
|
final Widget Function(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy)?
|
||||||
initialButtonsBuilder;
|
initialButtonsBuilder;
|
||||||
|
|
||||||
|
/// Test seam: builds the auth service this flow drives. Defaults to the
|
||||||
|
/// production [PlexAuthService.create].
|
||||||
|
final Future<PlexAuthService> Function()? serviceFactory;
|
||||||
|
|
||||||
const PlexPinAuthFlow({
|
const PlexPinAuthFlow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onTokenReceived,
|
required this.onTokenReceived,
|
||||||
@@ -68,6 +82,7 @@ class PlexPinAuthFlow extends StatefulWidget {
|
|||||||
this.initializeService = true,
|
this.initializeService = true,
|
||||||
this.initialUseQr,
|
this.initialUseQr,
|
||||||
this.initialButtonsBuilder,
|
this.initialButtonsBuilder,
|
||||||
|
this.serviceFactory,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -85,12 +100,14 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_useQr = widget.initialUseQr ?? PlatformDetector.isTV();
|
_useQr =
|
||||||
|
widget.initialUseQr ??
|
||||||
|
(PlatformDetector.isTV() || plexSignInRequiresInAppQr(isAutomotive: PlatformDetector.isAutomotive()));
|
||||||
if (widget.initializeService) unawaited(_initService());
|
if (widget.initializeService) unawaited(_initService());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initService() async {
|
Future<void> _initService() async {
|
||||||
final svc = await PlexAuthService.create();
|
final svc = await (widget.serviceFactory?.call() ?? PlexAuthService.create());
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
svc.dispose();
|
svc.dispose();
|
||||||
return;
|
return;
|
||||||
@@ -113,9 +130,13 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
|
|||||||
Future<void> _start({required bool useQr}) async {
|
Future<void> _start({required bool useQr}) async {
|
||||||
final svc = _authService;
|
final svc = _authService;
|
||||||
if (svc == null) return;
|
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;
|
final attemptId = ++_attemptId;
|
||||||
setState(() {
|
setState(() {
|
||||||
_useQr = useQr;
|
_useQr = resolvedUseQr;
|
||||||
_isPolling = true;
|
_isPolling = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_qrAuthUrl = null;
|
_qrAuthUrl = null;
|
||||||
@@ -129,7 +150,7 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
|
|||||||
final url = svc.getAuthUrl(pinCode);
|
final url = svc.getAuthUrl(pinCode);
|
||||||
|
|
||||||
if (!_isCurrentAttempt(attemptId)) return;
|
if (!_isCurrentAttempt(attemptId)) return;
|
||||||
if (useQr) {
|
if (resolvedUseQr) {
|
||||||
setState(() => _qrAuthUrl = url);
|
setState(() => _qrAuthUrl = url);
|
||||||
} else {
|
} else {
|
||||||
final uri = Uri.parse(url);
|
final uri = Uri.parse(url);
|
||||||
@@ -157,7 +178,7 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
|
|||||||
|
|
||||||
// Auto-close the in-app browser on mobile (no-op on desktop / when
|
// Auto-close the in-app browser on mobile (no-op on desktop / when
|
||||||
// already closed).
|
// already closed).
|
||||||
if (!useQr) {
|
if (!resolvedUseQr) {
|
||||||
try {
|
try {
|
||||||
await closeInAppWebView();
|
await closeInAppWebView();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -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<Map<String, dynamic>> createPin() async => <String, dynamic>{'id': 1, 'code': 'ABCD'};
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getAuthUrl(String pinCode) => 'https://app.plex.tv/auth#?code=$pinCode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> pollPinUntilClaimed(
|
||||||
|
int pinId, {
|
||||||
|
Duration timeout = const Duration(minutes: 2),
|
||||||
|
bool Function()? shouldCancel,
|
||||||
|
}) => Completer<String?>().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 = <String>[];
|
||||||
|
|
||||||
|
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<void> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user