diff --git a/.gitmodules b/.gitmodules
index 369f288f1a7..b10fda5f0a6 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -129,3 +129,6 @@
[submodule "vendor/nim-unicodedb"]
path = vendor/nim-unicodedb
url = https://github.com/nitely/nim-unicodedb
+[submodule "vendor/status-keycard-qt"]
+ path = vendor/status-keycard-qt
+ url = https://github.com/status-im/status-keycard-qt
diff --git a/Makefile b/Makefile
index 3aa9a292566..01e894beaf6 100644
--- a/Makefile
+++ b/Makefile
@@ -40,6 +40,7 @@ GIT_ROOT ?= $(shell git rev-parse --show-toplevel 2>/dev/null || echo .)
tests-nim-linux \
status-go \
status-keycard-go \
+ status-keycard-qt \
statusq-sanity-checker \
run-statusq-sanity-checker \
statusq-tests \
@@ -248,6 +249,9 @@ NIMSDS_LIBDIR := $(NIM_SDS_SOURCE_DIR)/build
NIMSDS_LIBFILE := $(NIMSDS_LIBDIR)/libsds.$(LIB_EXT)
NIM_EXTRA_PARAMS += --passL:"-L$(NIMSDS_LIBDIR)" --passL:"-lsds"
STATUSGO_MAKE_PARAMS += NIM_SDS_SOURCE_DIR="$(NIM_SDS_SOURCE_DIR)"
+# Keycard library selection: set to 1 to use status-keycard-qt (Qt/C++), 0 for status-keycard-go (Go)
+# Default: use status-keycard-go for now (stable), switch to 1 to test status-keycard-qt
+USE_STATUS_KEYCARD_QT ?= 0
INCLUDE_DEBUG_SYMBOLS ?= false
ifeq ($(INCLUDE_DEBUG_SYMBOLS),true)
@@ -501,6 +505,62 @@ $(STATUSKEYCARDGO): | deps
$(if $(filter 1 true,$(USE_MOCKED_KEYCARD_LIB)), build-mocked-lib, build-lib) \
$(STATUSKEYCARDGO_MAKE_PARAMS) $(HANDLE_OUTPUT)
+##
+## status-keycard-qt (Qt/C++ based keycard library)
+##
+
+# Allow using local status-keycard-qt for development
+STATUS_KEYCARD_QT_DIR ?= vendor/status-keycard-qt
+KEYCARD_QT ?= ""
+
+# Determine build directory based on platform
+ifeq ($(mkspecs),macx)
+STATUS_KEYCARD_QT_BUILD_DIR := $(STATUS_KEYCARD_QT_DIR)/build/macos
+else ifeq ($(mkspecs),win32)
+STATUS_KEYCARD_QT_BUILD_DIR := $(STATUS_KEYCARD_QT_DIR)/build/windows
+else
+STATUS_KEYCARD_QT_BUILD_DIR := $(STATUS_KEYCARD_QT_DIR)/build/linux
+endif
+
+export STATUSKEYCARD_QT_LIB := $(STATUS_KEYCARD_QT_BUILD_DIR)/libstatus-keycard-qt.$(LIB_EXT)
+export STATUSKEYCARD_QT_LIBDIR := $(STATUS_KEYCARD_QT_BUILD_DIR)
+
+status-keycard-qt: $(STATUSKEYCARD_QT_LIB)
+$(STATUSKEYCARD_QT_LIB): | deps check-qt-dir
+ echo -e $(BUILD_MSG) "status-keycard-qt"
+ + STATUS_KEYCARD_QT_DIR="$(STATUS_KEYCARD_QT_DIR)" \
+ KEYCARD_QT_DIR="$(KEYCARD_QT_DIR)" \
+ QMAKE="$(QMAKE)" \
+ cmake -S "${STATUS_KEYCARD_QT_DIR}" -B "${STATUS_KEYCARD_QT_BUILD_DIR}" \
+ $(COMMON_CMAKE_CONFIG_PARAMS) \
+ -DBUILD_TESTING=OFF \
+ -DBUILD_EXAMPLES=OFF \
+ -DBUILD_SHARED_LIBS=ON \
+ -DKEYCARD_QT_SOURCE_DIR=${KEYCARD_QT} \
+ $(HANDLE_OUTPUT)
+ cmake --build $(STATUS_KEYCARD_QT_BUILD_DIR) --target status-keycard-qt $(HANDLE_OUTPUT)
+
+status-keycard-qt-clean:
+ echo -e "\033[92mCleaning:\033[39m status-keycard-qt"
+ rm -rf $(STATUS_KEYCARD_QT_BUILD_DIR)
+
+##
+## Keycard library selection
+##
+
+# Set the keycard library and paths based on USE_STATUS_KEYCARD_QT
+ifeq ($(USE_STATUS_KEYCARD_QT),1)
+ KEYCARD_LIB := $(STATUSKEYCARD_QT_LIB)
+ KEYCARD_LIBDIR := $(STATUSKEYCARD_QT_LIBDIR)
+ KEYCARD_LINKNAME := status-keycard-qt
+ KEYCARD_DYLIB_NAME := libstatus-keycard-qt.dylib
+else
+ KEYCARD_LIB := $(STATUSKEYCARDGO)
+ KEYCARD_LIBDIR := $(STATUSKEYCARDGO_LIBDIR)
+ KEYCARD_LINKNAME := keycard
+ KEYCARD_DYLIB_NAME := libkeycard.dylib
+endif
+
QRCODEGEN := vendor/QR-Code-generator/c/libqrcodegen.a
$(QRCODEGEN): | deps
@@ -611,7 +671,7 @@ $(NIM_STATUS_CLIENT): update-qmake-previous
endif
$(NIM_STATUS_CLIENT): NIM_PARAMS += $(RESOURCES_LAYOUT)
-$(NIM_STATUS_CLIENT): $(NIM_SOURCES) | statusq dotherside check-qt-dir $(STATUSGO) $(STATUSKEYCARDGO) $(QRCODEGEN) rcc deps
+$(NIM_STATUS_CLIENT): $(NIM_SOURCES) | statusq dotherside check-qt-dir $(STATUSGO) $(KEYCARD_LIB) $(QRCODEGEN) rcc deps
echo -e $(BUILD_MSG) "$@"
$(ENV_SCRIPT) nim c $(NIM_PARAMS) \
--mm:refc \
@@ -619,8 +679,8 @@ $(NIM_STATUS_CLIENT): $(NIM_SOURCES) | statusq dotherside check-qt-dir $(STATUSG
--passL:"-lstatus" \
--passL:"-L$(STATUSQ_INSTALL_PATH)/StatusQ" \
--passL:"-lStatusQ" \
- --passL:"-L$(STATUSKEYCARDGO_LIBDIR)" \
- --passL:"-lkeycard" \
+ --passL:"-L$(KEYCARD_LIBDIR)" \
+ --passL:"-l$(KEYCARD_LINKNAME)" \
--passL:"$(QRCODEGEN)" \
--passL:"-lm" \
--parallelBuild:0 \
@@ -631,8 +691,8 @@ ifeq ($(mkspecs),macx)
@rpath/libstatus.dylib \
bin/nim_status_client
install_name_tool -change \
- libkeycard.dylib \
- @rpath/libkeycard.dylib \
+ $(KEYCARD_DYLIB_NAME) \
+ @rpath/$(KEYCARD_DYLIB_NAME) \
bin/nim_status_client
endif
@@ -850,7 +910,7 @@ zip-windows: check-pkg-target-windows $(STATUS_CLIENT_7Z)
clean-destdir:
rm -rf bin/*
-clean: | clean-common clean-destdir statusq-clean status-go-clean dotherside-clean storybook-clean clean-translations
+clean: | clean-common clean-destdir statusq-clean status-go-clean status-keycard-qt-clean dotherside-clean storybook-clean clean-translations
rm -rf bottles/* pkg/* tmp/* $(STATUSKEYCARDGO)
+ $(MAKE) -C vendor/QR-Code-generator/c/ --no-print-directory clean
@@ -868,12 +928,12 @@ run: $(RUN_TARGET)
run-linux: nim_status_client
echo -e "\033[92mRunning:\033[39m bin/nim_status_client"
- LD_LIBRARY_PATH="$(QT_LIBDIR)":"$(LIBWAKU_LIBDIR)":"$(NIMSDS_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(STATUSKEYCARDGO_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(LD_LIBRARY_PATH)" \
+ LD_LIBRARY_PATH="$(QT_LIBDIR)":"$(LIBWAKU_LIBDIR)":"$(NIMSDS_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(KEYCARD_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(LD_LIBRARY_PATH)" \
./bin/nim_status_client $(ARGS)
run-linux-gdb: nim_status_client
echo -e "\033[92mRunning:\033[39m bin/nim_status_client"
- LD_LIBRARY_PATH="$(QT_LIBDIR)":"$(LIBWAKU_LIBDIR)":"$(NIMSDS_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(STATUSKEYCARDGO_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(LD_LIBRARY_PATH)" \
+ LD_LIBRARY_PATH="$(QT_LIBDIR)":"$(LIBWAKU_LIBDIR)":"$(NIMSDS_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(KEYCARD_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(LD_LIBRARY_PATH)" \
gdb -ex=r ./bin/nim_status_client $(ARGS)
run-macos: nim_status_client
@@ -884,12 +944,13 @@ run-macos: nim_status_client
ln -fs ../../../nim_status_client ./
fileicon set bin/nim_status_client status-dev.icns
echo -e "\033[92mRunning:\033[39m bin/StatusDev.app/Contents/MacOS/nim_status_client"
+ DYLD_LIBRARY_PATH="$(STATUSGO_LIBDIR)":"$(KEYCARD_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(DYLD_LIBRARY_PATH)" \
./bin/StatusDev.app/Contents/MacOS/nim_status_client $(ARGS)
run-windows: STATUS_RC_FILE = status-dev.rc
run-windows: compile_windows_resources nim_status_client
echo -e "\033[92mRunning:\033[39m bin/nim_status_client.exe"
- PATH="$(DOTHERSIDE_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(STATUSKEYCARDGO_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(PATH)" \
+ PATH="$(DOTHERSIDE_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(KEYCARD_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(PATH)" \
./bin/nim_status_client.exe $(ARGS)
NIM_TEST_FILES := $(wildcard test/nim/*.nim)
@@ -908,11 +969,16 @@ endef
export PATH := $(call qmkq,QT_INSTALL_BINS):$(call qmkq,QT_HOST_BINS):$(call qmkq,QT_HOST_LIBEXECS):$(PATH)
export QTDIR := $(call qmkq,QT_INSTALL_PREFIX)
+#Force keycard support for mobile builds
+ifeq ($(USE_STATUS_KEYCARD_QT),1)
+ MOBILE_FLAGS += "FLAG_KEYCARD_ENABLED=1"
+endif
+
mobile-run: deps-common
echo -e "\033[92mRunning:\033[39m mobile app"
- $(MAKE) -C mobile run
+ $(MAKE) -C mobile run DEBUG=1 $(MOBILE_FLAGS)
-mobile-build: USE_SYSTEM_NIM=1
+mobile-build: USE_SYSTEM_NIM=1 $(MOBILE_FLAGS)
mobile-build: | deps-common
echo -e "\033[92mBuilding:\033[39m mobile app ($(or $(PACKAGE_TYPE),default))"
ifeq ($(PACKAGE_TYPE),aab)
diff --git a/mobile/DEV_SETUP.md b/mobile/DEV_SETUP.md
index f4883603abd..f48e2026ab4 100644
--- a/mobile/DEV_SETUP.md
+++ b/mobile/DEV_SETUP.md
@@ -32,6 +32,22 @@ export QTDIR=$HOME/qt/6.9.2/ios
make mobile-run
```
+Running the app requires a code sign identity. See [Signing](#signing)
+
+#### Keycard
+
+The keycard support is disabled by default in the mobile makefile for IOS. It requires a paid apple developer account to run the app with NFC enabled.
+
+To enable keycard use the `USE_STATUS_KEYCARD_QT=1` flag for the main Makefile and use a paid account by updating the `DEVELOPMENT_TEAM` flag and the bundle if (if the development team isn't Status).
+
+#### Signing
+
+By default the app isn't signed.
+
+To sign the app the `DEVELOPMENT_TEAM` flag needs to be provided. If the development team is not Status development team, then the app bundle id needs to be updated to a unique bundle id.
+
+####
+
### Android Development Setup
#### Prerequisites - can be installed using the Android Studio
diff --git a/mobile/Makefile b/mobile/Makefile
index 99a8272f90d..4d164350dd0 100644
--- a/mobile/Makefile
+++ b/mobile/Makefile
@@ -3,6 +3,18 @@
STATUS_GO_LIB := $(LIB_PATH)/libstatus$(LIB_EXT)
+# FLAG_KEYCARD_ENABLED: Controls NFC/Keycard support
+# - iOS: Default 0 (disabled) - Build without NFC, works with free Apple Developer account
+# - Android: Default 1 (enabled) - NFC support doesn't require paid account on Android
+# - Set to 1: Build with NFC support (iOS requires paid Apple Developer account)
+# - Set to 0: Build without NFC support
+# Usage: make build-ios FLAG_KEYCARD_ENABLED=1
+ifeq ($(OS),android)
+ FLAG_KEYCARD_ENABLED ?= 1
+else
+ FLAG_KEYCARD_ENABLED ?= 0
+endif
+
$(info Configuring build system for $(OS) $(ARCH) with QT $(QT_MAJOR))
# default rule
@@ -16,6 +28,7 @@ statusq: clean-statusq $(STATUS_Q_LIB)
dotherside: clean-dotherside $(DOTHERSIDE_LIB)
openssl: clean-openssl $(OPENSSL_LIB)
qrcodegen: clean-qrcodegen $(QRCODEGEN_LIB)
+status-keycard-qt: clean-status-keycard-qt $(STATUS_KEYCARD_QT_LIB)
nim-status-client: clean-nim-status-client $(NIM_STATUS_CLIENT_LIB)
status-desktop-rcc: clean-status-desktop-rcc $(STATUS_DESKTOP_RCC)
@@ -62,6 +75,11 @@ $(QRCODEGEN_LIB): $(QRCODEGEN_FILES)
@QRCODEGEN=$(QRCODEGEN) $(QRCODEGEN_SCRIPT) $(HANDLE_OUTPUT)
@echo "QRCodeGen built $(QRCODEGEN_LIB)"
+$(STATUS_KEYCARD_QT_LIB): $(STATUS_KEYCARD_QT_FILES) $(STATUS_KEYCARD_QT_SCRIPT) $(OPENSSL_LIB)
+ @echo "Building status-keycard-qt"
+ @STATUS_KEYCARD_QT=$(STATUS_KEYCARD_QT) KEYCARD_QT=$(KEYCARD_QT) BUILD_DIR=$(BUILD_PATH) $(STATUS_KEYCARD_QT_SCRIPT) $(HANDLE_OUTPUT)
+ @echo "status-keycard-qt built $(STATUS_KEYCARD_QT_LIB)"
+
$(STATUS_DESKTOP_RCC): $(STATUS_DESKTOP_UI_FILES) compile-translations
@echo "Building Status Desktop rcc"
@make -C $(STATUS_DESKTOP) rcc $(HANDLE_OUTPUT)
@@ -69,16 +87,39 @@ $(STATUS_DESKTOP_RCC): $(STATUS_DESKTOP_UI_FILES) compile-translations
$(NIM_STATUS_CLIENT_LIB): $(STATUS_DESKTOP_NIM_FILES) $(NIM_STATUS_CLIENT_SCRIPT) $(STATUS_DESKTOP_RCC) $(DOTHERSIDE_LIB) $(OPENSSL_LIB) $(STATUS_Q_LIB) $(STATUS_GO_LIB) $(QRCODEGEN_LIB)
@echo "Building Status Desktop Lib"
- @STATUS_DESKTOP=$(STATUS_DESKTOP) LIB_SUFFIX=$(LIB_SUFFIX) LIB_EXT=$(LIB_EXT) USE_QML_SERVER=$(USE_QML_SERVER) $(NIM_STATUS_CLIENT_SCRIPT) $(HANDLE_OUTPUT)
+ @STATUS_DESKTOP=$(STATUS_DESKTOP) \
+ LIB_SUFFIX=$(LIB_SUFFIX) \
+ LIB_EXT=$(LIB_EXT) \
+ USE_QML_SERVER=$(USE_QML_SERVER) \
+ BUNDLE_IDENTIFIER="$(BUNDLE_IDENTIFIER)" \
+ DEBUG=$(DEBUG) \
+ FLAG_DAPPS_ENABLED=$(FLAG_DAPPS_ENABLED) \
+ FLAG_CONNECTOR_ENABLED=$(FLAG_CONNECTOR_ENABLED) \
+ FLAG_KEYCARD_ENABLED=$(FLAG_KEYCARD_ENABLED) \
+ FLAG_SINGLE_STATUS_INSTANCE_ENABLED=$(FLAG_SINGLE_STATUS_INSTANCE_ENABLED) \
+ FLAG_BROWSER_ENABLED=$(FLAG_BROWSER_ENABLED) \
+ $(NIM_STATUS_CLIENT_SCRIPT) $(HANDLE_OUTPUT)
@echo "Status Desktop Lib built $(NIM_STATUS_CLIENT_LIB)"
# non-phony targets
-$(TARGET): $(APP_SCRIPT) $(STATUS_GO_LIB) $(STATUS_Q_LIB) $(DOTHERSIDE_LIB) $(OPENSSL_LIB) $(QRCODEGEN_LIB) $(NIM_STATUS_CLIENT_LIB) $(STATUS_DESKTOP_RCC) $(WRAPPER_APP_FILES)
+$(TARGET): $(APP_SCRIPT) $(STATUS_GO_LIB) $(STATUS_Q_LIB) $(DOTHERSIDE_LIB) $(OPENSSL_LIB) $(QRCODEGEN_LIB) $(STATUS_KEYCARD_QT_LIB) $(NIM_STATUS_CLIENT_LIB) $(STATUS_DESKTOP_RCC) $(WRAPPER_APP_FILES)
@echo "Building app"
ifeq ($(OS),android)
- @STATUS_DESKTOP=$(STATUS_DESKTOP) BUILD_TYPE=$(PACKAGE_TYPE) BIN_DIR=$(BIN_PATH) BUILD_DIR=$(BUILD_PATH) QT_MAJOR=$(QT_MAJOR) $(APP_SCRIPT) $(HANDLE_OUTPUT)
+ @STATUS_DESKTOP=$(STATUS_DESKTOP) \
+ BUILD_TYPE=$(PACKAGE_TYPE) \
+ BIN_DIR=$(BIN_PATH) \
+ BUILD_DIR=$(BUILD_PATH) \
+ QT_MAJOR=$(QT_MAJOR) \
+ FLAG_KEYCARD_ENABLED=$(FLAG_KEYCARD_ENABLED) \
+ $(APP_SCRIPT) $(HANDLE_OUTPUT)
else
- @STATUS_DESKTOP=$(STATUS_DESKTOP) BIN_DIR=$(BIN_PATH) BUILD_DIR=$(BUILD_PATH) QT_MAJOR=$(QT_MAJOR) $(APP_SCRIPT) $(HANDLE_OUTPUT)
+ @STATUS_DESKTOP=$(STATUS_DESKTOP) \
+ BIN_DIR=$(BIN_PATH) \
+ BUILD_DIR=$(BUILD_PATH) \
+ QT_MAJOR=$(QT_MAJOR) \
+ DEVELOPMENT_TEAM="$(DEVELOPMENT_TEAM)" \
+ FLAG_KEYCARD_ENABLED=$(FLAG_KEYCARD_ENABLED) \
+ $(APP_SCRIPT) $(HANDLE_OUTPUT)
endif
@echo "Built $(TARGET)"
@@ -128,7 +169,7 @@ endif
all: $(TARGET)
.PHONY: clean
-clean: clean-statusq clean-dotherside clean-openssl clean-qrcodegen clean-nim-status-client clean-status-desktop-rcc
+clean: clean-statusq clean-dotherside clean-openssl clean-qrcodegen clean-status-keycard-qt clean-nim-status-client clean-status-desktop-rcc
@echo "Cleaning"
@rm -rf $(ROOT_DIR)/bin $(ROOT_DIR)/build $(ROOT_DIR)/lib
@@ -158,6 +199,13 @@ clean-qrcodegen:
@rm -f $(QRCODEGEN_LIB)
@cd $(QRCODEGEN) && make clean
+# keycard-qt is now automatically built by status-keycard-qt via CMake FetchContent
+# Its build artifacts are cleaned as part of status-keycard-qt's build directory
+.PHONY: clean-status-keycard-qt
+clean-status-keycard-qt:
+ @rm -f $(STATUS_KEYCARD_QT_LIB)
+ @rm -rf $(STATUS_KEYCARD_QT)/build/$(OS)
+
.PHONY: clean-nim-status-client
clean-nim-status-client:
@rm -f $(NIM_STATUS_CLIENT_LIB)
diff --git a/mobile/android/qt6/AndroidManifest.xml b/mobile/android/qt6/AndroidManifest.xml
index 7305b3591a8..39f90e1954c 100644
--- a/mobile/android/qt6/AndroidManifest.xml
+++ b/mobile/android/qt6/AndroidManifest.xml
@@ -17,6 +17,7 @@
+
diff --git a/mobile/ios/Info.plist b/mobile/ios/Info.plist.template
similarity index 84%
rename from mobile/ios/Info.plist
rename to mobile/ios/Info.plist.template
index ca93a55d2a3..e39f6171873 100644
--- a/mobile/ios/Info.plist
+++ b/mobile/ios/Info.plist.template
@@ -66,5 +66,18 @@
Log in securely to your account.
ITSAppUsesNonExemptEncryption
+
+
+
diff --git a/mobile/ios/Status-NoKeycard.entitlements b/mobile/ios/Status-NoKeycard.entitlements
new file mode 100644
index 00000000000..35c3f6949d0
--- /dev/null
+++ b/mobile/ios/Status-NoKeycard.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Status.entitlements b/mobile/ios/Status.entitlements
new file mode 100644
index 00000000000..8e1b20cbd2b
--- /dev/null
+++ b/mobile/ios/Status.entitlements
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ com.apple.developer.nfc.readersession.formats
+
+ TAG
+
+
+
+
+
+
+
diff --git a/mobile/scripts/Common.mk b/mobile/scripts/Common.mk
index 896787f8dad..bd8364c62dd 100644
--- a/mobile/scripts/Common.mk
+++ b/mobile/scripts/Common.mk
@@ -33,6 +33,7 @@ STATUS_GO?=$(STATUS_DESKTOP)/vendor/status-go
DOTHERSIDE?=$(STATUS_DESKTOP)/vendor/DOtherSide
OPENSSL?=$(ROOT_DIR)/vendors/openssl
QRCODEGEN?=$(STATUS_DESKTOP)/vendor/QR-Code-generator/c
+STATUS_KEYCARD_QT?=$(STATUS_DESKTOP)/vendor/status-keycard-qt
# compile macros
TARGET_PREFIX := Status
@@ -61,6 +62,7 @@ STATUS_GO_FILES := $(shell find $(STATUS_GO) -type f \( -iname '*.go' \))
DOTHERSIDE_FILES := $(shell find $(DOTHERSIDE) -type f \( -iname '*.cpp' -o -iname '*.h' \))
OPENSSL_FILES := $(shell find $(OPENSSL) -type f \( -iname '*.c' -o -iname '*.h' \))
QRCODEGEN_FILES := $(shell find $(QRCODEGEN) -type f \( -iname '*.c' -o -iname '*.h' \))
+STATUS_KEYCARD_QT_FILES := $(shell find $(STATUS_KEYCARD_QT) -type f \( -iname '*.cpp' -o -iname '*.h' \) 2>/dev/null || echo "")
WRAPPER_APP_FILES := $(shell find $(WRAPPER_APP) -type f)
# script files
@@ -69,6 +71,7 @@ STATUS_GO_SCRIPT := $(SCRIPTS_PATH)/buildStatusGo.sh
DOTHERSIDE_SCRIPT := $(SCRIPTS_PATH)/buildDOtherSide.sh
OPENSSL_SCRIPT := $(SCRIPTS_PATH)/ios/buildOpenSSL.sh
QRCODEGEN_SCRIPT := $(SCRIPTS_PATH)/buildQRCodeGen.sh
+STATUS_KEYCARD_QT_SCRIPT := $(SCRIPTS_PATH)/buildStatusKeycardQt.sh
NIM_STATUS_CLIENT_SCRIPT := $(SCRIPTS_PATH)/buildNimStatusClient.sh
APP_SCRIPT := $(SCRIPTS_PATH)/buildApp.sh
RUN_SCRIPT := $(SCRIPTS_PATH)/$(OS)/run.sh
@@ -79,6 +82,7 @@ STATUS_Q_LIB := $(LIB_PATH)/libStatusQ$(LIB_SUFFIX)$(LIB_EXT)
OPENSSL_LIB := $(LIB_PATH)/libssl_3$(LIB_EXT)
QRCODEGEN_LIB := $(LIB_PATH)/libqrcodegen.a
QZXING_LIB := $(LIB_PATH)/libqzxing.a
+STATUS_KEYCARD_QT_LIB := $(LIB_PATH)/libstatus-keycard-qt$(LIB_EXT)
NIM_STATUS_CLIENT_LIB := $(LIB_PATH)/libnim_status_client$(LIB_EXT)
STATUS_DESKTOP_RCC := $(STATUS_DESKTOP)/ui/resources.qrc
ifeq ($(OS), ios)
diff --git a/mobile/scripts/buildApp.sh b/mobile/scripts/buildApp.sh
index 3ec8de95c81..313c6ea57c5 100755
--- a/mobile/scripts/buildApp.sh
+++ b/mobile/scripts/buildApp.sh
@@ -11,6 +11,7 @@ BUILD_DIR=${BUILD_DIR:-"$CWD/../build"}
ANDROID_ABI=${ANDROID_ABI:-"arm64-v8a"}
BUILD_TYPE=${BUILD_TYPE:-"apk"}
SIGN_IOS=${SIGN_IOS:-"false"}
+FLAG_KEYCARD_ENABLED=${FLAG_KEYCARD_ENABLED:-0}
QMAKE_BIN="${QMAKE:-qmake}"
QMAKE_CONFIG="CONFIG+=device CONFIG+=release"
@@ -33,6 +34,12 @@ fi
echo "Using version: $DESKTOP_VERSION; build version: $BUILD_VERSION"
+# Configure qmake with keycard flag
+QMAKE_DEFINES=""
+if [[ "${FLAG_KEYCARD_ENABLED}" == "1" ]]; then
+ QMAKE_DEFINES="DEFINES+=FLAG_KEYCARD_ENABLED"
+fi
+
if [[ "${OS}" == "android" ]]; then
if [[ -z "${JAVA_HOME}" ]]; then
echo "JAVA_HOME is not set. Please set JAVA_HOME to the path of your JDK 11 or later."
@@ -42,7 +49,7 @@ if [[ "${OS}" == "android" ]]; then
echo "Building for Android 35"
ANDROID_PLATFORM=android-35
- "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "$QMAKE_CONFIG" -spec android-clang ANDROID_ABIS="$ANDROID_ABI" APP_VARIANT="${APP_VARIANT}" VERSION="$DESKTOP_VERSION" -after
+ "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "$QMAKE_CONFIG" -spec android-clang ANDROID_ABIS="$ANDROID_ABI" APP_VARIANT="${APP_VARIANT}" VERSION="$DESKTOP_VERSION" ${QMAKE_DEFINES} -after
# Build the app
make -j"$(nproc)" apk_install_target
@@ -122,12 +129,36 @@ if [[ "${OS}" == "android" ]]; then
fi
fi
else
- "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "$QMAKE_CONFIG" -spec macx-ios-clang CONFIG+="$SDK" VERSION="$DESKTOP_VERSION" -after
+ # Generate Info.plist based on FLAG_KEYCARD_ENABLED
+ echo "Generating Info.plist (FLAG_KEYCARD_ENABLED=${FLAG_KEYCARD_ENABLED})..."
+ if [[ "${FLAG_KEYCARD_ENABLED}" == "1" ]]; then
+ # Enable NFC/Keycard support - uncomment NFC sections
+ sed -e '/$/d' \
+ "$CWD/../ios/Info.plist.template" > "$BUILD_DIR/Info.plist"
+ else
+ # Disable NFC/Keycard support - remove NFC sections entirely
+ sed '/$/d' \
+ "$CWD/../ios/Info.plist.template" > "$BUILD_DIR/Info.plist"
+ fi
+
+ "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "$QMAKE_CONFIG" -spec macx-ios-clang CONFIG+="$SDK" VERSION="$DESKTOP_VERSION" ${QMAKE_DEFINES} -after
+
+ # By default the app is not signed. Set the `DEVELOPMENT_TEAM` to the team ID to automatically sign the app.
+ SIGN_ARGS="CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO"
+ if [[ -n "${DEVELOPMENT_TEAM}" ]]; then
+ echo "Signing Configuration: ${SIGN_ARGS} DEVELOPMENT_TEAM=${DEVELOPMENT_TEAM}"
+ /usr/libexec/PlistBuddy -c "Set :objects:*:buildSettings:DEVELOPMENT_TEAM ${DEVELOPMENT_TEAM}" "$BUILD_DIR/Status.xcodeproj/project.pbxproj" 2>/dev/null || true
+ sed -i '' "s/DEVELOPMENT_TEAM = .*;/DEVELOPMENT_TEAM = ${DEVELOPMENT_TEAM};/g" "$BUILD_DIR/Status.xcodeproj/project.pbxproj"
+ SIGN_ARGS="CODE_SIGN_STYLE=Automatic DEVELOPMENT_TEAM=${DEVELOPMENT_TEAM} -allowProvisioningUpdates"
+ fi
# Compile resources
- xcodebuild -configuration Release -target "Qt Preprocess" -sdk "$SDK" -arch "$ARCH" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO CURRENT_PROJECT_VERSION=$BUILD_VERSION | xcbeautify
+ echo "Signing Configuration: ${SIGN_ARGS}"
+ xcodebuild -configuration Release -target "Qt Preprocess" -sdk "$SDK" -arch "$ARCH" ${SIGN_ARGS} | xcbeautify
# Compile the app
- xcodebuild -configuration Release -target Status install -sdk "$SDK" -arch "$ARCH" DSTROOT="$BIN_DIR" INSTALL_PATH="/" TARGET_BUILD_DIR="$BIN_DIR" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO CURRENT_PROJECT_VERSION=$BUILD_VERSION | xcbeautify
+ echo "Signing Configuration: ${SIGN_ARGS}"
+ xcodebuild -configuration Release -target Status install -sdk "$SDK" -arch "$ARCH" DSTROOT="$BIN_DIR" INSTALL_PATH="/" TARGET_BUILD_DIR="$BIN_DIR" ${SIGN_ARGS} | xcbeautify
if [[ ! -e "$BIN_DIR/Status.app/Info.plist" ]]; then
echo "Build failed"
diff --git a/mobile/scripts/buildNimStatusClient.sh b/mobile/scripts/buildNimStatusClient.sh
index ca6aa116f9f..0f82e9e4253 100755
--- a/mobile/scripts/buildNimStatusClient.sh
+++ b/mobile/scripts/buildNimStatusClient.sh
@@ -8,6 +8,12 @@ ANDROID_ABI=${ANDROID_ABI:-"arm64-v8a"}
LIB_DIR=${LIB_DIR}
LIB_SUFFIX=${LIB_SUFFIX:-""}
OS=${OS:-"android"}
+DEBUG=${DEBUG:-0}
+FLAG_DAPPS_ENABLED=${FLAG_DAPPS_ENABLED:-0}
+FLAG_CONNECTOR_ENABLED=${FLAG_CONNECTOR_ENABLED:-0}
+FLAG_KEYCARD_ENABLED=${FLAG_KEYCARD_ENABLED:-0}
+FLAG_SINGLE_STATUS_INSTANCE_ENABLED=${FLAG_SINGLE_STATUS_INSTANCE_ENABLED:-0}
+FLAG_BROWSER_ENABLED=${FLAG_BROWSER_ENABLED:-0}
DESKTOP_VERSION=$(eval cd "$STATUS_DESKTOP" && git describe --tags --dirty="-dirty" --always)
STATUSGO_VERSION=$(eval cd "$STATUS_DESKTOP/vendor/status-go" && git describe --tags --dirty="-dirty" --always)
@@ -22,7 +28,7 @@ if [[ "$OS" == "ios" ]]; then
PLATFORM_SPECIFIC=(--app:staticlib -d:ios --os:ios)
else
PLATFORM_SPECIFIC=(--app:lib --os:android -d:android -d:androidNDK -d:chronicles_sinks=textlines[logcat],textlines[nocolors,dynamic],textlines[file,nocolors] \
- --passL="-L$LIB_DIR" --passL="-lstatus" --passL="-lStatusQ$LIB_SUFFIX" --passL="-lDOtherSide$LIB_SUFFIX" --passL="-lqrcodegen" --passL="-lqzxing" --passL="-lssl_3" --passL="-lcrypto_3" -d:taskpool)
+ --passL="-L$LIB_DIR" --passL="-lstatus" --passL="-lStatusQ$LIB_SUFFIX" --passL="-lDOtherSide$LIB_SUFFIX" --passL="-lqrcodegen" --passL="-lqzxing" --passL="-lssl_3" --passL="-lcrypto_3" --passL="-lstatus-keycard-qt" -d:taskpool)
fi
if [ -n "$USE_QML_SERVER" ]; then
@@ -37,7 +43,13 @@ cd "$STATUS_DESKTOP"
# build nim compiler with host env
# setting compile time feature flags
-FEATURE_FLAGS="FLAG_DAPPS_ENABLED=0 FLAG_CONNECTOR_ENABLED=0 FLAG_KEYCARD_ENABLED=0 FLAG_SINGLE_STATUS_INSTANCE_ENABLED=0 FLAG_BROWSER_ENABLED=0"
+FEATURE_FLAGS=(
+ FLAG_DAPPS_ENABLED=$FLAG_DAPPS_ENABLED
+ FLAG_CONNECTOR_ENABLED=$FLAG_CONNECTOR_ENABLED
+ FLAG_KEYCARD_ENABLED=$FLAG_KEYCARD_ENABLED
+ FLAG_SINGLE_STATUS_INSTANCE_ENABLED=$FLAG_SINGLE_STATUS_INSTANCE_ENABLED
+ FLAG_BROWSER_ENABLED=$FLAG_BROWSER_ENABLED
+)
# app configuration defines
APP_CONFIG_DEFINES=(
@@ -48,21 +60,28 @@ APP_CONFIG_DEFINES=(
-d:GIT_COMMIT="$(git log --pretty=format:'%h' -n 1)"
)
+NIM_FLAGS=(
+ --mm:orc
+ -d:useMalloc
+ --opt:size
+ --cc:clang
+ --cpu:"$CARCH"
+ --noMain:on
+ --clang.exe="$CC"
+ --clang.linkerexe="$CC"
+ --dynlibOverrideAll
+ --nimcache:"$STATUS_DESKTOP"/nimcache
+)
+
+if [ "$DEBUG" -eq 1 ]; then
+ NIM_FLAGS+=(-d:debug -d:nimTypeNames)
+else
+ NIM_FLAGS+=(-d:release -d:lto -d:production)
+fi
+
# build status-client with feature flags
-env $FEATURE_FLAGS ./vendor/nimbus-build-system/scripts/env.sh nim c "${PLATFORM_SPECIFIC[@]}" "${APP_CONFIG_DEFINES[@]}" ${QML_SERVER_DEFINES} \
- --mm:orc \
- -d:useMalloc \
- --opt:size \
- -d:lto \
- --cc:clang \
- --cpu:"$CARCH" \
- --noMain:on \
- -d:release \
- -d:production \
- --clang.exe="$CC" \
- --clang.linkerexe="$CC" \
- --dynlibOverrideAll \
- --nimcache:"$STATUS_DESKTOP"/nimcache \
+env "${FEATURE_FLAGS[@]}" ./vendor/nimbus-build-system/scripts/env.sh nim c "${PLATFORM_SPECIFIC[@]}" "${APP_CONFIG_DEFINES[@]}" ${QML_SERVER_DEFINES} \
+ "${NIM_FLAGS[@]}" \
"$STATUS_DESKTOP"/src/nim_status_client.nim
mkdir -p "$LIB_DIR"
diff --git a/mobile/scripts/buildStatusKeycardQt.sh b/mobile/scripts/buildStatusKeycardQt.sh
new file mode 100755
index 00000000000..f3d03b60b8e
--- /dev/null
+++ b/mobile/scripts/buildStatusKeycardQt.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+set -ef pipefail
+
+BASEDIR=$(dirname "$0")
+
+# Load common config variables
+source "${BASEDIR}/commonCmakeConfig.sh"
+
+STATUS_KEYCARD_QT=${STATUS_KEYCARD_QT:="../vendors/status-desktop"}
+KEYCARD_QT=${KEYCARD_QT:=""}
+LIB_DIR=${LIB_DIR}
+LIB_EXT=${LIB_EXT:=".a"}
+
+BUILD_DIR=${BUILD_DIR:="${STATUS_KEYCARD_QT}/build/${OS}/${ARCH}"}
+BUILD_SHARED_LIBS=ON
+
+if [[ "${LIB_EXT}" == ".a" ]]; then
+ BUILD_SHARED_LIBS=OFF
+fi
+
+echo "Building status-keycard-qt for ${ARCH} using compiler: ${CC}"
+echo "BUILD_SHARED_LIBS=${BUILD_SHARED_LIBS}"
+
+printf 'COMMON_CMAKE_CONFIG: %s\n' "${COMMON_CMAKE_CONFIG[@]}"
+
+# Set OpenSSL paths (REQUIRED for key derivation in session_manager.cpp)
+MOBILE_ROOT="$(cd "${BASEDIR}/.." && pwd)"
+if [[ "$OS" == "android" ]]; then
+ OPENSSL_BUILD_DIR="${MOBILE_ROOT}/build/${OS}/qt6/openssl-${OS}-${ARCH}"
+elif [[ "$OS" == "ios" ]]; then
+ # iOS OpenSSL is built per-architecture
+ OPENSSL_BUILD_DIR="${MOBILE_ROOT}/build/${OS}/qt6/openssl-${OS}-${ARCH}"
+else
+ OPENSSL_BUILD_DIR="${MOBILE_ROOT}/build/${OS}/openssl-${OS}-${ARCH}"
+fi
+OPENSSL_BUILD_INCLUDE_DIR="${OPENSSL_BUILD_DIR}/include"
+OPENSSL_SOURCE_INCLUDE_DIR="${MOBILE_ROOT}/vendors/openssl/include"
+OPENSSL_CRYPTO_LIBRARY="${LIB_DIR}/libcrypto_3${LIB_EXT}"
+
+echo "OpenSSL paths:"
+echo " Build include dir: ${OPENSSL_BUILD_INCLUDE_DIR}"
+echo " Source include dir: ${OPENSSL_SOURCE_INCLUDE_DIR}"
+echo " Crypto library: ${OPENSSL_CRYPTO_LIBRARY}"
+
+# Configure with CMake
+# Use local keycard-qt for faster development builds (FetchContent will use this)
+# If KEYCARD_QT path doesn't exist, FetchContent will clone from GitHub
+if [[ -d "${KEYCARD_QT}" ]]; then
+ echo "Using local keycard-qt from: ${KEYCARD_QT}"
+ KEYCARD_QT_SOURCE_DIR_ARG="-DKEYCARD_QT_SOURCE_DIR=${KEYCARD_QT}"
+else
+ echo "Local keycard-qt not found, will fetch from GitHub"
+ KEYCARD_QT_SOURCE_DIR_ARG=""
+fi
+
+cmake -S "${STATUS_KEYCARD_QT}" -B "${BUILD_DIR}" \
+ "${COMMON_CMAKE_CONFIG[@]}" \
+ -DBUILD_TESTING=OFF \
+ -DBUILD_EXAMPLES=OFF \
+ -DBUILD_SHARED_LIBS=${BUILD_SHARED_LIBS} \
+ ${KEYCARD_QT_SOURCE_DIR_ARG} \
+ -DOPENSSL_BUILD_INCLUDE_DIR="${OPENSSL_BUILD_INCLUDE_DIR}" \
+ -DOPENSSL_SOURCE_INCLUDE_DIR="${OPENSSL_SOURCE_INCLUDE_DIR}" \
+ -DOPENSSL_CRYPTO_LIBRARY="${OPENSSL_CRYPTO_LIBRARY}"
+
+# Build the library
+make -C "${BUILD_DIR}" status-keycard-qt -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
+
+# Create lib directory
+mkdir -p "${LIB_DIR}"
+
+# Find and copy the built library
+# Note: keycard-qt is built as a static library and linked into libstatus-keycard-qt,
+# so we only need to copy one library file
+STATUS_KEYCARD_QT_LIB=$(find "${BUILD_DIR}" -name "libstatus-keycard-qt${LIB_EXT}" -o -name "libstatus-keycard-qt.dylib" | grep -v "\.so\." | head -n 1)
+
+if [[ -z "${STATUS_KEYCARD_QT_LIB}" ]]; then
+ # Try alternative patterns for static library
+ STATUS_KEYCARD_QT_LIB=$(find "${BUILD_DIR}" -name "libstatus-keycard-qt.a" | head -n 1)
+fi
+
+if [[ -z "${STATUS_KEYCARD_QT_LIB}" ]]; then
+ echo "Error: Could not find status-keycard-qt library in ${BUILD_DIR}"
+ echo "Build directory contents:"
+ find "${BUILD_DIR}" -name "*.so" -o -name "*.a" -o -name "*.dylib" | head -20
+ exit 1
+fi
+
+cp "${STATUS_KEYCARD_QT_LIB}" "${LIB_DIR}/libstatus-keycard-qt${LIB_EXT}"
+echo "Copied ${STATUS_KEYCARD_QT_LIB} to ${LIB_DIR}/libstatus-keycard-qt${LIB_EXT}"
+
diff --git a/mobile/scripts/commonCmakeConfig.sh b/mobile/scripts/commonCmakeConfig.sh
index d92d627be2d..31a927a3f26 100755
--- a/mobile/scripts/commonCmakeConfig.sh
+++ b/mobile/scripts/commonCmakeConfig.sh
@@ -2,7 +2,9 @@
set -ef pipefail
ARCH=${ARCH:-"x86_64"}
-QTDIR=${QTDIR:-$(qmake -query QT_INSTALL_PREFIX)}
+# Use $QMAKE if set, otherwise fall back to system qmake
+QMAKE_BIN=${QMAKE:-qmake}
+QTDIR=${QTDIR:-$($QMAKE_BIN -query QT_INSTALL_PREFIX)}
OS=${OS:-ios}
ANDROID_NDK_ROOT=${ANDROID_NDK_ROOT:-""}
@@ -46,4 +48,9 @@ COMMON_CMAKE_CONFIG+=(
-DCMAKE_BUILD_TYPE=Release
)
+# Add Android-specific flags only for Android
+if [[ "$OS" == "android" ]]; then
+ COMMON_CMAKE_CONFIG+=(-DANDROID_PLATFORM=android-35)
+fi
+
printf 'COMMON_CMAKE_CONFIG: %s\n' "${COMMON_CMAKE_CONFIG[@]}"
diff --git a/mobile/wrapperApp/Status.pro b/mobile/wrapperApp/Status.pro
index 4c974aa5397..526338ba62f 100644
--- a/mobile/wrapperApp/Status.pro
+++ b/mobile/wrapperApp/Status.pro
@@ -2,6 +2,14 @@ TEMPLATE = app
QT += quick gui qml webview svg widgets multimedia
+# Conditionally add NFC module only if keycard is enabled
+contains(DEFINES, FLAG_KEYCARD_ENABLED) {
+ message("Building with Keycard/NFC support enabled")
+ QT += nfc
+} else {
+ message("Building WITHOUT Keycard/NFC support (default for development)")
+}
+
equals(QT_MAJOR_VERSION, 6) {
message("qt 6 config!!")
QT += core5compat core
@@ -36,7 +44,10 @@ android {
$$PWD/../lib/$$LIB_PREFIX/libDOtherSide$$(LIB_SUFFIX)$$(LIB_EXT) \
$$PWD/../lib/$$LIB_PREFIX/libstatus.so \
$$PWD/../lib/$$LIB_PREFIX/libsds.so \
- $$PWD/../lib/$$LIB_PREFIX/libStatusQ$$(LIB_SUFFIX)$$(LIB_EXT) \
+ $$PWD/../lib/$$LIB_PREFIX/libStatusQ$$(LIB_SUFFIX)$$(LIB_EXT)
+ contains(DEFINES, FLAG_KEYCARD_ENABLED) {
+ ANDROID_EXTRA_LIBS += $$PWD/../lib/$$LIB_PREFIX/libstatus-keycard-qt.so
+ }
OTHER_FILES += \
$$ANDROID_PACKAGE_SOURCE_DIR/src/app/status/mobile/SecureAndroidAuthentication.java
@@ -45,18 +56,34 @@ android {
ios {
CONFIG += add_ios_ffmpeg_libraries
- QMAKE_INFO_PLIST = $$PWD/../ios/Info.plist
+ QMAKE_INFO_PLIST = $$OUT_PWD/Info.plist
QMAKE_IOS_DEPLOYMENT_TARGET=16.0
QMAKE_TARGET_BUNDLE_PREFIX = app.status
QMAKE_BUNDLE = mobile
QMAKE_ASSET_CATALOGS += $$PWD/../ios/Images.xcassets
QMAKE_IOS_LAUNCH_SCREEN = $$PWD/../ios/launch-image-universal.storyboard
- LIBS += -L$$PWD/../lib/$$LIB_PREFIX -lnim_status_client -lDOtherSideStatic -lstatusq -lstatus -lsds -lssl_3 -lcrypto_3 -lqzxing -lresolv -lqrcodegen
-
# --- iOS frameworks required by keychain_apple.mm ---
LIBS += -framework LocalAuthentication \
-framework Security \
-framework UIKit \
-framework Foundation
+
+ # Base libraries (always included)
+ LIBS += -L$$PWD/../lib/$$LIB_PREFIX -lnim_status_client -lDOtherSideStatic -lstatusq -lstatus -lsds -lssl_3 -lcrypto_3 -lqzxing -lresolv -lqrcodegen
+
+ contains(DEFINES, FLAG_KEYCARD_ENABLED) {
+ # Use entitlements with NFC support (requires paid Apple Developer account)
+ MY_ENTITLEMENTS.name = CODE_SIGN_ENTITLEMENTS
+ MY_ENTITLEMENTS.value = $$PWD/../ios/Status.entitlements
+ QMAKE_MAC_XCODE_SETTINGS += MY_ENTITLEMENTS
+
+ LIBS += -lstatus-keycard-qt -framework CoreNFC
+
+ } else {
+ # Use entitlements without NFC (allows building with free Apple account)
+ MY_ENTITLEMENTS.name = CODE_SIGN_ENTITLEMENTS
+ MY_ENTITLEMENTS.value = $$PWD/../ios/Status-NoKeycard.entitlements
+ QMAKE_MAC_XCODE_SETTINGS += MY_ENTITLEMENTS
+ }
}
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/constants.nim b/src/app/modules/keycard_channel/constants.nim
new file mode 100644
index 00000000000..523aa7b4f28
--- /dev/null
+++ b/src/app/modules/keycard_channel/constants.nim
@@ -0,0 +1,9 @@
+## Constants for keycard channel operational states
+## These values must match the strings emitted by status-keycard-qt
+
+const KEYCARD_CHANNEL_STATE_IDLE* = "idle"
+const KEYCARD_CHANNEL_STATE_WAITING_FOR_KEYCARD* = "waiting-for-keycard"
+const KEYCARD_CHANNEL_STATE_READING* = "reading"
+const KEYCARD_CHANNEL_STATE_ERROR* = "error"
+
+
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..79b77c47857
--- /dev/null
+++ b/src/app/modules/keycard_channel/module.nim
@@ -0,0 +1,49 @@
+import nimqml
+
+import io_interface, view, controller
+import app/global/global_singleton
+import app/core/eventemitter
+import ./constants
+
+export io_interface
+export constants
+
+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..51128d5e7ee
--- /dev/null
+++ b/src/app/modules/keycard_channel/view.nim
@@ -0,0 +1,62 @@
+import nimqml
+
+import ./io_interface
+import ./constants
+
+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.keycardChannelState = KEYCARD_CHANNEL_STATE_IDLE
+ 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
+
+ # Constants for channel states (readonly properties for QML)
+ proc getStateIdle*(self: View): string {.slot.} =
+ return KEYCARD_CHANNEL_STATE_IDLE
+ QtProperty[string] stateIdle:
+ read = getStateIdle
+
+ proc getStateWaitingForKeycard*(self: View): string {.slot.} =
+ return KEYCARD_CHANNEL_STATE_WAITING_FOR_KEYCARD
+ QtProperty[string] stateWaitingForKeycard:
+ read = getStateWaitingForKeycard
+
+ proc getStateReading*(self: View): string {.slot.} =
+ return KEYCARD_CHANNEL_STATE_READING
+ QtProperty[string] stateReading:
+ read = getStateReading
+
+ proc getStateError*(self: View): string {.slot.} =
+ return KEYCARD_CHANNEL_STATE_ERROR
+ QtProperty[string] stateError:
+ read = getStateError
+
+ proc setup(self: View) =
+ self.QObject.setup
+
+ proc delete*(self: View) =
+ self.QObject.delete
+
diff --git a/src/app/modules/main/wallet_section/send_new/module.nim b/src/app/modules/main/wallet_section/send_new/module.nim
index 537b9e8e4cf..b3d0c151761 100644
--- a/src/app/modules/main/wallet_section/send_new/module.nim
+++ b/src/app/modules/main/wallet_section/send_new/module.nim
@@ -190,7 +190,7 @@ method authenticateAndTransfer*(self: Module, uuid: string, fromAddr: string) =
self.controller.authenticate()
method onUserAuthenticated*(self: Module, password: string, pin: string) =
- if password.len == 0:
+ if password.len == 0 and pin.len == 0:
self.transactionWasSent(uuid = self.tmpSendTransactionDetails.uuid, chainId = 0, approvalTx = false, txHash = "", error = authenticationCanceled)
self.clearTmpData()
else:
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/modules/shared_modules/keycard_popup/internal/insert_keycard_state.nim b/src/app/modules/shared_modules/keycard_popup/internal/insert_keycard_state.nim
index 12b4795cdca..328663fa560 100644
--- a/src/app/modules/shared_modules/keycard_popup/internal/insert_keycard_state.nim
+++ b/src/app/modules/shared_modules/keycard_popup/internal/insert_keycard_state.nim
@@ -43,6 +43,20 @@ method resolveKeycardNextState*(self: InsertKeycardState, keycardFlowType: strin
return nil
if keycardFlowType == ResponseTypeValueCardInserted:
controller.setKeycardData(updatePredefinedKeycardData(controller.getKeycardData(), PredefinedKeycardData.WronglyInsertedCard, add = false))
+
+ # Special handling for LoadAccount flow - return to the state we came from
+ # (RepeatPin or PinSet) to continue waiting for ENTER_MNEMONIC event
+ if (self.flowType == FlowType.SetupNewKeycard or
+ self.flowType == FlowType.SetupNewKeycardNewSeedPhrase or
+ self.flowType == FlowType.SetupNewKeycardOldSeedPhrase) and
+ controller.getCurrentKeycardServiceFlow() == KCSFlowType.LoadAccount and
+ not self.getBackState.isNil:
+ let backStateType = self.getBackState.stateType
+ if backStateType == StateType.RepeatPin or backStateType == StateType.PinSet:
+ # Return to the previous state to continue waiting for mnemonic entry
+ return self.getBackState
+
+ # Default behavior for other flows
if self.flowType == FlowType.SetupNewKeycard:
return createState(StateType.KeycardInserted, self.flowType, self.getBackState)
return createState(StateType.KeycardInserted, self.flowType, nil)
diff --git a/src/app/modules/shared_modules/keycard_popup/internal/pin_set_state.nim b/src/app/modules/shared_modules/keycard_popup/internal/pin_set_state.nim
index 8644563e7af..359229d6a57 100644
--- a/src/app/modules/shared_modules/keycard_popup/internal/pin_set_state.nim
+++ b/src/app/modules/shared_modules/keycard_popup/internal/pin_set_state.nim
@@ -31,4 +31,28 @@ method executeCancelCommand*(self: PinSetState, controller: Controller) =
self.flowType == FlowType.SetupNewKeycardNewSeedPhrase or
self.flowType == FlowType.SetupNewKeycardOldSeedPhrase or
self.flowType == FlowType.UnlockKeycard:
- controller.terminateCurrentFlow(lastStepInTheCurrentFlow = false)
\ No newline at end of file
+ controller.terminateCurrentFlow(lastStepInTheCurrentFlow = false)
+
+method resolveKeycardNextState*(self: PinSetState, keycardFlowType: string, keycardEvent: KeycardEvent,
+ controller: Controller): State =
+ # Handle temporary card disconnection during LoadAccount flow (after card initialization)
+ # This can happen if the user hasn't tapped "Continue" yet and the card disconnects
+ if self.flowType == FlowType.SetupNewKeycard or
+ self.flowType == FlowType.SetupNewKeycardNewSeedPhrase or
+ self.flowType == FlowType.SetupNewKeycardOldSeedPhrase:
+ # INSERT_CARD during LoadAccount flow means card is reconnecting after initialization
+ if keycardFlowType == ResponseTypeValueInsertCard and
+ keycardEvent.error.len > 0 and
+ keycardEvent.error == ErrorConnection and
+ controller.getCurrentKeycardServiceFlow() == KCSFlowType.LoadAccount:
+ # Don't cancel the flow - transition to InsertKeycard state and wait for reconnection
+ controller.reRunCurrentFlowLater()
+ return createState(StateType.InsertKeycard, self.flowType, self)
+ # CARD_INSERTED after temporary disconnection - stay in PinSet and continue
+ if keycardFlowType == ResponseTypeValueCardInserted and
+ controller.getCurrentKeycardServiceFlow() == KCSFlowType.LoadAccount:
+ # Card reconnected successfully, stay in PinSet
+ return nil
+
+ # No specific handling needed - this state transitions via primary button
+ return nil
\ No newline at end of file
diff --git a/src/app/modules/shared_modules/keycard_popup/internal/repeat_pin_state.nim b/src/app/modules/shared_modules/keycard_popup/internal/repeat_pin_state.nim
index 67a4ab5833d..a9c6592d1c1 100644
--- a/src/app/modules/shared_modules/keycard_popup/internal/repeat_pin_state.nim
+++ b/src/app/modules/shared_modules/keycard_popup/internal/repeat_pin_state.nim
@@ -42,6 +42,25 @@ method executeCancelCommand*(self: RepeatPinState, controller: Controller) =
method resolveKeycardNextState*(self: RepeatPinState, keycardFlowType: string, keycardEvent: KeycardEvent,
controller: Controller): State =
+ # Handle temporary card disconnection during LoadAccount flow (after card initialization)
+ # This happens on Android/iOS when card is disconnected and needs to be re-detected
+ if self.flowType == FlowType.SetupNewKeycard or
+ self.flowType == FlowType.SetupNewKeycardNewSeedPhrase or
+ self.flowType == FlowType.SetupNewKeycardOldSeedPhrase:
+ # INSERT_CARD during LoadAccount flow means card is reconnecting after initialization
+ if keycardFlowType == ResponseTypeValueInsertCard and
+ keycardEvent.error.len > 0 and
+ keycardEvent.error == ErrorConnection and
+ controller.getCurrentKeycardServiceFlow() == KCSFlowType.LoadAccount:
+ # Don't cancel the flow - transition to InsertKeycard state and wait for reconnection
+ controller.reRunCurrentFlowLater()
+ return createState(StateType.InsertKeycard, self.flowType, self)
+ # CARD_INSERTED after temporary disconnection - stay in RepeatPin and continue waiting
+ if keycardFlowType == ResponseTypeValueCardInserted and
+ controller.getCurrentKeycardServiceFlow() == KCSFlowType.LoadAccount:
+ # Card reconnected successfully, continue waiting for ENTER_MNEMONIC event
+ return nil
+
let state = ensureReaderAndCardPresence(self, keycardFlowType, keycardEvent, controller)
if not state.isNil:
return state
diff --git a/src/app_service/service/keycard/service.nim b/src/app_service/service/keycard/service.nim
index 2767ea6e1c5..55968848015 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 == "channel-state-changed":
+ 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))
@@ -151,8 +154,15 @@ QtObject:
return seedPhrase
proc updateLocalPayloadForCurrentFlow(self: Service, obj: JsonNode, cleanBefore = false) {.featureGuard(KEYCARD_ENABLED).} =
+ # CRITICAL FIX: Check if obj is the same reference as setPayloadForCurrentFlow
+ # This happens when onTimeout calls startFlow(self.setPayloadForCurrentFlow)
+ # If we iterate and modify the same object, the iterator gets corrupted!
+ if cast[pointer](obj) == cast[pointer](self.setPayloadForCurrentFlow):
+ return
+
if cleanBefore:
self.setPayloadForCurrentFlow = %* {}
+
for k, v in obj:
self.setPayloadForCurrentFlow[k] = v
diff --git a/src/app_service/service/keycardV2/service.nim b/src/app_service/service/keycardV2/service.nim
index ad764b6f475..e24125f0713 100644
--- a/src/app_service/service/keycardV2/service.nim
+++ b/src/app_service/service/keycardV2/service.nim
@@ -21,6 +21,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 +61,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 +104,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 +113,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 == "status-changed":
let keycardEvent = jsonSignal["event"].toKeycardEventDto()
-
self.events.emit(SIGNAL_KEYCARD_STATE_UPDATED, KeycardEventArg(keycardEvent: keycardEvent))
+ elif signalType == "channel-state-changed":
+ 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 +270,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/src/nim_status_client.nim b/src/nim_status_client.nim
index 98edb11d40b..180f89c1740 100644
--- a/src/nim_status_client.nim
+++ b/src/nim_status_client.nim
@@ -85,7 +85,7 @@ proc prepareLogging() =
let logFile = fmt"app_{formattedDate}.log"
discard output.open(LOGDIR & logFile, fmAppend)
- let defaultLogLvl = if defined(production): chronicles.LogLevel.INFO else: chronicles.LogLevel.DEBUG
+ let defaultLogLvl = chronicles.LogLevel.DEBUG
# default log level can be overriden by LOG_LEVEL env parameter
let logLvl = try: parseEnum[chronicles.LogLevel](main_constants.LOG_LEVEL)
except: defaultLogLvl
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/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml b/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml
index fa355bd3fa9..9b4bf6c2cf3 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.ImhNone which allows any input based on the validator.
+ */
+ property int inputMethodHints: Qt.ImhNone
+
signal pinEditedManually()
QtObject {
@@ -158,9 +165,10 @@ Item {
Convenient method to force active focus in case it gets stolen by any other component.
*/
function forceFocus() {
- if (Utils.isMobile)
- return
inputText.forceActiveFocus()
+ if (Qt.inputMethod.visible == false) {
+ Qt.inputMethod.show()
+ }
d.activateBlink()
}
@@ -208,10 +216,14 @@ 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
- validator: d.statusValidator.validatorObj
+ inputMethodHints: root.inputMethodHints
+ // validator: d.statusValidator.validatorObj
onTextChanged: {
// Modify state of current introduced character position:
if(text.length >= (d.currentPinIndex + 1)) {
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/components/LoginKeycardBox.qml b/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml
index 39ac90c954f..b7410eb5a85 100644
--- a/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml
+++ b/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml
@@ -113,6 +113,7 @@ Control {
objectName: "pinInput"
validator: StatusIntValidator { bottom: 0; top: 999999 }
visible: false
+ inputMethodHints: Qt.ImhDigitsOnly
onPinInputChanged: {
if (pinInput.length === 6) {
@@ -235,6 +236,7 @@ Control {
PropertyChanges {
target: pinInputField
visible: true
+ focus: true
}
PropertyChanges {
target: background
@@ -251,4 +253,9 @@ Control {
}
}
]
+
+ 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..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))
}
diff --git a/ui/i18n/qml_base_en.ts b/ui/i18n/qml_base_en.ts
index fa371bd8aee..6be4f42c943 100644
--- a/ui/i18n/qml_base_en.ts
+++ b/ui/i18n/qml_base_en.ts
@@ -2693,6 +2693,26 @@ Do you wish to override the security check and continue?
Zoom
+
+ Clear site data
+
+
+
+ Use it to reset the current site if it doesn't load or work properly.
+
+
+
+ Clearing cache...
+
+
+
+ Clear cache
+
+
+
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+
+
BrowserTabView
@@ -7622,13 +7642,6 @@ Please add it and try again.
-
- FeeRow
-
- Max.
-
-
-
FeesBox
@@ -8891,6 +8904,45 @@ L2 fee: %2
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
@@ -8986,10 +9038,6 @@ Are you sure you want to do this?
PIN correct
-
- Keycard blocked
-
-
%n attempt(s) remaining
diff --git a/ui/i18n/qml_base_lokalise_en.ts b/ui/i18n/qml_base_lokalise_en.ts
index e883eb29140..fb2de900555 100644
--- a/ui/i18n/qml_base_lokalise_en.ts
+++ b/ui/i18n/qml_base_lokalise_en.ts
@@ -3298,6 +3298,31 @@
BrowserSettingsMenu
Zoom
+
+ Clear site data
+ BrowserSettingsMenu
+ Clear site data
+
+
+ Use it to reset the current site if it doesn't load or work properly.
+ BrowserSettingsMenu
+ Use it to reset the current site if it doesn't load or work properly.
+
+
+ Clearing cache...
+ BrowserSettingsMenu
+ Clearing cache...
+
+
+ Clear cache
+ BrowserSettingsMenu
+ Clear cache
+
+
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+ BrowserSettingsMenu
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+
BrowserTabView
@@ -9310,14 +9335,6 @@
Remove
-
- FeeRow
-
- Max.
- FeeRow
- Max.
-
-
FeesBox
@@ -10847,6 +10864,54 @@
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
+
+
+ Keycard Error
+ KeycardChannelDrawer
+ Keycard Error
+
+
+ An error occurred. Please try again.
+ KeycardChannelDrawer
+ An error occurred. Please try again.
+
+
+ Success
+ KeycardChannelDrawer
+ Success
+
+
+ Keycard operation completed successfully
+ KeycardChannelDrawer
+ Keycard operation completed successfully
+
+
+ Dismiss
+ KeycardChannelDrawer
+ Dismiss
+
+
+ Ready to scan
+ KeycardChannelDrawer
+ Ready to scan
+
+
KeycardConfirmation
@@ -10959,11 +11024,6 @@
KeycardEnterPinPage
PIN correct
-
- Keycard blocked
- KeycardEnterPinPage
- Keycard blocked
-
%n attempt(s) remaining
KeycardEnterPinPage
diff --git a/ui/i18n/qml_cs.ts b/ui/i18n/qml_cs.ts
index bd7d952cf4e..771d537a425 100644
--- a/ui/i18n/qml_cs.ts
+++ b/ui/i18n/qml_cs.ts
@@ -2700,6 +2700,26 @@ Do you wish to override the security check and continue?
Zoom
+
+ Clear site data
+
+
+
+ Use it to reset the current site if it doesn't load or work properly.
+
+
+
+ Clearing cache...
+
+
+
+ Clear cache
+
+
+
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+
+
BrowserTabView
@@ -7649,13 +7669,6 @@ Please add it and try again.
Odstranit
-
- FeeRow
-
- Max.
-
-
-
FeesBox
@@ -8927,6 +8940,45 @@ L2 poplatek: %2
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
@@ -9022,10 +9074,6 @@ Are you sure you want to do this?
PIN correct
-
- Keycard blocked
-
-
%n attempt(s) remaining
diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts
index cb8528ff640..2753ae01ee2 100644
--- a/ui/i18n/qml_es.ts
+++ b/ui/i18n/qml_es.ts
@@ -2697,6 +2697,26 @@ Do you wish to override the security check and continue?
Settings
Ajustes
+
+ Clear site data
+
+
+
+ Use it to reset the current site if it doesn't load or work properly.
+
+
+
+ Clearing cache...
+
+
+
+ Clear cache
+
+
+
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+
+
BrowserTabView
@@ -7636,13 +7656,6 @@ Por favor, agrégala e intenta de nuevo.
Eliminar
-
- FeeRow
-
- Max.
-
-
-
FeesBox
@@ -8906,6 +8919,45 @@ 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
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
@@ -9001,10 +9053,6 @@ Are you sure you want to do this?
PIN correct
PIN correcto
-
- Keycard blocked
-
-
%n attempt(s) remaining
diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts
index 256d4bbe2a0..b6521fb9b8b 100644
--- a/ui/i18n/qml_ko.ts
+++ b/ui/i18n/qml_ko.ts
@@ -2470,6 +2470,10 @@ To backup you recovery phrase, write it down and store it securely in a safe pla
Backups are automatic (every 30 mins), secure (encrypted with your profile private key), and private (your data is stored <b>only</b> on your device).
백업은 자동(30분마다), 안전(프로필 개인 키로 암호화), 그리고 비공개(데이터는 <b>오직</b> 귀하의 기기에만 저장됨)입니다.
+
+ Choose a folder to store your backup files in.
+
+
BalanceExceeded
@@ -2685,6 +2689,26 @@ Do you wish to override the security check and continue?
Zoom
확대/축소
+
+ Clear site data
+
+
+
+ Use it to reset the current site if it doesn't load or work properly.
+
+
+
+ Clearing cache...
+
+
+
+ Clear cache
+
+
+
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+
+
BrowserTabView
@@ -6740,14 +6764,26 @@ Remember your password and don't share it with anyone.
Enable on-device message backup?
기기 내 메시지 백업을 활성화하시겠어요?
-
- On-device backups are:<br>Automatic – every 30 minutes<br>Secure – encrypted with your profile private key<br>Private – stored only on your device
- 기기 내 백업은:<br>자동 – 30분마다<br>안전 – 프로필 개인 키로 암호화<br>비공개 – 귀하의 기기에만 저장
-
Backups let you restore your 1-on-1, group, and community messages if you need to reinstall the app or switch devices. You can skip this step now and enable it anytime under: <i>Settings > On-device backup > Backup data</i>
백업을 사용하면 앱을 다시 설치하거나 기기를 전환해야 할 때 1:1, 그룹 및 커뮤니티 메시지를 복원할 수 있어요. 지금 이 단계를 건너뛰고 나중에 언제든지 <i>설정 > 기기 내 백업 > 데이터 백업</i>에서 활성화할 수 있어요.
+
+ Enable on-device backup?
+
+
+
+ On-device backups are:<br><b>Automatic</b> – created every 30 minutes<br><b>Secure</b> – encrypted with your profile’s private key<br><b>Private</b> – stored only on your device
+
+
+
+ To enable backups, choose a folder to store your backup files under the <b>Backup location</b> setting.<br><br>You can also <b>optionally</b> back up your <b>1-on-1, group, and community messages</b> by turning on the <b>Backup your messages</b> toggle under the <b>Backup data</b> setting.
+
+
+
+ Go to settings
+
+
EnsAddedView
@@ -7592,13 +7628,6 @@ Please add it and try again.
제거
-
- FeeRow
-
- Max.
-
-
-
FeesBox
@@ -8854,6 +8883,45 @@ L2 수수료: %2
키 페어는 다른 사람과 공유할 수 있는 공개 주소와, 지갑을 제어하는 비밀 개인 키로 이루어져 있습니다. 지금 Keycard에서 키 페어를 생성 중입니다 — 과정이 끝날 때까지 분리하지 마세요.
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
@@ -8951,10 +9019,6 @@ Are you sure you want to do this?
PIN correct
PIN이 올바릅니다
-
- Keycard blocked
- Keycard가 차단됨
-
%n attempt(s) remaining
diff --git a/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml
new file mode 100644
index 00000000000..069306582c9
--- /dev/null
+++ b/ui/imports/shared/popups/KeycardChannelDrawer.qml
@@ -0,0 +1,320 @@
+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 flasing 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: "idle"
+
+ /// Emitted when the user dismisses the drawer without completing the operation
+ signal dismissed()
+
+ // ============================================================
+ // INTERNAL STATE MANAGEMENT - Queue-based approach
+ // ============================================================
+
+ QtObject {
+ id: d
+
+ // Timing constants
+ readonly property int minimumStateDuration: 600 // ms - minimum time to show each state
+ readonly property int successDisplayDuration: 1200 // ms - how long to show success before closing
+ readonly property int transitionDuration: 50 // ms - fade animation duration
+
+ // Display states (internal representation)
+ readonly property string stateWaitingForCard: "waiting-for-card"
+ readonly property string stateReading: "reading"
+ readonly property string stateSuccess: "success"
+ readonly property string stateError: "error"
+ readonly property string stateIdle: "" // empty = not showing anything
+
+ // 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: "idle"
+
+ /// Map backend state to display state
+ function mapBackendStateToDisplayState(backendState) {
+ switch(backendState) {
+ case "waiting-for-keycard":
+ return stateWaitingForCard
+ case "reading":
+ return stateReading
+ case "error":
+ return stateError
+ case "idle":
+ // Success detection: were we just reading?
+ if (previousBackendState === "reading") {
+ return stateSuccess
+ }
+ return stateIdle
+ 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) {
+ console.log("KeycardChannelDrawer: Skipping duplicate state in queue")
+ return
+ }
+
+ // Don't queue if it's the same as current display state and queue is empty
+ if (stateQueue.length === 0 && state === displayState) {
+ console.log("KeycardChannelDrawer: Skipping - same as current display state")
+ 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
+
+ // Open drawer if showing a state
+ if (nextState !== stateIdle && !root.opened) {
+ root.open()
+ }
+
+ // Determine timer duration based on state
+ if (nextState === stateSuccess) {
+ stateTimer.interval = successDisplayDuration
+ } else if (nextState === stateIdle) {
+ // Closing - clear any remaining queue (stale states from before completion)
+ root.close()
+ if (stateQueue.length > 0) {
+ processNextState()
+ }
+ return
+ } else {
+ stateTimer.interval = minimumStateDuration
+ }
+
+ // Start timer for next transition
+ stateTimer.restart()
+ }
+
+ /// Handle backend state changes
+ function onBackendStateChanged() {
+ const newDisplayState = mapBackendStateToDisplayState(root.currentState)
+
+ // Special handling: Backend went to idle unexpectedly (not after reading)
+ // Clear everything and close immediately
+ if (newDisplayState === stateIdle && displayState !== stateSuccess) {
+ console.log("KeycardChannelDrawer: Unexpected idle, clearing and closing")
+ stateQueue = []
+ stateTimer.stop()
+ displayState = stateIdle
+ previousBackendState = root.currentState
+ root.close()
+ return // Don't process further
+ }
+
+ // Update previous state tracking
+ previousBackendState = root.currentState
+
+ // 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.close()
+ }
+ }
+
+ // Single timer that handles all state transitions
+ 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
+ onCurrentStateChanged: {
+ d.onBackendStateChanged()
+ }
+
+ // Initialize on component load
+ Component.onCompleted: {
+ d.previousBackendState = root.currentState
+ d.onBackendStateChanged()
+ }
+
+ // ============================================================
+ // DIALOG CONFIGURATION
+ // ============================================================
+
+ closePolicy: Popup.NoAutoClose
+ modal: true
+
+ header: null
+ footer: null
+ padding: Theme.padding
+
+ implicitWidth: 480
+
+ // ============================================================
+ // CONTENT
+ // ============================================================
+
+ contentItem: ColumnLayout {
+ spacing: Theme.padding
+
+ // State display area
+ Item {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 300
+ // Waiting for card state
+ KeycardStateDisplay {
+ id: waitingDisplay
+ anchors.fill: parent
+ visible: opacity > 0
+ opacity: d.displayState === d.stateWaitingForCard ? 1 : 0
+
+ iconSource: Assets.png("onboarding/carousel/keycard")
+ title: qsTr("Ready to scan")
+ description: qsTr("Please tap your Keycard to the back of your device")
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: d.transitionDuration
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+
+ // Reading state
+ KeycardStateDisplay {
+ id: readingDisplay
+ anchors.fill: parent
+ visible: opacity > 0
+ opacity: d.displayState === d.stateReading ? 1 : 0
+
+ iconSource: Assets.png("onboarding/status_generate_keycard")
+ title: qsTr("Reading Keycard")
+ description: qsTr("Please keep your Keycard in place")
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: d.transitionDuration
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+
+ // Success state
+ KeycardStateDisplay {
+ id: successDisplay
+ anchors.fill: parent
+ visible: opacity > 0
+ opacity: d.displayState === d.stateSuccess ? 1 : 0
+
+ iconSource: Assets.png("onboarding/status_key")
+ title: qsTr("Success")
+ description: qsTr("Keycard operation completed successfully")
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: d.transitionDuration
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+
+ // Error state
+ KeycardStateDisplay {
+ id: errorDisplay
+ anchors.fill: parent
+ visible: opacity > 0
+ opacity: d.displayState === d.stateError ? 1 : 0
+
+ iconSource: Assets.png("onboarding/status_generate_keys")
+ title: qsTr("Keycard Error")
+ description: qsTr("An error occurred. Please try again.")
+ isError: true
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: d.transitionDuration
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+ }
+
+ // 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
+ // Preserve the spacing for the button even if it's not visible
+ opacity: d.displayState !== d.stateSuccess && d.displayState !== d.stateIdle ? 1 : 0
+ text: qsTr("Dismiss")
+ type: StatusButton.Type.Normal
+
+ onClicked: {
+ d.clearAndClose()
+ root.dismissed()
+ }
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+}
diff --git a/ui/imports/shared/popups/KeycardStateDisplay.qml b/ui/imports/shared/popups/KeycardStateDisplay.qml
new file mode 100644
index 00000000000..32d6ac5ec72
--- /dev/null
+++ b/ui/imports/shared/popups/KeycardStateDisplay.qml
@@ -0,0 +1,77 @@
+import QtQuick
+import QtQuick.Layouts
+
+import StatusQ.Core
+import StatusQ.Core.Theme
+import StatusQ.Components
+
+/// 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
+
+ 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 !== ""
+ }
+ }
+}
+
diff --git a/ui/imports/shared/popups/qmldir b/ui/imports/shared/popups/qmldir
index f6835d28445..b0cfc59dbff 100644
--- a/ui/imports/shared/popups/qmldir
+++ b/ui/imports/shared/popups/qmldir
@@ -16,6 +16,8 @@ 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
+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..1eba48c3244
--- /dev/null
+++ b/ui/imports/shared/stores/KeycardStateStore.qml
@@ -0,0 +1,24 @@
+import QtQuick
+
+QtObject {
+ id: root
+
+ readonly property var keycardChannelModuleInst: typeof keycardChannelModule !== "undefined" ? keycardChannelModule : null
+
+ // Channel state property
+ readonly property string state: keycardChannelModuleInst ? keycardChannelModuleInst.keycardChannelState : "idle"
+
+ // State constants (for convenience)
+ readonly property string stateIdle: keycardChannelModuleInst ? keycardChannelModuleInst.stateIdle : "idle"
+ readonly property string stateWaitingForKeycard: keycardChannelModuleInst ? keycardChannelModuleInst.stateWaitingForKeycard : "waiting-for-keycard"
+ readonly property string stateReading: keycardChannelModuleInst ? keycardChannelModuleInst.stateReading : "reading"
+ readonly property string stateError: keycardChannelModuleInst ? keycardChannelModuleInst.stateError : "error"
+
+ // Helper properties for common state checks
+ readonly property bool isIdle: state === stateIdle
+ readonly property bool isWaitingForKeycard: state === stateWaitingForKeycard
+ readonly property bool isReading: state === stateReading
+ readonly property bool isError: state === stateError
+}
+
+
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/main.qml b/ui/main.qml
index 08a5d44c508..da202287f61 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,15 @@ Window {
}
}
+
+ Loader {
+ active: SQUtils.Utils.isAndroid
+ sourceComponent: KeycardChannelDrawer {
+ id: keycardChannelDrawer
+ currentState: applicationWindow.keycardStateStore.state
+ }
+ }
+
Loader {
id: macOSSafeAreaLoader
anchors.left: parent.left
diff --git a/vendor/status-keycard-qt b/vendor/status-keycard-qt
new file mode 160000
index 00000000000..9fb564360e9
--- /dev/null
+++ b/vendor/status-keycard-qt
@@ -0,0 +1 @@
+Subproject commit 9fb564360e95cb04aa36e31670628d174c486153