diff --git a/lib/ui/landing/bloc/landing_cubit.dart b/lib/ui/landing/bloc/landing_cubit.dart index 3a09711bb7..35f81408fd 100644 --- a/lib/ui/landing/bloc/landing_cubit.dart +++ b/lib/ui/landing/bloc/landing_cubit.dart @@ -42,28 +42,66 @@ class LandingCubit extends Cubit { final MultiAuthStateNotifier multiAuthChangeNotifier; } +typedef ProvisioningIdLoader = Future> Function(); +typedef ProvisioningLoader = + Future> Function(String deviceId); +typedef ProvisioningVerifier = + FutureOr<(Account, String)> Function( + String secret, + signal.ECKeyPair keyPair, + ); + class LandingQrCodeCubit extends LandingCubit { LandingQrCodeCubit( MultiAuthStateNotifier multiAuthChangeNotifier, - Locale locale, - ) : super( - multiAuthChangeNotifier, - locale, - LandingState( - status: multiAuthChangeNotifier.current != null - ? LandingStatus.provisioning - : LandingStatus.init, - ), - ) { - if (multiAuthChangeNotifier.current != null) return; + Locale locale, { + this.autoStart = true, + ProvisioningIdLoader? provisioningIdLoader, + ProvisioningLoader? provisioningLoader, + ProvisioningVerifier? provisioningVerifier, + Stream Function()? pollingStreamFactory, + this.expirationTickLimit = 60, + this.pollingFailureLimit = 3, + String Function()? expiredMessageBuilder, + }) : super( + multiAuthChangeNotifier, + locale, + LandingState( + status: multiAuthChangeNotifier.current != null + ? LandingStatus.provisioning + : LandingStatus.init, + ), + ) { + _provisioningIdLoader = + provisioningIdLoader ?? + (() => client.provisioningApi.getProvisioningId( + Platform.operatingSystem, + )); + _provisioningLoader = + provisioningLoader ?? + ((deviceId) => client.provisioningApi.getProvisioning(deviceId)); + _provisioningVerifier = provisioningVerifier; + _pollingStreamFactory = + pollingStreamFactory ?? + (() => Stream.periodic(const Duration(seconds: 1), (i) => i)); + _expiredMessageBuilder = + expiredMessageBuilder ?? (() => Localization.current.qrCodeExpiredDesc); + if (!autoStart || multiAuthChangeNotifier.current != null) return; requestAuthUrl(); } - final StreamController<(int, String, signal.ECKeyPair)> - periodicStreamController = - StreamController<(int, String, signal.ECKeyPair)>(); + final bool autoStart; + late final ProvisioningIdLoader _provisioningIdLoader; + late final ProvisioningLoader _provisioningLoader; + late final ProvisioningVerifier? _provisioningVerifier; + late final Stream Function() _pollingStreamFactory; + late final String Function() _expiredMessageBuilder; + final int expirationTickLimit; + final int pollingFailureLimit; StreamSubscription? _periodicSubscription; + int _requestVersion = 0; + int _pollingFailureCount = 0; void _cancelPeriodicSubscription() { final periodicSubscription = _periodicSubscription; @@ -71,12 +109,32 @@ class LandingQrCodeCubit extends LandingCubit { unawaited(periodicSubscription?.cancel()); } - Future requestAuthUrl() async { + Future requestAuthUrl({bool isAutoRefresh = false}) async { _cancelPeriodicSubscription(); - try { - final rsp = await client.provisioningApi.getProvisioningId( - Platform.operatingSystem, + final requestVersion = ++_requestVersion; + _pollingFailureCount = 0; + if (state.authUrl == null) { + emit( + state.copyWith( + status: LandingStatus.init, + clearErrorMessage: true, + ), + ); + } else if (state.status == LandingStatus.needReload) { + emit( + state.copyWith( + status: LandingStatus.ready, + clearErrorMessage: true, + ), ); + } + try { + final rsp = await _provisioningIdLoader(); + if (requestVersion != _requestVersion) return; + if (isAutoRefresh) { + i('landing qr auto refresh succeeded'); + } + final keyPair = signal.Curve.generateKeyPair(); final pubKey = Uri.encodeComponent( base64Encode(keyPair.publicKey.serialize()), @@ -87,57 +145,78 @@ class LandingQrCodeCubit extends LandingCubit { authUrl: 'mixin://device/auth?id=${rsp.data.deviceId}&pub_key=$pubKey', status: LandingStatus.ready, + clearErrorMessage: true, ), ); - _periodicSubscription = - Stream.periodic( - const Duration(seconds: 1), - (i) => i, - ) - .asyncBufferMap( - (event) => - _checkLanding(event.last, rsp.data.deviceId, keyPair), - ) - .listen((event) {}); + _periodicSubscription = _pollingStreamFactory() + .asyncBufferMap( + (event) => _checkLanding( + requestVersion, + event.last, + rsp.data.deviceId, + keyPair, + ), + ) + .listen((event) {}); } catch (error, stack) { + if (requestVersion != _requestVersion) return; e('requestAuthUrl failed: $error $stack'); - emit(state.needReload('Failed to request auth: $error')); + emit( + state.needReload( + isAutoRefresh + ? 'Failed to refresh QR code: $error' + : 'Failed to request auth: $error', + ), + ); } } Future _checkLanding( + int requestVersion, int count, String deviceId, signal.ECKeyPair keyPair, ) async { - if (_periodicSubscription == null) return; + if (_periodicSubscription == null || requestVersion != _requestVersion) { + return; + } - if (count > 60) { + if (count > expirationTickLimit) { _cancelPeriodicSubscription(); - emit(state.needReload(Localization.current.qrCodeExpiredDesc)); + i('landing qr expired, auto refreshing'); + unawaited(requestAuthUrl(isAutoRefresh: true)); return; } String secret; try { - secret = (await client.provisioningApi.getProvisioning( - deviceId, - )).data.secret; + secret = (await _provisioningLoader(deviceId)).data.secret; } catch (e) { + if (requestVersion != _requestVersion) return; + _pollingFailureCount += 1; + if (_pollingFailureCount >= pollingFailureLimit) { + _cancelPeriodicSubscription(); + w('landing qr polling failed, entering retry state'); + emit(state.needReload(_expiredMessageBuilder())); + } return; } + _pollingFailureCount = 0; if (secret.isEmpty) return; _cancelPeriodicSubscription(); + if (requestVersion != _requestVersion) return; emit(state.copyWith(status: LandingStatus.provisioning)); try { final (acount, privateKey) = await _verify(secret, keyPair); + if (requestVersion != _requestVersion) return; multiAuthChangeNotifier.signIn( AuthState(account: acount, privateKey: privateKey), ); } catch (error, stack) { + if (requestVersion != _requestVersion) return; emit(state.needReload('Failed to verify: $error')); e('_verify: $error $stack'); } @@ -147,6 +226,11 @@ class LandingQrCodeCubit extends LandingCubit { String secret, signal.ECKeyPair keyPair, ) async { + final provisioningVerifier = _provisioningVerifier; + if (provisioningVerifier != null) { + return provisioningVerifier(secret, keyPair); + } + final result = signal.decrypt( base64Encode(keyPair.privateKey.serialize()), secret, @@ -189,7 +273,6 @@ class LandingQrCodeCubit extends LandingCubit { @override Future close() async { await _periodicSubscription?.cancel(); - await periodicStreamController.close(); await super.close(); } } diff --git a/lib/ui/landing/bloc/landing_state.dart b/lib/ui/landing/bloc/landing_state.dart index b527c9176c..ad9fea05ff 100644 --- a/lib/ui/landing/bloc/landing_state.dart +++ b/lib/ui/landing/bloc/landing_state.dart @@ -23,9 +23,14 @@ class LandingState extends Equatable { authUrl: authUrl, ); - LandingState copyWith({String? authUrl, LandingStatus? status}) => - LandingState( - authUrl: authUrl ?? this.authUrl, - status: status ?? this.status, - ); + LandingState copyWith({ + String? authUrl, + LandingStatus? status, + String? errorMessage, + bool clearErrorMessage = false, + }) => LandingState( + authUrl: authUrl ?? this.authUrl, + status: status ?? this.status, + errorMessage: clearErrorMessage ? null : errorMessage ?? this.errorMessage, + ); } diff --git a/lib/ui/landing/landing_qrcode.dart b/lib/ui/landing/landing_qrcode.dart index c30dcd4096..e033370345 100644 --- a/lib/ui/landing/landing_qrcode.dart +++ b/lib/ui/landing/landing_qrcode.dart @@ -89,6 +89,7 @@ class _QrCode extends HookConsumerWidget { if (url != null) { qrCode = QrCode( + key: ValueKey(url), image: const AssetImage(Resources.assetsImagesLogoPng), data: url, ); diff --git a/test/ui/landing/bloc/landing_qrcode_cubit_test.dart b/test/ui/landing/bloc/landing_qrcode_cubit_test.dart new file mode 100644 index 0000000000..333ff3c0e7 --- /dev/null +++ b/test/ui/landing/bloc/landing_qrcode_cubit_test.dart @@ -0,0 +1,172 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_app/ui/landing/bloc/landing_cubit.dart'; +import 'package:flutter_app/ui/landing/bloc/landing_state.dart'; +import 'package:flutter_app/ui/provider/multi_auth_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +void main() { + group('LandingQrCodeCubit', () { + test('older auth url request must not override newer qr code', () async { + final notifier = MultiAuthStateNotifier(const MultiAuthState()); + final first = Completer>(); + final second = Completer>(); + var requestCount = 0; + + final cubit = LandingQrCodeCubit( + notifier, + const Locale('en'), + autoStart: false, + provisioningIdLoader: () { + requestCount += 1; + return requestCount == 1 ? first.future : second.future; + }, + pollingStreamFactory: () => const Stream.empty(), + ); + + unawaited(cubit.requestAuthUrl()); + unawaited(cubit.requestAuthUrl()); + + second.complete( + MixinResponse(ProvisioningId(deviceId: 'new', expiredAt: null)), + ); + await pumpEventQueue(); + + first.complete( + MixinResponse(ProvisioningId(deviceId: 'old', expiredAt: null)), + ); + await pumpEventQueue(); + + expect(cubit.state.status, LandingStatus.ready); + expect(cubit.state.authUrl, contains('id=new')); + expect(cubit.state.authUrl, isNot(contains('id=old'))); + + await cubit.close(); + }); + + test('expiration automatically refreshes qr code once', () async { + final notifier = MultiAuthStateNotifier(const MultiAuthState()); + final pollingStreams = [ + StreamController(), + StreamController(), + ]; + var pollingIndex = 0; + var requestCount = 0; + + final cubit = LandingQrCodeCubit( + notifier, + const Locale('en'), + autoStart: false, + expirationTickLimit: 0, + provisioningIdLoader: () async { + requestCount += 1; + return MixinResponse( + ProvisioningId( + deviceId: requestCount == 1 ? 'first' : 'second', + expiredAt: null, + ), + ); + }, + provisioningLoader: (_) async => MixinResponse( + Provisioning( + deviceId: 'device', + expiredAt: null, + secret: '', + platform: null, + provisioningCode: null, + sessionId: null, + userId: null, + ), + ), + pollingStreamFactory: () => pollingStreams[pollingIndex++].stream, + ); + + await cubit.requestAuthUrl(); + pollingStreams[0].add(0); + await pumpEventQueue(); + pollingStreams[0].add(1); + await pumpEventQueue(); + + expect(requestCount, 2); + expect(cubit.state.status, LandingStatus.ready); + expect(cubit.state.authUrl, contains('id=second')); + + await cubit.close(); + for (final controller in pollingStreams) { + await controller.close(); + } + }); + + test('failed auto refresh moves qr code to retry state', () async { + final notifier = MultiAuthStateNotifier(const MultiAuthState()); + final pollingController = StreamController(); + var requestCount = 0; + + final cubit = LandingQrCodeCubit( + notifier, + const Locale('en'), + autoStart: false, + expirationTickLimit: 0, + provisioningIdLoader: () async { + requestCount += 1; + if (requestCount == 1) { + return MixinResponse( + ProvisioningId(deviceId: 'first', expiredAt: null), + ); + } + throw Exception('refresh failed'); + }, + provisioningLoader: (_) async => MixinResponse( + Provisioning( + deviceId: 'device', + expiredAt: null, + secret: '', + platform: null, + provisioningCode: null, + sessionId: null, + userId: null, + ), + ), + pollingStreamFactory: () => pollingController.stream, + ); + + await cubit.requestAuthUrl(); + pollingController.add(1); + await pumpEventQueue(); + + expect(cubit.state.status, LandingStatus.needReload); + expect(cubit.state.authUrl, contains('id=first')); + + await cubit.close(); + await pollingController.close(); + }); + + test('polling failure threshold surfaces retry state', () async { + final notifier = MultiAuthStateNotifier(const MultiAuthState()); + final pollingController = StreamController(); + + final cubit = LandingQrCodeCubit( + notifier, + const Locale('en'), + autoStart: false, + pollingFailureLimit: 1, + provisioningIdLoader: () async => + MixinResponse(ProvisioningId(deviceId: 'first', expiredAt: null)), + provisioningLoader: (_) async => throw Exception('network'), + pollingStreamFactory: () => pollingController.stream, + expiredMessageBuilder: () => 'expired', + ); + + await cubit.requestAuthUrl(); + pollingController.add(0); + await pumpEventQueue(); + + expect(cubit.state.status, LandingStatus.needReload); + + await cubit.close(); + await pollingController.close(); + }); + }); +}