Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 119 additions & 36 deletions lib/ui/landing/bloc/landing_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -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',
Comment on lines +168 to +169
Copy link

Copilot AI Mar 31, 2026

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).

Suggested change
? 'Failed to refresh QR code: $error'
: 'Failed to request auth: $error',
? 'Failed to refresh QR code. Please try again.'
: 'Failed to request authentication. Please try again.',

Copilot uses AI. Check for mistakes.
),
);
}
}

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()));
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When provisioning polling fails repeatedly, the cubit emits _expiredMessageBuilder() which defaults to Localization.current.qrCodeExpiredDesc. That message is misleading for network/provisioning failures. Consider using a dedicated (localized) message for polling failures, or passing an error-specific builder separate from the QR-expired copy.

Suggested change
emit(state.needReload(_expiredMessageBuilder()));
emit(
state.needReload(
'Unable to refresh the QR code due to network or server issues. Please try again.',
),
);

Copilot uses AI. Check for mistakes.
}
return;
}
_pollingFailureCount = 0;
if (secret.isEmpty) return;

_cancelPeriodicSubscription();
if (requestVersion != _requestVersion) return;
emit(state.copyWith(status: LandingStatus.provisioning));
Comment on lines 206 to 210
Copy link

Copilot AI Mar 31, 2026

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 uses AI. Check for mistakes.

try {
final (acount, privateKey) = await _verify(secret, keyPair);
if (requestVersion != _requestVersion) return;
multiAuthChangeNotifier.signIn(
AuthState(account: acount, privateKey: privateKey),
Comment on lines 213 to 216
Copy link

Copilot AI Mar 31, 2026

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.

Suggested change
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),

Copilot uses AI. Check for mistakes.
);
} catch (error, stack) {
if (requestVersion != _requestVersion) return;
emit(state.needReload('Failed to verify: $error'));
e('_verify: $error $stack');
}
Expand All @@ -147,6 +226,11 @@ class LandingQrCodeCubit extends LandingCubit<LandingState> {
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,
Expand Down Expand Up @@ -189,7 +273,6 @@ class LandingQrCodeCubit extends LandingCubit<LandingState> {
@override
Future<void> close() async {
await _periodicSubscription?.cancel();
await periodicStreamController.close();
await super.close();
}
}
Expand Down
15 changes: 10 additions & 5 deletions lib/ui/landing/bloc/landing_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
1 change: 1 addition & 0 deletions lib/ui/landing/landing_qrcode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class _QrCode extends HookConsumerWidget {

if (url != null) {
qrCode = QrCode(
key: ValueKey(url),
image: const AssetImage(Resources.assetsImagesLogoPng),
data: url,
);
Expand Down
Loading
Loading