Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/app/modules/onboarding/controller.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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()
3 changes: 3 additions & 0 deletions src/app/modules/onboarding/io_interface.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/app/modules/onboarding/module.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/app/modules/onboarding/view.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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

3 changes: 3 additions & 0 deletions src/app_service/service/keycardV2/service.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we ignore the signal if there is a previous detection in progress?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not necessarily. Start can be called at any point even if the API was previously started. So the keycard client doesn't need to track the API state. The result will be a re-read of keycard to get the current metadata. It's probably something we'll want either way.


proc delete*(self: Service) =
self.QObject.delete

153 changes: 153 additions & 0 deletions storybook/qmlTests/tests/tst_OnboardingLayout.qml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ Item {
signalName: "loginRequested"
}

SignalSpy {
id: keycardRequestedSpy
target: controlUnderTest
signalName: "keycardRequested"
}

property OnboardingLayout controlUnderTest: null

StatusTestCase {
Expand Down Expand Up @@ -204,6 +210,7 @@ Item {
dynamicSpy.cleanup()
finishedSpy.clear()
loginSpy.clear()
keycardRequestedSpy.clear()
}

function getCurrentPage(stack, pageClass) {
Expand Down Expand Up @@ -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)
}
}
}
13 changes: 10 additions & 3 deletions ui/app/AppLayouts/Onboarding/OnboardingFlow.qml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ OnboardingStackView {
signal linkActivated(string link)

signal finished(int flow)
signal keycardRequested()

// Thirdparty services
required property bool privacyModeFeatureEnabled
Expand Down Expand Up @@ -239,7 +240,7 @@ OnboardingStackView {

onUnblockWithSeedphraseRequested: root.push(unblockWithSeedphraseFlow)
onUnblockWithPukRequested: root.push(unblockWithPukFlow)

onKeycardRequested: root.keycardRequested()
onVisibleChanged: {
if (!visible)
root.dismissBiometricsRequested()
Expand Down Expand Up @@ -277,7 +278,10 @@ OnboardingStackView {
root.push(useRecoveryPhraseFlow,
{ type: UseRecoveryPhraseFlow.Type.NewProfile })
}
onCreateProfileWithEmptyKeycardRequested: root.push(keycardCreateProfileFlow)
onCreateProfileWithEmptyKeycardRequested: {
root.keycardRequested()
root.push(keycardCreateProfileFlow)
}
}
}

Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions ui/app/AppLayouts/Onboarding/OnboardingLayout.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -200,6 +202,10 @@ Page {
onExportKeysRequested: root.onboardingStore.exportRecoverKeys()
onImportLocalBackupRequested: (importFilePath) => d.backupImportFileUrl = importFilePath
onFinished: (flow) => d.finishFlow(flow)
onKeycardRequested: {
root.keycardRequested()
root.onboardingStore.startKeycardDetection()
}

onBiometricsRequested: (profileId) => {
const isKeycardProfile = SQUtils.ModelUtils.getByKey(
Expand Down
7 changes: 7 additions & 0 deletions ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ OnboardingPage {
signal unblockWithSeedphraseRequested()
signal unblockWithPukRequested()
signal lostKeycardFlowRequested()
signal keycardRequested()

QtObject {
id: d
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down