diff --git a/.gitmodules b/.gitmodules index 369f288f1a7..b10fda5f0a6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -129,3 +129,6 @@ [submodule "vendor/nim-unicodedb"] path = vendor/nim-unicodedb url = https://github.com/nitely/nim-unicodedb +[submodule "vendor/status-keycard-qt"] + path = vendor/status-keycard-qt + url = https://github.com/status-im/status-keycard-qt diff --git a/Makefile b/Makefile index 3aa9a292566..01e894beaf6 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ GIT_ROOT ?= $(shell git rev-parse --show-toplevel 2>/dev/null || echo .) tests-nim-linux \ status-go \ status-keycard-go \ + status-keycard-qt \ statusq-sanity-checker \ run-statusq-sanity-checker \ statusq-tests \ @@ -248,6 +249,9 @@ NIMSDS_LIBDIR := $(NIM_SDS_SOURCE_DIR)/build NIMSDS_LIBFILE := $(NIMSDS_LIBDIR)/libsds.$(LIB_EXT) NIM_EXTRA_PARAMS += --passL:"-L$(NIMSDS_LIBDIR)" --passL:"-lsds" STATUSGO_MAKE_PARAMS += NIM_SDS_SOURCE_DIR="$(NIM_SDS_SOURCE_DIR)" +# Keycard library selection: set to 1 to use status-keycard-qt (Qt/C++), 0 for status-keycard-go (Go) +# Default: use status-keycard-go for now (stable), switch to 1 to test status-keycard-qt +USE_STATUS_KEYCARD_QT ?= 0 INCLUDE_DEBUG_SYMBOLS ?= false ifeq ($(INCLUDE_DEBUG_SYMBOLS),true) @@ -501,6 +505,62 @@ $(STATUSKEYCARDGO): | deps $(if $(filter 1 true,$(USE_MOCKED_KEYCARD_LIB)), build-mocked-lib, build-lib) \ $(STATUSKEYCARDGO_MAKE_PARAMS) $(HANDLE_OUTPUT) +## +## status-keycard-qt (Qt/C++ based keycard library) +## + +# Allow using local status-keycard-qt for development +STATUS_KEYCARD_QT_DIR ?= vendor/status-keycard-qt +KEYCARD_QT ?= "" + +# Determine build directory based on platform +ifeq ($(mkspecs),macx) +STATUS_KEYCARD_QT_BUILD_DIR := $(STATUS_KEYCARD_QT_DIR)/build/macos +else ifeq ($(mkspecs),win32) +STATUS_KEYCARD_QT_BUILD_DIR := $(STATUS_KEYCARD_QT_DIR)/build/windows +else +STATUS_KEYCARD_QT_BUILD_DIR := $(STATUS_KEYCARD_QT_DIR)/build/linux +endif + +export STATUSKEYCARD_QT_LIB := $(STATUS_KEYCARD_QT_BUILD_DIR)/libstatus-keycard-qt.$(LIB_EXT) +export STATUSKEYCARD_QT_LIBDIR := $(STATUS_KEYCARD_QT_BUILD_DIR) + +status-keycard-qt: $(STATUSKEYCARD_QT_LIB) +$(STATUSKEYCARD_QT_LIB): | deps check-qt-dir + echo -e $(BUILD_MSG) "status-keycard-qt" + + STATUS_KEYCARD_QT_DIR="$(STATUS_KEYCARD_QT_DIR)" \ + KEYCARD_QT_DIR="$(KEYCARD_QT_DIR)" \ + QMAKE="$(QMAKE)" \ + cmake -S "${STATUS_KEYCARD_QT_DIR}" -B "${STATUS_KEYCARD_QT_BUILD_DIR}" \ + $(COMMON_CMAKE_CONFIG_PARAMS) \ + -DBUILD_TESTING=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_SHARED_LIBS=ON \ + -DKEYCARD_QT_SOURCE_DIR=${KEYCARD_QT} \ + $(HANDLE_OUTPUT) + cmake --build $(STATUS_KEYCARD_QT_BUILD_DIR) --target status-keycard-qt $(HANDLE_OUTPUT) + +status-keycard-qt-clean: + echo -e "\033[92mCleaning:\033[39m status-keycard-qt" + rm -rf $(STATUS_KEYCARD_QT_BUILD_DIR) + +## +## Keycard library selection +## + +# Set the keycard library and paths based on USE_STATUS_KEYCARD_QT +ifeq ($(USE_STATUS_KEYCARD_QT),1) + KEYCARD_LIB := $(STATUSKEYCARD_QT_LIB) + KEYCARD_LIBDIR := $(STATUSKEYCARD_QT_LIBDIR) + KEYCARD_LINKNAME := status-keycard-qt + KEYCARD_DYLIB_NAME := libstatus-keycard-qt.dylib +else + KEYCARD_LIB := $(STATUSKEYCARDGO) + KEYCARD_LIBDIR := $(STATUSKEYCARDGO_LIBDIR) + KEYCARD_LINKNAME := keycard + KEYCARD_DYLIB_NAME := libkeycard.dylib +endif + QRCODEGEN := vendor/QR-Code-generator/c/libqrcodegen.a $(QRCODEGEN): | deps @@ -611,7 +671,7 @@ $(NIM_STATUS_CLIENT): update-qmake-previous endif $(NIM_STATUS_CLIENT): NIM_PARAMS += $(RESOURCES_LAYOUT) -$(NIM_STATUS_CLIENT): $(NIM_SOURCES) | statusq dotherside check-qt-dir $(STATUSGO) $(STATUSKEYCARDGO) $(QRCODEGEN) rcc deps +$(NIM_STATUS_CLIENT): $(NIM_SOURCES) | statusq dotherside check-qt-dir $(STATUSGO) $(KEYCARD_LIB) $(QRCODEGEN) rcc deps echo -e $(BUILD_MSG) "$@" $(ENV_SCRIPT) nim c $(NIM_PARAMS) \ --mm:refc \ @@ -619,8 +679,8 @@ $(NIM_STATUS_CLIENT): $(NIM_SOURCES) | statusq dotherside check-qt-dir $(STATUSG --passL:"-lstatus" \ --passL:"-L$(STATUSQ_INSTALL_PATH)/StatusQ" \ --passL:"-lStatusQ" \ - --passL:"-L$(STATUSKEYCARDGO_LIBDIR)" \ - --passL:"-lkeycard" \ + --passL:"-L$(KEYCARD_LIBDIR)" \ + --passL:"-l$(KEYCARD_LINKNAME)" \ --passL:"$(QRCODEGEN)" \ --passL:"-lm" \ --parallelBuild:0 \ @@ -631,8 +691,8 @@ ifeq ($(mkspecs),macx) @rpath/libstatus.dylib \ bin/nim_status_client install_name_tool -change \ - libkeycard.dylib \ - @rpath/libkeycard.dylib \ + $(KEYCARD_DYLIB_NAME) \ + @rpath/$(KEYCARD_DYLIB_NAME) \ bin/nim_status_client endif @@ -850,7 +910,7 @@ zip-windows: check-pkg-target-windows $(STATUS_CLIENT_7Z) clean-destdir: rm -rf bin/* -clean: | clean-common clean-destdir statusq-clean status-go-clean dotherside-clean storybook-clean clean-translations +clean: | clean-common clean-destdir statusq-clean status-go-clean status-keycard-qt-clean dotherside-clean storybook-clean clean-translations rm -rf bottles/* pkg/* tmp/* $(STATUSKEYCARDGO) + $(MAKE) -C vendor/QR-Code-generator/c/ --no-print-directory clean @@ -868,12 +928,12 @@ run: $(RUN_TARGET) run-linux: nim_status_client echo -e "\033[92mRunning:\033[39m bin/nim_status_client" - LD_LIBRARY_PATH="$(QT_LIBDIR)":"$(LIBWAKU_LIBDIR)":"$(NIMSDS_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(STATUSKEYCARDGO_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(LD_LIBRARY_PATH)" \ + LD_LIBRARY_PATH="$(QT_LIBDIR)":"$(LIBWAKU_LIBDIR)":"$(NIMSDS_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(KEYCARD_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(LD_LIBRARY_PATH)" \ ./bin/nim_status_client $(ARGS) run-linux-gdb: nim_status_client echo -e "\033[92mRunning:\033[39m bin/nim_status_client" - LD_LIBRARY_PATH="$(QT_LIBDIR)":"$(LIBWAKU_LIBDIR)":"$(NIMSDS_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(STATUSKEYCARDGO_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(LD_LIBRARY_PATH)" \ + LD_LIBRARY_PATH="$(QT_LIBDIR)":"$(LIBWAKU_LIBDIR)":"$(NIMSDS_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(KEYCARD_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(LD_LIBRARY_PATH)" \ gdb -ex=r ./bin/nim_status_client $(ARGS) run-macos: nim_status_client @@ -884,12 +944,13 @@ run-macos: nim_status_client ln -fs ../../../nim_status_client ./ fileicon set bin/nim_status_client status-dev.icns echo -e "\033[92mRunning:\033[39m bin/StatusDev.app/Contents/MacOS/nim_status_client" + DYLD_LIBRARY_PATH="$(STATUSGO_LIBDIR)":"$(KEYCARD_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(DYLD_LIBRARY_PATH)" \ ./bin/StatusDev.app/Contents/MacOS/nim_status_client $(ARGS) run-windows: STATUS_RC_FILE = status-dev.rc run-windows: compile_windows_resources nim_status_client echo -e "\033[92mRunning:\033[39m bin/nim_status_client.exe" - PATH="$(DOTHERSIDE_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(STATUSKEYCARDGO_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(PATH)" \ + PATH="$(DOTHERSIDE_LIBDIR)":"$(STATUSGO_LIBDIR)":"$(KEYCARD_LIBDIR)":"$(STATUSQ_INSTALL_PATH)/StatusQ":"$(PATH)" \ ./bin/nim_status_client.exe $(ARGS) NIM_TEST_FILES := $(wildcard test/nim/*.nim) @@ -908,11 +969,16 @@ endef export PATH := $(call qmkq,QT_INSTALL_BINS):$(call qmkq,QT_HOST_BINS):$(call qmkq,QT_HOST_LIBEXECS):$(PATH) export QTDIR := $(call qmkq,QT_INSTALL_PREFIX) +#Force keycard support for mobile builds +ifeq ($(USE_STATUS_KEYCARD_QT),1) + MOBILE_FLAGS += "FLAG_KEYCARD_ENABLED=1" +endif + mobile-run: deps-common echo -e "\033[92mRunning:\033[39m mobile app" - $(MAKE) -C mobile run + $(MAKE) -C mobile run DEBUG=1 $(MOBILE_FLAGS) -mobile-build: USE_SYSTEM_NIM=1 +mobile-build: USE_SYSTEM_NIM=1 $(MOBILE_FLAGS) mobile-build: | deps-common echo -e "\033[92mBuilding:\033[39m mobile app ($(or $(PACKAGE_TYPE),default))" ifeq ($(PACKAGE_TYPE),aab) diff --git a/mobile/DEV_SETUP.md b/mobile/DEV_SETUP.md index f4883603abd..f48e2026ab4 100644 --- a/mobile/DEV_SETUP.md +++ b/mobile/DEV_SETUP.md @@ -32,6 +32,22 @@ export QTDIR=$HOME/qt/6.9.2/ios make mobile-run ``` +Running the app requires a code sign identity. See [Signing](#signing) + +#### Keycard + +The keycard support is disabled by default in the mobile makefile for IOS. It requires a paid apple developer account to run the app with NFC enabled. + +To enable keycard use the `USE_STATUS_KEYCARD_QT=1` flag for the main Makefile and use a paid account by updating the `DEVELOPMENT_TEAM` flag and the bundle if (if the development team isn't Status). + +#### Signing + +By default the app isn't signed. + +To sign the app the `DEVELOPMENT_TEAM` flag needs to be provided. If the development team is not Status development team, then the app bundle id needs to be updated to a unique bundle id. + +#### + ### Android Development Setup #### Prerequisites - can be installed using the Android Studio diff --git a/mobile/Makefile b/mobile/Makefile index 99a8272f90d..4d164350dd0 100644 --- a/mobile/Makefile +++ b/mobile/Makefile @@ -3,6 +3,18 @@ STATUS_GO_LIB := $(LIB_PATH)/libstatus$(LIB_EXT) +# FLAG_KEYCARD_ENABLED: Controls NFC/Keycard support +# - iOS: Default 0 (disabled) - Build without NFC, works with free Apple Developer account +# - Android: Default 1 (enabled) - NFC support doesn't require paid account on Android +# - Set to 1: Build with NFC support (iOS requires paid Apple Developer account) +# - Set to 0: Build without NFC support +# Usage: make build-ios FLAG_KEYCARD_ENABLED=1 +ifeq ($(OS),android) + FLAG_KEYCARD_ENABLED ?= 1 +else + FLAG_KEYCARD_ENABLED ?= 0 +endif + $(info Configuring build system for $(OS) $(ARCH) with QT $(QT_MAJOR)) # default rule @@ -16,6 +28,7 @@ statusq: clean-statusq $(STATUS_Q_LIB) dotherside: clean-dotherside $(DOTHERSIDE_LIB) openssl: clean-openssl $(OPENSSL_LIB) qrcodegen: clean-qrcodegen $(QRCODEGEN_LIB) +status-keycard-qt: clean-status-keycard-qt $(STATUS_KEYCARD_QT_LIB) nim-status-client: clean-nim-status-client $(NIM_STATUS_CLIENT_LIB) status-desktop-rcc: clean-status-desktop-rcc $(STATUS_DESKTOP_RCC) @@ -62,6 +75,11 @@ $(QRCODEGEN_LIB): $(QRCODEGEN_FILES) @QRCODEGEN=$(QRCODEGEN) $(QRCODEGEN_SCRIPT) $(HANDLE_OUTPUT) @echo "QRCodeGen built $(QRCODEGEN_LIB)" +$(STATUS_KEYCARD_QT_LIB): $(STATUS_KEYCARD_QT_FILES) $(STATUS_KEYCARD_QT_SCRIPT) $(OPENSSL_LIB) + @echo "Building status-keycard-qt" + @STATUS_KEYCARD_QT=$(STATUS_KEYCARD_QT) KEYCARD_QT=$(KEYCARD_QT) BUILD_DIR=$(BUILD_PATH) $(STATUS_KEYCARD_QT_SCRIPT) $(HANDLE_OUTPUT) + @echo "status-keycard-qt built $(STATUS_KEYCARD_QT_LIB)" + $(STATUS_DESKTOP_RCC): $(STATUS_DESKTOP_UI_FILES) compile-translations @echo "Building Status Desktop rcc" @make -C $(STATUS_DESKTOP) rcc $(HANDLE_OUTPUT) @@ -69,16 +87,39 @@ $(STATUS_DESKTOP_RCC): $(STATUS_DESKTOP_UI_FILES) compile-translations $(NIM_STATUS_CLIENT_LIB): $(STATUS_DESKTOP_NIM_FILES) $(NIM_STATUS_CLIENT_SCRIPT) $(STATUS_DESKTOP_RCC) $(DOTHERSIDE_LIB) $(OPENSSL_LIB) $(STATUS_Q_LIB) $(STATUS_GO_LIB) $(QRCODEGEN_LIB) @echo "Building Status Desktop Lib" - @STATUS_DESKTOP=$(STATUS_DESKTOP) LIB_SUFFIX=$(LIB_SUFFIX) LIB_EXT=$(LIB_EXT) USE_QML_SERVER=$(USE_QML_SERVER) $(NIM_STATUS_CLIENT_SCRIPT) $(HANDLE_OUTPUT) + @STATUS_DESKTOP=$(STATUS_DESKTOP) \ + LIB_SUFFIX=$(LIB_SUFFIX) \ + LIB_EXT=$(LIB_EXT) \ + USE_QML_SERVER=$(USE_QML_SERVER) \ + BUNDLE_IDENTIFIER="$(BUNDLE_IDENTIFIER)" \ + DEBUG=$(DEBUG) \ + FLAG_DAPPS_ENABLED=$(FLAG_DAPPS_ENABLED) \ + FLAG_CONNECTOR_ENABLED=$(FLAG_CONNECTOR_ENABLED) \ + FLAG_KEYCARD_ENABLED=$(FLAG_KEYCARD_ENABLED) \ + FLAG_SINGLE_STATUS_INSTANCE_ENABLED=$(FLAG_SINGLE_STATUS_INSTANCE_ENABLED) \ + FLAG_BROWSER_ENABLED=$(FLAG_BROWSER_ENABLED) \ + $(NIM_STATUS_CLIENT_SCRIPT) $(HANDLE_OUTPUT) @echo "Status Desktop Lib built $(NIM_STATUS_CLIENT_LIB)" # non-phony targets -$(TARGET): $(APP_SCRIPT) $(STATUS_GO_LIB) $(STATUS_Q_LIB) $(DOTHERSIDE_LIB) $(OPENSSL_LIB) $(QRCODEGEN_LIB) $(NIM_STATUS_CLIENT_LIB) $(STATUS_DESKTOP_RCC) $(WRAPPER_APP_FILES) +$(TARGET): $(APP_SCRIPT) $(STATUS_GO_LIB) $(STATUS_Q_LIB) $(DOTHERSIDE_LIB) $(OPENSSL_LIB) $(QRCODEGEN_LIB) $(STATUS_KEYCARD_QT_LIB) $(NIM_STATUS_CLIENT_LIB) $(STATUS_DESKTOP_RCC) $(WRAPPER_APP_FILES) @echo "Building app" ifeq ($(OS),android) - @STATUS_DESKTOP=$(STATUS_DESKTOP) BUILD_TYPE=$(PACKAGE_TYPE) BIN_DIR=$(BIN_PATH) BUILD_DIR=$(BUILD_PATH) QT_MAJOR=$(QT_MAJOR) $(APP_SCRIPT) $(HANDLE_OUTPUT) + @STATUS_DESKTOP=$(STATUS_DESKTOP) \ + BUILD_TYPE=$(PACKAGE_TYPE) \ + BIN_DIR=$(BIN_PATH) \ + BUILD_DIR=$(BUILD_PATH) \ + QT_MAJOR=$(QT_MAJOR) \ + FLAG_KEYCARD_ENABLED=$(FLAG_KEYCARD_ENABLED) \ + $(APP_SCRIPT) $(HANDLE_OUTPUT) else - @STATUS_DESKTOP=$(STATUS_DESKTOP) BIN_DIR=$(BIN_PATH) BUILD_DIR=$(BUILD_PATH) QT_MAJOR=$(QT_MAJOR) $(APP_SCRIPT) $(HANDLE_OUTPUT) + @STATUS_DESKTOP=$(STATUS_DESKTOP) \ + BIN_DIR=$(BIN_PATH) \ + BUILD_DIR=$(BUILD_PATH) \ + QT_MAJOR=$(QT_MAJOR) \ + DEVELOPMENT_TEAM="$(DEVELOPMENT_TEAM)" \ + FLAG_KEYCARD_ENABLED=$(FLAG_KEYCARD_ENABLED) \ + $(APP_SCRIPT) $(HANDLE_OUTPUT) endif @echo "Built $(TARGET)" @@ -128,7 +169,7 @@ endif all: $(TARGET) .PHONY: clean -clean: clean-statusq clean-dotherside clean-openssl clean-qrcodegen clean-nim-status-client clean-status-desktop-rcc +clean: clean-statusq clean-dotherside clean-openssl clean-qrcodegen clean-status-keycard-qt clean-nim-status-client clean-status-desktop-rcc @echo "Cleaning" @rm -rf $(ROOT_DIR)/bin $(ROOT_DIR)/build $(ROOT_DIR)/lib @@ -158,6 +199,13 @@ clean-qrcodegen: @rm -f $(QRCODEGEN_LIB) @cd $(QRCODEGEN) && make clean +# keycard-qt is now automatically built by status-keycard-qt via CMake FetchContent +# Its build artifacts are cleaned as part of status-keycard-qt's build directory +.PHONY: clean-status-keycard-qt +clean-status-keycard-qt: + @rm -f $(STATUS_KEYCARD_QT_LIB) + @rm -rf $(STATUS_KEYCARD_QT)/build/$(OS) + .PHONY: clean-nim-status-client clean-nim-status-client: @rm -f $(NIM_STATUS_CLIENT_LIB) diff --git a/mobile/android/qt6/AndroidManifest.xml b/mobile/android/qt6/AndroidManifest.xml index 7305b3591a8..cb1a78ee1e2 100644 --- a/mobile/android/qt6/AndroidManifest.xml +++ b/mobile/android/qt6/AndroidManifest.xml @@ -17,6 +17,7 @@ + @@ -71,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/mobile/ios/Info.plist b/mobile/ios/Info.plist.template similarity index 84% rename from mobile/ios/Info.plist rename to mobile/ios/Info.plist.template index ca93a55d2a3..e39f6171873 100644 --- a/mobile/ios/Info.plist +++ b/mobile/ios/Info.plist.template @@ -66,5 +66,18 @@ Log in securely to your account. ITSAppUsesNonExemptEncryption + + + diff --git a/mobile/ios/Status-NoKeycard.entitlements b/mobile/ios/Status-NoKeycard.entitlements new file mode 100644 index 00000000000..35c3f6949d0 --- /dev/null +++ b/mobile/ios/Status-NoKeycard.entitlements @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mobile/ios/Status.entitlements b/mobile/ios/Status.entitlements new file mode 100644 index 00000000000..8e1b20cbd2b --- /dev/null +++ b/mobile/ios/Status.entitlements @@ -0,0 +1,16 @@ + + + + + + + com.apple.developer.nfc.readersession.formats + + TAG + + + + + + + diff --git a/mobile/scripts/Common.mk b/mobile/scripts/Common.mk index 896787f8dad..bd8364c62dd 100644 --- a/mobile/scripts/Common.mk +++ b/mobile/scripts/Common.mk @@ -33,6 +33,7 @@ STATUS_GO?=$(STATUS_DESKTOP)/vendor/status-go DOTHERSIDE?=$(STATUS_DESKTOP)/vendor/DOtherSide OPENSSL?=$(ROOT_DIR)/vendors/openssl QRCODEGEN?=$(STATUS_DESKTOP)/vendor/QR-Code-generator/c +STATUS_KEYCARD_QT?=$(STATUS_DESKTOP)/vendor/status-keycard-qt # compile macros TARGET_PREFIX := Status @@ -61,6 +62,7 @@ STATUS_GO_FILES := $(shell find $(STATUS_GO) -type f \( -iname '*.go' \)) DOTHERSIDE_FILES := $(shell find $(DOTHERSIDE) -type f \( -iname '*.cpp' -o -iname '*.h' \)) OPENSSL_FILES := $(shell find $(OPENSSL) -type f \( -iname '*.c' -o -iname '*.h' \)) QRCODEGEN_FILES := $(shell find $(QRCODEGEN) -type f \( -iname '*.c' -o -iname '*.h' \)) +STATUS_KEYCARD_QT_FILES := $(shell find $(STATUS_KEYCARD_QT) -type f \( -iname '*.cpp' -o -iname '*.h' \) 2>/dev/null || echo "") WRAPPER_APP_FILES := $(shell find $(WRAPPER_APP) -type f) # script files @@ -69,6 +71,7 @@ STATUS_GO_SCRIPT := $(SCRIPTS_PATH)/buildStatusGo.sh DOTHERSIDE_SCRIPT := $(SCRIPTS_PATH)/buildDOtherSide.sh OPENSSL_SCRIPT := $(SCRIPTS_PATH)/ios/buildOpenSSL.sh QRCODEGEN_SCRIPT := $(SCRIPTS_PATH)/buildQRCodeGen.sh +STATUS_KEYCARD_QT_SCRIPT := $(SCRIPTS_PATH)/buildStatusKeycardQt.sh NIM_STATUS_CLIENT_SCRIPT := $(SCRIPTS_PATH)/buildNimStatusClient.sh APP_SCRIPT := $(SCRIPTS_PATH)/buildApp.sh RUN_SCRIPT := $(SCRIPTS_PATH)/$(OS)/run.sh @@ -79,6 +82,7 @@ STATUS_Q_LIB := $(LIB_PATH)/libStatusQ$(LIB_SUFFIX)$(LIB_EXT) OPENSSL_LIB := $(LIB_PATH)/libssl_3$(LIB_EXT) QRCODEGEN_LIB := $(LIB_PATH)/libqrcodegen.a QZXING_LIB := $(LIB_PATH)/libqzxing.a +STATUS_KEYCARD_QT_LIB := $(LIB_PATH)/libstatus-keycard-qt$(LIB_EXT) NIM_STATUS_CLIENT_LIB := $(LIB_PATH)/libnim_status_client$(LIB_EXT) STATUS_DESKTOP_RCC := $(STATUS_DESKTOP)/ui/resources.qrc ifeq ($(OS), ios) diff --git a/mobile/scripts/buildApp.sh b/mobile/scripts/buildApp.sh index 3ec8de95c81..313c6ea57c5 100755 --- a/mobile/scripts/buildApp.sh +++ b/mobile/scripts/buildApp.sh @@ -11,6 +11,7 @@ BUILD_DIR=${BUILD_DIR:-"$CWD/../build"} ANDROID_ABI=${ANDROID_ABI:-"arm64-v8a"} BUILD_TYPE=${BUILD_TYPE:-"apk"} SIGN_IOS=${SIGN_IOS:-"false"} +FLAG_KEYCARD_ENABLED=${FLAG_KEYCARD_ENABLED:-0} QMAKE_BIN="${QMAKE:-qmake}" QMAKE_CONFIG="CONFIG+=device CONFIG+=release" @@ -33,6 +34,12 @@ fi echo "Using version: $DESKTOP_VERSION; build version: $BUILD_VERSION" +# Configure qmake with keycard flag +QMAKE_DEFINES="" +if [[ "${FLAG_KEYCARD_ENABLED}" == "1" ]]; then + QMAKE_DEFINES="DEFINES+=FLAG_KEYCARD_ENABLED" +fi + if [[ "${OS}" == "android" ]]; then if [[ -z "${JAVA_HOME}" ]]; then echo "JAVA_HOME is not set. Please set JAVA_HOME to the path of your JDK 11 or later." @@ -42,7 +49,7 @@ if [[ "${OS}" == "android" ]]; then echo "Building for Android 35" ANDROID_PLATFORM=android-35 - "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "$QMAKE_CONFIG" -spec android-clang ANDROID_ABIS="$ANDROID_ABI" APP_VARIANT="${APP_VARIANT}" VERSION="$DESKTOP_VERSION" -after + "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "$QMAKE_CONFIG" -spec android-clang ANDROID_ABIS="$ANDROID_ABI" APP_VARIANT="${APP_VARIANT}" VERSION="$DESKTOP_VERSION" ${QMAKE_DEFINES} -after # Build the app make -j"$(nproc)" apk_install_target @@ -122,12 +129,36 @@ if [[ "${OS}" == "android" ]]; then fi fi else - "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "$QMAKE_CONFIG" -spec macx-ios-clang CONFIG+="$SDK" VERSION="$DESKTOP_VERSION" -after + # Generate Info.plist based on FLAG_KEYCARD_ENABLED + echo "Generating Info.plist (FLAG_KEYCARD_ENABLED=${FLAG_KEYCARD_ENABLED})..." + if [[ "${FLAG_KEYCARD_ENABLED}" == "1" ]]; then + # Enable NFC/Keycard support - uncomment NFC sections + sed -e '/$/d' \ + "$CWD/../ios/Info.plist.template" > "$BUILD_DIR/Info.plist" + else + # Disable NFC/Keycard support - remove NFC sections entirely + sed '/$/d' \ + "$CWD/../ios/Info.plist.template" > "$BUILD_DIR/Info.plist" + fi + + "$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "$QMAKE_CONFIG" -spec macx-ios-clang CONFIG+="$SDK" VERSION="$DESKTOP_VERSION" ${QMAKE_DEFINES} -after + + # By default the app is not signed. Set the `DEVELOPMENT_TEAM` to the team ID to automatically sign the app. + SIGN_ARGS="CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO" + if [[ -n "${DEVELOPMENT_TEAM}" ]]; then + echo "Signing Configuration: ${SIGN_ARGS} DEVELOPMENT_TEAM=${DEVELOPMENT_TEAM}" + /usr/libexec/PlistBuddy -c "Set :objects:*:buildSettings:DEVELOPMENT_TEAM ${DEVELOPMENT_TEAM}" "$BUILD_DIR/Status.xcodeproj/project.pbxproj" 2>/dev/null || true + sed -i '' "s/DEVELOPMENT_TEAM = .*;/DEVELOPMENT_TEAM = ${DEVELOPMENT_TEAM};/g" "$BUILD_DIR/Status.xcodeproj/project.pbxproj" + SIGN_ARGS="CODE_SIGN_STYLE=Automatic DEVELOPMENT_TEAM=${DEVELOPMENT_TEAM} -allowProvisioningUpdates" + fi # Compile resources - xcodebuild -configuration Release -target "Qt Preprocess" -sdk "$SDK" -arch "$ARCH" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO CURRENT_PROJECT_VERSION=$BUILD_VERSION | xcbeautify + echo "Signing Configuration: ${SIGN_ARGS}" + xcodebuild -configuration Release -target "Qt Preprocess" -sdk "$SDK" -arch "$ARCH" ${SIGN_ARGS} | xcbeautify # Compile the app - xcodebuild -configuration Release -target Status install -sdk "$SDK" -arch "$ARCH" DSTROOT="$BIN_DIR" INSTALL_PATH="/" TARGET_BUILD_DIR="$BIN_DIR" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO CURRENT_PROJECT_VERSION=$BUILD_VERSION | xcbeautify + echo "Signing Configuration: ${SIGN_ARGS}" + xcodebuild -configuration Release -target Status install -sdk "$SDK" -arch "$ARCH" DSTROOT="$BIN_DIR" INSTALL_PATH="/" TARGET_BUILD_DIR="$BIN_DIR" ${SIGN_ARGS} | xcbeautify if [[ ! -e "$BIN_DIR/Status.app/Info.plist" ]]; then echo "Build failed" diff --git a/mobile/scripts/buildNimStatusClient.sh b/mobile/scripts/buildNimStatusClient.sh index ca6aa116f9f..0f82e9e4253 100755 --- a/mobile/scripts/buildNimStatusClient.sh +++ b/mobile/scripts/buildNimStatusClient.sh @@ -8,6 +8,12 @@ ANDROID_ABI=${ANDROID_ABI:-"arm64-v8a"} LIB_DIR=${LIB_DIR} LIB_SUFFIX=${LIB_SUFFIX:-""} OS=${OS:-"android"} +DEBUG=${DEBUG:-0} +FLAG_DAPPS_ENABLED=${FLAG_DAPPS_ENABLED:-0} +FLAG_CONNECTOR_ENABLED=${FLAG_CONNECTOR_ENABLED:-0} +FLAG_KEYCARD_ENABLED=${FLAG_KEYCARD_ENABLED:-0} +FLAG_SINGLE_STATUS_INSTANCE_ENABLED=${FLAG_SINGLE_STATUS_INSTANCE_ENABLED:-0} +FLAG_BROWSER_ENABLED=${FLAG_BROWSER_ENABLED:-0} DESKTOP_VERSION=$(eval cd "$STATUS_DESKTOP" && git describe --tags --dirty="-dirty" --always) STATUSGO_VERSION=$(eval cd "$STATUS_DESKTOP/vendor/status-go" && git describe --tags --dirty="-dirty" --always) @@ -22,7 +28,7 @@ if [[ "$OS" == "ios" ]]; then PLATFORM_SPECIFIC=(--app:staticlib -d:ios --os:ios) else PLATFORM_SPECIFIC=(--app:lib --os:android -d:android -d:androidNDK -d:chronicles_sinks=textlines[logcat],textlines[nocolors,dynamic],textlines[file,nocolors] \ - --passL="-L$LIB_DIR" --passL="-lstatus" --passL="-lStatusQ$LIB_SUFFIX" --passL="-lDOtherSide$LIB_SUFFIX" --passL="-lqrcodegen" --passL="-lqzxing" --passL="-lssl_3" --passL="-lcrypto_3" -d:taskpool) + --passL="-L$LIB_DIR" --passL="-lstatus" --passL="-lStatusQ$LIB_SUFFIX" --passL="-lDOtherSide$LIB_SUFFIX" --passL="-lqrcodegen" --passL="-lqzxing" --passL="-lssl_3" --passL="-lcrypto_3" --passL="-lstatus-keycard-qt" -d:taskpool) fi if [ -n "$USE_QML_SERVER" ]; then @@ -37,7 +43,13 @@ cd "$STATUS_DESKTOP" # build nim compiler with host env # setting compile time feature flags -FEATURE_FLAGS="FLAG_DAPPS_ENABLED=0 FLAG_CONNECTOR_ENABLED=0 FLAG_KEYCARD_ENABLED=0 FLAG_SINGLE_STATUS_INSTANCE_ENABLED=0 FLAG_BROWSER_ENABLED=0" +FEATURE_FLAGS=( + FLAG_DAPPS_ENABLED=$FLAG_DAPPS_ENABLED + FLAG_CONNECTOR_ENABLED=$FLAG_CONNECTOR_ENABLED + FLAG_KEYCARD_ENABLED=$FLAG_KEYCARD_ENABLED + FLAG_SINGLE_STATUS_INSTANCE_ENABLED=$FLAG_SINGLE_STATUS_INSTANCE_ENABLED + FLAG_BROWSER_ENABLED=$FLAG_BROWSER_ENABLED +) # app configuration defines APP_CONFIG_DEFINES=( @@ -48,21 +60,28 @@ APP_CONFIG_DEFINES=( -d:GIT_COMMIT="$(git log --pretty=format:'%h' -n 1)" ) +NIM_FLAGS=( + --mm:orc + -d:useMalloc + --opt:size + --cc:clang + --cpu:"$CARCH" + --noMain:on + --clang.exe="$CC" + --clang.linkerexe="$CC" + --dynlibOverrideAll + --nimcache:"$STATUS_DESKTOP"/nimcache +) + +if [ "$DEBUG" -eq 1 ]; then + NIM_FLAGS+=(-d:debug -d:nimTypeNames) +else + NIM_FLAGS+=(-d:release -d:lto -d:production) +fi + # build status-client with feature flags -env $FEATURE_FLAGS ./vendor/nimbus-build-system/scripts/env.sh nim c "${PLATFORM_SPECIFIC[@]}" "${APP_CONFIG_DEFINES[@]}" ${QML_SERVER_DEFINES} \ - --mm:orc \ - -d:useMalloc \ - --opt:size \ - -d:lto \ - --cc:clang \ - --cpu:"$CARCH" \ - --noMain:on \ - -d:release \ - -d:production \ - --clang.exe="$CC" \ - --clang.linkerexe="$CC" \ - --dynlibOverrideAll \ - --nimcache:"$STATUS_DESKTOP"/nimcache \ +env "${FEATURE_FLAGS[@]}" ./vendor/nimbus-build-system/scripts/env.sh nim c "${PLATFORM_SPECIFIC[@]}" "${APP_CONFIG_DEFINES[@]}" ${QML_SERVER_DEFINES} \ + "${NIM_FLAGS[@]}" \ "$STATUS_DESKTOP"/src/nim_status_client.nim mkdir -p "$LIB_DIR" diff --git a/mobile/scripts/buildStatusKeycardQt.sh b/mobile/scripts/buildStatusKeycardQt.sh new file mode 100755 index 00000000000..f3d03b60b8e --- /dev/null +++ b/mobile/scripts/buildStatusKeycardQt.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -ef pipefail + +BASEDIR=$(dirname "$0") + +# Load common config variables +source "${BASEDIR}/commonCmakeConfig.sh" + +STATUS_KEYCARD_QT=${STATUS_KEYCARD_QT:="../vendors/status-desktop"} +KEYCARD_QT=${KEYCARD_QT:=""} +LIB_DIR=${LIB_DIR} +LIB_EXT=${LIB_EXT:=".a"} + +BUILD_DIR=${BUILD_DIR:="${STATUS_KEYCARD_QT}/build/${OS}/${ARCH}"} +BUILD_SHARED_LIBS=ON + +if [[ "${LIB_EXT}" == ".a" ]]; then + BUILD_SHARED_LIBS=OFF +fi + +echo "Building status-keycard-qt for ${ARCH} using compiler: ${CC}" +echo "BUILD_SHARED_LIBS=${BUILD_SHARED_LIBS}" + +printf 'COMMON_CMAKE_CONFIG: %s\n' "${COMMON_CMAKE_CONFIG[@]}" + +# Set OpenSSL paths (REQUIRED for key derivation in session_manager.cpp) +MOBILE_ROOT="$(cd "${BASEDIR}/.." && pwd)" +if [[ "$OS" == "android" ]]; then + OPENSSL_BUILD_DIR="${MOBILE_ROOT}/build/${OS}/qt6/openssl-${OS}-${ARCH}" +elif [[ "$OS" == "ios" ]]; then + # iOS OpenSSL is built per-architecture + OPENSSL_BUILD_DIR="${MOBILE_ROOT}/build/${OS}/qt6/openssl-${OS}-${ARCH}" +else + OPENSSL_BUILD_DIR="${MOBILE_ROOT}/build/${OS}/openssl-${OS}-${ARCH}" +fi +OPENSSL_BUILD_INCLUDE_DIR="${OPENSSL_BUILD_DIR}/include" +OPENSSL_SOURCE_INCLUDE_DIR="${MOBILE_ROOT}/vendors/openssl/include" +OPENSSL_CRYPTO_LIBRARY="${LIB_DIR}/libcrypto_3${LIB_EXT}" + +echo "OpenSSL paths:" +echo " Build include dir: ${OPENSSL_BUILD_INCLUDE_DIR}" +echo " Source include dir: ${OPENSSL_SOURCE_INCLUDE_DIR}" +echo " Crypto library: ${OPENSSL_CRYPTO_LIBRARY}" + +# Configure with CMake +# Use local keycard-qt for faster development builds (FetchContent will use this) +# If KEYCARD_QT path doesn't exist, FetchContent will clone from GitHub +if [[ -d "${KEYCARD_QT}" ]]; then + echo "Using local keycard-qt from: ${KEYCARD_QT}" + KEYCARD_QT_SOURCE_DIR_ARG="-DKEYCARD_QT_SOURCE_DIR=${KEYCARD_QT}" +else + echo "Local keycard-qt not found, will fetch from GitHub" + KEYCARD_QT_SOURCE_DIR_ARG="" +fi + +cmake -S "${STATUS_KEYCARD_QT}" -B "${BUILD_DIR}" \ + "${COMMON_CMAKE_CONFIG[@]}" \ + -DBUILD_TESTING=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_SHARED_LIBS=${BUILD_SHARED_LIBS} \ + ${KEYCARD_QT_SOURCE_DIR_ARG} \ + -DOPENSSL_BUILD_INCLUDE_DIR="${OPENSSL_BUILD_INCLUDE_DIR}" \ + -DOPENSSL_SOURCE_INCLUDE_DIR="${OPENSSL_SOURCE_INCLUDE_DIR}" \ + -DOPENSSL_CRYPTO_LIBRARY="${OPENSSL_CRYPTO_LIBRARY}" + +# Build the library +make -C "${BUILD_DIR}" status-keycard-qt -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" + +# Create lib directory +mkdir -p "${LIB_DIR}" + +# Find and copy the built library +# Note: keycard-qt is built as a static library and linked into libstatus-keycard-qt, +# so we only need to copy one library file +STATUS_KEYCARD_QT_LIB=$(find "${BUILD_DIR}" -name "libstatus-keycard-qt${LIB_EXT}" -o -name "libstatus-keycard-qt.dylib" | grep -v "\.so\." | head -n 1) + +if [[ -z "${STATUS_KEYCARD_QT_LIB}" ]]; then + # Try alternative patterns for static library + STATUS_KEYCARD_QT_LIB=$(find "${BUILD_DIR}" -name "libstatus-keycard-qt.a" | head -n 1) +fi + +if [[ -z "${STATUS_KEYCARD_QT_LIB}" ]]; then + echo "Error: Could not find status-keycard-qt library in ${BUILD_DIR}" + echo "Build directory contents:" + find "${BUILD_DIR}" -name "*.so" -o -name "*.a" -o -name "*.dylib" | head -20 + exit 1 +fi + +cp "${STATUS_KEYCARD_QT_LIB}" "${LIB_DIR}/libstatus-keycard-qt${LIB_EXT}" +echo "Copied ${STATUS_KEYCARD_QT_LIB} to ${LIB_DIR}/libstatus-keycard-qt${LIB_EXT}" + diff --git a/mobile/scripts/commonCmakeConfig.sh b/mobile/scripts/commonCmakeConfig.sh index d92d627be2d..31a927a3f26 100755 --- a/mobile/scripts/commonCmakeConfig.sh +++ b/mobile/scripts/commonCmakeConfig.sh @@ -2,7 +2,9 @@ set -ef pipefail ARCH=${ARCH:-"x86_64"} -QTDIR=${QTDIR:-$(qmake -query QT_INSTALL_PREFIX)} +# Use $QMAKE if set, otherwise fall back to system qmake +QMAKE_BIN=${QMAKE:-qmake} +QTDIR=${QTDIR:-$($QMAKE_BIN -query QT_INSTALL_PREFIX)} OS=${OS:-ios} ANDROID_NDK_ROOT=${ANDROID_NDK_ROOT:-""} @@ -46,4 +48,9 @@ COMMON_CMAKE_CONFIG+=( -DCMAKE_BUILD_TYPE=Release ) +# Add Android-specific flags only for Android +if [[ "$OS" == "android" ]]; then + COMMON_CMAKE_CONFIG+=(-DANDROID_PLATFORM=android-35) +fi + printf 'COMMON_CMAKE_CONFIG: %s\n' "${COMMON_CMAKE_CONFIG[@]}" diff --git a/mobile/wrapperApp/Status.pro b/mobile/wrapperApp/Status.pro index 4c974aa5397..526338ba62f 100644 --- a/mobile/wrapperApp/Status.pro +++ b/mobile/wrapperApp/Status.pro @@ -2,6 +2,14 @@ TEMPLATE = app QT += quick gui qml webview svg widgets multimedia +# Conditionally add NFC module only if keycard is enabled +contains(DEFINES, FLAG_KEYCARD_ENABLED) { + message("Building with Keycard/NFC support enabled") + QT += nfc +} else { + message("Building WITHOUT Keycard/NFC support (default for development)") +} + equals(QT_MAJOR_VERSION, 6) { message("qt 6 config!!") QT += core5compat core @@ -36,7 +44,10 @@ android { $$PWD/../lib/$$LIB_PREFIX/libDOtherSide$$(LIB_SUFFIX)$$(LIB_EXT) \ $$PWD/../lib/$$LIB_PREFIX/libstatus.so \ $$PWD/../lib/$$LIB_PREFIX/libsds.so \ - $$PWD/../lib/$$LIB_PREFIX/libStatusQ$$(LIB_SUFFIX)$$(LIB_EXT) \ + $$PWD/../lib/$$LIB_PREFIX/libStatusQ$$(LIB_SUFFIX)$$(LIB_EXT) + contains(DEFINES, FLAG_KEYCARD_ENABLED) { + ANDROID_EXTRA_LIBS += $$PWD/../lib/$$LIB_PREFIX/libstatus-keycard-qt.so + } OTHER_FILES += \ $$ANDROID_PACKAGE_SOURCE_DIR/src/app/status/mobile/SecureAndroidAuthentication.java @@ -45,18 +56,34 @@ android { ios { CONFIG += add_ios_ffmpeg_libraries - QMAKE_INFO_PLIST = $$PWD/../ios/Info.plist + QMAKE_INFO_PLIST = $$OUT_PWD/Info.plist QMAKE_IOS_DEPLOYMENT_TARGET=16.0 QMAKE_TARGET_BUNDLE_PREFIX = app.status QMAKE_BUNDLE = mobile QMAKE_ASSET_CATALOGS += $$PWD/../ios/Images.xcassets QMAKE_IOS_LAUNCH_SCREEN = $$PWD/../ios/launch-image-universal.storyboard - LIBS += -L$$PWD/../lib/$$LIB_PREFIX -lnim_status_client -lDOtherSideStatic -lstatusq -lstatus -lsds -lssl_3 -lcrypto_3 -lqzxing -lresolv -lqrcodegen - # --- iOS frameworks required by keychain_apple.mm --- LIBS += -framework LocalAuthentication \ -framework Security \ -framework UIKit \ -framework Foundation + + # Base libraries (always included) + LIBS += -L$$PWD/../lib/$$LIB_PREFIX -lnim_status_client -lDOtherSideStatic -lstatusq -lstatus -lsds -lssl_3 -lcrypto_3 -lqzxing -lresolv -lqrcodegen + + contains(DEFINES, FLAG_KEYCARD_ENABLED) { + # Use entitlements with NFC support (requires paid Apple Developer account) + MY_ENTITLEMENTS.name = CODE_SIGN_ENTITLEMENTS + MY_ENTITLEMENTS.value = $$PWD/../ios/Status.entitlements + QMAKE_MAC_XCODE_SETTINGS += MY_ENTITLEMENTS + + LIBS += -lstatus-keycard-qt -framework CoreNFC + + } else { + # Use entitlements without NFC (allows building with free Apple account) + MY_ENTITLEMENTS.name = CODE_SIGN_ENTITLEMENTS + MY_ENTITLEMENTS.value = $$PWD/../ios/Status-NoKeycard.entitlements + QMAKE_MAC_XCODE_SETTINGS += MY_ENTITLEMENTS + } } diff --git a/src/app/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 3157be83261..5034a7bd65e 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 @@ -47,6 +48,9 @@ import app/core/[main] import constants as main_constants +when defined(android): + import app/android/push_notifications + logScope: topics = "app-controller" @@ -105,6 +109,7 @@ type # Modules onboardingModule: onboarding_module.AccessInterface mainModule: main_module.AccessInterface + keycardChannelModule: keycard_channel_module.AccessInterface ################################################# # Forward declaration section @@ -233,6 +238,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 +305,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 +355,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) = @@ -429,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/app/modules/keycard_channel/constants.nim b/src/app/modules/keycard_channel/constants.nim new file mode 100644 index 00000000000..523aa7b4f28 --- /dev/null +++ b/src/app/modules/keycard_channel/constants.nim @@ -0,0 +1,9 @@ +## Constants for keycard channel operational states +## These values must match the strings emitted by status-keycard-qt + +const KEYCARD_CHANNEL_STATE_IDLE* = "idle" +const KEYCARD_CHANNEL_STATE_WAITING_FOR_KEYCARD* = "waiting-for-keycard" +const KEYCARD_CHANNEL_STATE_READING* = "reading" +const KEYCARD_CHANNEL_STATE_ERROR* = "error" + + diff --git a/src/app/modules/keycard_channel/controller.nim b/src/app/modules/keycard_channel/controller.nim new file mode 100644 index 00000000000..294080279d3 --- /dev/null +++ b/src/app/modules/keycard_channel/controller.nim @@ -0,0 +1,27 @@ +import ./io_interface +import app/core/eventemitter +import app_service/service/keycardV2/service as keycard_serviceV2 + +type + Controller* = ref object of RootObj + delegate: io_interface.AccessInterface + events: EventEmitter + +proc newController*( + delegate: io_interface.AccessInterface, + events: EventEmitter +): Controller = + result = Controller() + result.delegate = delegate + result.events = events + +proc delete*(self: Controller) = + discard + +proc init*(self: Controller) = + # Listen to channel state changes + self.events.on(keycard_serviceV2.SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED) do(e: Args): + let args = keycard_serviceV2.KeycardChannelStateArg(e) + self.delegate.setKeycardChannelState(args.state) + + diff --git a/src/app/modules/keycard_channel/io_interface.nim b/src/app/modules/keycard_channel/io_interface.nim new file mode 100644 index 00000000000..cfbc0aee51d --- /dev/null +++ b/src/app/modules/keycard_channel/io_interface.nim @@ -0,0 +1,23 @@ +type + AccessInterface* {.pure inheritable.} = ref object of RootObj + ## Abstract class for any input/interaction with this module. + +method delete*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + +method load*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + +method isLoaded*(self: AccessInterface): bool {.base.} = + raise newException(ValueError, "No implementation available") + +# View Delegate Interface +# Delegate for the view must be declared here due to use of QtObject and multi +# inheritance, which is not well supported in Nim. +method viewDidLoad*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + +method setKeycardChannelState*(self: AccessInterface, state: string) {.base.} = + raise newException(ValueError, "No implementation available") + + diff --git a/src/app/modules/keycard_channel/module.nim b/src/app/modules/keycard_channel/module.nim new file mode 100644 index 00000000000..79b77c47857 --- /dev/null +++ b/src/app/modules/keycard_channel/module.nim @@ -0,0 +1,49 @@ +import nimqml + +import io_interface, view, controller +import app/global/global_singleton +import app/core/eventemitter +import ./constants + +export io_interface +export constants + +type + Module* = ref object of io_interface.AccessInterface + view: View + viewVariant: QVariant + controller: Controller + moduleLoaded: bool + +proc newModule*( + events: EventEmitter, +): Module = + result = Module() + result.view = view.newView(result) + result.viewVariant = newQVariant(result.view) + result.controller = controller.newController(result, events) + result.moduleLoaded = false + + singletonInstance.engine.setRootContextProperty("keycardChannelModule", result.viewVariant) + +method delete*(self: Module) = + self.view.delete + self.viewVariant.delete + self.controller.delete + +method load*(self: Module) = + self.controller.init() + self.view.load() + +method isLoaded*(self: Module): bool = + return self.moduleLoaded + +proc checkIfModuleDidLoad(self: Module) = + self.moduleLoaded = true + +method viewDidLoad*(self: Module) = + self.checkIfModuleDidLoad() + +method setKeycardChannelState*(self: Module, state: string) = + self.view.setKeycardChannelState(state) + diff --git a/src/app/modules/keycard_channel/view.nim b/src/app/modules/keycard_channel/view.nim new file mode 100644 index 00000000000..51128d5e7ee --- /dev/null +++ b/src/app/modules/keycard_channel/view.nim @@ -0,0 +1,62 @@ +import nimqml + +import ./io_interface +import ./constants + +QtObject: + type + View* = ref object of QObject + delegate: io_interface.AccessInterface + keycardChannelState: string # Operational channel state + + proc setup(self: View) + proc delete*(self: View) + proc newView*(delegate: io_interface.AccessInterface): View = + new(result, delete) + result.delegate = delegate + result.keycardChannelState = KEYCARD_CHANNEL_STATE_IDLE + result.setup() + + proc load*(self: View) = + self.delegate.viewDidLoad() + + proc keycardChannelStateChanged*(self: View) {.signal.} + proc setKeycardChannelState*(self: View, value: string) = + if self.keycardChannelState == value: + return + self.keycardChannelState = value + self.keycardChannelStateChanged() + proc getKeycardChannelState*(self: View): string {.slot.} = + return self.keycardChannelState + QtProperty[string] keycardChannelState: + read = getKeycardChannelState + write = setKeycardChannelState + notify = keycardChannelStateChanged + + # Constants for channel states (readonly properties for QML) + proc getStateIdle*(self: View): string {.slot.} = + return KEYCARD_CHANNEL_STATE_IDLE + QtProperty[string] stateIdle: + read = getStateIdle + + proc getStateWaitingForKeycard*(self: View): string {.slot.} = + return KEYCARD_CHANNEL_STATE_WAITING_FOR_KEYCARD + QtProperty[string] stateWaitingForKeycard: + read = getStateWaitingForKeycard + + proc getStateReading*(self: View): string {.slot.} = + return KEYCARD_CHANNEL_STATE_READING + QtProperty[string] stateReading: + read = getStateReading + + proc getStateError*(self: View): string {.slot.} = + return KEYCARD_CHANNEL_STATE_ERROR + QtProperty[string] stateError: + read = getStateError + + proc setup(self: View) = + self.QObject.setup + + proc delete*(self: View) = + self.QObject.delete + diff --git a/src/app/modules/main/wallet_section/send_new/module.nim b/src/app/modules/main/wallet_section/send_new/module.nim index 537b9e8e4cf..b3d0c151761 100644 --- a/src/app/modules/main/wallet_section/send_new/module.nim +++ b/src/app/modules/main/wallet_section/send_new/module.nim @@ -190,7 +190,7 @@ method authenticateAndTransfer*(self: Module, uuid: string, fromAddr: string) = self.controller.authenticate() method onUserAuthenticated*(self: Module, password: string, pin: string) = - if password.len == 0: + if password.len == 0 and pin.len == 0: self.transactionWasSent(uuid = self.tmpSendTransactionDetails.uuid, chainId = 0, approvalTx = false, txHash = "", error = authenticationCanceled) self.clearTmpData() else: diff --git a/src/app/modules/onboarding/controller.nim b/src/app/modules/onboarding/controller.nim index 5efb332c2f0..71676fb1f89 100644 --- a/src/app/modules/onboarding/controller.nim +++ b/src/app/modules/onboarding/controller.nim @@ -272,3 +272,6 @@ proc storeMetadataAsync*(self: Controller, name: string, paths: seq[string]) = proc asyncImportLocalBackupFile*(self: Controller, filePath: string) = self.generalService.asyncImportLocalBackupFile(filePath) + +proc startKeycardDetection*(self: Controller) = + self.keycardServiceV2.startDetection() \ No newline at end of file diff --git a/src/app/modules/onboarding/io_interface.nim b/src/app/modules/onboarding/io_interface.nim index 7e696a4cea4..3e5a759ca85 100644 --- a/src/app/modules/onboarding/io_interface.nim +++ b/src/app/modules/onboarding/io_interface.nim @@ -109,6 +109,9 @@ method requestDeleteBiometrics*(self: AccessInterface, account: string) {.base.} method requestLocalBackup*(self: AccessInterface, backupImportFileUrl: string) {.base.} = raise newException(ValueError, "No implementation available") +method startKeycardDetection*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + # This way (using concepts) is used only for the modules managed by AppController type DelegateInterface* = concept c diff --git a/src/app/modules/onboarding/module.nim b/src/app/modules/onboarding/module.nim index 7a73964944e..397937f144f 100644 --- a/src/app/modules/onboarding/module.nim +++ b/src/app/modules/onboarding/module.nim @@ -446,6 +446,9 @@ method requestLocalBackup*[T](self: Module[T], backupImportFileUrl: string) = method requestDeleteBiometrics*[T](self: Module[T], account: string) = self.view.deleteBiometricsRequested(account) +method startKeycardDetection*[T](self: Module[T]) = + self.controller.startKeycardDetection() + proc runPostLoginTasks*[T](self: Module[T]) = let tasks = self.postLoginTasks for task in tasks: diff --git a/src/app/modules/onboarding/view.nim b/src/app/modules/onboarding/view.nim index 3dd161aeccf..380d9b17811 100644 --- a/src/app/modules/onboarding/view.nim +++ b/src/app/modules/onboarding/view.nim @@ -195,6 +195,9 @@ QtObject: proc startKeycardFactoryReset(self: View) {.slot.} = self.delegate.startKeycardFactoryReset() + proc startKeycardDetection(self: View) {.slot.} = + self.delegate.startKeycardDetection() + proc delete*(self: View) = self.QObject.delete diff --git a/src/app/modules/shared_modules/keycard_popup/internal/insert_keycard_state.nim b/src/app/modules/shared_modules/keycard_popup/internal/insert_keycard_state.nim index 12b4795cdca..328663fa560 100644 --- a/src/app/modules/shared_modules/keycard_popup/internal/insert_keycard_state.nim +++ b/src/app/modules/shared_modules/keycard_popup/internal/insert_keycard_state.nim @@ -43,6 +43,20 @@ method resolveKeycardNextState*(self: InsertKeycardState, keycardFlowType: strin return nil if keycardFlowType == ResponseTypeValueCardInserted: controller.setKeycardData(updatePredefinedKeycardData(controller.getKeycardData(), PredefinedKeycardData.WronglyInsertedCard, add = false)) + + # Special handling for LoadAccount flow - return to the state we came from + # (RepeatPin or PinSet) to continue waiting for ENTER_MNEMONIC event + if (self.flowType == FlowType.SetupNewKeycard or + self.flowType == FlowType.SetupNewKeycardNewSeedPhrase or + self.flowType == FlowType.SetupNewKeycardOldSeedPhrase) and + controller.getCurrentKeycardServiceFlow() == KCSFlowType.LoadAccount and + not self.getBackState.isNil: + let backStateType = self.getBackState.stateType + if backStateType == StateType.RepeatPin or backStateType == StateType.PinSet: + # Return to the previous state to continue waiting for mnemonic entry + return self.getBackState + + # Default behavior for other flows if self.flowType == FlowType.SetupNewKeycard: return createState(StateType.KeycardInserted, self.flowType, self.getBackState) return createState(StateType.KeycardInserted, self.flowType, nil) diff --git a/src/app/modules/shared_modules/keycard_popup/internal/pin_set_state.nim b/src/app/modules/shared_modules/keycard_popup/internal/pin_set_state.nim index 8644563e7af..359229d6a57 100644 --- a/src/app/modules/shared_modules/keycard_popup/internal/pin_set_state.nim +++ b/src/app/modules/shared_modules/keycard_popup/internal/pin_set_state.nim @@ -31,4 +31,28 @@ method executeCancelCommand*(self: PinSetState, controller: Controller) = self.flowType == FlowType.SetupNewKeycardNewSeedPhrase or self.flowType == FlowType.SetupNewKeycardOldSeedPhrase or self.flowType == FlowType.UnlockKeycard: - controller.terminateCurrentFlow(lastStepInTheCurrentFlow = false) \ No newline at end of file + controller.terminateCurrentFlow(lastStepInTheCurrentFlow = false) + +method resolveKeycardNextState*(self: PinSetState, keycardFlowType: string, keycardEvent: KeycardEvent, + controller: Controller): State = + # Handle temporary card disconnection during LoadAccount flow (after card initialization) + # This can happen if the user hasn't tapped "Continue" yet and the card disconnects + if self.flowType == FlowType.SetupNewKeycard or + self.flowType == FlowType.SetupNewKeycardNewSeedPhrase or + self.flowType == FlowType.SetupNewKeycardOldSeedPhrase: + # INSERT_CARD during LoadAccount flow means card is reconnecting after initialization + if keycardFlowType == ResponseTypeValueInsertCard and + keycardEvent.error.len > 0 and + keycardEvent.error == ErrorConnection and + controller.getCurrentKeycardServiceFlow() == KCSFlowType.LoadAccount: + # Don't cancel the flow - transition to InsertKeycard state and wait for reconnection + controller.reRunCurrentFlowLater() + return createState(StateType.InsertKeycard, self.flowType, self) + # CARD_INSERTED after temporary disconnection - stay in PinSet and continue + if keycardFlowType == ResponseTypeValueCardInserted and + controller.getCurrentKeycardServiceFlow() == KCSFlowType.LoadAccount: + # Card reconnected successfully, stay in PinSet + return nil + + # No specific handling needed - this state transitions via primary button + return nil \ No newline at end of file diff --git a/src/app/modules/shared_modules/keycard_popup/internal/repeat_pin_state.nim b/src/app/modules/shared_modules/keycard_popup/internal/repeat_pin_state.nim index 67a4ab5833d..a9c6592d1c1 100644 --- a/src/app/modules/shared_modules/keycard_popup/internal/repeat_pin_state.nim +++ b/src/app/modules/shared_modules/keycard_popup/internal/repeat_pin_state.nim @@ -42,6 +42,25 @@ method executeCancelCommand*(self: RepeatPinState, controller: Controller) = method resolveKeycardNextState*(self: RepeatPinState, keycardFlowType: string, keycardEvent: KeycardEvent, controller: Controller): State = + # Handle temporary card disconnection during LoadAccount flow (after card initialization) + # This happens on Android/iOS when card is disconnected and needs to be re-detected + if self.flowType == FlowType.SetupNewKeycard or + self.flowType == FlowType.SetupNewKeycardNewSeedPhrase or + self.flowType == FlowType.SetupNewKeycardOldSeedPhrase: + # INSERT_CARD during LoadAccount flow means card is reconnecting after initialization + if keycardFlowType == ResponseTypeValueInsertCard and + keycardEvent.error.len > 0 and + keycardEvent.error == ErrorConnection and + controller.getCurrentKeycardServiceFlow() == KCSFlowType.LoadAccount: + # Don't cancel the flow - transition to InsertKeycard state and wait for reconnection + controller.reRunCurrentFlowLater() + return createState(StateType.InsertKeycard, self.flowType, self) + # CARD_INSERTED after temporary disconnection - stay in RepeatPin and continue waiting + if keycardFlowType == ResponseTypeValueCardInserted and + controller.getCurrentKeycardServiceFlow() == KCSFlowType.LoadAccount: + # Card reconnected successfully, continue waiting for ENTER_MNEMONIC event + return nil + let state = ensureReaderAndCardPresence(self, keycardFlowType, keycardEvent, controller) if not state.isNil: return state diff --git a/src/app_service/service/keycard/service.nim b/src/app_service/service/keycard/service.nim index 2767ea6e1c5..55968848015 100644 --- a/src/app_service/service/keycard/service.nim +++ b/src/app_service/service/keycard/service.nim @@ -125,6 +125,9 @@ QtObject: return let flowType = typeObj.getStr + if flowType == "channel-state-changed": + return #nothing related to flows here + let flowEvent = toKeycardEvent(eventObj) self.lastReceivedKeycardData = (flowType: flowType, flowEvent: flowEvent) self.events.emit(SIGNAL_KEYCARD_RESPONSE, KeycardLibArgs(flowType: flowType, flowEvent: flowEvent)) @@ -151,8 +154,15 @@ QtObject: return seedPhrase proc updateLocalPayloadForCurrentFlow(self: Service, obj: JsonNode, cleanBefore = false) {.featureGuard(KEYCARD_ENABLED).} = + # CRITICAL FIX: Check if obj is the same reference as setPayloadForCurrentFlow + # This happens when onTimeout calls startFlow(self.setPayloadForCurrentFlow) + # If we iterate and modify the same object, the iterator gets corrupted! + if cast[pointer](obj) == cast[pointer](self.setPayloadForCurrentFlow): + return + if cleanBefore: self.setPayloadForCurrentFlow = %* {} + for k, v in obj: self.setPayloadForCurrentFlow[k] = v diff --git a/src/app_service/service/keycardV2/service.nim b/src/app_service/service/keycardV2/service.nim index ad764b6f475..e24125f0713 100644 --- a/src/app_service/service/keycardV2/service.nim +++ b/src/app_service/service/keycardV2/service.nim @@ -21,6 +21,7 @@ const PUKLengthForStatusApp* = 12 const KeycardLibCallsInterval = 500 # 0.5 seconds const SIGNAL_KEYCARD_STATE_UPDATED* = "keycardStateUpdated" +const SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED* = "keycardChannelStateUpdated" const SIGNAL_KEYCARD_SET_PIN_FAILURE* = "keycardSetPinFailure" const SIGNAL_KEYCARD_AUTHORIZE_FINISHED* = "keycardAuthorizeFinished" const SIGNAL_KEYCARD_LOAD_MNEMONIC_FAILURE* = "keycardLoadMnemonicFailure" @@ -60,6 +61,9 @@ type KeycardExportedKeysArg* = ref object of Args exportedKeys*: KeycardExportedKeysDto + KeycardChannelStateArg* = ref object of Args + state*: string + include utils include app_service/common/async_tasks include async_tasks @@ -100,7 +104,6 @@ QtObject: if status_const.IS_MACOS and status_const.IS_INTEL: sleep 700 self.initializeRPC() - self.asyncStart(status_const.KEYCARDPAIRINGDATAFILE) discard proc initializeRPC(self: Service) {.slot, featureGuard(KEYCARD_ENABLED).} = @@ -110,10 +113,15 @@ QtObject: try: # Since only one service can register to signals, we pass the signal to the old service too var jsonSignal = signal.parseJson - if jsonSignal["type"].getStr == "status-changed": + let signalType = jsonSignal["type"].getStr + + if signalType == "status-changed": let keycardEvent = jsonSignal["event"].toKeycardEventDto() - self.events.emit(SIGNAL_KEYCARD_STATE_UPDATED, KeycardEventArg(keycardEvent: keycardEvent)) + elif signalType == "channel-state-changed": + let state = jsonSignal["event"]["state"].getStr + debug "keycardV2 service: emitting channel state update", state=state, signal=SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED + self.events.emit(SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED, KeycardChannelStateArg(state: state)) except Exception as e: error "error receiving a keycard signal", err=e.msg, data = signal @@ -262,6 +270,9 @@ QtObject: except Exception as e: error "error storing metadata", err=e.msg + proc startDetection*(self: Service) {.featureGuard(KEYCARD_ENABLED).} = + self.asyncStart(status_const.KEYCARDPAIRINGDATAFILE) + proc delete*(self: Service) = self.QObject.delete diff --git a/src/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/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/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/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/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 + diff --git a/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml b/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml index 81231830580..c5db6a8a355 100644 --- a/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml +++ b/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml @@ -72,6 +72,7 @@ OnboardingStackView { signal linkActivated(string link) signal finished(int flow) + signal keycardRequested() // Thirdparty services required property bool privacyModeFeatureEnabled @@ -239,7 +240,7 @@ OnboardingStackView { onUnblockWithSeedphraseRequested: root.push(unblockWithSeedphraseFlow) onUnblockWithPukRequested: root.push(unblockWithPukFlow) - + onKeycardRequested: root.keycardRequested() onVisibleChanged: { if (!visible) root.dismissBiometricsRequested() @@ -277,7 +278,10 @@ OnboardingStackView { root.push(useRecoveryPhraseFlow, { type: UseRecoveryPhraseFlow.Type.NewProfile }) } - onCreateProfileWithEmptyKeycardRequested: root.push(keycardCreateProfileFlow) + onCreateProfileWithEmptyKeycardRequested: { + root.keycardRequested() + root.push(keycardCreateProfileFlow) + } } } @@ -290,7 +294,10 @@ OnboardingStackView { thirdpartyServicesEnabled: root.thirdpartyServicesEnabled onLoginWithSyncingRequested: root.push(logInBySyncingFlow) - onLoginWithKeycardRequested: root.push(loginWithKeycardFlow) + onLoginWithKeycardRequested: { + root.keycardRequested() + root.push(loginWithKeycardFlow) + } onLoginWithSeedphraseRequested: { d.flow = Onboarding.OnboardingFlow.LoginWithSeedphrase diff --git a/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml b/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml index 3b8075f6812..07d3567a3ff 100644 --- a/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml +++ b/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml @@ -200,6 +200,7 @@ Page { onExportKeysRequested: root.onboardingStore.exportRecoverKeys() onImportLocalBackupRequested: (importFilePath) => d.backupImportFileUrl = importFilePath onFinished: (flow) => d.finishFlow(flow) + onKeycardRequested: root.onboardingStore.startKeycardDetection() onBiometricsRequested: (profileId) => { const isKeycardProfile = SQUtils.ModelUtils.getByKey( diff --git a/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml b/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml index 39ac90c954f..b7410eb5a85 100644 --- a/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml +++ b/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml @@ -113,6 +113,7 @@ Control { objectName: "pinInput" validator: StatusIntValidator { bottom: 0; top: 999999 } visible: false + inputMethodHints: Qt.ImhDigitsOnly onPinInputChanged: { if (pinInput.length === 6) { @@ -235,6 +236,7 @@ Control { PropertyChanges { target: pinInputField visible: true + focus: true } PropertyChanges { target: background @@ -251,4 +253,9 @@ Control { } } ] + + TapHandler { + enabled: pinInputField.visible + onTapped: pinInputField.forceFocus() + } } diff --git a/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml b/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml index 1ba5a2ae6ba..266718a53b9 100644 --- a/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml +++ b/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml @@ -161,6 +161,7 @@ KeycardBasePage { anchors.horizontalCenter: parent.horizontalCenter pinLen: Constants.keycard.general.keycardPinLength validator: StatusIntValidator { bottom: 0; top: 999999 } + inputMethodHints: Qt.ImhDigitsOnly onPinInputChanged: { if (pinInput.pinInput.length === pinInput.pinLen) { root.authorizationRequested(pinInput.pinInput) diff --git a/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml index 9c355b87ae0..c0dda72dcba 100644 --- a/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml +++ b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml @@ -91,6 +91,7 @@ OnboardingPage { signal unblockWithSeedphraseRequested() signal unblockWithPukRequested() signal lostKeycardFlowRequested() + signal keycardRequested() QtObject { id: d @@ -104,6 +105,12 @@ OnboardingPage { readonly property int loginModelCount: root.loginAccountsModel.ModelCount.count onLoginModelCountChanged: setSelectedLoginUser() + onCurrentProfileIsKeycardChanged: { + if (d.currentProfileIsKeycard) { + root.keycardRequested() + } + } + function setSelectedLoginUser() { if (loginModelCount > 0) { loginUserSelector.setSelection(d.settings.lastKeyUid) diff --git a/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml b/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml index c07a8d5c1e9..c2811116322 100644 --- a/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml +++ b/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml @@ -35,6 +35,10 @@ QtObject { readonly property int keycardRemainingPinAttempts: d.onboardingModuleInst.keycardRemainingPinAttempts readonly property int keycardRemainingPukAttempts: d.onboardingModuleInst.keycardRemainingPukAttempts + function startKeycardDetection() { + d.onboardingModuleInst.startKeycardDetection() + } + function finishOnboardingFlow(flow: int, data: Object) { // -> string return d.onboardingModuleInst.finishOnboardingFlow(flow, JSON.stringify(data)) } diff --git a/ui/i18n/qml_base_en.ts b/ui/i18n/qml_base_en.ts index fa371bd8aee..6be4f42c943 100644 --- a/ui/i18n/qml_base_en.ts +++ b/ui/i18n/qml_base_en.ts @@ -2693,6 +2693,26 @@ Do you wish to override the security check and continue? Zoom + + Clear site data + + + + Use it to reset the current site if it doesn't load or work properly. + + + + Clearing cache... + + + + Clear cache + + + + Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done. + + BrowserTabView @@ -7622,13 +7642,6 @@ Please add it and try again. - - FeeRow - - Max. - - - FeesBox @@ -8891,6 +8904,45 @@ L2 fee: %2 + + KeycardChannelDrawer + + Please tap your Keycard to the back of your device + + + + Reading Keycard + + + + Please keep your Keycard in place + + + + Keycard Error + + + + An error occurred. Please try again. + + + + Success + + + + Keycard operation completed successfully + + + + Dismiss + + + + Ready to scan + + + KeycardConfirmation @@ -8986,10 +9038,6 @@ Are you sure you want to do this? PIN correct - - Keycard blocked - - %n attempt(s) remaining diff --git a/ui/i18n/qml_base_lokalise_en.ts b/ui/i18n/qml_base_lokalise_en.ts index e883eb29140..fb2de900555 100644 --- a/ui/i18n/qml_base_lokalise_en.ts +++ b/ui/i18n/qml_base_lokalise_en.ts @@ -3298,6 +3298,31 @@ BrowserSettingsMenu Zoom + + Clear site data + BrowserSettingsMenu + Clear site data + + + Use it to reset the current site if it doesn't load or work properly. + BrowserSettingsMenu + Use it to reset the current site if it doesn't load or work properly. + + + Clearing cache... + BrowserSettingsMenu + Clearing cache... + + + Clear cache + BrowserSettingsMenu + Clear cache + + + Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done. + BrowserSettingsMenu + Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done. + BrowserTabView @@ -9310,14 +9335,6 @@ Remove - - FeeRow - - Max. - FeeRow - Max. - - FeesBox @@ -10847,6 +10864,54 @@ A key pair is your shareable public address and a secret private key that controls your wallet. Your key pair is being generated on your Keycard — keep it plugged in until the process completes. + + KeycardChannelDrawer + + Please tap your Keycard to the back of your device + KeycardChannelDrawer + Please tap your Keycard to the back of your device + + + Reading Keycard + KeycardChannelDrawer + Reading Keycard + + + Please keep your Keycard in place + KeycardChannelDrawer + Please keep your Keycard in place + + + Keycard Error + KeycardChannelDrawer + Keycard Error + + + An error occurred. Please try again. + KeycardChannelDrawer + An error occurred. Please try again. + + + Success + KeycardChannelDrawer + Success + + + Keycard operation completed successfully + KeycardChannelDrawer + Keycard operation completed successfully + + + Dismiss + KeycardChannelDrawer + Dismiss + + + Ready to scan + KeycardChannelDrawer + Ready to scan + + KeycardConfirmation @@ -10959,11 +11024,6 @@ KeycardEnterPinPage PIN correct - - Keycard blocked - KeycardEnterPinPage - Keycard blocked - %n attempt(s) remaining KeycardEnterPinPage diff --git a/ui/i18n/qml_cs.ts b/ui/i18n/qml_cs.ts index bd7d952cf4e..771d537a425 100644 --- a/ui/i18n/qml_cs.ts +++ b/ui/i18n/qml_cs.ts @@ -2700,6 +2700,26 @@ Do you wish to override the security check and continue? Zoom + + Clear site data + + + + Use it to reset the current site if it doesn't load or work properly. + + + + Clearing cache... + + + + Clear cache + + + + Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done. + + BrowserTabView @@ -7649,13 +7669,6 @@ Please add it and try again. Odstranit - - FeeRow - - Max. - - - FeesBox @@ -8927,6 +8940,45 @@ L2 poplatek: %2 + + KeycardChannelDrawer + + Please tap your Keycard to the back of your device + + + + Reading Keycard + + + + Please keep your Keycard in place + + + + Keycard Error + + + + An error occurred. Please try again. + + + + Success + + + + Keycard operation completed successfully + + + + Dismiss + + + + Ready to scan + + + KeycardConfirmation @@ -9022,10 +9074,6 @@ Are you sure you want to do this? PIN correct - - Keycard blocked - - %n attempt(s) remaining diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts index cb8528ff640..2753ae01ee2 100644 --- a/ui/i18n/qml_es.ts +++ b/ui/i18n/qml_es.ts @@ -2697,6 +2697,26 @@ Do you wish to override the security check and continue? Settings Ajustes + + Clear site data + + + + Use it to reset the current site if it doesn't load or work properly. + + + + Clearing cache... + + + + Clear cache + + + + Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done. + + BrowserTabView @@ -7636,13 +7656,6 @@ Por favor, agrégala e intenta de nuevo. Eliminar - - FeeRow - - Max. - - - FeesBox @@ -8906,6 +8919,45 @@ Tarifa L2: %2 Un par de claves es tu dirección pública compartible y una clave privada secreta que controla tu billetera. Tu par de claves se está generando en tu Keycard — manténlo conectado hasta que el proceso se complete. + + KeycardChannelDrawer + + Please tap your Keycard to the back of your device + + + + Reading Keycard + + + + Please keep your Keycard in place + + + + Keycard Error + + + + An error occurred. Please try again. + + + + Success + + + + Keycard operation completed successfully + + + + Dismiss + + + + Ready to scan + + + KeycardConfirmation @@ -9001,10 +9053,6 @@ Are you sure you want to do this? PIN correct PIN correcto - - Keycard blocked - - %n attempt(s) remaining diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts index 256d4bbe2a0..b6521fb9b8b 100644 --- a/ui/i18n/qml_ko.ts +++ b/ui/i18n/qml_ko.ts @@ -2470,6 +2470,10 @@ To backup you recovery phrase, write it down and store it securely in a safe pla Backups are automatic (every 30 mins), secure (encrypted with your profile private key), and private (your data is stored <b>only</b> on your device). 백업은 자동(30분마다), 안전(프로필 개인 키로 암호화), 그리고 비공개(데이터는 <b>오직</b> 귀하의 기기에만 저장됨)입니다. + + Choose a folder to store your backup files in. + + BalanceExceeded @@ -2685,6 +2689,26 @@ Do you wish to override the security check and continue? Zoom 확대/축소 + + Clear site data + + + + Use it to reset the current site if it doesn't load or work properly. + + + + Clearing cache... + + + + Clear cache + + + + Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done. + + BrowserTabView @@ -6740,14 +6764,26 @@ Remember your password and don't share it with anyone. Enable on-device message backup? 기기 내 메시지 백업을 활성화하시겠어요? - - On-device backups are:<br>Automatic – every 30 minutes<br>Secure – encrypted with your profile private key<br>Private – stored only on your device - 기기 내 백업은:<br>자동 – 30분마다<br>안전 – 프로필 개인 키로 암호화<br>비공개 – 귀하의 기기에만 저장 - Backups let you restore your 1-on-1, group, and community messages if you need to reinstall the app or switch devices. You can skip this step now and enable it anytime under: <i>Settings > On-device backup > Backup data</i> 백업을 사용하면 앱을 다시 설치하거나 기기를 전환해야 할 때 1:1, 그룹 및 커뮤니티 메시지를 복원할 수 있어요. 지금 이 단계를 건너뛰고 나중에 언제든지 <i>설정 > 기기 내 백업 > 데이터 백업</i>에서 활성화할 수 있어요. + + Enable on-device backup? + + + + On-device backups are:<br><b>Automatic</b> – created every 30 minutes<br><b>Secure</b> – encrypted with your profile’s private key<br><b>Private</b> – stored only on your device + + + + To enable backups, choose a folder to store your backup files under the <b>Backup location</b> setting.<br><br>You can also <b>optionally</b> back up your <b>1-on-1, group, and community messages</b> by turning on the <b>Backup your messages</b> toggle under the <b>Backup data</b> setting. + + + + Go to settings + + EnsAddedView @@ -7592,13 +7628,6 @@ Please add it and try again. 제거 - - FeeRow - - Max. - - - FeesBox @@ -8854,6 +8883,45 @@ L2 수수료: %2 키 페어는 다른 사람과 공유할 수 있는 공개 주소와, 지갑을 제어하는 비밀 개인 키로 이루어져 있습니다. 지금 Keycard에서 키 페어를 생성 중입니다 — 과정이 끝날 때까지 분리하지 마세요. + + KeycardChannelDrawer + + Please tap your Keycard to the back of your device + + + + Reading Keycard + + + + Please keep your Keycard in place + + + + Keycard Error + + + + An error occurred. Please try again. + + + + Success + + + + Keycard operation completed successfully + + + + Dismiss + + + + Ready to scan + + + KeycardConfirmation @@ -8951,10 +9019,6 @@ Are you sure you want to do this? PIN correct PIN이 올바릅니다 - - Keycard blocked - Keycard가 차단됨 - %n attempt(s) remaining diff --git a/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml new file mode 100644 index 00000000000..069306582c9 --- /dev/null +++ b/ui/imports/shared/popups/KeycardChannelDrawer.qml @@ -0,0 +1,320 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +import StatusQ.Core +import StatusQ.Core.Theme +import StatusQ.Components +import StatusQ.Controls +import StatusQ.Popups.Dialog + +/** + * @brief A drawer that displays the current keycard channel state. + * + * This channel drawer will inform the user about the current keycard channel state. + * It is built to avoid flasing the drawer when the state changes and allow the user to see the keycard states. + * The drawer will display the current state and the next state will be displayed after a short delay. + * The drawer will close automatically after the success, error or idle state is displayed. + * Some states can be dismissed by the user. + */ + +StatusDialog { + id: root + + // ============================================================ + // PUBLIC API + // ============================================================ + + /// The current keycard channel state from the backend + /// Expected values: "idle", "waiting-for-keycard", "reading", "error" + property string currentState: "idle" + + /// Emitted when the user dismisses the drawer without completing the operation + signal dismissed() + + // ============================================================ + // INTERNAL STATE MANAGEMENT - Queue-based approach + // ============================================================ + + QtObject { + id: d + + // Timing constants + readonly property int minimumStateDuration: 600 // ms - minimum time to show each state + readonly property int successDisplayDuration: 1200 // ms - how long to show success before closing + readonly property int transitionDuration: 50 // ms - fade animation duration + + // Display states (internal representation) + readonly property string stateWaitingForCard: "waiting-for-card" + readonly property string stateReading: "reading" + readonly property string stateSuccess: "success" + readonly property string stateError: "error" + readonly property string stateIdle: "" // empty = not showing anything + + // Current display state (what the user sees) + property string displayState: stateIdle + + // State queue - stores states to be displayed + property var stateQueue: [] + + // Track previous backend state for success detection + property string previousBackendState: "idle" + + /// Map backend state to display state + function mapBackendStateToDisplayState(backendState) { + switch(backendState) { + case "waiting-for-keycard": + return stateWaitingForCard + case "reading": + return stateReading + case "error": + return stateError + case "idle": + // Success detection: were we just reading? + if (previousBackendState === "reading") { + return stateSuccess + } + return stateIdle + default: + return stateIdle + } + } + + /// Add a state to the queue + function enqueueState(state) { + // Don't queue if it's the same as the last queued state + if (stateQueue.length > 0 && stateQueue[stateQueue.length - 1] === state) { + console.log("KeycardChannelDrawer: Skipping duplicate state in queue") + return + } + + // Don't queue if it's the same as current display state and queue is empty + if (stateQueue.length === 0 && state === displayState) { + console.log("KeycardChannelDrawer: Skipping - same as current display state") + return + } + + stateQueue.push(state) + + // If timer not running, start processing immediately + if (!stateTimer.running) { + processNextState() + } + } + + /// Process the next state from the queue + function processNextState() { + if (stateQueue.length === 0) { + return + } + + const nextState = stateQueue.shift() // Remove and get first item + + // Set the display state + displayState = nextState + + // Open drawer if showing a state + if (nextState !== stateIdle && !root.opened) { + root.open() + } + + // Determine timer duration based on state + if (nextState === stateSuccess) { + stateTimer.interval = successDisplayDuration + } else if (nextState === stateIdle) { + // Closing - clear any remaining queue (stale states from before completion) + root.close() + if (stateQueue.length > 0) { + processNextState() + } + return + } else { + stateTimer.interval = minimumStateDuration + } + + // Start timer for next transition + stateTimer.restart() + } + + /// Handle backend state changes + function onBackendStateChanged() { + const newDisplayState = mapBackendStateToDisplayState(root.currentState) + + // Special handling: Backend went to idle unexpectedly (not after reading) + // Clear everything and close immediately + if (newDisplayState === stateIdle && displayState !== stateSuccess) { + console.log("KeycardChannelDrawer: Unexpected idle, clearing and closing") + stateQueue = [] + stateTimer.stop() + displayState = stateIdle + previousBackendState = root.currentState + root.close() + return // Don't process further + } + + // Update previous state tracking + previousBackendState = root.currentState + + // Enqueue the new state + enqueueState(newDisplayState) + + // If we just enqueued success, also enqueue idle to close the drawer after + if (newDisplayState === stateSuccess) { + enqueueState(stateIdle) + } + } + + /// Clear queue and reset to idle + function clearAndClose() { + stateQueue = [] + stateTimer.stop() + displayState = stateIdle + root.close() + } + } + + // Single timer that handles all state transitions + Timer { + id: stateTimer + repeat: false + onTriggered: { + // When timer fires, move to next state in queue + d.processNextState() + } + } + + // Watch for backend state changes - push to queue + onCurrentStateChanged: { + d.onBackendStateChanged() + } + + // Initialize on component load + Component.onCompleted: { + d.previousBackendState = root.currentState + d.onBackendStateChanged() + } + + // ============================================================ + // DIALOG CONFIGURATION + // ============================================================ + + closePolicy: Popup.NoAutoClose + modal: true + + header: null + footer: null + padding: Theme.padding + + implicitWidth: 480 + + // ============================================================ + // CONTENT + // ============================================================ + + contentItem: ColumnLayout { + spacing: Theme.padding + + // State display area + Item { + Layout.fillWidth: true + Layout.preferredHeight: 300 + // Waiting for card state + KeycardStateDisplay { + id: waitingDisplay + anchors.fill: parent + visible: opacity > 0 + opacity: d.displayState === d.stateWaitingForCard ? 1 : 0 + + iconSource: Assets.png("onboarding/carousel/keycard") + title: qsTr("Ready to scan") + description: qsTr("Please tap your Keycard to the back of your device") + + Behavior on opacity { + NumberAnimation { + duration: d.transitionDuration + easing.type: Easing.InOutQuad + } + } + } + + // Reading state + KeycardStateDisplay { + id: readingDisplay + anchors.fill: parent + visible: opacity > 0 + opacity: d.displayState === d.stateReading ? 1 : 0 + + iconSource: Assets.png("onboarding/status_generate_keycard") + title: qsTr("Reading Keycard") + description: qsTr("Please keep your Keycard in place") + + Behavior on opacity { + NumberAnimation { + duration: d.transitionDuration + easing.type: Easing.InOutQuad + } + } + } + + // Success state + KeycardStateDisplay { + id: successDisplay + anchors.fill: parent + visible: opacity > 0 + opacity: d.displayState === d.stateSuccess ? 1 : 0 + + iconSource: Assets.png("onboarding/status_key") + title: qsTr("Success") + description: qsTr("Keycard operation completed successfully") + + Behavior on opacity { + NumberAnimation { + duration: d.transitionDuration + easing.type: Easing.InOutQuad + } + } + } + + // Error state + KeycardStateDisplay { + id: errorDisplay + anchors.fill: parent + visible: opacity > 0 + opacity: d.displayState === d.stateError ? 1 : 0 + + iconSource: Assets.png("onboarding/status_generate_keys") + title: qsTr("Keycard Error") + description: qsTr("An error occurred. Please try again.") + isError: true + + Behavior on opacity { + NumberAnimation { + duration: d.transitionDuration + easing.type: Easing.InOutQuad + } + } + } + } + + // Dismiss button (only show when not in success state) + StatusButton { + Layout.fillWidth: true + Layout.topMargin: Theme.halfPadding + Layout.leftMargin: Theme.xlPadding * 2 + Layout.rightMargin: Theme.xlPadding * 2 + // Preserve the spacing for the button even if it's not visible + opacity: d.displayState !== d.stateSuccess && d.displayState !== d.stateIdle ? 1 : 0 + text: qsTr("Dismiss") + type: StatusButton.Type.Normal + + onClicked: { + d.clearAndClose() + root.dismissed() + } + } + + Item { + Layout.fillHeight: true + } + } +} diff --git a/ui/imports/shared/popups/KeycardStateDisplay.qml b/ui/imports/shared/popups/KeycardStateDisplay.qml new file mode 100644 index 00000000000..32d6ac5ec72 --- /dev/null +++ b/ui/imports/shared/popups/KeycardStateDisplay.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Layouts + +import StatusQ.Core +import StatusQ.Core.Theme +import StatusQ.Components + +/// Reusable component for displaying a state in the KeycardChannelDrawer +/// Shows an icon, title, and description in a consistent layout +Item { + id: root + + // ============================================================ + // PUBLIC API + // ============================================================ + + /// Path to the icon image + property string iconSource: "" + + /// Main title text + property string title: "" + + /// Description text below the title + property string description: "" + + /// Whether this is an error state (affects text color) + property bool isError: false + + implicitWidth: layout.implicitWidth + implicitHeight: layout.implicitHeight + + // ============================================================ + // INTERNAL LAYOUT + // ============================================================ + + ColumnLayout { + id: layout + anchors.centerIn: parent + width: parent.width + spacing: Theme.padding + + // Icon + StatusImage { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 164 + Layout.preferredHeight: 164 + source: root.iconSource + visible: root.iconSource !== "" + } + + // Title + StatusBaseText { + Layout.fillWidth: true + Layout.topMargin: Theme.padding + horizontalAlignment: Text.AlignHCenter + text: root.title + font.pixelSize: Theme.fontSize25 + font.bold: true + color: root.isError ? Theme.palette.dangerColor1 : Theme.palette.directColor1 + wrapMode: Text.WordWrap + visible: root.title !== "" + } + + // Description + StatusBaseText { + Layout.fillWidth: true + Layout.topMargin: Theme.halfPadding + horizontalAlignment: Text.AlignHCenter + text: root.description + font.pixelSize: Theme.primaryTextFontSize + color: Theme.palette.baseColor1 + wrapMode: Text.WordWrap + visible: root.description !== "" + } + } +} + diff --git a/ui/imports/shared/popups/qmldir b/ui/imports/shared/popups/qmldir index f6835d28445..b0cfc59dbff 100644 --- a/ui/imports/shared/popups/qmldir +++ b/ui/imports/shared/popups/qmldir @@ -16,6 +16,8 @@ ImageContextMenu 1.0 ImageContextMenu.qml ImageCropWorkflow 1.0 ImageCropWorkflow.qml ImportCommunityPopup 1.0 ImportCommunityPopup.qml InviteFriendsPopup 1.0 InviteFriendsPopup.qml +KeycardChannelDrawer 1.0 KeycardChannelDrawer.qml +KeycardStateDisplay 1.0 KeycardStateDisplay.qml IntroduceYourselfPopup 1.0 IntroduceYourselfPopup.qml MarkAsIDVerifiedDialog 1.0 MarkAsIDVerifiedDialog.qml MarkAsUntrustedPopup 1.0 MarkAsUntrustedPopup.qml diff --git a/ui/imports/shared/stores/KeycardStateStore.qml b/ui/imports/shared/stores/KeycardStateStore.qml new file mode 100644 index 00000000000..1eba48c3244 --- /dev/null +++ b/ui/imports/shared/stores/KeycardStateStore.qml @@ -0,0 +1,24 @@ +import QtQuick + +QtObject { + id: root + + readonly property var keycardChannelModuleInst: typeof keycardChannelModule !== "undefined" ? keycardChannelModule : null + + // Channel state property + readonly property string state: keycardChannelModuleInst ? keycardChannelModuleInst.keycardChannelState : "idle" + + // State constants (for convenience) + readonly property string stateIdle: keycardChannelModuleInst ? keycardChannelModuleInst.stateIdle : "idle" + readonly property string stateWaitingForKeycard: keycardChannelModuleInst ? keycardChannelModuleInst.stateWaitingForKeycard : "waiting-for-keycard" + readonly property string stateReading: keycardChannelModuleInst ? keycardChannelModuleInst.stateReading : "reading" + readonly property string stateError: keycardChannelModuleInst ? keycardChannelModuleInst.stateError : "error" + + // Helper properties for common state checks + readonly property bool isIdle: state === stateIdle + readonly property bool isWaitingForKeycard: state === stateWaitingForKeycard + readonly property bool isReading: state === stateReading + readonly property bool isError: state === stateError +} + + diff --git a/ui/imports/shared/stores/qmldir b/ui/imports/shared/stores/qmldir index 2b838e77f8a..d6131c238bc 100644 --- a/ui/imports/shared/stores/qmldir +++ b/ui/imports/shared/stores/qmldir @@ -3,6 +3,7 @@ CommunityTokensStore 1.0 CommunityTokensStore.qml CurrenciesStore 1.0 CurrenciesStore.qml DAppsStore 1.0 DAppsStore.qml GifStore 1.0 GifStore.qml +KeycardStateStore 1.0 KeycardStateStore.qml MetricsStore 1.0 MetricsStore.qml NetworkConnectionStore 1.0 NetworkConnectionStore.qml NetworksStore 1.0 NetworksStore.qml diff --git a/ui/main.qml b/ui/main.qml index 08a5d44c508..da202287f61 100644 --- a/ui/main.qml +++ b/ui/main.qml @@ -52,6 +52,7 @@ Window { readonly property UtilsStore utilsStore: UtilsStore {} readonly property LanguageStore languageStore: LanguageStore {} readonly property bool appThemeDark: Theme.style === Theme.Style.Dark + readonly property KeycardStateStore keycardStateStore: KeycardStateStore {} readonly property bool portraitLayout: height > width property bool biometricFlowPending: false @@ -674,6 +675,15 @@ Window { } } + + Loader { + active: SQUtils.Utils.isAndroid + sourceComponent: KeycardChannelDrawer { + id: keycardChannelDrawer + currentState: applicationWindow.keycardStateStore.state + } + } + Loader { id: macOSSafeAreaLoader anchors.left: parent.left diff --git a/vendor/status-keycard-qt b/vendor/status-keycard-qt new file mode 160000 index 00000000000..9fb564360e9 --- /dev/null +++ b/vendor/status-keycard-qt @@ -0,0 +1 @@ +Subproject commit 9fb564360e95cb04aa36e31670628d174c486153