-
Notifications
You must be signed in to change notification settings - Fork 71
Improve landing QR code refresh flow #1949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -42,41 +42,99 @@ class LandingCubit<T> extends Cubit<T> { | |||||||||||||||||
| final MultiAuthStateNotifier multiAuthChangeNotifier; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| typedef ProvisioningIdLoader = Future<MixinResponse<ProvisioningId>> Function(); | ||||||||||||||||||
| typedef ProvisioningLoader = | ||||||||||||||||||
| Future<MixinResponse<Provisioning>> Function(String deviceId); | ||||||||||||||||||
| typedef ProvisioningVerifier = | ||||||||||||||||||
| FutureOr<(Account, String)> Function( | ||||||||||||||||||
| String secret, | ||||||||||||||||||
| signal.ECKeyPair keyPair, | ||||||||||||||||||
| ); | ||||||||||||||||||
|
|
||||||||||||||||||
| class LandingQrCodeCubit extends LandingCubit<LandingState> { | ||||||||||||||||||
| 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<int> 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<int> 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; | ||||||||||||||||||
| _periodicSubscription = null; | ||||||||||||||||||
| unawaited(periodicSubscription?.cancel()); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| Future<void> requestAuthUrl() async { | ||||||||||||||||||
| Future<void> 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<LandingState> { | |||||||||||||||||
| 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<void> _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())); | ||||||||||||||||||
|
||||||||||||||||||
| emit(state.needReload(_expiredMessageBuilder())); | |
| emit( | |
| state.needReload( | |
| 'Unable to refresh the QR code due to network or server issues. Please try again.', | |
| ), | |
| ); |
Copilot
AI
Mar 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential stale-request side effect: _cancelPeriodicSubscription() is called before the requestVersion != _requestVersion guard. If an older in-flight _checkLanding completes after a newer requestAuthUrl() has started, it can cancel the newer polling subscription before returning. Add a version/subscription guard immediately after the await (and before canceling/resetting state) so stale _checkLanding invocations become no-ops.
Copilot
AI
Mar 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo in destructuring assignment: acount should be account to avoid confusion and keep naming consistent with AuthState(account: ...) and the Account type.
| final (acount, privateKey) = await _verify(secret, keyPair); | |
| if (requestVersion != _requestVersion) return; | |
| multiAuthChangeNotifier.signIn( | |
| AuthState(account: acount, privateKey: privateKey), | |
| final (account, privateKey) = await _verify(secret, keyPair); | |
| if (requestVersion != _requestVersion) return; | |
| multiAuthChangeNotifier.signIn( | |
| AuthState(account: account, privateKey: privateKey), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The user-visible retry Tooltip error message is built from raw exceptions (e.g., "Failed to request auth: $error" / "Failed to refresh QR code: $error"). This can leak internal details and is not localized. Consider logging the detailed exception but emitting a localized, user-friendly message (optionally mapping known error types to specific copy).