From 0ee461a1c7ed28df406bd622ed778e848804d2ec Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Tue, 9 Dec 2025 16:42:39 +0200 Subject: [PATCH 1/2] fix(onboarding): Delay the keycard detection for when keycard is needed This is a fix for the mobile platforms that will show a drawer when the keycard is needed. We'll need to avoid showing the drawer every time at app start. --- src/app/modules/onboarding/controller.nim | 3 +++ src/app/modules/onboarding/io_interface.nim | 3 +++ src/app/modules/onboarding/module.nim | 3 +++ src/app/modules/onboarding/view.nim | 3 +++ src/app_service/service/keycardV2/service.nim | 3 +++ ui/app/AppLayouts/Onboarding/OnboardingFlow.qml | 13 ++++++++++--- ui/app/AppLayouts/Onboarding/OnboardingLayout.qml | 1 + ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml | 7 +++++++ .../Onboarding/stores/OnboardingStore.qml | 4 ++++ 9 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/app/modules/onboarding/controller.nim b/src/app/modules/onboarding/controller.nim index 5efb332c2f0..71676fb1f89 100644 --- a/src/app/modules/onboarding/controller.nim +++ b/src/app/modules/onboarding/controller.nim @@ -272,3 +272,6 @@ proc storeMetadataAsync*(self: Controller, name: string, paths: seq[string]) = proc asyncImportLocalBackupFile*(self: Controller, filePath: string) = self.generalService.asyncImportLocalBackupFile(filePath) + +proc startKeycardDetection*(self: Controller) = + self.keycardServiceV2.startDetection() \ No newline at end of file diff --git a/src/app/modules/onboarding/io_interface.nim b/src/app/modules/onboarding/io_interface.nim index 7e696a4cea4..3e5a759ca85 100644 --- a/src/app/modules/onboarding/io_interface.nim +++ b/src/app/modules/onboarding/io_interface.nim @@ -109,6 +109,9 @@ method requestDeleteBiometrics*(self: AccessInterface, account: string) {.base.} method requestLocalBackup*(self: AccessInterface, backupImportFileUrl: string) {.base.} = raise newException(ValueError, "No implementation available") +method startKeycardDetection*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + # This way (using concepts) is used only for the modules managed by AppController type DelegateInterface* = concept c diff --git a/src/app/modules/onboarding/module.nim b/src/app/modules/onboarding/module.nim index 7a73964944e..397937f144f 100644 --- a/src/app/modules/onboarding/module.nim +++ b/src/app/modules/onboarding/module.nim @@ -446,6 +446,9 @@ method requestLocalBackup*[T](self: Module[T], backupImportFileUrl: string) = method requestDeleteBiometrics*[T](self: Module[T], account: string) = self.view.deleteBiometricsRequested(account) +method startKeycardDetection*[T](self: Module[T]) = + self.controller.startKeycardDetection() + proc runPostLoginTasks*[T](self: Module[T]) = let tasks = self.postLoginTasks for task in tasks: diff --git a/src/app/modules/onboarding/view.nim b/src/app/modules/onboarding/view.nim index 3dd161aeccf..380d9b17811 100644 --- a/src/app/modules/onboarding/view.nim +++ b/src/app/modules/onboarding/view.nim @@ -195,6 +195,9 @@ QtObject: proc startKeycardFactoryReset(self: View) {.slot.} = self.delegate.startKeycardFactoryReset() + proc startKeycardDetection(self: View) {.slot.} = + self.delegate.startKeycardDetection() + proc delete*(self: View) = self.QObject.delete diff --git a/src/app_service/service/keycardV2/service.nim b/src/app_service/service/keycardV2/service.nim index e2e01c4d24b..dda560c715a 100644 --- a/src/app_service/service/keycardV2/service.nim +++ b/src/app_service/service/keycardV2/service.nim @@ -262,6 +262,9 @@ QtObject: except Exception as e: error "error storing metadata", err=e.msg + proc startDetection*(self: Service) {.featureGuard(KEYCARD_ENABLED).} = + self.asyncStart(status_const.KEYCARDPAIRINGDATAFILE) + proc delete*(self: Service) = self.QObject.delete diff --git a/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml b/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml index 81231830580..c5db6a8a355 100644 --- a/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml +++ b/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml @@ -72,6 +72,7 @@ OnboardingStackView { signal linkActivated(string link) signal finished(int flow) + signal keycardRequested() // Thirdparty services required property bool privacyModeFeatureEnabled @@ -239,7 +240,7 @@ OnboardingStackView { onUnblockWithSeedphraseRequested: root.push(unblockWithSeedphraseFlow) onUnblockWithPukRequested: root.push(unblockWithPukFlow) - + onKeycardRequested: root.keycardRequested() onVisibleChanged: { if (!visible) root.dismissBiometricsRequested() @@ -277,7 +278,10 @@ OnboardingStackView { root.push(useRecoveryPhraseFlow, { type: UseRecoveryPhraseFlow.Type.NewProfile }) } - onCreateProfileWithEmptyKeycardRequested: root.push(keycardCreateProfileFlow) + onCreateProfileWithEmptyKeycardRequested: { + root.keycardRequested() + root.push(keycardCreateProfileFlow) + } } } @@ -290,7 +294,10 @@ OnboardingStackView { thirdpartyServicesEnabled: root.thirdpartyServicesEnabled onLoginWithSyncingRequested: root.push(logInBySyncingFlow) - onLoginWithKeycardRequested: root.push(loginWithKeycardFlow) + onLoginWithKeycardRequested: { + root.keycardRequested() + root.push(loginWithKeycardFlow) + } onLoginWithSeedphraseRequested: { d.flow = Onboarding.OnboardingFlow.LoginWithSeedphrase diff --git a/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml b/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml index 3b8075f6812..07d3567a3ff 100644 --- a/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml +++ b/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml @@ -200,6 +200,7 @@ Page { onExportKeysRequested: root.onboardingStore.exportRecoverKeys() onImportLocalBackupRequested: (importFilePath) => d.backupImportFileUrl = importFilePath onFinished: (flow) => d.finishFlow(flow) + onKeycardRequested: root.onboardingStore.startKeycardDetection() onBiometricsRequested: (profileId) => { const isKeycardProfile = SQUtils.ModelUtils.getByKey( diff --git a/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml index 9c355b87ae0..c0dda72dcba 100644 --- a/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml +++ b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml @@ -91,6 +91,7 @@ OnboardingPage { signal unblockWithSeedphraseRequested() signal unblockWithPukRequested() signal lostKeycardFlowRequested() + signal keycardRequested() QtObject { id: d @@ -104,6 +105,12 @@ OnboardingPage { readonly property int loginModelCount: root.loginAccountsModel.ModelCount.count onLoginModelCountChanged: setSelectedLoginUser() + onCurrentProfileIsKeycardChanged: { + if (d.currentProfileIsKeycard) { + root.keycardRequested() + } + } + function setSelectedLoginUser() { if (loginModelCount > 0) { loginUserSelector.setSelection(d.settings.lastKeyUid) diff --git a/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml b/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml index c07a8d5c1e9..c2811116322 100644 --- a/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml +++ b/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml @@ -35,6 +35,10 @@ QtObject { readonly property int keycardRemainingPinAttempts: d.onboardingModuleInst.keycardRemainingPinAttempts readonly property int keycardRemainingPukAttempts: d.onboardingModuleInst.keycardRemainingPukAttempts + function startKeycardDetection() { + d.onboardingModuleInst.startKeycardDetection() + } + function finishOnboardingFlow(flow: int, data: Object) { // -> string return d.onboardingModuleInst.finishOnboardingFlow(flow, JSON.stringify(data)) } From 1a7ec9c07bbdbfb383305b04addcb640136a17d0 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Mon, 22 Dec 2025 15:59:57 +0200 Subject: [PATCH 2/2] chore(keycard): Add tests for `keycardRequested` signal --- .../qmlTests/tests/tst_OnboardingLayout.qml | 153 ++++++++++++++++++ .../Onboarding/OnboardingLayout.qml | 7 +- 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/storybook/qmlTests/tests/tst_OnboardingLayout.qml b/storybook/qmlTests/tests/tst_OnboardingLayout.qml index 127d2d2f8cd..98a25b3e008 100644 --- a/storybook/qmlTests/tests/tst_OnboardingLayout.qml +++ b/storybook/qmlTests/tests/tst_OnboardingLayout.qml @@ -168,6 +168,12 @@ Item { signalName: "loginRequested" } + SignalSpy { + id: keycardRequestedSpy + target: controlUnderTest + signalName: "keycardRequested" + } + property OnboardingLayout controlUnderTest: null StatusTestCase { @@ -204,6 +210,7 @@ Item { dynamicSpy.cleanup() finishedSpy.clear() loginSpy.clear() + keycardRequestedSpy.clear() } function getCurrentPage(stack, pageClass) { @@ -1356,5 +1363,151 @@ Item { // Verify visibility tryCompare(thirdPartyServices, "visible", true) } + + // TEST: Keycard requested signal emission tests + function test_keycardRequested_createProfileWithKeycard_data() { + return [{ tag: "create profile with keycard" }] // dummy to skip global data + } + + function test_keycardRequested_createProfileWithKeycard() { + verify(!!controlUnderTest) + + const stack = controlUnderTest.stack + verify(!!stack) + + // PAGE 1: Welcome + let page = getCurrentPage(stack, WelcomePage) + waitForRendering(page) + + const btnCreateProfile = findChild(controlUnderTest, "btnCreateProfile") + verify(!!btnCreateProfile) + mouseClick(btnCreateProfile) + + // PAGE 2: Help us improve + page = getCurrentPage(stack, HelpUsImproveStatusPage) + const shareButton = findChild(controlUnderTest, "btnShare") + mouseClick(shareButton) + + // PAGE 3: Create profile + page = getCurrentPage(stack, CreateProfilePage) + const btnCreateWithEmptyKeycard = findChild(controlUnderTest, "btnCreateWithEmptyKeycard") + verify(!!btnCreateWithEmptyKeycard) + + // Verify keycardRequested signal is NOT yet emitted + compare(keycardRequestedSpy.count, 0) + + // Click the button to create with keycard + mouseClick(btnCreateWithEmptyKeycard) + + // Verify keycardRequested signal WAS emitted + tryCompare(keycardRequestedSpy, "count", 1) + } + + function test_keycardRequested_loginWithKeycard_data() { + return [{ tag: "login with keycard" }] // dummy to skip global data + } + + function test_keycardRequested_loginWithKeycard() { + verify(!!controlUnderTest) + + const stack = controlUnderTest.stack + verify(!!stack) + + // PAGE 1: Welcome + let page = getCurrentPage(stack, WelcomePage) + waitForRendering(page) + + const btnLogin = findChild(controlUnderTest, "btnLogin") + verify(!!btnLogin) + mouseClick(btnLogin) + + // PAGE 2: Help us improve + page = getCurrentPage(stack, HelpUsImproveStatusPage) + const shareButton = findChild(controlUnderTest, "btnShare") + mouseClick(shareButton) + + // PAGE 3: Log in + page = getCurrentPage(stack, NewAccountLoginPage) + const btnWithKeycard = findChild(page, "btnWithKeycard") + verify(!!btnWithKeycard) + + // Verify keycardRequested signal is NOT yet emitted + compare(keycardRequestedSpy.count, 0) + + // Click the button to login with keycard + mouseClick(btnWithKeycard) + + // Verify keycardRequested signal WAS emitted + tryCompare(keycardRequestedSpy, "count", 1) + } + + function test_keycardRequested_selectKeycardProfileOnLoginScreen_data() { + return [{ tag: "select keycard profile" }] // dummy to skip global data + } + + function test_keycardRequested_selectKeycardProfileOnLoginScreen() { + verify(!!controlUnderTest) + controlUnderTest.onboardingStore.loginAccountsModel = loginAccountsModel + + const page = getCurrentPage(controlUnderTest.stack, LoginScreen) + + const userSelector = findChild(page, "loginUserSelector") + verify(!!userSelector) + + // Initially select a password-based profile (uid_1) + userSelector.setSelection("uid_1") + tryCompare(userSelector, "selectedProfileKeyId", "uid_1") + tryCompare(userSelector, "keycardCreatedAccount", false) + + // Clear any signals emitted during initial setup (LoginScreen may auto-select a profile) + keycardRequestedSpy.clear() + + // Verify keycardRequested signal is NOT emitted for password profile (after clearing) + compare(keycardRequestedSpy.count, 0) + + // Now select a keycard profile (uid_4) + userSelector.setSelection("uid_4") + tryCompare(userSelector, "selectedProfileKeyId", "uid_4") + tryCompare(userSelector, "keycardCreatedAccount", true) + + // Verify keycardRequested signal WAS emitted when switching to keycard profile + tryCompare(keycardRequestedSpy, "count", 1) + } + + function test_keycardRequested_notEmittedForPasswordFlow_data() { + return [{ tag: "password flow" }] // dummy to skip global data + } + + function test_keycardRequested_notEmittedForPasswordFlow() { + verify(!!controlUnderTest) + + const stack = controlUnderTest.stack + verify(!!stack) + + // PAGE 1: Welcome + let page = getCurrentPage(stack, WelcomePage) + waitForRendering(page) + + const btnCreateProfile = findChild(controlUnderTest, "btnCreateProfile") + verify(!!btnCreateProfile) + mouseClick(btnCreateProfile) + + // PAGE 2: Help us improve + page = getCurrentPage(stack, HelpUsImproveStatusPage) + const shareButton = findChild(controlUnderTest, "btnShare") + mouseClick(shareButton) + + // PAGE 3: Create profile + page = getCurrentPage(stack, CreateProfilePage) + const btnCreateWithPassword = findChild(controlUnderTest, "btnCreateWithPassword") + verify(!!btnCreateWithPassword) + mouseClick(btnCreateWithPassword) + + // PAGE 4: Create password + page = getCurrentPage(stack, CreatePasswordPage) + + // Verify keycardRequested signal was NOT emitted throughout the password flow + compare(keycardRequestedSpy.count, 0) + } } } diff --git a/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml b/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml index 07d3567a3ff..3c0110fc573 100644 --- a/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml +++ b/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml @@ -55,6 +55,8 @@ Page { // "data:var": contains "password" or "pin" signal loginRequested(string keyUid, int method, var data) + signal keycardRequested() + function restartFlow() { unload() onboardingFlow.restart() @@ -200,7 +202,10 @@ Page { onExportKeysRequested: root.onboardingStore.exportRecoverKeys() onImportLocalBackupRequested: (importFilePath) => d.backupImportFileUrl = importFilePath onFinished: (flow) => d.finishFlow(flow) - onKeycardRequested: root.onboardingStore.startKeycardDetection() + onKeycardRequested: { + root.keycardRequested() + root.onboardingStore.startKeycardDetection() + } onBiometricsRequested: (profileId) => { const isKeycardProfile = SQUtils.ModelUtils.getByKey(