From 6c90a8d9ab7bc2febd1f4ac9b17ccf9bb808d343 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Tue, 9 Dec 2025 16:13:24 +0200 Subject: [PATCH 01/12] feat: Add status-keycard-qt lib This commit adds the status-keycard-qt submodule and updates the build configuration to support it. In this initial version the `status-keycard-qt` option can be enabled by using the `USE_STATUS_KEYCARD_QT` flag. - desktop: `USE_STATUS_KEYCARD_QT` will enable/disable the qt implementation. By default the go version is used - ios: `USE_STATUS_KEYCARD_QT` will enable/disable the qt implementation - android: the qt implementation is enabled by default --- .gitmodules | 3 + Makefile | 88 ++++++++++++-- mobile/DEV_SETUP.md | 16 +++ mobile/Makefile | 58 ++++++++- mobile/android/qt6/AndroidManifest.xml | 1 + .../ios/{Info.plist => Info.plist.template} | 13 ++ mobile/ios/Status-NoKeycard.entitlements | 8 ++ mobile/ios/Status.entitlements | 16 +++ mobile/scripts/Common.mk | 4 + mobile/scripts/buildApp.sh | 39 +++++- mobile/scripts/buildKeycardQt.sh | 112 ++++++++++++++++++ mobile/scripts/buildNimStatusClient.sh | 51 +++++--- mobile/scripts/buildStatusKeycardQt.sh | 91 ++++++++++++++ mobile/scripts/commonCmakeConfig.sh | 9 +- mobile/wrapperApp/Status.pro | 35 +++++- 15 files changed, 503 insertions(+), 41 deletions(-) rename mobile/ios/{Info.plist => Info.plist.template} (84%) create mode 100644 mobile/ios/Status-NoKeycard.entitlements create mode 100644 mobile/ios/Status.entitlements create mode 100755 mobile/scripts/buildKeycardQt.sh create mode 100755 mobile/scripts/buildStatusKeycardQt.sh 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..a04b4797931 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) $(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/buildKeycardQt.sh b/mobile/scripts/buildKeycardQt.sh new file mode 100755 index 00000000000..4cdeaf8d95b --- /dev/null +++ b/mobile/scripts/buildKeycardQt.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -ef pipefail + +BASEDIR=$(dirname "$0") + +# Load common config variables +source "${BASEDIR}/commonCmakeConfig.sh" + +KEYCARD_QT=${KEYCARD_QT:="/Users/alexjbanca/Repos/keycard-qt"} +LIB_DIR=${LIB_DIR} +LIB_EXT=${LIB_EXT:=".a"} + +BUILD_DIR="${KEYCARD_QT}/build/${OS}/${ARCH}" +BUILD_SHARED_LIBS=ON + +if [[ "${LIB_EXT}" == ".a" ]]; then + BUILD_SHARED_LIBS=OFF +fi + +echo "Building keycard-qt for ${ARCH} using compiler: ${CC} with CMAKE_TOOLCHAIN_FILE ${CMAKE_TOOLCHAIN_FILE}" +echo "BUILD_SHARED_LIBS=${BUILD_SHARED_LIBS}" + +printf 'COMMON_CMAKE_CONFIG: %s\n' "${COMMON_CMAKE_CONFIG[@]}" + +# Set OpenSSL paths for secure channel cryptography +# We need BOTH the source and build include directories: +# - Build dir has generated headers (configuration.h, opensslv.h, etc.) +# - Source dir has the main API headers (ec.h, evp.h, etc.) +MOBILE_ROOT="$(cd "${BASEDIR}/.." && pwd)" +# Note: BUILD_PATH from the makefile includes qt6 subdirectory for android +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}" +OPENSSL_SSL_LIBRARY="${LIB_DIR}/libssl_3${LIB_EXT}" + +echo "OpenSSL paths:" +echo " OPENSSL_BUILD_DIR=${OPENSSL_BUILD_DIR}" +echo " OPENSSL_BUILD_INCLUDE_DIR=${OPENSSL_BUILD_INCLUDE_DIR}" +echo " OPENSSL_SOURCE_INCLUDE_DIR=${OPENSSL_SOURCE_INCLUDE_DIR}" +echo " OPENSSL_CRYPTO_LIBRARY=${OPENSSL_CRYPTO_LIBRARY}" +echo " OPENSSL_SSL_LIBRARY=${OPENSSL_SSL_LIBRARY}" + +# Configure with CMake +# Pass both OpenSSL include directories (build + source) +CMAKE_ARGS=( + "${COMMON_CMAKE_CONFIG[@]}" + -DBUILD_TESTING=OFF + -DBUILD_EXAMPLES=OFF + -DBUILD_SHARED_LIBS=${BUILD_SHARED_LIBS} + -DOPENSSL_BUILD_INCLUDE_DIR="${OPENSSL_BUILD_INCLUDE_DIR}" + -DOPENSSL_SOURCE_INCLUDE_DIR="${OPENSSL_SOURCE_INCLUDE_DIR}" + -DOPENSSL_CRYPTO_LIBRARY="${OPENSSL_CRYPTO_LIBRARY}" + -DOPENSSL_SSL_LIBRARY="${OPENSSL_SSL_LIBRARY}" +) + +# Add Android-specific flags only for Android builds +if [[ "$OS" == "android" ]]; then + CMAKE_ARGS+=( + -DUSE_ANDROID_NFC_BACKEND=OFF + -DENABLE_QT_NFC_ANDROID_WORKAROUNDS=ON + ) + echo "Android build: Using Android NFC backend" +else + echo "iOS build: Using standard Qt NFC backend (CoreNFC)" +fi + +cmake -S "${KEYCARD_QT}" -B "${BUILD_DIR}" "${CMAKE_ARGS[@]}" + +# Build the library +make -C "${BUILD_DIR}" 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 +KEYCARD_QT_LIB=$(find "${BUILD_DIR}" -name "libkeycard-qt${LIB_EXT}" -o -name "libkeycard-qt.dylib" | grep -v "\.so\." | head -n 1) + +if [[ -z "${KEYCARD_QT_LIB}" ]]; then + # Try alternative patterns for static library + KEYCARD_QT_LIB=$(find "${BUILD_DIR}" -name "libkeycard-qt.a" | head -n 1) +fi + +if [[ -z "${KEYCARD_QT_LIB}" ]]; then + echo "Error: Could not find keycard-qt library in ${BUILD_DIR}" + exit 1 +fi + +cp "${KEYCARD_QT_LIB}" "${LIB_DIR}/libkeycard-qt${LIB_EXT}" +echo "Copied ${KEYCARD_QT_LIB} to ${LIB_DIR}/libkeycard-qt${LIB_EXT}" + +# Update Android NFC backend selection in manifest +if [[ "${USE_ANDROID_NFC_BACKEND:-OFF}" == "ON" ]]; then + echo "Android NFC backend enabled - updating manifest resources..." + BOOLS_XML="${MOBILE_ROOT}/android/qt6/res/values/bools.xml" + if [[ -f "${BOOLS_XML}" ]]; then + sed -i.bak 's/true<\/bool>/false<\/bool>/' "${BOOLS_XML}" + echo "Updated ${BOOLS_XML} to disable Qt NFC (use Android NFC backend)" + else + echo "Warning: ${BOOLS_XML} not found, cannot update NFC backend selection" + fi +else + echo "Qt NFC backend with workarounds enabled (default)" +fi + 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..69eb2f05612 --- /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:="/Users/alexjbanca/Repos/status-keycard-qt-status"} +KEYCARD_QT=${KEYCARD_QT:="/Users/alexjbanca/Repos/keycard-qt-status"} +LIB_DIR=${LIB_DIR} +LIB_EXT=${LIB_EXT:=".a"} + +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 + } } From 8bb761367d7d72826525a4a455af2935a7ccdc38 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Wed, 10 Dec 2025 10:05:30 +0200 Subject: [PATCH 02/12] fix: Remove buildKeycardQt.sh as it's not needed --- mobile/scripts/buildKeycardQt.sh | 112 ------------------------------- 1 file changed, 112 deletions(-) delete mode 100755 mobile/scripts/buildKeycardQt.sh diff --git a/mobile/scripts/buildKeycardQt.sh b/mobile/scripts/buildKeycardQt.sh deleted file mode 100755 index 4cdeaf8d95b..00000000000 --- a/mobile/scripts/buildKeycardQt.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env bash -set -ef pipefail - -BASEDIR=$(dirname "$0") - -# Load common config variables -source "${BASEDIR}/commonCmakeConfig.sh" - -KEYCARD_QT=${KEYCARD_QT:="/Users/alexjbanca/Repos/keycard-qt"} -LIB_DIR=${LIB_DIR} -LIB_EXT=${LIB_EXT:=".a"} - -BUILD_DIR="${KEYCARD_QT}/build/${OS}/${ARCH}" -BUILD_SHARED_LIBS=ON - -if [[ "${LIB_EXT}" == ".a" ]]; then - BUILD_SHARED_LIBS=OFF -fi - -echo "Building keycard-qt for ${ARCH} using compiler: ${CC} with CMAKE_TOOLCHAIN_FILE ${CMAKE_TOOLCHAIN_FILE}" -echo "BUILD_SHARED_LIBS=${BUILD_SHARED_LIBS}" - -printf 'COMMON_CMAKE_CONFIG: %s\n' "${COMMON_CMAKE_CONFIG[@]}" - -# Set OpenSSL paths for secure channel cryptography -# We need BOTH the source and build include directories: -# - Build dir has generated headers (configuration.h, opensslv.h, etc.) -# - Source dir has the main API headers (ec.h, evp.h, etc.) -MOBILE_ROOT="$(cd "${BASEDIR}/.." && pwd)" -# Note: BUILD_PATH from the makefile includes qt6 subdirectory for android -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}" -OPENSSL_SSL_LIBRARY="${LIB_DIR}/libssl_3${LIB_EXT}" - -echo "OpenSSL paths:" -echo " OPENSSL_BUILD_DIR=${OPENSSL_BUILD_DIR}" -echo " OPENSSL_BUILD_INCLUDE_DIR=${OPENSSL_BUILD_INCLUDE_DIR}" -echo " OPENSSL_SOURCE_INCLUDE_DIR=${OPENSSL_SOURCE_INCLUDE_DIR}" -echo " OPENSSL_CRYPTO_LIBRARY=${OPENSSL_CRYPTO_LIBRARY}" -echo " OPENSSL_SSL_LIBRARY=${OPENSSL_SSL_LIBRARY}" - -# Configure with CMake -# Pass both OpenSSL include directories (build + source) -CMAKE_ARGS=( - "${COMMON_CMAKE_CONFIG[@]}" - -DBUILD_TESTING=OFF - -DBUILD_EXAMPLES=OFF - -DBUILD_SHARED_LIBS=${BUILD_SHARED_LIBS} - -DOPENSSL_BUILD_INCLUDE_DIR="${OPENSSL_BUILD_INCLUDE_DIR}" - -DOPENSSL_SOURCE_INCLUDE_DIR="${OPENSSL_SOURCE_INCLUDE_DIR}" - -DOPENSSL_CRYPTO_LIBRARY="${OPENSSL_CRYPTO_LIBRARY}" - -DOPENSSL_SSL_LIBRARY="${OPENSSL_SSL_LIBRARY}" -) - -# Add Android-specific flags only for Android builds -if [[ "$OS" == "android" ]]; then - CMAKE_ARGS+=( - -DUSE_ANDROID_NFC_BACKEND=OFF - -DENABLE_QT_NFC_ANDROID_WORKAROUNDS=ON - ) - echo "Android build: Using Android NFC backend" -else - echo "iOS build: Using standard Qt NFC backend (CoreNFC)" -fi - -cmake -S "${KEYCARD_QT}" -B "${BUILD_DIR}" "${CMAKE_ARGS[@]}" - -# Build the library -make -C "${BUILD_DIR}" 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 -KEYCARD_QT_LIB=$(find "${BUILD_DIR}" -name "libkeycard-qt${LIB_EXT}" -o -name "libkeycard-qt.dylib" | grep -v "\.so\." | head -n 1) - -if [[ -z "${KEYCARD_QT_LIB}" ]]; then - # Try alternative patterns for static library - KEYCARD_QT_LIB=$(find "${BUILD_DIR}" -name "libkeycard-qt.a" | head -n 1) -fi - -if [[ -z "${KEYCARD_QT_LIB}" ]]; then - echo "Error: Could not find keycard-qt library in ${BUILD_DIR}" - exit 1 -fi - -cp "${KEYCARD_QT_LIB}" "${LIB_DIR}/libkeycard-qt${LIB_EXT}" -echo "Copied ${KEYCARD_QT_LIB} to ${LIB_DIR}/libkeycard-qt${LIB_EXT}" - -# Update Android NFC backend selection in manifest -if [[ "${USE_ANDROID_NFC_BACKEND:-OFF}" == "ON" ]]; then - echo "Android NFC backend enabled - updating manifest resources..." - BOOLS_XML="${MOBILE_ROOT}/android/qt6/res/values/bools.xml" - if [[ -f "${BOOLS_XML}" ]]; then - sed -i.bak 's/true<\/bool>/false<\/bool>/' "${BOOLS_XML}" - echo "Updated ${BOOLS_XML} to disable Qt NFC (use Android NFC backend)" - else - echo "Warning: ${BOOLS_XML} not found, cannot update NFC backend selection" - fi -else - echo "Qt NFC backend with workarounds enabled (default)" -fi - From 450a51fe93ef814860a4b0a4e8b891f8619a649f Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Wed, 10 Dec 2025 10:52:16 +0200 Subject: [PATCH 03/12] fix: Missing `status-keycard-qt` submodule --- vendor/status-keycard-qt | 1 + 1 file changed, 1 insertion(+) create mode 160000 vendor/status-keycard-qt diff --git a/vendor/status-keycard-qt b/vendor/status-keycard-qt new file mode 160000 index 00000000000..d13e9d43ea2 --- /dev/null +++ b/vendor/status-keycard-qt @@ -0,0 +1 @@ +Subproject commit d13e9d43ea255a67a95c3039b4c6b141f4722500 From adadfcdcf3015ff754b76cfe26835bebb3eaf499 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Wed, 10 Dec 2025 17:53:26 +0200 Subject: [PATCH 04/12] fix: Remove hardcoded paths from the build script --- mobile/Makefile | 2 +- mobile/scripts/buildStatusKeycardQt.sh | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile/Makefile b/mobile/Makefile index a04b4797931..4d164350dd0 100644 --- a/mobile/Makefile +++ b/mobile/Makefile @@ -77,7 +77,7 @@ $(QRCODEGEN_LIB): $(QRCODEGEN_FILES) $(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) $(STATUS_KEYCARD_QT_SCRIPT) $(HANDLE_OUTPUT) + @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 diff --git a/mobile/scripts/buildStatusKeycardQt.sh b/mobile/scripts/buildStatusKeycardQt.sh index 69eb2f05612..f3d03b60b8e 100755 --- a/mobile/scripts/buildStatusKeycardQt.sh +++ b/mobile/scripts/buildStatusKeycardQt.sh @@ -6,12 +6,12 @@ BASEDIR=$(dirname "$0") # Load common config variables source "${BASEDIR}/commonCmakeConfig.sh" -STATUS_KEYCARD_QT=${STATUS_KEYCARD_QT:="/Users/alexjbanca/Repos/status-keycard-qt-status"} -KEYCARD_QT=${KEYCARD_QT:="/Users/alexjbanca/Repos/keycard-qt-status"} +STATUS_KEYCARD_QT=${STATUS_KEYCARD_QT:="../vendors/status-desktop"} +KEYCARD_QT=${KEYCARD_QT:=""} LIB_DIR=${LIB_DIR} LIB_EXT=${LIB_EXT:=".a"} -BUILD_DIR="${STATUS_KEYCARD_QT}/build/${OS}/${ARCH}" +BUILD_DIR=${BUILD_DIR:="${STATUS_KEYCARD_QT}/build/${OS}/${ARCH}"} BUILD_SHARED_LIBS=ON if [[ "${LIB_EXT}" == ".a" ]]; then From 7e8edd3ee4631d506cfe3e01a0bda47b321d1d12 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Thu, 11 Dec 2025 10:11:54 +0200 Subject: [PATCH 05/12] fix: Bump status-keycard-qt --- vendor/status-keycard-qt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/status-keycard-qt b/vendor/status-keycard-qt index d13e9d43ea2..b0649f29715 160000 --- a/vendor/status-keycard-qt +++ b/vendor/status-keycard-qt @@ -1 +1 @@ -Subproject commit d13e9d43ea255a67a95c3039b4c6b141f4722500 +Subproject commit b0649f2971590b8d8c4d3541c23a35f2d226c3fa From f23b896c3c418cf9866b787e7a24e9b86721b807 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Thu, 11 Dec 2025 14:43:31 +0200 Subject: [PATCH 06/12] fix(keycard-qt): Bump to include a fix for generateMnemonic --- vendor/status-keycard-qt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/status-keycard-qt b/vendor/status-keycard-qt index b0649f29715..9fb564360e9 160000 --- a/vendor/status-keycard-qt +++ b/vendor/status-keycard-qt @@ -1 +1 @@ -Subproject commit b0649f2971590b8d8c4d3541c23a35f2d226c3fa +Subproject commit 9fb564360e95cb04aa36e31670628d174c486153 From 58d65b8294a46481a34d250894750933c6735d46 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Tue, 9 Dec 2025 16:39:12 +0200 Subject: [PATCH 07/12] fix(keycard): Improve exception handling - src/app/modules/main/wallet_section/send_new/module.nim Verify if both password and pin are empty before emitting the `authenticationCancelled` signal - handle card disconnect/reconnect when the user input is needed (enter pin, puk etc) - treat keycard message with empty error as successful - src/app_service/service/keycard/service.nim - avoid modifying the json container with itself. We're currently iterating the container and modifying the service member - leading to a freeze. --- .../main/wallet_section/send_new/module.nim | 2 +- .../internal/insert_keycard_state.nim | 14 ++++++++++ .../keycard_popup/internal/pin_set_state.nim | 26 ++++++++++++++++++- .../internal/repeat_pin_state.nim | 19 ++++++++++++++ src/app_service/service/keycard/service.nim | 7 +++++ 5 files changed, 66 insertions(+), 2 deletions(-) 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/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..32eec8db972 100644 --- a/src/app_service/service/keycard/service.nim +++ b/src/app_service/service/keycard/service.nim @@ -151,8 +151,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 From 397f697962a013b0c2d2b53084feb89669b01749 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Tue, 9 Dec 2025 16:42:39 +0200 Subject: [PATCH 08/12] fix(onboarding): Delay the keycard detection for when keycard is needed This is a fix for the mobile platforms that will show a drawer when the keycard is needed. We'll need to avoid showing the drawer every time at app start. --- src/app/modules/onboarding/controller.nim | 3 +++ src/app/modules/onboarding/io_interface.nim | 3 +++ src/app/modules/onboarding/module.nim | 3 +++ src/app/modules/onboarding/view.nim | 3 +++ src/app_service/service/keycardV2/service.nim | 3 +++ ui/app/AppLayouts/Onboarding/OnboardingFlow.qml | 13 ++++++++++--- ui/app/AppLayouts/Onboarding/OnboardingLayout.qml | 1 + ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml | 7 +++++++ .../Onboarding/stores/OnboardingStore.qml | 4 ++++ 9 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/app/modules/onboarding/controller.nim b/src/app/modules/onboarding/controller.nim index 5efb332c2f0..71676fb1f89 100644 --- a/src/app/modules/onboarding/controller.nim +++ b/src/app/modules/onboarding/controller.nim @@ -272,3 +272,6 @@ proc storeMetadataAsync*(self: Controller, name: string, paths: seq[string]) = proc asyncImportLocalBackupFile*(self: Controller, filePath: string) = self.generalService.asyncImportLocalBackupFile(filePath) + +proc startKeycardDetection*(self: Controller) = + self.keycardServiceV2.startDetection() \ No newline at end of file diff --git a/src/app/modules/onboarding/io_interface.nim b/src/app/modules/onboarding/io_interface.nim index 7e696a4cea4..3e5a759ca85 100644 --- a/src/app/modules/onboarding/io_interface.nim +++ b/src/app/modules/onboarding/io_interface.nim @@ -109,6 +109,9 @@ method requestDeleteBiometrics*(self: AccessInterface, account: string) {.base.} method requestLocalBackup*(self: AccessInterface, backupImportFileUrl: string) {.base.} = raise newException(ValueError, "No implementation available") +method startKeycardDetection*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + # This way (using concepts) is used only for the modules managed by AppController type DelegateInterface* = concept c diff --git a/src/app/modules/onboarding/module.nim b/src/app/modules/onboarding/module.nim index 7a73964944e..397937f144f 100644 --- a/src/app/modules/onboarding/module.nim +++ b/src/app/modules/onboarding/module.nim @@ -446,6 +446,9 @@ method requestLocalBackup*[T](self: Module[T], backupImportFileUrl: string) = method requestDeleteBiometrics*[T](self: Module[T], account: string) = self.view.deleteBiometricsRequested(account) +method startKeycardDetection*[T](self: Module[T]) = + self.controller.startKeycardDetection() + proc runPostLoginTasks*[T](self: Module[T]) = let tasks = self.postLoginTasks for task in tasks: diff --git a/src/app/modules/onboarding/view.nim b/src/app/modules/onboarding/view.nim index 3dd161aeccf..380d9b17811 100644 --- a/src/app/modules/onboarding/view.nim +++ b/src/app/modules/onboarding/view.nim @@ -195,6 +195,9 @@ QtObject: proc startKeycardFactoryReset(self: View) {.slot.} = self.delegate.startKeycardFactoryReset() + proc startKeycardDetection(self: View) {.slot.} = + self.delegate.startKeycardDetection() + proc delete*(self: View) = self.QObject.delete diff --git a/src/app_service/service/keycardV2/service.nim b/src/app_service/service/keycardV2/service.nim index ad764b6f475..9454190be4a 100644 --- a/src/app_service/service/keycardV2/service.nim +++ b/src/app_service/service/keycardV2/service.nim @@ -262,6 +262,9 @@ QtObject: except Exception as e: error "error storing metadata", err=e.msg + proc startDetection*(self: Service) {.featureGuard(KEYCARD_ENABLED).} = + self.asyncStart(status_const.KEYCARDPAIRINGDATAFILE) + proc delete*(self: Service) = self.QObject.delete diff --git a/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml b/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml index 81231830580..c5db6a8a355 100644 --- a/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml +++ b/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml @@ -72,6 +72,7 @@ OnboardingStackView { signal linkActivated(string link) signal finished(int flow) + signal keycardRequested() // Thirdparty services required property bool privacyModeFeatureEnabled @@ -239,7 +240,7 @@ OnboardingStackView { onUnblockWithSeedphraseRequested: root.push(unblockWithSeedphraseFlow) onUnblockWithPukRequested: root.push(unblockWithPukFlow) - + onKeycardRequested: root.keycardRequested() onVisibleChanged: { if (!visible) root.dismissBiometricsRequested() @@ -277,7 +278,10 @@ OnboardingStackView { root.push(useRecoveryPhraseFlow, { type: UseRecoveryPhraseFlow.Type.NewProfile }) } - onCreateProfileWithEmptyKeycardRequested: root.push(keycardCreateProfileFlow) + onCreateProfileWithEmptyKeycardRequested: { + root.keycardRequested() + root.push(keycardCreateProfileFlow) + } } } @@ -290,7 +294,10 @@ OnboardingStackView { thirdpartyServicesEnabled: root.thirdpartyServicesEnabled onLoginWithSyncingRequested: root.push(logInBySyncingFlow) - onLoginWithKeycardRequested: root.push(loginWithKeycardFlow) + onLoginWithKeycardRequested: { + root.keycardRequested() + root.push(loginWithKeycardFlow) + } onLoginWithSeedphraseRequested: { d.flow = Onboarding.OnboardingFlow.LoginWithSeedphrase diff --git a/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml b/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml index 3b8075f6812..07d3567a3ff 100644 --- a/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml +++ b/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml @@ -200,6 +200,7 @@ Page { onExportKeysRequested: root.onboardingStore.exportRecoverKeys() onImportLocalBackupRequested: (importFilePath) => d.backupImportFileUrl = importFilePath onFinished: (flow) => d.finishFlow(flow) + onKeycardRequested: root.onboardingStore.startKeycardDetection() onBiometricsRequested: (profileId) => { const isKeycardProfile = SQUtils.ModelUtils.getByKey( diff --git a/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml index 9c355b87ae0..c0dda72dcba 100644 --- a/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml +++ b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml @@ -91,6 +91,7 @@ OnboardingPage { signal unblockWithSeedphraseRequested() signal unblockWithPukRequested() signal lostKeycardFlowRequested() + signal keycardRequested() QtObject { id: d @@ -104,6 +105,12 @@ OnboardingPage { readonly property int loginModelCount: root.loginAccountsModel.ModelCount.count onLoginModelCountChanged: setSelectedLoginUser() + onCurrentProfileIsKeycardChanged: { + if (d.currentProfileIsKeycard) { + root.keycardRequested() + } + } + function setSelectedLoginUser() { if (loginModelCount > 0) { loginUserSelector.setSelection(d.settings.lastKeyUid) diff --git a/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml b/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml index c07a8d5c1e9..c2811116322 100644 --- a/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml +++ b/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml @@ -35,6 +35,10 @@ QtObject { readonly property int keycardRemainingPinAttempts: d.onboardingModuleInst.keycardRemainingPinAttempts readonly property int keycardRemainingPukAttempts: d.onboardingModuleInst.keycardRemainingPukAttempts + function startKeycardDetection() { + d.onboardingModuleInst.startKeycardDetection() + } + function finishOnboardingFlow(flow: int, data: Object) { // -> string return d.onboardingModuleInst.finishOnboardingFlow(flow, JSON.stringify(data)) } From bcf9a248fe8ec3af11b1d407e753130a4b4be9b4 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Tue, 9 Dec 2025 16:45:23 +0200 Subject: [PATCH 09/12] feat(keycard): Add support for keycard channel events The keycard channel events will inform the app of the channel state (waiting for keycard, reading, error, idle). This will be used on mobile platforms to control a drawer that informs the user when it's required to tap the keycard. --- src/app/boot/app_controller.nim | 9 +++ src/app/modules/keycard_channel/constants.nim | 9 +++ .../modules/keycard_channel/controller.nim | 27 ++++++++ .../modules/keycard_channel/io_interface.nim | 23 +++++++ src/app/modules/keycard_channel/module.nim | 49 +++++++++++++++ src/app/modules/keycard_channel/view.nim | 62 +++++++++++++++++++ src/app_service/service/keycard/service.nim | 3 + src/app_service/service/keycardV2/service.nim | 14 ++++- 8 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/app/modules/keycard_channel/constants.nim create mode 100644 src/app/modules/keycard_channel/controller.nim create mode 100644 src/app/modules/keycard_channel/io_interface.nim create mode 100644 src/app/modules/keycard_channel/module.nim create mode 100644 src/app/modules/keycard_channel/view.nim 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_service/service/keycard/service.nim b/src/app_service/service/keycard/service.nim index 32eec8db972..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)) diff --git a/src/app_service/service/keycardV2/service.nim b/src/app_service/service/keycardV2/service.nim index 9454190be4a..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 From 240efa310b4af5404b04adda587ba6d14e4c1d15 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Tue, 9 Dec 2025 18:05:30 +0200 Subject: [PATCH 10/12] feat(Keycard): Adding a keycard info drawer for Android to guide the keycard interactions Adding a `KeycardChannelDrawer` that's guiding the user whenever the keycard is needed. On IOS the system drawer is used --- storybook/pages/KeycardChannelDrawerPage.qml | 222 ++++++++++++ .../src/StatusQ/Controls/StatusPinInput.qml | 22 +- .../Onboarding/components/LoginKeycardBox.qml | 7 + .../Onboarding/pages/KeycardEnterPinPage.qml | 1 + ui/i18n/qml_base_en.ts | 70 +++- ui/i18n/qml_base_lokalise_en.ts | 86 ++++- ui/i18n/qml_cs.ts | 70 +++- ui/i18n/qml_es.ts | 70 +++- ui/i18n/qml_ko.ts | 94 ++++- .../shared/popups/KeycardChannelDrawer.qml | 320 ++++++++++++++++++ .../shared/popups/KeycardStateDisplay.qml | 77 +++++ ui/imports/shared/popups/qmldir | 2 + .../shared/stores/KeycardStateStore.qml | 24 ++ ui/imports/shared/stores/qmldir | 1 + ui/main.qml | 10 + 15 files changed, 1010 insertions(+), 66 deletions(-) create mode 100644 storybook/pages/KeycardChannelDrawerPage.qml create mode 100644 ui/imports/shared/popups/KeycardChannelDrawer.qml create mode 100644 ui/imports/shared/popups/KeycardStateDisplay.qml create mode 100644 ui/imports/shared/stores/KeycardStateStore.qml 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/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/i18n/qml_base_en.ts b/ui/i18n/qml_base_en.ts index fa371bd8aee..0e0b1a566e0 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 + + Insert Keycard + + + + 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 + + + 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..ccc5b05fc1a 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 + + Insert Keycard + KeycardChannelDrawer + Insert Keycard + + + 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 + + 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..7593529222d 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 + + Insert Keycard + + + + 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 + + + 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..7b02e2a18c3 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 + + Insert Keycard + + + + 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 + + + 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..71e9074157a 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 + + Insert Keycard + + + + 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 + + + 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..a81d263f021 --- /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("Insert Keycard") + 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 From b1b580994d557d7d3cabb091ea71cc67f87f21e0 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Thu, 11 Dec 2025 15:50:22 +0200 Subject: [PATCH 11/12] fix: Update waiting for keycard text in the keycard drawer --- ui/i18n/qml_base_en.ts | 8 ++++---- ui/i18n/qml_base_lokalise_en.ts | 10 +++++----- ui/i18n/qml_cs.ts | 8 ++++---- ui/i18n/qml_es.ts | 8 ++++---- ui/i18n/qml_ko.ts | 8 ++++---- ui/imports/shared/popups/KeycardChannelDrawer.qml | 2 +- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/ui/i18n/qml_base_en.ts b/ui/i18n/qml_base_en.ts index 0e0b1a566e0..6be4f42c943 100644 --- a/ui/i18n/qml_base_en.ts +++ b/ui/i18n/qml_base_en.ts @@ -8906,10 +8906,6 @@ L2 fee: %2 KeycardChannelDrawer - - Insert Keycard - - Please tap your Keycard to the back of your device @@ -8942,6 +8938,10 @@ L2 fee: %2 Dismiss + + Ready to scan + + KeycardConfirmation diff --git a/ui/i18n/qml_base_lokalise_en.ts b/ui/i18n/qml_base_lokalise_en.ts index ccc5b05fc1a..fb2de900555 100644 --- a/ui/i18n/qml_base_lokalise_en.ts +++ b/ui/i18n/qml_base_lokalise_en.ts @@ -10866,11 +10866,6 @@ KeycardChannelDrawer - - Insert Keycard - KeycardChannelDrawer - Insert Keycard - Please tap your Keycard to the back of your device KeycardChannelDrawer @@ -10911,6 +10906,11 @@ KeycardChannelDrawer Dismiss + + Ready to scan + KeycardChannelDrawer + Ready to scan + KeycardConfirmation diff --git a/ui/i18n/qml_cs.ts b/ui/i18n/qml_cs.ts index 7593529222d..771d537a425 100644 --- a/ui/i18n/qml_cs.ts +++ b/ui/i18n/qml_cs.ts @@ -8942,10 +8942,6 @@ L2 poplatek: %2 KeycardChannelDrawer - - Insert Keycard - - Please tap your Keycard to the back of your device @@ -8978,6 +8974,10 @@ L2 poplatek: %2 Dismiss + + Ready to scan + + KeycardConfirmation diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts index 7b02e2a18c3..2753ae01ee2 100644 --- a/ui/i18n/qml_es.ts +++ b/ui/i18n/qml_es.ts @@ -8921,10 +8921,6 @@ Tarifa L2: %2 KeycardChannelDrawer - - Insert Keycard - - Please tap your Keycard to the back of your device @@ -8957,6 +8953,10 @@ Tarifa L2: %2 Dismiss + + Ready to scan + + KeycardConfirmation diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts index 71e9074157a..b6521fb9b8b 100644 --- a/ui/i18n/qml_ko.ts +++ b/ui/i18n/qml_ko.ts @@ -8885,10 +8885,6 @@ L2 수수료: %2 KeycardChannelDrawer - - Insert Keycard - - Please tap your Keycard to the back of your device @@ -8921,6 +8917,10 @@ L2 수수료: %2 Dismiss + + Ready to scan + + KeycardConfirmation diff --git a/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml index a81d263f021..069306582c9 100644 --- a/ui/imports/shared/popups/KeycardChannelDrawer.qml +++ b/ui/imports/shared/popups/KeycardChannelDrawer.qml @@ -226,7 +226,7 @@ StatusDialog { opacity: d.displayState === d.stateWaitingForCard ? 1 : 0 iconSource: Assets.png("onboarding/carousel/keycard") - title: qsTr("Insert Keycard") + title: qsTr("Ready to scan") description: qsTr("Please tap your Keycard to the back of your device") Behavior on opacity { From 3843c63a4c07e91c91eda28926206c05c765c863 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Fri, 12 Dec 2025 12:35:55 +0200 Subject: [PATCH 12/12] feat(pushNotifications): Adding infrastructure for Android push notifications This commit connects the native code with status-go push notification infrastructure. It handles: - Firebase token generation - App config - Android permissions - Forwarding the token to status-go --- mobile/android/qt6/AndroidManifest.xml | 9 + mobile/android/qt6/build.gradle | 9 + mobile/android/qt6/google-services.json | 155 ++++++++ .../status/mobile/PushNotificationHelper.java | 293 ++++++++++++++ .../mobile/PushNotificationService.java | 110 ++++++ src/app/android/push_notifications.nim | 136 +++++++ src/app/boot/app_controller.nim | 8 + src/backend/backend.nim | 15 + src/nim_status_client.nim | 7 + src/statusq_bridge.nim | 16 + ui/StatusQ/CMakeLists.txt | 2 + .../StatusQ/pushnotification_android.h | 178 +++++++++ ui/StatusQ/src/externc.cpp | 66 ++++ ui/StatusQ/src/pushnotification_android.cpp | 373 ++++++++++++++++++ 14 files changed, 1377 insertions(+) create mode 100644 mobile/android/qt6/google-services.json create mode 100644 mobile/android/qt6/src/app/status/mobile/PushNotificationHelper.java create mode 100644 mobile/android/qt6/src/app/status/mobile/PushNotificationService.java create mode 100644 src/app/android/push_notifications.nim create mode 100644 ui/StatusQ/include/StatusQ/pushnotification_android.h create mode 100644 ui/StatusQ/src/pushnotification_android.cpp diff --git a/mobile/android/qt6/AndroidManifest.xml b/mobile/android/qt6/AndroidManifest.xml index 39f90e1954c..cb1a78ee1e2 100644 --- a/mobile/android/qt6/AndroidManifest.xml +++ b/mobile/android/qt6/AndroidManifest.xml @@ -72,5 +72,14 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/qtprovider_paths"/> + + + + + + + diff --git a/mobile/android/qt6/build.gradle b/mobile/android/qt6/build.gradle index 1656e3faaf2..0fb63ba364f 100644 --- a/mobile/android/qt6/build.gradle +++ b/mobile/android/qt6/build.gradle @@ -6,6 +6,8 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.8.0' + // Add Firebase Google Services plugin + classpath 'com.google.gms:google-services:4.4.0' } } @@ -29,6 +31,10 @@ dependencies { implementation 'androidx.core:core-splashscreen:1.0.1' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.documentfile:documentfile:1.0.1' + + // Firebase dependencies for Push Notifications + implementation platform('com.google.firebase:firebase-bom:33.7.0') + implementation 'com.google.firebase:firebase-messaging' } android { @@ -101,3 +107,6 @@ android { } } } + +// Apply Google Services plugin (must be at the bottom) +apply plugin: 'com.google.gms.google-services' diff --git a/mobile/android/qt6/google-services.json b/mobile/android/qt6/google-services.json new file mode 100644 index 00000000000..29eb3e43cc9 --- /dev/null +++ b/mobile/android/qt6/google-services.json @@ -0,0 +1,155 @@ +{ + "project_info": { + "project_number": "854811651919", + "firebase_url": "https://status-react-app.firebaseio.com", + "project_id": "status-react-app", + "storage_bucket": "status-react-app.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:854811651919:android:b1652e09df58f195005f3a", + "android_client_info": { + "package_name": "app.status.mobile" + } + }, + "oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAOF4W1j8GYeXzzVKRfNKlXywD6bx0rJtQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "854811651919-30s20e3l0me0ins0vc4185jbnj7ja49o.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "im.status.ethereum" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:854811651919:android:11ee7444ded8a00a", + "android_client_info": { + "package_name": "im.status.ethereum" + } + }, + "oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAOF4W1j8GYeXzzVKRfNKlXywD6bx0rJtQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "854811651919-30s20e3l0me0ins0vc4185jbnj7ja49o.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "im.status.ethereum" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:854811651919:android:15dbe4af1e06ca3e005f3a", + "android_client_info": { + "package_name": "im.status.ethereum.debug" + } + }, + "oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAOF4W1j8GYeXzzVKRfNKlXywD6bx0rJtQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "854811651919-30s20e3l0me0ins0vc4185jbnj7ja49o.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "im.status.ethereum" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:854811651919:android:1d0d69fe8c1bb89b005f3a", + "android_client_info": { + "package_name": "im.status.ethereum.pr" + } + }, + "oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAOF4W1j8GYeXzzVKRfNKlXywD6bx0rJtQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "854811651919-30s20e3l0me0ins0vc4185jbnj7ja49o.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "im.status.ethereum" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/mobile/android/qt6/src/app/status/mobile/PushNotificationHelper.java b/mobile/android/qt6/src/app/status/mobile/PushNotificationHelper.java new file mode 100644 index 00000000000..916a2d532da --- /dev/null +++ b/mobile/android/qt6/src/app/status/mobile/PushNotificationHelper.java @@ -0,0 +1,293 @@ +package app.status.mobile; + +import android.Manifest; +import android.app.Activity; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.util.Log; +import androidx.core.app.ActivityCompat; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; + +/** + * Helper class for managing push notifications + * + * This class provides: + * - JNI bridge between Java and C++/Qt + * - Notification display functionality + * - Notification channel management + * - Deep link handling + */ +public class PushNotificationHelper { + private static final String TAG = "PushNotificationHelper"; + private static final String CHANNEL_ID = "status-messages"; + private static final String CHANNEL_NAME = "Status Messages"; + + // Store application context (set by PushNotificationService) + private static Context sApplicationContext = null; + + /** + * Initialize with application context + * Should be called from PushNotificationService.onCreate() + */ + public static void initialize(Context context) { + if (context != null) { + sApplicationContext = context.getApplicationContext(); + Log.d(TAG, "PushNotificationHelper initialized with context"); + } + } + + /** + * Check if notification permission is granted (Android 13+) + * Returns true if permission is granted or not required (Android 12-) + */ + public static boolean hasNotificationPermission(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Android 13+ + int result = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ); + boolean hasPermission = result == PackageManager.PERMISSION_GRANTED; + return hasPermission; + } + return true; + } + + /** + * Request notification permission (Android 13+ only) + * For Android 12 and below, this does nothing as permission is not required + */ + public static void requestNotificationPermission(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Android 13+ + if (context instanceof Activity) { + Activity activity = (Activity) context; + if (!hasNotificationPermission(context)) { + Log.d(TAG, "Requesting POST_NOTIFICATIONS permission..."); + ActivityCompat.requestPermissions( + activity, + new String[]{Manifest.permission.POST_NOTIFICATIONS}, + 1001 // Request code + ); + } else { + Log.d(TAG, "Notification permission already granted"); + } + } else { + Log.w(TAG, "Cannot request permission: context is not an Activity"); + } + } else { + Log.d(TAG, "Android 12- : notification permission not required"); + } + } + + /** + * Request FCM token at app startup + * This method can be called from C++ layer to explicitly request the token + * The token is delivered via onFCMTokenReceived callback + */ + public static void requestFCMToken(Context context) { + Log.d(TAG, "Requesting FCM token from Firebase..."); + + // Store context if not already set + if (sApplicationContext == null && context != null) { + sApplicationContext = context.getApplicationContext(); + } + + // Check notification permission first + if (!hasNotificationPermission(context)) { + Log.w(TAG, "Notification permission not granted. Token request may fail."); + } + + // Request token with completion listener + // This works whether the token is cached or needs to be fetched + com.google.firebase.messaging.FirebaseMessaging.getInstance().getToken() + .addOnCompleteListener(task -> { + if (task.isSuccessful() && task.getResult() != null) { + String token = task.getResult(); + Log.d(TAG, "FCM token obtained: " + token); + // Pass token to native layer + onFCMTokenReceived(token); + } else { + Log.e(TAG, "Failed to get FCM token", task.getException()); + } + }); + } + + /** + * Called from FirebaseMessagingService when new token is received + * This method calls into C++/Qt layer via JNI + */ + public static void onFCMTokenReceived(String token) { + Log.d(TAG, "FCM Token ready to pass to native layer: " + token); + + // Call native C++ method (implemented in C++ via JNI registration) + nativeOnFCMTokenReceived(token); + } + + /** + * Called from FirebaseMessagingService when push notification received + * Passes encrypted data to status-go for processing + */ + public static void onPushNotificationReceived(String encryptedMessage, + String chatId, + String publicKey) { + Log.d(TAG, "Push notification received, passing to native layer"); + + // Call native C++ method to forward to status-go + nativeOnPushNotificationReceived(encryptedMessage, chatId, publicKey); + } + + /** + * Called from C++/Qt layer to display a notification + * This is called after status-go processes and decrypts the message + * + * @param title Notification title (chat name or sender name) + * @param message Notification message text + * @param identifier JSON string containing notification metadata for handling clicks + */ + public static void showNotification(String title, String message, String identifier) { + Log.d(TAG, "showNotification called - Title: " + title + ", Message: " + message); + + try { + // Get application context from Qt + Context context = getApplicationContext(); + if (context == null) { + Log.e(TAG, "Failed to get application context"); + return; + } + + // Create notification channel (required for Android O+) + createNotificationChannel(context); + + // Parse identifier to get deep link + String deepLink = extractDeepLinkFromIdentifier(identifier); + + // Create intent for when notification is tapped + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(android.net.Uri.parse(deepLink)); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + PendingIntent pendingIntent = PendingIntent.getActivity( + context, + identifier.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + // Build notification + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) // TODO: Use app icon + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setAutoCancel(true) + .setContentIntent(pendingIntent); + + // Show notification + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + notificationManager.notify(identifier.hashCode(), builder.build()); + + Log.d(TAG, "Notification displayed successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error showing notification", e); + } + } + + /** + * Clear all notifications for a specific chat + * Called when user opens a chat + */ + public static void clearNotifications(String chatId) { + Log.d(TAG, "Clearing notifications for chat: " + chatId); + + try { + Context context = getApplicationContext(); + if (context == null) return; + + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + // For now, just cancel by chat ID hash + // In a full implementation, we'd track notification IDs + notificationManager.cancel(chatId.hashCode()); + + } catch (Exception e) { + Log.e(TAG, "Error clearing notifications", e); + } + } + + /** + * Create notification channel (required for Android O+) + */ + private static void createNotificationChannel(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("Status chat message notifications"); + channel.enableVibration(true); + channel.setShowBadge(true); + + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + if (notificationManager != null) { + notificationManager.createNotificationChannel(channel); + } + } + } + + /** + * Extract deep link from notification identifier JSON + * The identifier contains metadata like: {"chatId": "...", "deepLink": "status-app://..."} + */ + private static String extractDeepLinkFromIdentifier(String identifier) { + try { + org.json.JSONObject json = new org.json.JSONObject(identifier); + if (json.has("chatId")) { + String chatId = json.getString("chatId"); + return "status-app://chat/" + chatId; + } + } catch (Exception e) { + Log.w(TAG, "Failed to parse identifier, using default deep link", e); + } + return "status-app://"; + } + + /** + * Get application context + * Returns the context stored during initialization + */ + private static Context getApplicationContext() { + if (sApplicationContext == null) { + Log.e(TAG, "PushNotificationHelper not initialized! Call initialize() first."); + } + return sApplicationContext; + } + + // ============================================================================ + // Native methods (implemented in C++ and registered via JNI) + // ============================================================================ + + /** + * Called when FCM token is received + * Implemented in pushnotification_android.cpp + */ + private static native void nativeOnFCMTokenReceived(String token); + + /** + * Called when push notification is received + * Implemented in pushnotification_android.cpp + */ + private static native void nativeOnPushNotificationReceived( + String encryptedMessage, + String chatId, + String publicKey + ); +} + diff --git a/mobile/android/qt6/src/app/status/mobile/PushNotificationService.java b/mobile/android/qt6/src/app/status/mobile/PushNotificationService.java new file mode 100644 index 00000000000..dbf75672b6f --- /dev/null +++ b/mobile/android/qt6/src/app/status/mobile/PushNotificationService.java @@ -0,0 +1,110 @@ +package app.status.mobile; + +import android.util.Log; +import androidx.annotation.NonNull; +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +/** + * FCM Service for receiving push notifications and device tokens + * + * This service handles: + * - New FCM token generation (onNewToken) + * - Incoming push notifications (onMessageReceived) + * + * The token is passed to C++/Qt layer via JNI for status-go registration + */ +public class PushNotificationService extends FirebaseMessagingService { + private static final String TAG = "PushNotificationService"; + + /** + * Called when the service is created by an FCM event + * Note: This is NOT called on app startup, only when FCM events occur + */ + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "PushNotificationService created by FCM event"); + // Initialize helper if not already done + PushNotificationHelper.initialize(this); + } + + /** + * Called when FCM generates a new token for this device + * This happens: + * - On first app install + * - When user reinstalls app + * - When user clears app data + * - When Firebase decides to rotate the token + */ + @Override + public void onNewToken(@NonNull String token) { + super.onNewToken(token); + Log.d(TAG, "New FCM token received: " + token); + + // Pass token to C++ layer which will forward to status-go + PushNotificationHelper.onFCMTokenReceived(token); + } + + /** + * Called when a push notification is received + * + * For Status, this will contain encrypted message data: + * - encryptedMessage: The encrypted message payload + * - chatId: The chat identifier + * - publicKey: Sender's public key + * + * status-go will decrypt and process the message, then emit + * a localNotifications signal that our Nim layer handles + */ + @Override + public void onMessageReceived(@NonNull RemoteMessage remoteMessage) { + super.onMessageReceived(remoteMessage); + + Log.d(TAG, "Push notification received from: " + remoteMessage.getFrom()); + + // Check if message contains data payload + if (remoteMessage.getData().size() > 0) { + Log.d(TAG, "Message data payload: " + remoteMessage.getData()); + + // Extract push notification data + String encryptedMessage = remoteMessage.getData().get("encryptedMessage"); + String chatId = remoteMessage.getData().get("chatId"); + String publicKey = remoteMessage.getData().get("publicKey"); + + if (encryptedMessage != null && chatId != null && publicKey != null) { + // Pass to C++ layer to forward to status-go + PushNotificationHelper.onPushNotificationReceived( + encryptedMessage, + chatId, + publicKey + ); + } else { + Log.w(TAG, "Push notification missing required fields"); + } + } + + // Check if message contains a notification payload + if (remoteMessage.getNotification() != null) { + String title = remoteMessage.getNotification().getTitle(); + String body = remoteMessage.getNotification().getBody(); + Log.d(TAG, "Message Notification - Title: " + title + ", Body: " + body); + + // For Status, we typically don't use the notification payload + // as status-go generates local notifications after decrypting + // But we log it for debugging + } + } + + /** + * Called when a message is deleted on the server + * This can happen if the message couldn't be delivered within the TTL + */ + @Override + public void onDeletedMessages() { + super.onDeletedMessages(); + Log.d(TAG, "Messages were deleted on the server"); + // For Status, we don't need to handle this as messages are on Waku + } +} + diff --git a/src/app/android/push_notifications.nim b/src/app/android/push_notifications.nim new file mode 100644 index 00000000000..216d5706426 --- /dev/null +++ b/src/app/android/push_notifications.nim @@ -0,0 +1,136 @@ +import chronicles, json +import ../../backend/backend +import ../../backend/settings +import ../../statusq_bridge + +logScope: + topics = "android-push-notifications" + +# Push notification token types (from protobuf.PushNotificationRegistration_TokenType) +const + UNKNOWN_TOKEN_TYPE* = 0 + APN_TOKEN* = 1 + FIREBASE_TOKEN* = 2 + +# Global state - stores the FCM token until user logs in +var g_fcmToken: string = "" +var g_tokenRegistered: bool = false + +# Global callback handlers - must use cdecl calling convention +proc onPushNotificationTokenReceived(token: cstring) {.cdecl, exportc.} = + # Initialize Nim GC for foreign thread calls + when declared(setupForeignThreadGc): + setupForeignThreadGc() + when declared(nimGC_setStackBottom): + var locals {.volatile, noinit.}: pointer + locals = addr(locals) + nimGC_setStackBottom(locals) + + let tokenStr = $token + # Store the token globally - we'll register it after user logs in + g_fcmToken = tokenStr + g_tokenRegistered = false + + debug "FCM token received, will register after user login" + +proc onPushNotificationReceived(encryptedMessage: cstring, chatId: cstring, publicKey: cstring) {.cdecl, exportc.} = + # Initialize Nim GC for foreign thread calls + when declared(setupForeignThreadGc): + setupForeignThreadGc() + when declared(nimGC_setStackBottom): + var locals {.volatile, noinit.}: pointer + locals = addr(locals) + nimGC_setStackBottom(locals) + + debug "Push notification received", + encryptedMessage = $encryptedMessage, + chatId = $chatId, + publicKey = $publicKey + + # NOTE: In most cases, you don't need to process this manually! + # + # The push notification serves as a WAKE-UP CALL: + # 1. It wakes up the app (if backgrounded/closed) + # 2. status-go connects to Waku automatically + # 3. Waku delivers messages through normal flow + # 4. status-go decrypts and generates local notifications + # + # The encrypted data here is for: + # - Quick preview before Waku sync (future enhancement) + # - Message prioritization + # - Offline handling (future enhancement) + # + # For now, just logging is sufficient. status-go handles the rest! + +proc registerPushNotificationToken*(): bool = + ## Register the stored FCM token with status-go + ## This should be called after user login when messenger is ready + ## Returns true if registration was attempted, false if no token available + + if g_fcmToken.len == 0: + debug "No FCM token available to register" + return false + + if g_tokenRegistered: + debug "FCM token already registered" + return false + + debug "Registering FCM token with status-go (post-login)...", token=g_fcmToken + + try: + # First, ensure messenger notifications are enabled + # This is required for the push notification client to be initialized + # TODO: This should be done by the user through the onboarding/settings + debug "Enabling messenger notifications in settings..." + let enableResponse = saveSettings("messenger-notifications-enabled?", true) + if not enableResponse.error.isNil: + error "Failed to enable messenger notifications", error=enableResponse.error + return false + debug "Messenger notifications enabled" + + # Now register with status-go using the proper backend API + # Parameters: + # - deviceToken: FCM token from Firebase + # - apnTopic: empty string for Android (only used for iOS) + # - tokenType: FIREBASE_TOKEN (2) for Android + debug "Registering FCM token with status-go..." + let response = registerForPushNotifications(g_fcmToken, "", FIREBASE_TOKEN) + + debug "Successfully registered for push notifications", response=response + g_tokenRegistered = true + return true + except Exception as e: + error "Failed to register for push notifications", error=e.msg + return false + +proc requestNotificationPermission*() = + ## Request notification permission (Android 13+ only) + ## On Android 12 and below, this is a no-op + ## Should be called before requesting FCM token + debug "Requesting notification permission..." + statusq_requestNotificationPermission() + +proc hasNotificationPermission*(): bool = + ## Check if notification permission is granted + ## Returns true on Android 12- (permission not required) + ## Returns actual permission status on Android 13+ + statusq_hasNotificationPermission() + +proc initializeAndroidPushNotifications*() = + ## Initialize push notifications on Android + ## This should be called once during app startup + debug "Initializing Android push notifications..." + + # Register our callbacks with StatusQ C++ layer + statusq_initPushNotifications( + onPushNotificationTokenReceived, + onPushNotificationReceived + ) + + # Request notification permission (Android 13+ only) + # This will show a system dialog on Android 13+ + # On Android 12 and below, this does nothing + requestNotificationPermission() + + debug "Android push notifications initialized" + diff --git a/src/app/boot/app_controller.nim b/src/app/boot/app_controller.nim index 1d2c1658229..5034a7bd65e 100644 --- a/src/app/boot/app_controller.nim +++ b/src/app/boot/app_controller.nim @@ -48,6 +48,9 @@ import app/core/[main] import constants as main_constants +when defined(android): + import app/android/push_notifications + logScope: topics = "app-controller" @@ -438,6 +441,11 @@ proc load(self: AppController) = proc userLoggedIn*(self: AppController): string = try: self.generalService.startMessenger() + + # After messenger is started, register push notification token if available + when defined(android): + discard registerPushNotificationToken() + return "" except Exception as e: let errDescription = e.msg diff --git a/src/backend/backend.nim b/src/backend/backend.nim index 8a270807be1..17c751703bf 100644 --- a/src/backend/backend.nim +++ b/src/backend/backend.nim @@ -326,3 +326,18 @@ rpc(fetchMarketTokenPageAsync, "wallet"): rpc(unsubscribeFromLeaderboard, "wallet"): discard + +# Push Notifications +rpc(registerForPushNotifications, "wakuext"): + deviceToken: string + apnTopic: string + tokenType: int + +rpc(unregisterFromPushNotifications, "wakuext"): + discard + +rpc(enablePushNotificationsFromContactsOnly, "wakuext"): + discard + +rpc(disablePushNotificationsFromContactsOnly, "wakuext"): + discard diff --git a/src/nim_status_client.nim b/src/nim_status_client.nim index 98edb11d40b..da2b6a0c4cc 100644 --- a/src/nim_status_client.nim +++ b/src/nim_status_client.nim @@ -21,6 +21,9 @@ featureGuard KEYCARD_ENABLED: var keycardServiceQObjPointer: pointer var keycardServiceV2QObjPointer: pointer +when defined(android): + import app/android/push_notifications + when defined(macosx) and defined(arm64): import posix @@ -252,6 +255,10 @@ proc mainProc() = singletonInstance.engine.setRootContextProperty("featureFlagsRootContextProperty", newQVariant(singletonInstance.featureFlags())) statusq_registerQmlTypes() + + # Initialize push notifications (Android only) + when defined(android): + initializeAndroidPushNotifications() app.installEventFilter(urlSchemeEvent) diff --git a/src/statusq_bridge.nim b/src/statusq_bridge.nim index b25947b0998..64802f2f355 100644 --- a/src/statusq_bridge.nim +++ b/src/statusq_bridge.nim @@ -1,3 +1,19 @@ # Declarations of methods exposed from StatusQ proc statusq_registerQmlTypes*() {.cdecl, importc.} + +when defined(android): + # Push notification callback types + type + PushNotificationTokenCallback* = proc(token: cstring) {.cdecl.} + PushNotificationReceivedCallback* = proc(encryptedMessage: cstring, chatId: cstring, publicKey: cstring) {.cdecl.} + + # Android push notification initialization + proc statusq_initPushNotifications*( + tokenCallback: PushNotificationTokenCallback, + receivedCallback: PushNotificationReceivedCallback + ) {.cdecl, importc.} + + # Android notification permission (Android 13+) + proc statusq_requestNotificationPermission*() {.cdecl, importc.} + proc statusq_hasNotificationPermission*(): bool {.cdecl, importc.} diff --git a/ui/StatusQ/CMakeLists.txt b/ui/StatusQ/CMakeLists.txt index a48c67e61b1..c15333e4933 100644 --- a/ui/StatusQ/CMakeLists.txt +++ b/ui/StatusQ/CMakeLists.txt @@ -186,6 +186,7 @@ add_library(StatusQ ${LIB_TYPE} include/StatusQ/typesregistration.h include/StatusQ/undefinedfilter.h include/StatusQ/urlutils.h + include/StatusQ/pushnotification_android.h src/audioutils.cpp src/clipboardutils.cpp src/constantrole.cpp @@ -250,6 +251,7 @@ elseif (${CMAKE_SYSTEM_NAME} MATCHES "Android") target_sources(StatusQ PRIVATE src/keychain_android.cpp src/safutils_android.cpp + src/pushnotification_android.cpp ) else () target_sources(StatusQ PRIVATE diff --git a/ui/StatusQ/include/StatusQ/pushnotification_android.h b/ui/StatusQ/include/StatusQ/pushnotification_android.h new file mode 100644 index 00000000000..fa0086f2682 --- /dev/null +++ b/ui/StatusQ/include/StatusQ/pushnotification_android.h @@ -0,0 +1,178 @@ +#pragma once + +#include +#include + +#ifdef Q_OS_ANDROID +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// C API for Nim integration +typedef void (*PushNotificationTokenCallback)(const char* token); +typedef void (*PushNotificationReceivedCallback)(const char* encryptedMessage, const char* chatId, const char* publicKey); + +/** + * Initialize push notifications with callbacks + * This is called from Nim code with cdecl callback functions + * + * @param tokenCallback Function to call when FCM token is received + * @param receivedCallback Function to call when push notification is received + */ +void statusq_initPushNotifications( + PushNotificationTokenCallback tokenCallback, + PushNotificationReceivedCallback receivedCallback +); + +/** + * Show a notification (called from Nim via existing OSNotification) + * + * @param title Notification title + * @param message Notification message + * @param identifier JSON metadata + */ +void statusq_showAndroidNotification(const char* title, const char* message, const char* identifier); + +#ifdef __cplusplus +} +#endif + +#ifdef Q_OS_ANDROID + +/** + * Android Push Notification Bridge + * + * This class bridges between: + * - Java Android code (FCM, NotificationManager) + * - Qt/C++ layer + * - Nim backend + * + * It handles: + * - FCM token registration + * - Notification display + * - JNI callbacks from Java + */ +class PushNotificationAndroid : public QObject +{ + Q_OBJECT + +public: + /** + * Get singleton instance + */ + static PushNotificationAndroid* instance(); + + /** + * Initialize push notifications with callbacks + * - Stores Nim callbacks + * - Registers JNI native methods + * - Requests FCM token + * + * @param tokenCallback Callback for FCM token reception + * @param receivedCallback Callback for push notification reception + */ + void initialize(PushNotificationTokenCallback tokenCallback, + PushNotificationReceivedCallback receivedCallback); + + /** + * Check if notification permission is granted (Android 13+ only) + * @return true if permission granted or not required (Android 12-) + */ + bool hasNotificationPermission(); + + /** + * Request notification permission (Android 13+ only) + * Shows system permission dialog on Android 13+ + * Does nothing on Android 12 and below + */ + void requestNotificationPermission(); + + /** + * Request FCM token from Firebase + * The token will be delivered via tokenReceived() signal + */ + void requestFCMToken(); + + /** + * Show a notification (called from Nim layer) + * + * @param title Notification title + * @param message Notification message + * @param identifier JSON string with metadata (chatId, etc.) + */ + void showNotification(const QString& title, const QString& message, const QString& identifier); + + /** + * Clear notifications for a specific chat + * + * @param chatId The chat identifier + */ + void clearNotifications(const QString& chatId); + +signals: + /** + * Emitted when FCM token is received from Firebase + * + * @param token The FCM device token + */ + void tokenReceived(const QString& token); + + /** + * Emitted when a push notification is received (via FCM) + * This is for the encrypted payload that needs to be processed by status-go + * + * @param encryptedMessage The encrypted message data + * @param chatId The chat identifier + * @param publicKey The sender's public key + */ + void pushNotificationReceived(const QString& encryptedMessage, + const QString& chatId, + const QString& publicKey); + + /** + * Emitted when user taps a notification + * + * @param identifier The notification identifier (contains chatId, etc.) + */ + void notificationTapped(const QString& identifier); + +private: + PushNotificationAndroid(QObject* parent = nullptr); + ~PushNotificationAndroid() = default; + + /** + * Register JNI native methods + * This allows Java code to call C++ methods + */ + void registerNativeMethods(); + + /** + * Check if Google Play Services is available + */ + bool isGooglePlayServicesAvailable(); + + // JNI callback handlers (called from Java) + friend void jni_onFCMTokenReceived(JNIEnv* env, jobject obj, jstring token); + friend void jni_onPushNotificationReceived(JNIEnv* env, jobject obj, + jstring encryptedMessage, + jstring chatId, + jstring publicKey); + + static PushNotificationAndroid* s_instance; + bool m_initialized; +}; + +// JNI callback functions (implemented in .cpp) +// Note: These are C++ functions, not extern "C", because they're registered via JNI RegisterNatives +// which handles the calling convention. They're declared as friends above. +void jni_onFCMTokenReceived(JNIEnv* env, jobject obj, jstring token); +void jni_onPushNotificationReceived(JNIEnv* env, jobject obj, + jstring encryptedMessage, + jstring chatId, + jstring publicKey); + +#endif // Q_OS_ANDROID + diff --git a/ui/StatusQ/src/externc.cpp b/ui/StatusQ/src/externc.cpp index baa2ab6218b..8289330dec9 100644 --- a/ui/StatusQ/src/externc.cpp +++ b/ui/StatusQ/src/externc.cpp @@ -1,8 +1,13 @@ #include +#include #include #include +#ifdef Q_OS_ANDROID +#include +#endif + extern "C" { Q_DECL_EXPORT void statusq_registerQmlTypes() { @@ -13,4 +18,65 @@ Q_DECL_EXPORT float statusq_getMobileUIScaleFactor(float baseWidth, float baseDp return MobileUI::getSmartScaleFactor(baseWidth, baseDpi, baseScale); } +// ============================================================================ +// Android Push Notifications C API +// ============================================================================ + +Q_DECL_EXPORT void statusq_initPushNotifications( + PushNotificationTokenCallback tokenCallback, + PushNotificationReceivedCallback receivedCallback) +{ +#ifdef Q_OS_ANDROID + qDebug() << "[StatusQ C API] Initializing Android push notifications..."; + PushNotificationAndroid::instance()->initialize(tokenCallback, receivedCallback); +#else + Q_UNUSED(tokenCallback); + Q_UNUSED(receivedCallback); + qDebug() << "[StatusQ C API] Push notifications not available on this platform"; +#endif +} + +Q_DECL_EXPORT void statusq_requestNotificationPermission() +{ +#ifdef Q_OS_ANDROID + qDebug() << "[StatusQ C API] Requesting notification permission..."; + PushNotificationAndroid::instance()->requestNotificationPermission(); +#else + qDebug() << "[StatusQ C API] Permission request not needed on this platform"; +#endif +} + +Q_DECL_EXPORT bool statusq_hasNotificationPermission() +{ +#ifdef Q_OS_ANDROID + return PushNotificationAndroid::instance()->hasNotificationPermission(); +#else + return true; // Other platforms don't require permission +#endif +} + +Q_DECL_EXPORT void statusq_showAndroidNotification( + const char* title, + const char* message, + const char* identifier) +{ +#ifdef Q_OS_ANDROID + if (!title || !message || !identifier) { + qWarning() << "[StatusQ C API] Invalid notification parameters"; + return; + } + + PushNotificationAndroid::instance()->showNotification( + QString::fromUtf8(title), + QString::fromUtf8(message), + QString::fromUtf8(identifier) + ); +#else + Q_UNUSED(title); + Q_UNUSED(message); + Q_UNUSED(identifier); + qDebug() << "[StatusQ C API] showNotification not available on this platform"; +#endif +} + } // extern "C" diff --git a/ui/StatusQ/src/pushnotification_android.cpp b/ui/StatusQ/src/pushnotification_android.cpp new file mode 100644 index 00000000000..7850f095b89 --- /dev/null +++ b/ui/StatusQ/src/pushnotification_android.cpp @@ -0,0 +1,373 @@ +#include "StatusQ/pushnotification_android.h" + +#ifdef Q_OS_ANDROID + +#include +#include +#include +#include + +// Static instance and callbacks +PushNotificationAndroid* PushNotificationAndroid::s_instance = nullptr; +static PushNotificationTokenCallback s_tokenCallback = nullptr; +static PushNotificationReceivedCallback s_receivedCallback = nullptr; + +PushNotificationAndroid::PushNotificationAndroid(QObject* parent) + : QObject(parent) + , m_initialized(false) +{ +} + +PushNotificationAndroid* PushNotificationAndroid::instance() +{ + if (!s_instance) { + s_instance = new PushNotificationAndroid(qApp); + } + return s_instance; +} + +void PushNotificationAndroid::initialize(PushNotificationTokenCallback tokenCallback, + PushNotificationReceivedCallback receivedCallback) +{ + if (m_initialized) { + qDebug() << "[PushNotificationAndroid] Already initialized"; + return; + } + + qDebug() << "[PushNotificationAndroid] Initializing..."; + + // Store callbacks + s_tokenCallback = tokenCallback; + s_receivedCallback = receivedCallback; + + // Check if Google Play Services is available + if (!isGooglePlayServicesAvailable()) { + qWarning() << "[PushNotificationAndroid] Google Play Services not available"; + return; + } + + // Register JNI native methods + registerNativeMethods(); + + // Request FCM token + requestFCMToken(); + + m_initialized = true; + qDebug() << "[PushNotificationAndroid] Initialization complete"; +} + +bool PushNotificationAndroid::hasNotificationPermission() +{ + QJniEnvironment env; + if (!env.isValid()) { + qWarning() << "[PushNotificationAndroid] Invalid JNI environment"; + return false; + } + + QJniObject activity = QNativeInterface::QAndroidApplication::context(); + if (!activity.isValid()) { + qWarning() << "[PushNotificationAndroid] Failed to get Android context"; + return false; + } + + jboolean result = QJniObject::callStaticMethod( + "app/status/mobile/PushNotificationHelper", + "hasNotificationPermission", + "(Landroid/content/Context;)Z", + activity.object() + ); + + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + return false; + } + + return result; +} + +void PushNotificationAndroid::requestNotificationPermission() +{ + qDebug() << "[PushNotificationAndroid] Requesting notification permission..."; + + QJniEnvironment env; + if (!env.isValid()) { + qWarning() << "[PushNotificationAndroid] Invalid JNI environment"; + return; + } + + QJniObject activity = QNativeInterface::QAndroidApplication::context(); + if (!activity.isValid()) { + qWarning() << "[PushNotificationAndroid] Failed to get Android context"; + return; + } + + QJniObject::callStaticMethod( + "app/status/mobile/PushNotificationHelper", + "requestNotificationPermission", + "(Landroid/content/Context;)V", + activity.object() + ); + + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + qWarning() << "[PushNotificationAndroid] Exception requesting permission"; + return; + } + + qDebug() << "[PushNotificationAndroid] Permission request sent"; +} + +void PushNotificationAndroid::requestFCMToken() +{ + qDebug() << "[PushNotificationAndroid] Requesting FCM token via Java layer..."; + + QJniEnvironment env; + if (!env.isValid()) { + qWarning() << "[PushNotificationAndroid] Invalid JNI environment"; + return; + } + + // Get Android application context + QJniObject activity = QNativeInterface::QAndroidApplication::context(); + if (!activity.isValid()) { + qWarning() << "[PushNotificationAndroid] Failed to get Android context"; + return; + } + + // Check permission first + if (!hasNotificationPermission()) { + qWarning() << "[PushNotificationAndroid] Notification permission not granted!"; + qWarning() << "[PushNotificationAndroid] Call requestNotificationPermission() first"; + } + + // Call PushNotificationHelper.requestFCMToken() which handles the Task listener + QJniObject::callStaticMethod( + "app/status/mobile/PushNotificationHelper", + "requestFCMToken", + "(Landroid/content/Context;)V", + activity.object() + ); + + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + qWarning() << "[PushNotificationAndroid] Exception requesting FCM token"; + return; + } + + qDebug() << "[PushNotificationAndroid] FCM token request sent, waiting for callback..."; +} + +void PushNotificationAndroid::showNotification(const QString& title, + const QString& message, + const QString& identifier) +{ + qDebug() << "[PushNotificationAndroid] Showing notification:" << title; + + QJniEnvironment env; + if (!env.isValid()) { + qWarning() << "[PushNotificationAndroid] Invalid JNI environment"; + return; + } + + // Call Java helper method to display notification + QJniObject::callStaticMethod( + "app/status/mobile/PushNotificationHelper", + "showNotification", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", + QJniObject::fromString(title).object(), + QJniObject::fromString(message).object(), + QJniObject::fromString(identifier).object() + ); + + // Check for exceptions + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + qWarning() << "[PushNotificationAndroid] Exception showing notification"; + } +} + +void PushNotificationAndroid::clearNotifications(const QString& chatId) +{ + qDebug() << "[PushNotificationAndroid] Clearing notifications for chat:" << chatId; + + QJniEnvironment env; + if (!env.isValid()) { + return; + } + + QJniObject::callStaticMethod( + "app/status/mobile/PushNotificationHelper", + "clearNotifications", + "(Ljava/lang/String;)V", + QJniObject::fromString(chatId).object() + ); + + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + } +} + +void PushNotificationAndroid::registerNativeMethods() +{ + qDebug() << "[PushNotificationAndroid] Registering JNI native methods..."; + + QJniEnvironment env; + if (!env.isValid()) { + qWarning() << "[PushNotificationAndroid] Invalid JNI environment"; + return; + } + + // Find the PushNotificationHelper class + jclass helperClass = env->FindClass("app/status/mobile/PushNotificationHelper"); + if (!helperClass) { + qWarning() << "[PushNotificationAndroid] Could not find PushNotificationHelper class"; + env->ExceptionDescribe(); + env->ExceptionClear(); + return; + } + + // Define native methods + JNINativeMethod methods[] = { + { + const_cast("nativeOnFCMTokenReceived"), + const_cast("(Ljava/lang/String;)V"), + reinterpret_cast(jni_onFCMTokenReceived) + }, + { + const_cast("nativeOnPushNotificationReceived"), + const_cast("(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"), + reinterpret_cast(jni_onPushNotificationReceived) + } + }; + + // Register methods + jint result = env->RegisterNatives(helperClass, methods, 2); + if (result != JNI_OK) { + qWarning() << "[PushNotificationAndroid] Failed to register native methods:" << result; + env->ExceptionDescribe(); + env->ExceptionClear(); + } else { + qDebug() << "[PushNotificationAndroid] Native methods registered successfully"; + } + + env->DeleteLocalRef(helperClass); +} + +bool PushNotificationAndroid::isGooglePlayServicesAvailable() +{ + QJniEnvironment env; + if (!env.isValid()) { + return false; + } + + // Check if Google Play Services is available + // This is a simplified check - in production, you might want more thorough checking + try { + QJniObject context = QJniObject::callStaticObjectMethod( + "org/qtproject/qt/android/QtNative", + "activity", + "()Landroid/app/Activity;" + ); + + if (!context.isValid()) { + return false; + } + + // Try to get FirebaseApp - if this works, Firebase is available + QJniObject firebaseApp = QJniObject::callStaticObjectMethod( + "com/google/firebase/FirebaseApp", + "getInstance", + "()Lcom/google/firebase/FirebaseApp;" + ); + + return firebaseApp.isValid(); + + } catch (...) { + qWarning() << "[PushNotificationAndroid] Exception checking Play Services"; + return false; + } +} + +// ============================================================================ +// JNI Callback Implementations +// ============================================================================ + +void jni_onFCMTokenReceived(JNIEnv* env, jobject obj, jstring token) +{ + Q_UNUSED(obj); + + if (!env || !token) { + qWarning() << "[PushNotificationAndroid] Invalid parameters in onFCMTokenReceived"; + return; + } + + // Convert jstring to QString and const char* + const char* tokenChars = env->GetStringUTFChars(token, nullptr); + QString tokenStr = QString::fromUtf8(tokenChars); + + qDebug() << "[PushNotificationAndroid] FCM Token received:" << tokenStr; + + // Call Nim callback if registered + if (s_tokenCallback != nullptr) { + qDebug() << "[PushNotificationAndroid] Calling Nim callback with token"; + s_tokenCallback(tokenChars); + } else { + qWarning() << "[PushNotificationAndroid] No callback registered for token!"; + } + + env->ReleaseStringUTFChars(token, tokenChars); + + // Also emit signal for backward compatibility + if (PushNotificationAndroid::s_instance) { + emit PushNotificationAndroid::s_instance->tokenReceived(tokenStr); + } +} + +void jni_onPushNotificationReceived(JNIEnv* env, jobject obj, + jstring encryptedMessage, + jstring chatId, + jstring publicKey) +{ + Q_UNUSED(obj); + + if (!env || !encryptedMessage || !chatId || !publicKey) { + qWarning() << "[PushNotificationAndroid] Invalid parameters in onPushNotificationReceived"; + return; + } + + // Convert jstrings to const char* + const char* encMsgChars = env->GetStringUTFChars(encryptedMessage, nullptr); + const char* chatIdChars = env->GetStringUTFChars(chatId, nullptr); + const char* pubKeyChars = env->GetStringUTFChars(publicKey, nullptr); + + qDebug() << "[PushNotificationAndroid] Push notification received for chat:" << chatIdChars; + + // Call Nim callback if registered + if (s_receivedCallback != nullptr) { + qDebug() << "[PushNotificationAndroid] Calling Nim callback with notification data"; + s_receivedCallback(encMsgChars, chatIdChars, pubKeyChars); + } else { + qWarning() << "[PushNotificationAndroid] No callback registered for notifications!"; + } + + env->ReleaseStringUTFChars(encryptedMessage, encMsgChars); + env->ReleaseStringUTFChars(chatId, chatIdChars); + env->ReleaseStringUTFChars(publicKey, pubKeyChars); + + // Also emit signal for backward compatibility + if (PushNotificationAndroid::s_instance) { + emit PushNotificationAndroid::s_instance->pushNotificationReceived( + QString::fromUtf8(encMsgChars), + QString::fromUtf8(chatIdChars), + QString::fromUtf8(pubKeyChars) + ); + } +} + +#endif // Q_OS_ANDROID +