diff --git a/src/app/boot/app_controller.nim b/src/app/boot/app_controller.nim
index 3157be83261..1d2c1658229 100644
--- a/src/app/boot/app_controller.nim
+++ b/src/app/boot/app_controller.nim
@@ -40,6 +40,7 @@ import app_service/service/market/service as market_service
import app/modules/onboarding/module as onboarding_module
import app/modules/onboarding/post_onboarding/[keycard_replacement_task, keycard_convert_account, save_biometrics_task]
import app/modules/main/module as main_module
+import app/modules/keycard_channel/module as keycard_channel_module
import app/core/notifications/notifications_manager
import app/global/global_singleton
import app/global/app_signals
@@ -105,6 +106,7 @@ type
# Modules
onboardingModule: onboarding_module.AccessInterface
mainModule: main_module.AccessInterface
+ keycardChannelModule: keycard_channel_module.AccessInterface
#################################################
# Forward declaration section
@@ -233,6 +235,7 @@ proc newAppController*(statusFoundation: StatusFoundation): AppController =
result.marketService = market_service.newService(statusFoundation.events, result.settingsService)
# Modules
+ result.keycardChannelModule = keycard_channel_module.newModule(statusFoundation.events)
result.onboardingModule = onboarding_module.newModule[AppController](
result,
statusFoundation.events,
@@ -299,6 +302,9 @@ proc delete*(self: AppController) =
self.onboardingModule.delete
self.onboardingModule = nil
self.mainModule.delete
+ if not self.keycardChannelModule.isNil:
+ self.keycardChannelModule.delete
+ self.keycardChannelModule = nil
self.appSettingsVariant.delete
self.localAppSettingsVariant.delete
@@ -346,6 +352,9 @@ proc initializeQmlContext(self: AppController) =
singletonInstance.engine.setRootContextProperty("globalUtils", self.globalUtilsVariant)
singletonInstance.engine.setRootContextProperty("metrics", self.metricsVariant)
+ # Load keycard channel module (available before login for Session API)
+ self.keycardChannelModule.load()
+
singletonInstance.engine.load(newQUrl("qrc:///main.qml"))
proc onboardingDidLoad*(self: AppController) =
diff --git a/src/app/modules/keycard_channel/controller.nim b/src/app/modules/keycard_channel/controller.nim
new file mode 100644
index 00000000000..294080279d3
--- /dev/null
+++ b/src/app/modules/keycard_channel/controller.nim
@@ -0,0 +1,27 @@
+import ./io_interface
+import app/core/eventemitter
+import app_service/service/keycardV2/service as keycard_serviceV2
+
+type
+ Controller* = ref object of RootObj
+ delegate: io_interface.AccessInterface
+ events: EventEmitter
+
+proc newController*(
+ delegate: io_interface.AccessInterface,
+ events: EventEmitter
+): Controller =
+ result = Controller()
+ result.delegate = delegate
+ result.events = events
+
+proc delete*(self: Controller) =
+ discard
+
+proc init*(self: Controller) =
+ # Listen to channel state changes
+ self.events.on(keycard_serviceV2.SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED) do(e: Args):
+ let args = keycard_serviceV2.KeycardChannelStateArg(e)
+ self.delegate.setKeycardChannelState(args.state)
+
+
diff --git a/src/app/modules/keycard_channel/io_interface.nim b/src/app/modules/keycard_channel/io_interface.nim
new file mode 100644
index 00000000000..cfbc0aee51d
--- /dev/null
+++ b/src/app/modules/keycard_channel/io_interface.nim
@@ -0,0 +1,23 @@
+type
+ AccessInterface* {.pure inheritable.} = ref object of RootObj
+ ## Abstract class for any input/interaction with this module.
+
+method delete*(self: AccessInterface) {.base.} =
+ raise newException(ValueError, "No implementation available")
+
+method load*(self: AccessInterface) {.base.} =
+ raise newException(ValueError, "No implementation available")
+
+method isLoaded*(self: AccessInterface): bool {.base.} =
+ raise newException(ValueError, "No implementation available")
+
+# View Delegate Interface
+# Delegate for the view must be declared here due to use of QtObject and multi
+# inheritance, which is not well supported in Nim.
+method viewDidLoad*(self: AccessInterface) {.base.} =
+ raise newException(ValueError, "No implementation available")
+
+method setKeycardChannelState*(self: AccessInterface, state: string) {.base.} =
+ raise newException(ValueError, "No implementation available")
+
+
diff --git a/src/app/modules/keycard_channel/module.nim b/src/app/modules/keycard_channel/module.nim
new file mode 100644
index 00000000000..26f8956aa81
--- /dev/null
+++ b/src/app/modules/keycard_channel/module.nim
@@ -0,0 +1,47 @@
+import nimqml
+
+import io_interface, view, controller
+import app/global/global_singleton
+import app/core/eventemitter
+
+export io_interface
+
+type
+ Module* = ref object of io_interface.AccessInterface
+ view: View
+ viewVariant: QVariant
+ controller: Controller
+ moduleLoaded: bool
+
+proc newModule*(
+ events: EventEmitter,
+): Module =
+ result = Module()
+ result.view = view.newView(result)
+ result.viewVariant = newQVariant(result.view)
+ result.controller = controller.newController(result, events)
+ result.moduleLoaded = false
+
+ singletonInstance.engine.setRootContextProperty("keycardChannelModule", result.viewVariant)
+
+method delete*(self: Module) =
+ self.view.delete
+ self.viewVariant.delete
+ self.controller.delete
+
+method load*(self: Module) =
+ self.controller.init()
+ self.view.load()
+
+method isLoaded*(self: Module): bool =
+ return self.moduleLoaded
+
+proc checkIfModuleDidLoad(self: Module) =
+ self.moduleLoaded = true
+
+method viewDidLoad*(self: Module) =
+ self.checkIfModuleDidLoad()
+
+method setKeycardChannelState*(self: Module, state: string) =
+ self.view.setKeycardChannelState(state)
+
diff --git a/src/app/modules/keycard_channel/view.nim b/src/app/modules/keycard_channel/view.nim
new file mode 100644
index 00000000000..ec6e65f6cae
--- /dev/null
+++ b/src/app/modules/keycard_channel/view.nim
@@ -0,0 +1,42 @@
+import nimqml
+
+import ./io_interface
+
+QtObject:
+ type
+ View* = ref object of QObject
+ delegate: io_interface.AccessInterface
+ keycardChannelState: string # Operational channel state
+
+ proc setup(self: View)
+ proc delete*(self: View)
+ proc newView*(delegate: io_interface.AccessInterface): View =
+ new(result, delete)
+ result.delegate = delegate
+ result.setup()
+
+ proc load*(self: View) =
+ self.delegate.viewDidLoad()
+
+ proc keycardChannelStateChanged*(self: View) {.signal.}
+ proc setKeycardChannelState*(self: View, value: string) =
+ if self.keycardChannelState == value:
+ return
+ self.keycardChannelState = value
+ self.keycardChannelStateChanged()
+ proc getKeycardChannelState*(self: View): string {.slot.} =
+ return self.keycardChannelState
+ QtProperty[string] keycardChannelState:
+ read = getKeycardChannelState
+ write = setKeycardChannelState
+ notify = keycardChannelStateChanged
+
+ proc keycardDismissed*(self: View) {.slot.} =
+ self.setKeycardChannelState("")
+
+ proc setup(self: View) =
+ self.QObject.setup
+
+ proc delete*(self: View) =
+ self.QObject.delete
+
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/keycard/constants.nim b/src/app_service/service/keycard/constants.nim
index c162037d03d..0b4059e3d5a 100644
--- a/src/app_service/service/keycard/constants.nim
+++ b/src/app_service/service/keycard/constants.nim
@@ -109,4 +109,7 @@ const ResponseParamWhisperKey* = RequestParamWhisperKey
const ResponseParamMnemonicIdxs* = RequestParamMnemonicIdxs
const ResponseParamTXSignature* = RequestParamTXSignature
const ResponseParamExportedKey* = RequestParamExportedKey
-const ResponseParamMasterKeyAddress* = RequestParamMasterKeyAddress
\ No newline at end of file
+const ResponseParamMasterKeyAddress* = RequestParamMasterKeyAddress
+
+const SignalKeycardStatusChanged* = "status-changed"
+const SignalKeycardChannelStateChanged* = "channel-state-changed"
diff --git a/src/app_service/service/keycard/service.nim b/src/app_service/service/keycard/service.nim
index 32eec8db972..5710fca09ff 100644
--- a/src/app_service/service/keycard/service.nim
+++ b/src/app_service/service/keycard/service.nim
@@ -125,6 +125,9 @@ QtObject:
return
let flowType = typeObj.getStr
+ if flowType == SignalKeycardChannelStateChanged:
+ return #nothing related to flows here
+
let flowEvent = toKeycardEvent(eventObj)
self.lastReceivedKeycardData = (flowType: flowType, flowEvent: flowEvent)
self.events.emit(SIGNAL_KEYCARD_RESPONSE, KeycardLibArgs(flowType: flowType, flowEvent: flowEvent))
diff --git a/src/app_service/service/keycardV2/service.nim b/src/app_service/service/keycardV2/service.nim
index e2e01c4d24b..ea7b56d7e03 100644
--- a/src/app_service/service/keycardV2/service.nim
+++ b/src/app_service/service/keycardV2/service.nim
@@ -9,6 +9,7 @@ import ./dto, rpc
featureGuard KEYCARD_ENABLED:
import keycard_go
import constants as status_const
+ import ../keycard/constants as keycard_constants
export dto
@@ -21,6 +22,7 @@ const PUKLengthForStatusApp* = 12
const KeycardLibCallsInterval = 500 # 0.5 seconds
const SIGNAL_KEYCARD_STATE_UPDATED* = "keycardStateUpdated"
+const SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED* = "keycardChannelStateUpdated"
const SIGNAL_KEYCARD_SET_PIN_FAILURE* = "keycardSetPinFailure"
const SIGNAL_KEYCARD_AUTHORIZE_FINISHED* = "keycardAuthorizeFinished"
const SIGNAL_KEYCARD_LOAD_MNEMONIC_FAILURE* = "keycardLoadMnemonicFailure"
@@ -60,6 +62,9 @@ type
KeycardExportedKeysArg* = ref object of Args
exportedKeys*: KeycardExportedKeysDto
+ KeycardChannelStateArg* = ref object of Args
+ state*: string
+
include utils
include app_service/common/async_tasks
include async_tasks
@@ -100,7 +105,6 @@ QtObject:
if status_const.IS_MACOS and status_const.IS_INTEL:
sleep 700
self.initializeRPC()
- self.asyncStart(status_const.KEYCARDPAIRINGDATAFILE)
discard
proc initializeRPC(self: Service) {.slot, featureGuard(KEYCARD_ENABLED).} =
@@ -110,10 +114,15 @@ QtObject:
try:
# Since only one service can register to signals, we pass the signal to the old service too
var jsonSignal = signal.parseJson
- if jsonSignal["type"].getStr == "status-changed":
+ let signalType = jsonSignal["type"].getStr
+
+ if signalType == keycard_constants.SignalKeycardStatusChanged:
let keycardEvent = jsonSignal["event"].toKeycardEventDto()
-
self.events.emit(SIGNAL_KEYCARD_STATE_UPDATED, KeycardEventArg(keycardEvent: keycardEvent))
+ elif signalType == keycard_constants.SignalKeycardChannelStateChanged:
+ let state = jsonSignal["event"]["state"].getStr
+ debug "keycardV2 service: emitting channel state update", state=state, signal=SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED
+ self.events.emit(SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED, KeycardChannelStateArg(state: state))
except Exception as e:
error "error receiving a keycard signal", err=e.msg, data = signal
@@ -262,6 +271,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/storybook/pages/KeycardChannelDrawerPage.qml b/storybook/pages/KeycardChannelDrawerPage.qml
new file mode 100644
index 00000000000..fb7952c0409
--- /dev/null
+++ b/storybook/pages/KeycardChannelDrawerPage.qml
@@ -0,0 +1,222 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import Storybook
+
+import StatusQ.Core
+import StatusQ.Core.Theme
+import StatusQ.Controls
+import StatusQ.Components
+
+import shared.popups
+
+SplitView {
+ id: root
+
+ orientation: Qt.Horizontal
+
+ Logs { id: logs }
+
+ // Helper timers for test scenarios
+ Timer {
+ id: timer1
+ interval: 1500
+ onTriggered: {
+ if (root.currentScenario === "success") {
+ logs.logEvent("Changing to reading state")
+ stateCombo.currentIndex = 2 // reading
+ timer2.start()
+ } else if (root.currentScenario === "error") {
+ logs.logEvent("Changing to reading state")
+ stateCombo.currentIndex = 2 // reading
+ timer2.start()
+ } else if (root.currentScenario === "quick") {
+ logs.logEvent("Quick change to reading")
+ stateCombo.currentIndex = 2 // reading
+ timer2.start()
+ }
+ }
+ }
+
+ Timer {
+ id: timer2
+ interval: root.currentScenario === "quick" ? 300 : 1500
+ onTriggered: {
+ if (root.currentScenario === "success") {
+ logs.logEvent("Changing to idle state (success)")
+ stateCombo.currentIndex = 0 // idle (will trigger success)
+ } else if (root.currentScenario === "error") {
+ logs.logEvent("Changing to error state")
+ stateCombo.currentIndex = 3 // error
+ } else if (root.currentScenario === "quick") {
+ logs.logEvent("Quick change to idle (success)")
+ stateCombo.currentIndex = 0 // idle
+ }
+ root.currentScenario = ""
+ }
+ }
+
+ property string currentScenario: ""
+
+ Item {
+ SplitView.fillWidth: true
+ SplitView.fillHeight: true
+
+ KeycardChannelDrawer {
+ id: drawer
+
+ currentState: stateCombo.currentValue
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ onDismissed: {
+ logs.logEvent("KeycardChannelDrawer::dismissed()")
+ }
+ }
+ }
+
+ LogsAndControlsPanel {
+ id: logsAndControlsPanel
+
+ SplitView.preferredWidth: 350
+ SplitView.fillHeight: true
+
+ logsView.logText: logs.logText
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Theme.padding
+
+ // State control section
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Theme.halfPadding
+
+ Label {
+ Layout.preferredWidth: 120
+ text: "Current state:"
+ }
+
+ ComboBox {
+ id: stateCombo
+ Layout.fillWidth: true
+
+ textRole: "text"
+ valueRole: "value"
+
+ model: ListModel {
+ ListElement { text: "Idle"; value: "idle" }
+ ListElement { text: "Waiting for Keycard"; value: "waiting-for-keycard" }
+ ListElement { text: "Reading"; value: "reading" }
+ ListElement { text: "Error"; value: "error" }
+ }
+
+ currentIndex: 0
+ }
+ }
+
+ // State info display
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: infoColumn.implicitHeight + Theme.padding * 2
+ color: Theme.palette.baseColor5
+ radius: Theme.radius
+ border.width: 1
+ border.color: Theme.palette.baseColor2
+
+ ColumnLayout {
+ id: infoColumn
+ anchors.fill: parent
+ anchors.margins: Theme.padding
+ spacing: Theme.halfPadding
+
+ StatusBaseText {
+ Layout.fillWidth: true
+ text: "State Information"
+ font.bold: true
+ font.pixelSize: Theme.primaryTextFontSize
+ }
+
+ StatusBaseText {
+ Layout.fillWidth: true
+ text: "Current: %1".arg(stateCombo.currentText)
+ font.pixelSize: Theme.tertiaryTextFontSize
+ color: Theme.palette.baseColor1
+ }
+
+ StatusBaseText {
+ Layout.fillWidth: true
+ text: "Opened: %1".arg(drawer.opened ? "Yes" : "No")
+ font.pixelSize: Theme.tertiaryTextFontSize
+ color: Theme.palette.baseColor1
+ }
+ }
+ }
+
+ // Scenario buttons section
+ Label {
+ Layout.fillWidth: true
+ Layout.topMargin: Theme.padding
+ text: "Test Scenarios:"
+ font.bold: true
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: "Simulate Success Flow"
+ onClicked: {
+ logs.logEvent("Starting success flow simulation")
+ root.currentScenario = "success"
+ stateCombo.currentIndex = 1 // waiting-for-keycard
+ timer1.start()
+ }
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: "Simulate Error Flow"
+ onClicked: {
+ logs.logEvent("Starting error flow simulation")
+ root.currentScenario = "error"
+ stateCombo.currentIndex = 1 // waiting-for-keycard
+ timer1.start()
+ }
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: "Simulate Quick State Changes"
+ onClicked: {
+ logs.logEvent("Testing state queue with rapid changes")
+ root.currentScenario = "quick"
+ stateCombo.currentIndex = 1 // waiting-for-keycard
+ timer1.interval = 300
+ timer1.start()
+ }
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: "Open Drawer Manually"
+ onClicked: {
+ logs.logEvent("Manually opening drawer")
+ drawer.open()
+ }
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: "Clear Logs"
+ onClicked: logs.clear()
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+}
+
+// category: Popups
+// status: good
+
diff --git a/storybook/qmlTests/tests/tst_OnboardingLayout.qml b/storybook/qmlTests/tests/tst_OnboardingLayout.qml
index 127d2d2f8cd..3477512023a 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", 0)
+ }
+
+ 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/StatusQ/src/StatusQ/Controls/StatusPinInput.qml b/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml
index fa355bd3fa9..cab7a3734e3 100644
--- a/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml
+++ b/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml
@@ -102,6 +102,13 @@ Item {
*/
property int additionalSpacing: 0
+ /*!
+ \qmlproperty flags StatusPinInput::inputMethodHints
+ This property allows you to customize the input method hints for the virtual keyboard.
+ The default value is Qt.ImhDigitsOnly which allows only digits input.
+ */
+ property int inputMethodHints: Qt.ImhDigitsOnly
+
signal pinEditedManually()
QtObject {
@@ -158,8 +165,6 @@ Item {
Convenient method to force active focus in case it gets stolen by any other component.
*/
function forceFocus() {
- if (Utils.isMobile)
- return
inputText.forceActiveFocus()
d.activateBlink()
}
@@ -208,9 +213,13 @@ Item {
TextInput {
id: inputText
objectName: "pinInputTextInput"
- visible: false
- focus: !Utils.isMobile
+ visible: true
+ // Set explicit dimensions for Android keyboard input to work
+ width: 1
+ height: 1
+ opacity: 0
maximumLength: root.pinLen
+ inputMethodHints: root.inputMethodHints
validator: d.statusValidator.validatorObj
onTextChanged: {
// Modify state of current introduced character position:
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..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,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(
diff --git a/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml b/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml
index 39ac90c954f..3ed4892d762 100644
--- a/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml
+++ b/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml
@@ -6,6 +6,7 @@ import StatusQ.Core
import StatusQ.Controls
import StatusQ.Controls.Validators
import StatusQ.Core.Theme
+import StatusQ.Core.Utils as SQUtils
import AppLayouts.Onboarding.enums
import AppLayouts.Onboarding.controls
@@ -33,6 +34,8 @@ Control {
signal loginRequested(string pin)
+ signal keycardRequested()
+
function clear() {
d.wrongPin = false
pinInputField.statesInitialization()
@@ -82,11 +85,20 @@ Control {
elide: Text.ElideRight
color: Theme.palette.baseColor1
linkColor: hoveredLink ? Theme.palette.hoverColor(color) : color
+ visible: text !== ""
HoverHandler {
cursorShape: !!parent.hoveredLink ? Qt.PointingHandCursor : undefined
}
onLinkActivated: root.detailedErrorPopupRequested()
}
+
+ StatusButton {
+ Layout.fillWidth: true
+ id: scanKeycardButton
+ text: qsTr("Scan keycard")
+ visible: SQUtils.Utils.isMobile
+ onClicked: root.keycardRequested()
+ }
Column {
id: lockedButtons
Layout.fillWidth: true
@@ -113,6 +125,7 @@ Control {
objectName: "pinInput"
validator: StatusIntValidator { bottom: 0; top: 999999 }
visible: false
+ inputMethodHints: Qt.ImhDigitsOnly
onPinInputChanged: {
if (pinInput.length === 6) {
@@ -123,6 +136,11 @@ Control {
d.wrongPin = false
root.pinEditedManually()
}
+ onVisibleChanged: {
+ if (visible) {
+ pinInputField.forceFocus()
+ }
+ }
}
}
@@ -130,7 +148,7 @@ Control {
// normal/intro states
State {
name: "plugin"
- when: root.keycardState === Onboarding.KeycardState.PluginReader
+ when: root.keycardState === Onboarding.KeycardState.PluginReader && !SQUtils.Utils.isMobile
PropertyChanges {
target: infoText
text: qsTr("Plug in Keycard reader...")
@@ -138,7 +156,7 @@ Control {
},
State {
name: "insert"
- when: root.keycardState === Onboarding.KeycardState.InsertKeycard
+ when: root.keycardState === Onboarding.KeycardState.InsertKeycard && !SQUtils.Utils.isMobile
PropertyChanges {
target: infoText
text: qsTr("Insert your Keycard...")
@@ -146,7 +164,7 @@ Control {
},
State {
name: "reading"
- when: root.keycardState === Onboarding.KeycardState.ReadingKeycard
+ when: root.keycardState === Onboarding.KeycardState.ReadingKeycard && !SQUtils.Utils.isMobile
PropertyChanges {
target: infoText
text: qsTr("Reading Keycard...")
@@ -159,7 +177,7 @@ Control {
PropertyChanges {
target: infoText
color: Theme.palette.dangerColor1
- text: qsTr("Oops this isn't a Keycard.
Remove card and insert a Keycard.")
+ text: qsTr("Oops this isn't a Keycard.
Try using a Keycard instead.")
}
},
State {
@@ -168,20 +186,33 @@ Control {
PropertyChanges {
target: infoText
color: Theme.palette.dangerColor1
- text: qsTr("Wrong Keycard for this profile inserted")
+ text: qsTr("Wrong Keycard for this profile")
+ }
+ PropertyChanges {
+ target: scanKeycardButton
+ visible: SQUtils.Utils.isMobile
}
},
State {
name: "genericError"
- when: root.keycardState === -1 ||
+ when: (root.keycardState === -1 ||
root.keycardState === Onboarding.KeycardState.NoPCSCService ||
- root.keycardState === Onboarding.KeycardState.MaxPairingSlotsReached // TODO add a generic/fallback keycard error here too
+ root.keycardState === Onboarding.KeycardState.MaxPairingSlotsReached ) && !SQUtils.Utils.isMobile// TODO add a generic/fallback keycard error here too
PropertyChanges {
target: infoText
color: Theme.palette.dangerColor1
text: qsTr("Issue detecting Keycard.
Remove and re-insert reader and Keycard.")
}
},
+ State {
+ name: "maxPairingSlotsReached"
+ when: root.keycardState === Onboarding.KeycardState.MaxPairingSlotsReached && SQUtils.Utils.isMobile
+ PropertyChanges {
+ target: infoText
+ color: Theme.palette.dangerColor1
+ text: qsTr("Max pairing slots reached.")
+ }
+ },
State {
name: "blocked"
when: root.keycardState === Onboarding.KeycardState.BlockedPIN ||
@@ -202,7 +233,11 @@ Control {
PropertyChanges {
target: infoText
color: Theme.palette.dangerColor1
- text: qsTr("The inserted Keycard is empty.
Insert the correct Keycard for this profile.")
+ text: qsTr("The scanned Keycard is empty.
Use the correct Keycard for this profile.")
+ }
+ PropertyChanges {
+ target: scanKeycardButton
+ visible: SQUtils.Utils.isMobile
}
},
State {
@@ -227,7 +262,7 @@ Control {
// exit states
State {
name: "notEmpty"
- when: root.keycardState === Onboarding.KeycardState.NotEmpty && !d.wrongPin
+ when: root.keycardState === Onboarding.KeycardState.NotEmpty && !d.wrongPin
PropertyChanges {
target: infoText
text: qsTr("Enter Keycard PIN")
@@ -235,20 +270,21 @@ Control {
PropertyChanges {
target: pinInputField
visible: true
+ focus: true
}
PropertyChanges {
target: background
border.color: Theme.palette.primaryColor1
}
- StateChangeScript {
- script: {
- pinInputField.forceFocus()
- }
- }
PropertyChanges {
target: touchIdIcon
visible: root.isBiometricsLogin
}
}
]
+
+ TapHandler {
+ enabled: pinInputField.visible
+ onTapped: pinInputField.forceFocus()
+ }
}
diff --git a/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml b/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml
index 1ba5a2ae6ba..266718a53b9 100644
--- a/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml
+++ b/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml
@@ -161,6 +161,7 @@ KeycardBasePage {
anchors.horizontalCenter: parent.horizontalCenter
pinLen: Constants.keycard.general.keycardPinLength
validator: StatusIntValidator { bottom: 0; top: 999999 }
+ inputMethodHints: Qt.ImhDigitsOnly
onPinInputChanged: {
if (pinInput.pinInput.length === pinInput.pinLen) {
root.authorizationRequested(pinInput.pinInput)
diff --git a/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml
index 9c355b87ae0..b45b2d7fc3e 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
@@ -290,6 +291,7 @@ OnboardingPage {
onDetailedErrorPopupRequested: detailedErrorPopupComp.createObject(root, {detailedError: loginError}).open()
onBiometricsRequested: root.biometricsRequested(loginUserSelector.selectedProfileKeyId)
onLoginRequested: (pin) => d.doKeycardLogin(pin)
+ onKeycardRequested: root.keycardRequested()
}
Item { Layout.fillHeight: true }
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))
}
diff --git a/ui/i18n/qml_base_en.ts b/ui/i18n/qml_base_en.ts
index 0eb4c5f68fe..4bf74d1a916 100644
--- a/ui/i18n/qml_base_en.ts
+++ b/ui/i18n/qml_base_en.ts
@@ -8961,6 +8961,61 @@ L2 fee: %2
+
+ KeycardChannelDrawer
+
+ Ready to scan
+
+
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Keycard Not Supported
+
+
+
+ Your device does not support keycard operations. Please try again with a different device.
+
+
+
+ Keycard Not Available
+
+
+
+ Please enable NFC on your device to use the Keycard.
+
+
+
+ Dismiss
+
+
+
KeycardConfirmation
diff --git a/ui/i18n/qml_base_lokalise_en.ts b/ui/i18n/qml_base_lokalise_en.ts
index 5fd49d75b8b..f12dbcf1578 100644
--- a/ui/i18n/qml_base_lokalise_en.ts
+++ b/ui/i18n/qml_base_lokalise_en.ts
@@ -10933,6 +10933,69 @@
A key pair is your shareable public address and a secret private key that controls your wallet. Your key pair is being generated on your Keycard — keep it plugged in until the process completes.
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+ KeycardChannelDrawer
+ Please tap your Keycard to the back of your device
+
+
+ Reading Keycard
+ KeycardChannelDrawer
+ Reading Keycard
+
+
+ Please keep your Keycard in place
+ KeycardChannelDrawer
+ Please keep your Keycard in place
+
+
+ Success
+ KeycardChannelDrawer
+ Success
+
+
+ Keycard operation completed successfully
+ KeycardChannelDrawer
+ Keycard operation completed successfully
+
+
+ Keycard Error
+ KeycardChannelDrawer
+ Keycard Error
+
+
+ An error occurred. Please try again.
+ KeycardChannelDrawer
+ An error occurred. Please try again.
+
+
+ Keycard Not Supported
+ KeycardChannelDrawer
+ Keycard Not Supported
+
+
+ Your device does not support keycard operations. Please try again with a different device.
+ KeycardChannelDrawer
+ Your device does not support keycard operations. Please try again with a different device.
+
+
+ Keycard Not Available
+ KeycardChannelDrawer
+ Keycard Not Available
+
+
+ Please enable NFC on your device to use the Keycard.
+ KeycardChannelDrawer
+ Please enable NFC on your device to use the Keycard.
+
+
+ Dismiss
+ KeycardChannelDrawer
+ Dismiss
+
+
KeycardConfirmation
diff --git a/ui/i18n/qml_cs.ts b/ui/i18n/qml_cs.ts
index ea48c2838ae..3e8d54bea3b 100644
--- a/ui/i18n/qml_cs.ts
+++ b/ui/i18n/qml_cs.ts
@@ -9011,6 +9011,57 @@ L2 poplatek: %2
Pár klíčů je vaše sdílitelná veřejná adresa a tajný soukromý klíč, který ovládá vaši peněženku. Váš pár klíčů se generuje na vaší Keycard – nechte ji připojenou, dokud proces neskončí.
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Keycard Not Supported
+
+
+
+ Your device does not support keycard operations. Please try again with a different device.
+
+
+
+ Keycard Not Available
+
+
+
+ Please enable NFC on your device to use the Keycard.
+
+
+
+ Dismiss
+
+
+
KeycardConfirmation
diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts
index 3438eb849bb..3d9f3460e49 100644
--- a/ui/i18n/qml_es.ts
+++ b/ui/i18n/qml_es.ts
@@ -8976,6 +8976,57 @@ Tarifa L2: %2
Un par de claves es tu dirección pública compartible y una clave privada secreta que controla tu billetera. Tu par de claves se está generando en tu Keycard — manténlo conectado hasta que el proceso se complete.
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Keycard Not Supported
+
+
+
+ Your device does not support keycard operations. Please try again with a different device.
+
+
+
+ Keycard Not Available
+
+
+
+ Please enable NFC on your device to use the Keycard.
+
+
+
+ Dismiss
+
+
+
KeycardConfirmation
diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts
index 2ac4880e5c1..57384e7e825 100644
--- a/ui/i18n/qml_ko.ts
+++ b/ui/i18n/qml_ko.ts
@@ -8940,6 +8940,57 @@ L2 수수료: %2
키 페어는 다른 사람과 공유할 수 있는 공개 주소와, 지갑을 제어하는 비밀 개인 키로 이루어져 있습니다. 지금 Keycard에서 키 페어를 생성 중입니다 — 과정이 끝날 때까지 분리하지 마세요.
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Keycard Not Supported
+
+
+
+ Your device does not support keycard operations. Please try again with a different device.
+
+
+
+ Keycard Not Available
+
+
+
+ Please enable NFC on your device to use the Keycard.
+
+
+
+ Dismiss
+
+
+
KeycardConfirmation
diff --git a/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml
new file mode 100644
index 00000000000..8e56ffd5725
--- /dev/null
+++ b/ui/imports/shared/popups/KeycardChannelDrawer.qml
@@ -0,0 +1,178 @@
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+
+import StatusQ.Core
+import StatusQ.Core.Theme
+import StatusQ.Components
+import StatusQ.Controls
+import StatusQ.Popups.Dialog
+
+/**
+ * @brief A drawer that displays the current keycard channel state.
+ *
+ * This channel drawer will inform the user about the current keycard channel state.
+ * It is built to avoid flashing the drawer when the state changes and allow the user to see the keycard states.
+ * The drawer will display the current state and the next state will be displayed after a short delay.
+ * The drawer will close automatically after the success, error or idle state is displayed.
+ * Some states can be dismissed by the user.
+ */
+
+StatusDialog {
+ id: root
+
+ // ============================================================
+ // PUBLIC API
+ // ============================================================
+
+ /// The current keycard channel state from the backend
+ /// Expected values: "idle", "waiting-for-keycard", "reading", "error"
+ property string currentState: ""
+
+ /// Emitted when the user dismisses the drawer without completing the operation
+ signal dismissed()
+
+ // ============================================================
+ // STATE MANAGEMENT
+ // ============================================================
+
+ KeycardChannelStateManager {
+ id: stateManager
+ backendState: root.currentState
+
+ onReadyToOpen: {
+ if (!root.opened) {
+ root.open()
+ }
+ }
+
+ onReadyToClose: {
+ root.close()
+ }
+ }
+
+ // ============================================================
+ // DIALOG CONFIGURATION
+ // ============================================================
+
+ closePolicy: Popup.NoAutoClose
+ modal: true
+
+ header: null
+ footer: null
+ padding: Theme.padding
+
+ implicitWidth: 480
+
+ // ============================================================
+ // CONTENT
+ // ============================================================
+
+ contentItem: ColumnLayout {
+ id: content
+ spacing: Theme.padding
+
+ // State display
+ KeycardStateDisplay {
+ id: stateDisplay
+ Layout.fillWidth: true
+ Layout.preferredHeight: 300
+ opacity: stateManager.displayState !== "" ? 1 : 0
+
+ states: [
+ State {
+ name: "waiting"
+ when: stateManager.displayState === stateManager.stateWaitingForCard
+ PropertyChanges {
+ target: stateDisplay
+ iconSource: Assets.png("onboarding/carousel/keycard")
+ title: qsTr("Ready to scan")
+ description: qsTr("Please tap your Keycard to the back of your device")
+ isError: false
+ showLoading: false
+ }
+ },
+ State {
+ name: "reading"
+ when: stateManager.displayState === stateManager.stateReading
+ PropertyChanges {
+ target: stateDisplay
+ iconSource: Assets.png("onboarding/status_generate_keycard")
+ title: qsTr("Reading Keycard")
+ description: qsTr("Please keep your Keycard in place")
+ isError: false
+ showLoading: true
+ }
+ },
+ State {
+ name: "success"
+ when: stateManager.displayState === stateManager.stateSuccess
+ PropertyChanges {
+ target: stateDisplay
+ iconSource: Assets.png("onboarding/status_key")
+ title: qsTr("Success")
+ description: qsTr("Keycard operation completed successfully")
+ isError: false
+ showLoading: false
+ }
+ },
+ State {
+ name: "error"
+ when: stateManager.displayState === stateManager.stateError
+ PropertyChanges {
+ target: stateDisplay
+ iconSource: Assets.png("onboarding/status_generate_keys")
+ title: qsTr("Keycard Error")
+ description: qsTr("An error occurred. Please try again.")
+ isError: true
+ showLoading: false
+ }
+ },
+ State {
+ name: "not-supported"
+ when: stateManager.displayState === stateManager.stateNotSupported
+ PropertyChanges {
+ target: stateDisplay
+ iconSource: Assets.png("onboarding/status_generate_keys")
+ title: qsTr("Keycard Not Supported")
+ description: qsTr("Your device does not support keycard operations. Please try again with a different device.")
+ isError: true
+ showLoading: false
+ }
+ },
+ State {
+ name: "not-available"
+ when: stateManager.displayState === stateManager.stateNotAvailable
+ PropertyChanges {
+ target: stateDisplay
+ iconSource: Assets.png("onboarding/status_generate_keys")
+ title: qsTr("Keycard Not Available")
+ description: qsTr("Please enable NFC on your device to use the Keycard.")
+ isError: true
+ showLoading: false
+ }
+ }
+ ]
+ }
+
+ // Dismiss button (only show when not in success state)
+ StatusButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Theme.halfPadding
+ Layout.leftMargin: Theme.xlPadding * 2
+ Layout.rightMargin: Theme.xlPadding * 2
+ opacity: stateManager.displayState !== "success" && stateManager.displayState !== "" ? 1 : 0
+ text: qsTr("Dismiss")
+ type: StatusButton.Type.Normal
+
+ onClicked: {
+ root.dismissed()
+ }
+ }
+
+ Item {
+ Layout.fillHeight: true
+ Layout.minimumHeight: Theme.padding
+ }
+ }
+}
diff --git a/ui/imports/shared/popups/KeycardChannelStateManager.qml b/ui/imports/shared/popups/KeycardChannelStateManager.qml
new file mode 100644
index 00000000000..2521037f4fb
--- /dev/null
+++ b/ui/imports/shared/popups/KeycardChannelStateManager.qml
@@ -0,0 +1,201 @@
+import QtQuick
+
+import utils
+
+/**
+ * @brief State manager for KeycardChannelDrawer
+ *
+ * Manages the queue-based state transitions for keycard operations.
+ * Handles timing, state transitions, and provides signals for UI updates.
+ * This component is separate from the UI to enable independent testing
+ * and maintain separation of concerns.
+ */
+QtObject {
+ id: root
+
+ // ============================================================
+ // PUBLIC API
+ // ============================================================
+
+ /// Input: Backend state from the keycard system
+ /// Expected values: "idle", "waiting-for-keycard", "reading", "error", "not-supported", "not-available"
+ property string backendState: Constants.keycardChannelState.idle
+
+ /// Output: Current display state for the UI
+ /// Values: "", "waiting-for-card", "reading", "success", "error", "not-supported", "not-available"
+ readonly property string displayState: d.displayState
+
+ /// Configuration: Minimum time to show each state (ms)
+ property int minimumStateDuration: 600
+
+ /// Configuration: How long to show success before closing (ms)
+ property int successDisplayDuration: 1200
+
+ // Display states definition
+ // These are slightly different from the backend states
+ readonly property string stateSuccess: "success"
+ readonly property string stateIdle: ""
+ readonly property string stateReading: Constants.keycardChannelState.reading
+ readonly property string stateError: Constants.keycardChannelState.error
+ readonly property string stateWaitingForCard: Constants.keycardChannelState.waitingForKeycard
+ readonly property string stateNotSupported: Constants.keycardChannelState.notSupported
+ readonly property string stateNotAvailable: Constants.keycardChannelState.notAvailable
+
+ /// Signals
+ signal readyToOpen() // Drawer should open
+ signal readyToClose() // Drawer should close
+
+ /// Public method: Clear queue and reset to idle
+ function clearAndClose() {
+ d.clearAndClose()
+ }
+
+ // ============================================================
+ // INTERNAL IMPLEMENTATION
+ // ============================================================
+
+ property QtObject d: QtObject {
+ // Current display state (what the user sees)
+ property string displayState: stateIdle
+
+ // State queue - stores states to be displayed
+ property var stateQueue: []
+
+ // Track previous backend state for success detection
+ property string previousBackendState: Constants.keycardChannelState.idle
+
+ /// Map backend state to display state
+ function mapBackendStateToDisplayState(backendState) {
+ switch(backendState) {
+ case Constants.keycardChannelState.waitingForKeycard:
+ case Constants.keycardChannelState.reading:
+ case Constants.keycardChannelState.error:
+ return backendState
+ case Constants.keycardChannelState.idle:
+ // Success detection: were we just reading?
+ if (previousBackendState === Constants.keycardChannelState.reading) {
+ return stateSuccess
+ }
+ return stateIdle
+ case Constants.keycardChannelState.notSupported:
+ return stateNotSupported
+ case Constants.keycardChannelState.notAvailable:
+ return stateNotAvailable
+ default:
+ return stateIdle
+ }
+ }
+
+ /// Add a state to the queue
+ function enqueueState(state) {
+ // Don't queue if it's the same as the last queued state
+ if (stateQueue.length > 0 && stateQueue[stateQueue.length - 1] === state) {
+ return
+ }
+
+ // Don't queue if it's the same as current display state and queue is empty
+ if (stateQueue.length === 0 && state === displayState) {
+ return
+ }
+
+ stateQueue.push(state)
+
+ // If timer not running, start processing immediately
+ if (!stateTimer.running) {
+ processNextState()
+ }
+ }
+
+ /// Process the next state from the queue
+ function processNextState() {
+ if (stateQueue.length === 0) {
+ return
+ }
+
+ const nextState = stateQueue.shift() // Remove and get first item
+
+ // Set the display state
+ displayState = nextState
+
+ // Signal to open drawer if showing a state
+ if (nextState !== stateIdle) {
+ root.readyToOpen()
+ }
+
+ // Determine timer duration based on state
+ if (nextState === stateSuccess) {
+ stateTimer.interval = root.successDisplayDuration
+ } else if (nextState === stateIdle) {
+ // Closing - signal to close drawer
+ root.readyToClose()
+ // Clear any remaining queue (stale states from before completion)
+ if (stateQueue.length > 0) {
+ processNextState()
+ }
+ return
+ } else {
+ stateTimer.interval = root.minimumStateDuration
+ }
+
+ // Start timer for next transition
+ stateTimer.restart()
+ }
+
+ /// Handle backend state changes
+ function onBackendStateChanged() {
+ const newDisplayState = mapBackendStateToDisplayState(root.backendState)
+
+ // Special handling: Backend went to idle unexpectedly (not after reading)
+ // Clear everything and close immediately
+ if (newDisplayState === stateIdle && displayState !== stateSuccess) {
+ stateQueue = []
+ stateTimer.stop()
+ displayState = stateIdle
+ previousBackendState = root.backendState
+ root.readyToClose()
+ return // Don't process further
+ }
+
+ // Update previous state tracking
+ previousBackendState = root.backendState
+
+ // Enqueue the new state
+ enqueueState(newDisplayState)
+
+ // If we just enqueued success, also enqueue idle to close the drawer after
+ if (newDisplayState === stateSuccess) {
+ enqueueState(stateIdle)
+ }
+ }
+
+ /// Clear queue and reset to idle
+ function clearAndClose() {
+ stateQueue = []
+ stateTimer.stop()
+ displayState = stateIdle
+ root.readyToClose()
+ }
+ }
+
+ // Single timer that handles all state transitions
+ property Timer stateTimer: Timer {
+ id: stateTimer
+ repeat: false
+ onTriggered: {
+ // When timer fires, move to next state in queue
+ d.processNextState()
+ }
+ }
+
+ // Watch for backend state changes - push to queue
+ onBackendStateChanged: {
+ d.onBackendStateChanged()
+ }
+
+ // Initialize on component load
+ Component.onCompleted: {
+ d.previousBackendState = root.backendState
+ d.onBackendStateChanged()
+ }
+}
+
diff --git a/ui/imports/shared/popups/KeycardStateDisplay.qml b/ui/imports/shared/popups/KeycardStateDisplay.qml
new file mode 100644
index 00000000000..d7bb342cf6b
--- /dev/null
+++ b/ui/imports/shared/popups/KeycardStateDisplay.qml
@@ -0,0 +1,89 @@
+import QtQuick
+import QtQuick.Layouts
+
+import StatusQ.Core
+import StatusQ.Core.Theme
+import StatusQ.Components
+
+import shared
+
+/// Reusable component for displaying a state in the KeycardChannelDrawer
+/// Shows an icon, title, and description in a consistent layout
+Item {
+ id: root
+
+ // ============================================================
+ // PUBLIC API
+ // ============================================================
+
+ /// Path to the icon image
+ property string iconSource: ""
+
+ /// Main title text
+ property string title: ""
+
+ /// Description text below the title
+ property string description: ""
+
+ /// Whether this is an error state (affects text color)
+ property bool isError: false
+
+ /// Whether to show a loading animation
+ property bool showLoading: false
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: layout.implicitHeight
+
+ // ============================================================
+ // INTERNAL LAYOUT
+ // ============================================================
+
+ ColumnLayout {
+ id: layout
+ anchors.centerIn: parent
+ width: parent.width
+ spacing: Theme.padding
+
+ // Icon
+ StatusImage {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: 164
+ Layout.preferredHeight: 164
+ source: root.iconSource
+ visible: root.iconSource !== ""
+ }
+
+ // Title
+ StatusBaseText {
+ Layout.fillWidth: true
+ Layout.topMargin: Theme.padding
+ horizontalAlignment: Text.AlignHCenter
+ text: root.title
+ font.pixelSize: Theme.fontSize25
+ font.bold: true
+ color: root.isError ? Theme.palette.dangerColor1 : Theme.palette.directColor1
+ wrapMode: Text.WordWrap
+ visible: root.title !== ""
+ }
+
+ // Description
+ StatusBaseText {
+ Layout.fillWidth: true
+ Layout.topMargin: Theme.halfPadding
+ horizontalAlignment: Text.AlignHCenter
+ text: root.description
+ font.pixelSize: Theme.primaryTextFontSize
+ color: Theme.palette.baseColor1
+ wrapMode: Text.WordWrap
+ visible: root.description !== ""
+ }
+
+ // Loading animation (shown for reading state)
+ LoadingAnimation {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Theme.padding
+ visible: root.showLoading
+ }
+ }
+}
+
diff --git a/ui/imports/shared/popups/qmldir b/ui/imports/shared/popups/qmldir
index f6835d28445..f11e01d968d 100644
--- a/ui/imports/shared/popups/qmldir
+++ b/ui/imports/shared/popups/qmldir
@@ -16,6 +16,9 @@ ImageContextMenu 1.0 ImageContextMenu.qml
ImageCropWorkflow 1.0 ImageCropWorkflow.qml
ImportCommunityPopup 1.0 ImportCommunityPopup.qml
InviteFriendsPopup 1.0 InviteFriendsPopup.qml
+KeycardChannelDrawer 1.0 KeycardChannelDrawer.qml
+KeycardChannelStateManager 1.0 KeycardChannelStateManager.qml
+KeycardStateDisplay 1.0 KeycardStateDisplay.qml
IntroduceYourselfPopup 1.0 IntroduceYourselfPopup.qml
MarkAsIDVerifiedDialog 1.0 MarkAsIDVerifiedDialog.qml
MarkAsUntrustedPopup 1.0 MarkAsUntrustedPopup.qml
diff --git a/ui/imports/shared/stores/KeycardStateStore.qml b/ui/imports/shared/stores/KeycardStateStore.qml
new file mode 100644
index 00000000000..1ce07039478
--- /dev/null
+++ b/ui/imports/shared/stores/KeycardStateStore.qml
@@ -0,0 +1,17 @@
+import QtQuick
+import utils
+
+QtObject {
+ id: root
+
+ readonly property var keycardChannelModuleInst: typeof keycardChannelModule !== "undefined" ? keycardChannelModule : null
+
+ // Channel state property
+ readonly property string state: keycardChannelModuleInst ? keycardChannelModuleInst.keycardChannelState : Constants.keycardChannelState.idle
+
+ function keycardDismissed() {
+ keycardChannelModuleInst.keycardDismissed()
+ }
+}
+
+
diff --git a/ui/imports/shared/stores/qmldir b/ui/imports/shared/stores/qmldir
index 2b838e77f8a..d6131c238bc 100644
--- a/ui/imports/shared/stores/qmldir
+++ b/ui/imports/shared/stores/qmldir
@@ -3,6 +3,7 @@ CommunityTokensStore 1.0 CommunityTokensStore.qml
CurrenciesStore 1.0 CurrenciesStore.qml
DAppsStore 1.0 DAppsStore.qml
GifStore 1.0 GifStore.qml
+KeycardStateStore 1.0 KeycardStateStore.qml
MetricsStore 1.0 MetricsStore.qml
NetworkConnectionStore 1.0 NetworkConnectionStore.qml
NetworksStore 1.0 NetworksStore.qml
diff --git a/ui/imports/utils/Constants.qml b/ui/imports/utils/Constants.qml
index b0023eb04c9..d182352a7c7 100644
--- a/ui/imports/utils/Constants.qml
+++ b/ui/imports/utils/Constants.qml
@@ -1439,4 +1439,14 @@ QtObject {
InProgress,
Completed
}
+
+ readonly property QtObject keycardChannelState: QtObject {
+ readonly property string idle: "idle"
+ readonly property string waitingForKeycard: "waiting-for-keycard"
+ readonly property string reading: "reading"
+ readonly property string error: "error"
+ readonly property string notSupported: "not-supported"
+ readonly property string notAvailable: "not-available"
+ }
+
}
diff --git a/ui/main.qml b/ui/main.qml
index b1f328af706..7a7c2da1a3d 100644
--- a/ui/main.qml
+++ b/ui/main.qml
@@ -52,6 +52,7 @@ Window {
readonly property UtilsStore utilsStore: UtilsStore {}
readonly property LanguageStore languageStore: LanguageStore {}
readonly property bool appThemeDark: Theme.style === Theme.Style.Dark
+ readonly property KeycardStateStore keycardStateStore: KeycardStateStore {}
readonly property bool portraitLayout: height > width
property bool biometricFlowPending: false
@@ -674,6 +675,18 @@ Window {
}
}
+
+ Loader {
+ active: SQUtils.Utils.isAndroid
+ sourceComponent: KeycardChannelDrawer {
+ id: keycardChannelDrawer
+ currentState: applicationWindow.keycardStateStore.state
+ onDismissed: {
+ applicationWindow.keycardStateStore.keycardDismissed()
+ }
+ }
+ }
+
Loader {
id: macOSSafeAreaLoader
anchors.left: parent.left