From 4fdcf9ec1d6f2e1058c15fc70d4fe7cef9eea06d Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Tue, 27 Jan 2026 11:34:00 +0200 Subject: [PATCH] feat(android): status-go as a service Add an option to run status-go as a service on android using Androdi Binder as a transport. See ADR for more details docs/adr/0001-android-status-go-as-a-service.md --- .../0001-android-status-go-as-a-service.md | 437 ++++++++++++++++++ docs/architecture.md | 4 + mobile/Makefile | 33 +- mobile/android/qt6/AndroidManifest.xml | 24 + .../status/mobile/ipc/IStatusGoService.aidl | 29 ++ .../mobile/ipc/IStatusGoSignalListener.aidl | 7 + mobile/android/qt6/build.gradle | 4 + .../src/app/status/mobile/StatusGoStub.java | 60 +++ .../app/status/mobile/StatusQtActivity.java | 14 + .../status/mobile/ipc/StatusGoService.java | 300 ++++++++++++ .../mobile/ipc/StatusGoServiceClient.java | 232 ++++++++++ mobile/scripts/Common.mk | 5 + mobile/scripts/buildApp.sh | 1 + mobile/scripts/buildNimStatusClient.sh | 2 +- .../statusgo_service/statusgo_service_jni.cpp | 156 +++++++ mobile/statusgo_stub/statusgo_stub.cpp | 173 +++++++ mobile/wrapperApp/Status.pro | 2 + src/app/boot/app_controller.nim | 11 +- src/app/modules/onboarding/module.nim | 13 + src/app_service/service/accounts/service.nim | 8 + src/backend/accounts.nim | 16 + ui/main.qml | 12 +- vendor/nim-status-go | 2 +- vendor/status-go | 2 +- 24 files changed, 1538 insertions(+), 9 deletions(-) create mode 100644 docs/adr/0001-android-status-go-as-a-service.md create mode 100644 mobile/android/qt6/aidl/app/status/mobile/ipc/IStatusGoService.aidl create mode 100644 mobile/android/qt6/aidl/app/status/mobile/ipc/IStatusGoSignalListener.aidl create mode 100644 mobile/android/qt6/src/app/status/mobile/StatusGoStub.java create mode 100644 mobile/android/qt6/src/app/status/mobile/ipc/StatusGoService.java create mode 100644 mobile/android/qt6/src/app/status/mobile/ipc/StatusGoServiceClient.java create mode 100644 mobile/statusgo_service/statusgo_service_jni.cpp create mode 100644 mobile/statusgo_stub/statusgo_stub.cpp diff --git a/docs/adr/0001-android-status-go-as-a-service.md b/docs/adr/0001-android-status-go-as-a-service.md new file mode 100644 index 0000000000..94508c4227 --- /dev/null +++ b/docs/adr/0001-android-status-go-as-a-service.md @@ -0,0 +1,437 @@ +# ADR-0001: Android "status-go as a Service" (separate process + Binder IPC) + +## Status +- **Proposed** +- **Date**: 2026-01-16 +- **Owners**: Status Desktop (Android target) + +## Why + +We need: + +- A backend that survives UI process death (swipe-away, memory pressure). +- Decrypted notifications when UI is not running. +- Push notifications for de-googled phones (no firebase dependency). +- A design that matches Android’s process model and OEM restrictions. + +Historically, `status-go` ran in the UI process; killing the UI killed messaging and decryption. This ADR documents the separate-process service architecture now implemented. + +## How (implementation) + +### Transport and contract + +- **IPC:** Binder (AIDL). +- **RPC shape:** `call(method, argsJson)` and `callToFile(...)` for large payloads. +- **Signals:** service emits JSON to UI via `RemoteCallbackList` + `IStatusGoSignalListener`. +- **Login state:** UI uses `status_account.getActiveAccount()` over the service RPC path as the authoritative check for resume vs onboarding. + +### Process split + +- **Service process:** Android `Service` in its own process, loads `libstatus_service.so` which links real `libstatus.so`. +- **UI process:** links a stub library implementing the C API; stub forwards all calls to the service via Binder and re-injects signals into the existing pipeline. + +### Compilation and running + +- The UI process links the **stub** library and starts/binds the service early (Activity startup). +- The service process loads **real** `libstatus.so` via the JNI wrapper and handles all status-go calls. +- The AIDL surface is intentionally minimal to avoid churn. + +### Lifetime behavior and mitigations + +- Service starts **foreground immediately** to avoid `ForegroundServiceDidNotStartInTime`. +- Uses `START_STICKY` so the OS can restart it. +- On logout, clears session markers and stops itself. +- `DeadObjectException` is handled in the client with a rebind and single retry. +- Resume marker file exists for telemetry only and is deleted on service start; it must not be used for resume gating. + +## What was considered (transport options) + +- **Binder (AIDL)**: chosen. Low latency, lifecycle-aware, Android-native. +- **Local TCP/Unix + JSON-RPC**: more security hardening and weaker lifecycle integration. +- **Typed AIDL per method**: too much churn as status-go evolves. +- **gRPC/protobuf IPC**: heavier stack without solving Android lifecycle constraints. + +## What next + +- **Lower battery impact:** add a true sleep mode in status-go (suspend Waku), with wake on app open or push. +- **Transport replaceability:** keep the generic call/args shape as a stable boundary so Binder could be swapped for another IPC later with minimal churn. + +## References (code) + +- Service process: [mobile/android/qt6/src/app/status/mobile/ipc/StatusGoService.java](https://github.com/status-im/status-app/blob/master/mobile/android/qt6/src/app/status/mobile/ipc/StatusGoService.java) +- AIDL: [mobile/android/qt6/aidl/app/status/mobile/ipc/IStatusGoService.aidl](https://github.com/status-im/status-app/blob/master/mobile/android/qt6/aidl/app/status/mobile/ipc/IStatusGoService.aidl) +- UI client + stub: [mobile/android/qt6/src/app/status/mobile/ipc/StatusGoServiceClient.java](https://github.com/status-im/status-app/blob/master/mobile/android/qt6/src/app/status/mobile/ipc/StatusGoServiceClient.java), + [mobile/android/qt6/src/app/status/mobile/StatusGoStub.java](https://github.com/status-im/status-app/blob/master/mobile/android/qt6/src/app/status/mobile/StatusGoStub.java) +- Resume gating: [src/app/modules/onboarding/module.nim](https://github.com/status-im/status-app/blob/master/src/app/modules/onboarding/module.nim), [src/app/boot/app_controller.nim](https://github.com/status-im/status-app/blob/master/src/app/boot/app_controller.nim) +- Stub generator: [vendor/status-go/tools/generate-stub-bindings](https://github.com/status-im/status-app/blob/master/vendor/status-go/tools/generate-stub-bindings) + +### Data/control flow (signals) + +1. status-go emits a JSON signal in the service process. +2. JNI calls back into `StatusGoService.onNativeSignal(json)`. +3. Service broadcasts to registered `IStatusGoSignalListener` clients. +4. UI client forwards into native `StatusGoStub.nativeDeliverSignal(json)` so existing signal pipelines keep working. + +### Login state detection (“should we show onboarding or AppMain?”) + +Goal: avoid file-based heuristics and decide based on the **live** service state. + +- UI startup performs a short poll loop before loading `main.qml`: + - if a keyUid is returned and matches a local account, UI resumes into AppMain. + - else UI proceeds to onboarding. + +Key design constraint: + +- **Never claim “logged in” after OS-kill** when the service process was actually terminated. + - Keys are in memory; if the process died, they are gone. + - Therefore the service must not “restore logged-in” from a stale file marker. + +## Service lifetime design + +Android lifetime is not a single mode; this proposal outlines multiple modes depending on user settings and device capabilities. + +### Mode A: Foreground “keepalive” while logged in (status-mobile style) + +**When**: user enables messenger notifications / wants background message processing. + +**How**: + +- Service promotes itself to a foreground service *only while logged in*. +- Foreground notification indicates “Status is running”. +- Use `START_STICKY` so system can restart the service process if needed. + +**Pros** +- Best reliability across OEMs. +- Allows local decrypted notifications without push round-trips. +- UI process can die and be restarted; service stays alive. + +**Cons** +- Always-on notification (acceptable by product decision, but not ideal UX for all users). +- Power usage can be non-trivial if Waku stays connected. + +### Mode B: “Push-driven / on-demand” service (sleep until wake) + +**When**: user disables background messaging or wants reduced resource usage; also useful for “push enabled” devices. + +**How (conceptual)**: + +- Keep the service process **not running** most of the time (or running but “suspended”). +- Wake it only when needed: + - app is opened, or + - a push notification arrives (GMS/FCM path). + +**Two implementation variants** + +1) **Hard sleep (process not running)** + - Don’t run the service until needed. + - On push: start service, run minimal work, stop service. + - **Constraint**: cannot keep keys unlocked across kills → this only supports “wake for sync” if keys can be unlocked. + +2) **Soft sleep (process alive but networking suspended)** + - Service remains alive (likely still foreground or exempted), but Waku/network subsystems are paused. + - On push/app-open: resume networking and process. + - Requires explicit status-go API support: + - e.g. `wakuext_suspend()` / `wakuext_resume()` or a high-level “messenger sleep” toggle. + +**Pros** +- Reduced battery/network usage vs always-connected Waku. +- Can be aligned with “push as wakeup” concept - similar to Signal. + +**Cons / risks** +- Android push delivery is not guaranteed (Doze, OEM restrictions, network). +- FCM data messages may be delayed or dropped when app is background restricted. +- For encrypted pushes: decryption still needs keys; if process was killed, you are effectively logged out for background work. + +### Mode C: Hybrid (recommended long-term) + +- Use Mode A for users who enable background messaging (most reliable). +- Consider Mode B (soft sleep) as an optimization knob: + - keep service alive (foreground) but pause Waku when idle, + - wake on push or periodic lightweight alarms. + +## App resilience + +### Scenario matrix + +| Scenario | UI process | Service process | Expected UX | +|---|---:|---:|---| +| User closes window / swipe away Recents (service is foreground) | killed | alive | Next open: **resume to AppMain** (no login) | +| UI process killed by OS (service alive) | killed | alive | Next open: **resume to AppMain** | +| OS kills service process (memory pressure / user force-stop) | any | killed | Next open: **onboarding** (must re-auth) | +| App update/reinstall leads to stale Binder | restarted | restarted | Client must detect `DeadObjectException`, reconnect and retry once | + +### Lifetime & failure analysis (detailed) + +This section describes what happens for key lifecycle/failure events and how the design should behave. + +#### UI crash (Qt/Nim/QML process crash) + +- **What happens** + - UI process terminates (SIGABRT/SIGSEGV/uncaught exception). + - Binder callbacks to UI will fail; the service removes dead binder clients. +- **Expected behavior** + - **Service keeps running** (when foreground keepalive is enabled / policy keeps it alive). + - Messages can continue syncing; local-notifications can still be produced by the service. + - On next app open, UI should **query ``status_account.getActiveAccount()``** and resume if the service is logged in. +- **Mitigations** + - `RemoteCallbackList` on the service side to clean up dead callbacks. + - UI binder client reconnects on restart (and retries once on `DeadObjectException`). + +#### Service crash (Java Service process crash) + +Examples: `StatusGoService` throws, process hits ANR, or the OS kills the service due to a crash. + +- **What happens** + - UI binder calls fail with `DeadObjectException`. + - Any in-memory keys/session state are lost with the process. +- **Expected behavior** + - UI resets binder connection, rebinds, and retries once. + - If the service comes back but is **not logged in**, UI falls back to onboarding. + - UI must not auto-resume from any stale marker; “logged-in” is authoritative only when returned by the live service. +- **Mitigations** + - Reconnect+retry once on `DeadObjectException` (client-side). + - Optional future: a “service ready” signal/handshake so UI does not call feature RPCs before status-go is initialized. + +#### status-go crash (native crash inside the service process) + +This is a special case of service crash where the crash occurs in `libstatus.so` (JNI/native). + +- **What happens** + - The service process dies (tombstone / fatal signal). + - On restart, the service process is “fresh” and **not logged in** (keys lost). +- **Expected behavior** + - UI treats this as “service not logged in” and shows onboarding. + - If configured `START_STICKY`, Android may restart the service, but it still won’t have unlocked keys. +- **Mitigations** + - Optional: telemetry and a UI-visible “backend crashed” banner when this happens while UI is active. + - Optional: explicit version/handshake checks so UI can detect a “fresh” backend. + +#### App reinstall / update + +We distinguish two common cases: + +1) **Update install (same package, data typically preserved)** + - **What happens** + - UI process and service process may be restarted during update. + - Existing Binder handles can become stale → `DeadObjectException`. + - **Expected behavior** + - UI reconnects/retries once. + - If the service process was restarted, it is not logged in; UI should show onboarding. + - If the service survived (rare during update), UI can resume if `status_account.getActiveAccount()` returns a non-empty account. + - **Mitigations** + - Keep reconnection logic in the client. + +2) **Uninstall + install (fresh install)** + - **What happens** + - Android removes the app package and kills all its processes. + - App-private data directory is removed; the service cannot persist. + - **Expected behavior** + - Fresh install behaves as logged-out; onboarding shown. + - **Mitigations** + - None required; this is expected platform behavior. + +#### App uninstall + +- **What happens** + - Android kills UI + service processes. + - All app private storage is removed. +- **Expected behavior** + - No background work remains; no notifications from the app after uninstall. +- **Mitigations** + - None; ensure no exported components allow external restarts after uninstall (standard Android packaging). + +### Why “logged-in” must be an in-memory fact + +If the service process is not alive, *there is no unlocked key material*. Any persistent marker would be misleading and can cause: + +- UI “auto-resume” into logged-in flows, +- immediate backend RPC failures, +- unstable teardown (crashes) due to inconsistent state. + +Therefore: + +- The canonical “logged-in” state is the live service’s in-memory keyUid. +- Persistence markers are allowed only as debugging/telemetry, not as a resume authority. + +## Push notifications approach + +### Current baseline (status-mobile style) + +- Android chat notifications are primarily delivered via **status-go local-notifications** while the service is running. +- Remote “generic chat push” is avoided to prevent duplicates and low-quality content. + +### Proposed evolution: push as a wake-up signal + +This ADR supports a roadmap where FCM acts as a wake signal when enabled. + +#### Option 1: Wake service and resume Waku (no payload decryption in push handler) + +- Push arrives (data message). +- Start service (if not running) or signal it (if running). +- Service wakes Waku and lets the normal message sync deliver messages. +- status-go generates decrypted local-notifications as messages arrive. + +**Pros** +- Minimal cryptographic surface in the push handler. +- Avoids needing to decrypt message payload directly from push. + +**Cons** +- Requires Waku to sync quickly after wake; might still be delayed by background constraints. + +#### Option 2: Decrypt push payload directly (advanced) + +- Push payload includes encrypted message preview. +- Service decrypts immediately and posts a local notification without waiting for Waku. + +**Pros** +- Faster perceived notifications. + +**Cons** +- Key availability constraints remain (service must be alive + unlocked keys). +- Higher complexity and security review surface. + +### Non-GMS devices fallback + +For non-GMS builds/devices, a push-driven model is not reliable. The fallback is: + +- foreground keepalive (Mode A), or +- periodic background work via WorkManager/AlarmManager (best-effort), understanding delivery limits. + +## IPC / bindings analysis + +### Proposed approach: Binder (AIDL) + “C API stub” + +**What we would do** + +- Keep the existing Nim ↔ C API boundary stable: Nim calls a C ABI “status-go” API. +- Replace the implementation behind those symbols in the UI process with a stub that forwards to the service. +- Use Android-native IPC (Binder + AIDL) for robustness and performance. + +**Why this is a good fit (for Android)** + +- Low latency IPC, lifecycle-aware, well-supported by Android tooling. +- Easy to build “signals” using `RemoteCallbackList`. +- AIDL gives a typed interface and a stable contract surface. + +**Costs** + +- Two binaries/processes to reason about. +- Any new status-go API surface must be exposed through: + - stub symbol, Java bridge, AIDL method (or generic `call`), service implementation. +- More complex debugging (two PIDs, logcat filtering). + +### Alternatives considered + +#### 1) In-process status-go (no IPC) + +**Pros** +- Simplest integration surface. +- No IPC glue. + +**Cons** +- Cannot survive UI process death. +- Decrypted notifications become unreliable. +- Hard to make resilient to Android lifecycle. + +Considered but not recommended for Android given the resilience requirements. + +#### 2) Local TCP/Unix socket + JSON-RPC + +**Pros** +- Cross-platform story (same IPC on desktop/mobile). +- Can reuse existing JSON-RPC patterns. + +**Cons** +- More security hardening required (authn, binding to loopback, file permissions). +- Harder lifecycle integration vs Binder. +- Performance overhead and more error-prone in Android background conditions. + +Considered; not recommended as the primary Android path due to lifecycle/security/perf trade-offs vs Binder. + +#### 3) AIDL with typed methods for every status-go call + +**Pros** +- Fully typed contract; compile-time safety. + +**Cons** +- Large API surface; heavy maintenance as status-go evolves. +- Code generation churn. + +We propose keeping a **generic `call(method, argsJson)`** RPC shape to contain churn. + +#### 4) gRPC / protobuf IPC + +**Pros** +- Strongly typed; tooling ecosystem. + +**Cons** +- Significant additional stack; lifecycle and background constraints unchanged. +- More complexity than needed for in-device IPC. + +Considered; likely too heavy for on-device IPC in this context. + +### Code generation strategy + +The current approach minimizes codegen: + +- AIDL generates `IStatusGoService` and `IStatusGoSignalListener`. +- The C ABI stub bindings are generated from `libstatus.h` (exported C API) by `vendor/status-go/tools/generate-stub-bindings`. + - Outputs are written to `vendor/status-go/build/bin/` and consumed by the mobile build. +- Everything else stays “generic method + json args”. + +If stronger typing becomes valuable later, we could add **a thin typed facade** on top of the generic call without changing the underlying transport. + +## Operational notes / maintenance burden + +### What this adds to day-to-day development + +- Two-process debugging: + - UI PID and service PID. + - Need to filter logs per PID when diagnosing startup/resume. +- Binder failure modes: + - `DeadObjectException` must be handled with reconnect + retry. + - Service version mismatch risks after upgrades. +- Build complexity: + - Separate JNI wrapper library (`status_service`) and stub library in the UI process. + +### Mitigations + +- Keep the AIDL surface minimal: + - `call/callToFile`, `registerSignalListener`. +- Use a single “gateway” for RPC errors and reconnect behavior: + - `StatusGoServiceClient.call()` retries once on `DeadObjectException`. +- Keep “login state” checks authoritative and cheap: + - ``status_account.getActiveAccount()`` used during startup gating. + +## Consequences + +### Positive + +- UI can restart without losing the backend session (when service is alive). +- Enables decrypted notifications via status-go local notifications pipeline. +- Allows explicit lifetime policies (foreground keepalive vs on-demand). + +### Negative / trade-offs + +- Additional code and maintenance surface (service, IPC, stub). +- “Always-on” notification required for the most reliable background mode. +- If OS kills the service process, the user must re-authenticate (expected, secure behavior). + +## Follow-ups / roadmap + +### Near-term hardening + +- Add structured telemetry for: + - service start reasons (app open vs push) + - time-to-ready + - reconnect/retry counts + +### Sleep / wake work (if desired) + +- Define status-go API for suspend/resume of Waku/messenger (soft sleep). +- Tie sleep policy to: + - notifications enabled + - device doze state + - network type / battery saver +- Implement “push wake” integration that triggers resume without decrypting push payload initially. + diff --git a/docs/architecture.md b/docs/architecture.md index 703b834664..fe3fb3dbb1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,9 @@ # Architecture of the Status App +## Architecture Decision Records (ADRs) + +- [docs/adr/0001-android-status-go-as-a-service.md](/docs/adr/0001-android-status-go-as-a-service.md): Android architecture where `status-go` runs in a separate Service process and the UI talks to it via Binder IPC. ## Top level architecture @@ -8,6 +11,7 @@ This shows the flow from the UI all the way to the backend. We do not use servers. [status-go](https://github.com/status-im/status-go) is what our app considers the backend and as such, it has it's own local databases to contain the user data. + ```mermaid flowchart LR qml["Frontend (QML)"] --> statusq(["StatusQ"]) diff --git a/mobile/Makefile b/mobile/Makefile index 6e4e59ee1f..ddbd02958c 100644 --- a/mobile/Makefile +++ b/mobile/Makefile @@ -1,8 +1,6 @@ -include ./scripts/EnvVariables.mk -include ./scripts/Common.mk -STATUS_GO_LIB := $(LIB_PATH)/libstatus$(LIB_EXT) - # FLAG_KEYCARD_ENABLED: Controls NFC/Keycard support # - iOS: Default 1 (enabled) - Build with NFC support, works with free Apple Developer account # - Android: Default 1 (enabled) - NFC support doesn't require paid account on Android @@ -40,6 +38,7 @@ ifeq ($(OS),android) ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \ ANDROID_API="$(ANDROID_API)" \ HOST_OS="$(HOST_OS)" \ + NIM_SDS_SOURCE_DIR="$(NIM_SDS_SOURCE_DIR)" \ USE_SYSTEM_NIM=$(USE_SYSTEM_NIM) \ GO_GENERATE_CMD="go generate" \ SHELL=/bin/sh @@ -55,6 +54,29 @@ endif @cp $(NIM_SDS_SOURCE_DIR)/build/libsds$(LIB_EXT) $(LIB_PATH)/libsds$(LIB_EXT) @cp ../vendor/status-go/build/bin/libstatus$(LIB_EXT) $(LIB_PATH)/libstatus$(LIB_EXT) +$(STATUS_GO_STUB_LIB): $(STATUS_GO_LIB) + @echo "Building status-go stub library (UI process)" + @mkdir -p $(LIB_PATH) + @$(MAKE) $(STATUS_GO_STUB_GEN) + @$(CXX) -shared -fPIC -O2 \ + -o $(STATUS_GO_STUB_LIB) \ + $(STATUS_DESKTOP)/mobile/statusgo_stub/statusgo_stub.cpp \ + $(STATUS_GO_STUB_GEN) \ + -llog -lc++_shared + +$(STATUS_GO_SERVICE_LIB): $(STATUS_GO_LIB) + @echo "Building status-go service JNI library (service process)" + @mkdir -p $(LIB_PATH) + @$(MAKE) $(STATUS_GO_STUB_GEN) + @$(CXX) -shared -fPIC -O2 \ + -o $(STATUS_GO_SERVICE_LIB) \ + $(STATUS_DESKTOP)/mobile/statusgo_service/statusgo_service_jni.cpp \ + $(STATUS_GO_SERVICE_GEN) \ + -L$(LIB_PATH) -lstatus -llog -lc++_shared + +$(STATUS_GO_STUB_GEN): + @$(MAKE) -C $(STATUS_DESKTOP)/vendor/status-go statusgo-stub-bindings + $(STATUS_Q_LIB): $(STATUS_Q_FILES) $(STATUS_Q_SCRIPT) $(STATUS_Q_UI_FILES) @echo "Building StatusQ" @STATUSQ=$(STATUSQ) QT_MAJOR=$(QT_MAJOR) LIB_SUFFIX=$(LIB_SUFFIX) LIB_EXT=$(LIB_EXT) $(STATUS_Q_SCRIPT) $(HANDLE_OUTPUT) @@ -84,7 +106,12 @@ $(STATUS_DESKTOP_RCC): $(STATUS_DESKTOP_UI_FILES) compile-translations @make -C $(STATUS_DESKTOP) rcc $(HANDLE_OUTPUT) @echo "Status Desktop rcc built" -$(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) + +ifeq ($(OS),android) +$(NIM_STATUS_CLIENT_LIB): $(STATUS_GO_STUB_LIB) $(STATUS_GO_SERVICE_LIB) +endif + +$(NIM_STATUS_CLIENT_LIB): $(STATUS_DESKTOP_NIM_FILES) $(NIM_STATUS_CLIENT_SCRIPT) $(STATUS_DESKTOP_RCC) $(DOTHERSIDE_LIB) $(OPENSSL_LIB) $(STATUS_Q_LIB) $(QRCODEGEN_LIB) @echo "Building Status Desktop Lib" @STATUS_DESKTOP=$(STATUS_DESKTOP) \ LIB_SUFFIX=$(LIB_SUFFIX) \ diff --git a/mobile/android/qt6/AndroidManifest.xml b/mobile/android/qt6/AndroidManifest.xml index ef003df7b1..e583fb52a4 100644 --- a/mobile/android/qt6/AndroidManifest.xml +++ b/mobile/android/qt6/AndroidManifest.xml @@ -2,6 +2,16 @@ + + + + @@ -10,6 +20,8 @@ + + @@ -116,5 +128,17 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/qtprovider_paths"/> + + + + + diff --git a/mobile/android/qt6/aidl/app/status/mobile/ipc/IStatusGoService.aidl b/mobile/android/qt6/aidl/app/status/mobile/ipc/IStatusGoService.aidl new file mode 100644 index 0000000000..aacc5d886b --- /dev/null +++ b/mobile/android/qt6/aidl/app/status/mobile/ipc/IStatusGoService.aidl @@ -0,0 +1,29 @@ +package app.status.mobile.ipc; + +import app.status.mobile.ipc.IStatusGoSignalListener; + +interface IStatusGoService { + /** Generic call into status-go exports (method name is the C export name). */ + String call(String method, String argsJson); + + /** + * Same as call(), but writes the response to a file in the service's cache dir + * and returns the absolute file path. This avoids Binder size limits for large JSON. + */ + String callToFile(String method, String argsJson); + + /** Register a signal listener. */ + void registerSignalListener(IStatusGoSignalListener listener); + + /** Unregister a signal listener. */ + void unregisterSignalListener(IStatusGoSignalListener listener); + + /** + * UI visibility hint used for notification suppression. + * + * If {@code visible=true}, the UI is in foreground; the service should not post OS + * message notifications (to avoid duplicates / to match “only when background” behavior). + */ + void setUiVisible(boolean visible); +} + diff --git a/mobile/android/qt6/aidl/app/status/mobile/ipc/IStatusGoSignalListener.aidl b/mobile/android/qt6/aidl/app/status/mobile/ipc/IStatusGoSignalListener.aidl new file mode 100644 index 0000000000..5dd58f5379 --- /dev/null +++ b/mobile/android/qt6/aidl/app/status/mobile/ipc/IStatusGoSignalListener.aidl @@ -0,0 +1,7 @@ +package app.status.mobile.ipc; + +/** One-way signal stream from status-go service to UI process. */ +oneway interface IStatusGoSignalListener { + void onSignal(String jsonSignal); +} + diff --git a/mobile/android/qt6/build.gradle b/mobile/android/qt6/build.gradle index cfea37d9da..e54e56e0fa 100644 --- a/mobile/android/qt6/build.gradle +++ b/mobile/android/qt6/build.gradle @@ -61,6 +61,10 @@ android { buildToolsVersion androidBuildToolsVersion ndkVersion = androidNdkVersion + buildFeatures { + aidl true + } + sourceSets { main { manifest.srcFile 'AndroidManifest.xml' diff --git a/mobile/android/qt6/src/app/status/mobile/StatusGoStub.java b/mobile/android/qt6/src/app/status/mobile/StatusGoStub.java new file mode 100644 index 0000000000..c17d6c6d25 --- /dev/null +++ b/mobile/android/qt6/src/app/status/mobile/StatusGoStub.java @@ -0,0 +1,60 @@ +package app.status.mobile; + +import android.content.Context; +import app.status.mobile.ipc.StatusGoServiceClient; + +/** + * UI-process bridge used by the native status-go stub library (libstatus_stub.so). + * + * For now this is a placeholder: it provides a stable Java surface for the native stub + * to call into. Next step is to back this by a Binder client that talks to the + * separate status-go service process. + */ +public final class StatusGoStub { + static { + // Loads libstatus_stub.so (needed for JNI_OnLoad + nativeInit). + System.loadLibrary("status_stub"); + } + + private StatusGoStub() {} + + // Native: supplies the Java class that implements call(). + private static native void nativeInit(Class bridgeClass); + + // Native: used later to deliver signals from Binder listener to Nim callback. + public static native void nativeDeliverSignal(String jsonSignal); + + /** Must be called early (e.g. Activity.onCreate) to bind the Java bridge. */ + public static void ensureInitialized(Context context) { + nativeInit(StatusGoStub.class); + // Start/bind the status-go service early so first RPC doesn't block too long. + StatusGoServiceClient.get().ensureStartedAndBound(context); + } + + /** + * Called from native status-go stub. + * @param method status-go exported method name (e.g. "CallPrivateRPC") + * @param argsJson JSON array of string args (placeholder encoding for now) + */ + public static String call(String method, String argsJson) { + // Called from native stub exports; forward to separate-process service. + if (sContext == null) { + return "{\"error\":\"StatusGoStub not initialized\"}"; + } + return StatusGoServiceClient.get().call(sContext, method, argsJson); + } + + /** Hint to the service whether the UI is currently visible (foreground). */ + public static void setUiVisible(boolean visible) { + if (sContext == null) return; + StatusGoServiceClient.get().setUiVisible(sContext, visible); + } + + private static volatile Context sContext; + + /** Called by Activity. Keep application context for later native calls. */ + public static void setContext(Context context) { + sContext = context.getApplicationContext(); + } +} + diff --git a/mobile/android/qt6/src/app/status/mobile/StatusQtActivity.java b/mobile/android/qt6/src/app/status/mobile/StatusQtActivity.java index b424c81021..caf769b189 100644 --- a/mobile/android/qt6/src/app/status/mobile/StatusQtActivity.java +++ b/mobile/android/qt6/src/app/status/mobile/StatusQtActivity.java @@ -3,11 +3,13 @@ package app.status.mobile; import org.qtproject.qt.android.bindings.QtActivity; import android.os.Build; import android.os.Bundle; +import android.content.pm.PackageManager; import androidx.core.splashscreen.SplashScreen; import java.util.concurrent.atomic.AtomicBoolean; import android.content.Intent; import android.net.Uri; import android.provider.Settings; +import im.status.mobileui.PushNotificationHelper; public class StatusQtActivity extends QtActivity { private static final AtomicBoolean splashShouldHide = new AtomicBoolean(false); @@ -21,6 +23,14 @@ public class StatusQtActivity extends QtActivity { @Override public void onCreate(Bundle savedInstanceState) { + // Initialize the status-go UI stub bridge early. + // (In the service-based architecture this forwards to the separate status-go process.) + StatusGoStub.setContext(this); + StatusGoStub.ensureInitialized(this); + + // IMPORTANT: call super.onCreate() after starting/binding the service. + // QtActivity may start the Qt (Nim) side during super.onCreate(), and the Nim + // onboarding resume check queries the service immediately on startup. super.onCreate(savedInstanceState); sInstance = this; @@ -38,11 +48,15 @@ public class StatusQtActivity extends QtActivity { protected void onResume() { super.onResume(); ShakeDetector.onResume(this); + // Inform the status-go service that UI is visible so it can suppress OS notifications. + StatusGoStub.setUiVisible(true); } @Override protected void onPause() { ShakeDetector.onPause(); + // Inform the status-go service that UI is no longer in foreground. + StatusGoStub.setUiVisible(false); super.onPause(); } diff --git a/mobile/android/qt6/src/app/status/mobile/ipc/StatusGoService.java b/mobile/android/qt6/src/app/status/mobile/ipc/StatusGoService.java new file mode 100644 index 0000000000..8e14d80f6e --- /dev/null +++ b/mobile/android/qt6/src/app/status/mobile/ipc/StatusGoService.java @@ -0,0 +1,300 @@ +package app.status.mobile.ipc; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.RemoteCallbackList; +import android.os.RemoteException; +import android.util.Log; + +import androidx.core.app.NotificationCompat; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import org.json.JSONObject; + +import im.status.mobileui.PushNotificationHelper; + +/** + * Separate-process status-go host. + * + * Runs in its own Android process (see AndroidManifest.xml) and is intended to be the + * only process that links/uses the real libstatus.so. UI process talks to it over Binder. + */ +public final class StatusGoService extends Service { + private static final String TAG = "StatusGoService"; + + public static final String ACTION_START = "app.status.mobile.ipc.StatusGoService.START"; + public static final String ACTION_STOP = "app.status.mobile.ipc.StatusGoService.STOP"; + + private static final String CHANNEL_ID = "statusgo"; + private static final int NOTIFICATION_ID = 4242; + + private final RemoteCallbackList listeners = new RemoteCallbackList<>(); + private volatile boolean foregroundStarted = false; + private volatile boolean uiVisible = false; + private volatile long uiVisibleLastUpdateMs = 0L; + + static { + // Loads libstatus_service.so (JNI wrapper that links real libstatus.so). + System.loadLibrary("status_service"); + } + + private static native void nativeInit(StatusGoService self); + private static native String nativeCall(String method, String argsJson); + + /** + * Defense-in-depth: ensure only our own app UID can invoke Binder methods. + * + * Note: this service is also declared with android:exported="false" and a signature-level + * permission in AndroidManifest.xml. This runtime check protects against accidental manifest + * changes and makes the security property explicit at the IPC boundary. + */ + private void enforceCallerIsSameApp() { + final int callingUid = Binder.getCallingUid(); + final int myUid = getApplicationInfo() != null ? getApplicationInfo().uid : -1; + if (callingUid != myUid) { + throw new SecurityException("Unauthorized caller uid=" + callingUid); + } + } + + /** Called from native (status-go callback). */ + @SuppressWarnings("unused") + private void onNativeSignal(String jsonSignal) { + maybeStartForegroundFromSignal(jsonSignal); + maybeShowOsNotificationFromSignal(jsonSignal); + + final int n = listeners.beginBroadcast(); + try { + for (int i = 0; i < n; i++) { + try { + listeners.getBroadcastItem(i).onSignal(jsonSignal); + } catch (RemoteException ignored) { + // RemoteCallbackList handles dead clients. + } + } + } finally { + listeners.finishBroadcast(); + } + } + + /** + * Show OS notifications from the service process. + * + * This is required to deliver OS notifications when the UI process is killed. + * We suppress notifications when the UI is in foreground (uiVisible=true). + */ + private void maybeShowOsNotificationFromSignal(String jsonSignal) { + if (jsonSignal == null || jsonSignal.isEmpty()) return; + // If UI is (recently) visible, suppress OS notifications to avoid duplicates. + // If UI crashed while visible, there will be no further heartbeats; fall back to showing + // notifications after a short timeout. + if (uiVisible) { + final long now = System.currentTimeMillis(); + final long last = uiVisibleLastUpdateMs; + if (last > 0 && (now - last) < 5000) { + return; + } + } + try { + final JSONObject root = new JSONObject(jsonSignal); + final String type = root.optString("type", ""); + if ("local-notifications".equals(type)) { + // Preferred path: status-go already computed title/body/deepLink for OS notifications. + final JSONObject eventWrap = root.optJSONObject("event"); + if (eventWrap == null) return; + + final boolean deleted = eventWrap.optBoolean("deleted", false); + if (deleted) return; + + final String title = eventWrap.optString("title", ""); + final String message = eventWrap.optString("message", ""); + final String deepLink = eventWrap.optString("deepLink", ""); + final String conversationId = eventWrap.optString("conversationId", ""); + + Log.d(TAG, "local-notifications received: title=" + title + " conversationId=" + conversationId); + + final JSONObject identifier = new JSONObject(); + if (deepLink != null) identifier.put("deepLink", deepLink); + if (conversationId != null) identifier.put("conversationId", conversationId); + + PushNotificationHelper.showNotification( + title != null ? title : "Status", + message != null ? message : "", + identifier.toString() + ); + return; + } + } catch (Throwable t) { + // Best-effort only; do not crash the service for notification display. + } + } + + private void maybeStartForegroundFromSignal(String jsonSignal) { + if (jsonSignal == null || jsonSignal.isEmpty()) return; + try { + final JSONObject root = new JSONObject(jsonSignal); + final String type = root.optString("type", ""); + if (type.isEmpty()) return; + + // On successful node login, keep this service as a foreground service so it survives + // swipe-away from Recents. + if ("node.login".equals(type)) { + final JSONObject event = root.optJSONObject("event"); + if (event == null) return; + final String err = event.optString("error", ""); + if (err != null && !err.isEmpty()) return; + ensureForegroundStarted(); + return; + } + } catch (Throwable t) { + // Best-effort only; don't crash the service. + } + } + + private void ensureForegroundStarted() { + if (foregroundStarted) return; + try { + createNotificationChannel(); + startForeground(NOTIFICATION_ID, buildNotification()); + foregroundStarted = true; + } catch (Throwable t) { + // Best-effort only; don't crash service. + } + } + + private void maybeStopOnLogoutCall(String method, String respJson) { + if (method == null) return; + if (!method.equalsIgnoreCase("Logout")) return; + if (respJson == null || respJson.isEmpty()) return; + try { + final JSONObject resp = new JSONObject(respJson); + final String err = resp.optString("error", ""); + if (err != null && !err.isEmpty()) return; + try { + stopForeground(true); + } catch (Throwable ignored) {} + foregroundStarted = false; + stopSelf(); + Log.i(TAG, "stopped after logout"); + } catch (Throwable t) { + // Best-effort only. + } + } + + private final IStatusGoService.Stub binder = new IStatusGoService.Stub() { + @Override + public String call(String method, String argsJson) { + enforceCallerIsSameApp(); + String resp = nativeCall(method, argsJson); + maybeStopOnLogoutCall(method, resp); + return resp; + } + + @Override + public String callToFile(String method, String argsJson) { + enforceCallerIsSameApp(); + String resp = nativeCall(method, argsJson); + if (resp == null) resp = "{\"error\":\"null response\"}"; + maybeStopOnLogoutCall(method, resp); + try { + File f = File.createTempFile("statusgo_", ".json", getCacheDir()); + try (FileOutputStream os = new FileOutputStream(f, false)) { + os.write(resp.getBytes(StandardCharsets.UTF_8)); + } + return f.getAbsolutePath(); + } catch (Throwable t) { + Log.w(TAG, "callToFile failed", t); + return "{\"error\":\"callToFile failed\"}"; + } + } + + @Override + public void registerSignalListener(IStatusGoSignalListener listener) { + enforceCallerIsSameApp(); + if (listener != null) listeners.register(listener); + } + + @Override + public void unregisterSignalListener(IStatusGoSignalListener listener) { + enforceCallerIsSameApp(); + if (listener != null) listeners.unregister(listener); + } + + @Override + public void setUiVisible(boolean visible) { + enforceCallerIsSameApp(); + uiVisible = visible; + uiVisibleLastUpdateMs = visible ? System.currentTimeMillis() : 0L; + } + }; + + @Override + public void onCreate() { + super.onCreate(); + Log.i(TAG, "onCreate()"); + // Do not automatically become a foreground service on creation. We only need to be + // foreground while the user is logged in (then we survive swipe-away from Recents). + PushNotificationHelper.initialize(this); + nativeInit(this); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + final String action = intent != null ? intent.getAction() : null; + Log.i(TAG, "onStartCommand action=" + action); + if (ACTION_STOP.equals(action)) { + try { + stopForeground(true); + } catch (Throwable ignored) {} + foregroundStarted = false; + stopSelf(); + return START_NOT_STICKY; + } + // Ensure we can be started from background components (e.g. FCM) without risking + // ForegroundServiceDidNotStartInTime. We can downgrade/stop later if needed. + ensureForegroundStarted(); + return START_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + @Override + public void onDestroy() { + Log.i(TAG, "onDestroy()"); + listeners.kill(); + super.onDestroy(); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (nm == null) return; + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, + "Status background", + NotificationManager.IMPORTANCE_LOW + ); + ch.setDescription("Keeps Status background service running for messaging."); + nm.createNotificationChannel(ch); + } + + private Notification buildNotification() { + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Status is running") + .setContentText("Background service for messaging and notifications") + .setSmallIcon(android.R.drawable.stat_notify_chat) + .setOngoing(true) + .build(); + } +} + diff --git a/mobile/android/qt6/src/app/status/mobile/ipc/StatusGoServiceClient.java b/mobile/android/qt6/src/app/status/mobile/ipc/StatusGoServiceClient.java new file mode 100644 index 0000000000..4e060d979f --- /dev/null +++ b/mobile/android/qt6/src/app/status/mobile/ipc/StatusGoServiceClient.java @@ -0,0 +1,232 @@ +package app.status.mobile.ipc; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Build; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import app.status.mobile.StatusGoStub; + +/** UI-process Binder client for {@link StatusGoService}. */ +public final class StatusGoServiceClient { + private static final String TAG = "StatusGoServiceClient"; + private static final long CONNECT_TIMEOUT_MS = 8000; + + private static volatile StatusGoServiceClient sInstance; + + public static StatusGoServiceClient get() { + if (sInstance == null) { + synchronized (StatusGoServiceClient.class) { + if (sInstance == null) sInstance = new StatusGoServiceClient(); + } + } + return sInstance; + } + + private final Object lock = new Object(); + private IStatusGoService service; + private CountDownLatch connectedLatch; + private boolean bound = false; + + private final IStatusGoSignalListener signalListener = new IStatusGoSignalListener.Stub() { + @Override + public void onSignal(String jsonSignal) { + // Forward into the native stub callback (SetSignalEventCallback). + StatusGoStub.nativeDeliverSignal(jsonSignal); + } + }; + + private final ServiceConnection conn = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + synchronized (lock) { + service = IStatusGoService.Stub.asInterface(binder); + bound = true; + try { + service.registerSignalListener(signalListener); + } catch (RemoteException e) { + Log.w(TAG, "registerSignalListener failed", e); + } + if (connectedLatch != null) connectedLatch.countDown(); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + synchronized (lock) { + service = null; + connectedLatch = null; + bound = false; + } + } + }; + + private StatusGoServiceClient() {} + + private void resetConnection(Context appContext) { + synchronized (lock) { + service = null; + connectedLatch = null; + } + try { + if (bound) { + appContext.getApplicationContext().unbindService(conn); + } + } catch (Throwable ignored) { + } finally { + synchronized (lock) { + bound = false; + } + } + } + + public void ensureStartedAndBound(Context context) { + final Context app = context.getApplicationContext(); + synchronized (lock) { + if (service != null) return; + if (connectedLatch == null) connectedLatch = new CountDownLatch(1); + } + + Intent i = new Intent(app, StatusGoService.class); + i.setAction(StatusGoService.ACTION_START); + try { + // Start as a normal service; StatusGoService promotes itself to foreground only + // when logged in. + app.startService(i); + } catch (Throwable t) { + // Fallback: some OEMs are strict; best-effort start as foreground service. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + app.startForegroundService(i); + } else { + app.startService(i); + } + } + // Bind for request/response + app.bindService(i, conn, Context.BIND_AUTO_CREATE); + } + + public String call(Context context, String method, String argsJson) { + final Context app = context.getApplicationContext(); + ensureStartedAndBound(app); + IStatusGoService s; + CountDownLatch latch; + synchronized (lock) { + s = service; + latch = connectedLatch; + } + if (s == null && latch != null) { + try { + latch.await(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + } + } + synchronized (lock) { + s = service; + } + if (s == null) { + return "{\"error\":\"status-go service not connected\"}"; + } + try { + final String pathOrErr = s.callToFile(method, argsJson); + if (pathOrErr == null) { + return "{\"error\":\"status-go service returned null\"}"; + } + if (!pathOrErr.startsWith("/")) { + // service returned an error JSON (small) + return pathOrErr; + } + final File f = new File(pathOrErr); + byte[] data; + try { + data = Files.readAllBytes(f.toPath()); + } catch (IOException e) { + Log.w(TAG, "Failed to read response file: " + pathOrErr, e); + return "{\"error\":\"failed to read response file\"}"; + } finally { + // best-effort cleanup + //noinspection ResultOfMethodCallIgnored + f.delete(); + } + return new String(data, StandardCharsets.UTF_8); + } catch (RemoteException e) { + Log.w(TAG, "call failed", e); + // After reinstall/update (or service crash), binder can become a dead object. + // Reset, rebind, and retry once to avoid cascading JSON parse errors upstream. + if (e instanceof android.os.DeadObjectException) { + resetConnection(app); + ensureStartedAndBound(app); + synchronized (lock) { + s = service; + latch = connectedLatch; + } + if (s == null && latch != null) { + try { + latch.await(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + } + } + synchronized (lock) { + s = service; + } + if (s != null) { + try { + final String pathOrErr2 = s.callToFile(method, argsJson); + if (pathOrErr2 == null) { + return "{\"error\":\"status-go service returned null\"}"; + } + if (!pathOrErr2.startsWith("/")) { + return pathOrErr2; + } + final File f2 = new File(pathOrErr2); + byte[] data2; + try { + data2 = Files.readAllBytes(f2.toPath()); + } catch (IOException io) { + Log.w(TAG, "Failed to read response file: " + pathOrErr2, io); + return "{\"error\":\"failed to read response file\"}"; + } finally { + //noinspection ResultOfMethodCallIgnored + f2.delete(); + } + return new String(data2, StandardCharsets.UTF_8); + } catch (RemoteException e2) { + Log.w(TAG, "call retry failed", e2); + } + } + } + return "{\"error\":\"status-go service call failed\"}"; + } + } + + /** Best-effort hint for whether UI is currently in foreground. */ + public void setUiVisible(Context context, boolean visible) { + final Context app = context.getApplicationContext(); + ensureStartedAndBound(app); + IStatusGoService s; + synchronized (lock) { + s = service; + } + if (s == null) return; + try { + s.setUiVisible(visible); + } catch (RemoteException e) { + Log.w(TAG, "setUiVisible failed", e); + if (e instanceof android.os.DeadObjectException) { + resetConnection(app); + ensureStartedAndBound(app); + } + } + } +} + diff --git a/mobile/scripts/Common.mk b/mobile/scripts/Common.mk index 8dd60924f1..23ddaaf20d 100644 --- a/mobile/scripts/Common.mk +++ b/mobile/scripts/Common.mk @@ -34,6 +34,7 @@ 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 +NIM_SDS_SOURCE_DIR ?= $(STATUS_DESKTOP)/vendor/nim-sds # compile macros TARGET_PREFIX := Status @@ -62,6 +63,8 @@ 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) +STATUS_GO_STUB_GEN := $(STATUS_DESKTOP)/vendor/status-go/build/bin/statusgo_stub_exports.cpp +STATUS_GO_SERVICE_GEN := $(STATUS_DESKTOP)/vendor/status-go/build/bin/statusgo_service_dispatch.cpp # script files STATUS_Q_SCRIPT := $(SCRIPTS_PATH)/buildStatusQ.sh @@ -82,6 +85,8 @@ QRCODEGEN_LIB := $(LIB_PATH)/libqrcodegen.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 +STATUS_GO_STUB_LIB := $(LIB_PATH)/libstatus_stub$(LIB_EXT) +STATUS_GO_SERVICE_LIB := $(LIB_PATH)/libstatus_service$(LIB_EXT) ifeq ($(OS), ios) DOTHERSIDE_LIB := $(LIB_PATH)/libDOtherSideStatic$(LIB_SUFFIX)$(LIB_EXT) LIB_ZXING := $(LIB_PATH)/libZXing$(LIB_SUFFIX)$(LIB_EXT) diff --git a/mobile/scripts/buildApp.sh b/mobile/scripts/buildApp.sh index ff3aeb9453..652c1b836f 100755 --- a/mobile/scripts/buildApp.sh +++ b/mobile/scripts/buildApp.sh @@ -67,6 +67,7 @@ if [[ "${OS}" == "android" ]]; then cp "$CWD/../android/qt${QT_MAJOR}"/{AndroidManifest.xml,build.gradle,settings.gradle,gradle.properties} "$BUILD_DIR/android-build/" rsync -a --exclude='libs.xml' "$CWD/../android/qt${QT_MAJOR}/res/" "$BUILD_DIR/android-build/res/" 2>/dev/null || true rsync -a "$CWD/../android/qt${QT_MAJOR}/src/" "$BUILD_DIR/android-build/src/" 2>/dev/null || true + rsync -a "$CWD/../android/qt${QT_MAJOR}/aidl/" "$BUILD_DIR/android-build/aidl/" 2>/dev/null || true if [[ -n "${MOBILEWEBVIEW_ANDROID_JAVA_SRC}" && \ -f "${MOBILEWEBVIEW_ANDROID_JAVA_SRC}/org/mobilewebview/MobileWebView.java" ]]; then diff --git a/mobile/scripts/buildNimStatusClient.sh b/mobile/scripts/buildNimStatusClient.sh index f7e653fafe..c4aef24af3 100755 --- a/mobile/scripts/buildNimStatusClient.sh +++ b/mobile/scripts/buildNimStatusClient.sh @@ -32,7 +32,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="-lssl_3" --passL="-lcrypto_3" --passL="-lstatus-keycard-qt" -d:taskpool) + --passL="-L$LIB_DIR" --passL="-lstatus_stub" --passL="-lStatusQ$LIB_SUFFIX" --passL="-lDOtherSide$LIB_SUFFIX" --passL="-lqrcodegen" --passL="-lssl_3" --passL="-lcrypto_3" --passL="-lstatus-keycard-qt" -d:taskpool) fi if [ -n "$USE_QML_SERVER" ]; then diff --git a/mobile/statusgo_service/statusgo_service_jni.cpp b/mobile/statusgo_service/statusgo_service_jni.cpp new file mode 100644 index 0000000000..50116af2ee --- /dev/null +++ b/mobile/statusgo_service/statusgo_service_jni.cpp @@ -0,0 +1,156 @@ +#include +#include +#include +#include +#include +#include + +// Real status-go exports (from libstatus.so) +extern "C" { + typedef void (*SignalCallback)(const char* signalJson); + void SetSignalEventCallback(SignalCallback cb); + void Free(void* p); +} + +// Generated dispatcher (links against libstatus.so and calls real exports) +extern "C" char* statusgo_service_dispatch(const char* method, const char** argv, size_t argc); + +namespace { +static JavaVM* g_vm = nullptr; +static jobject g_serviceObj = nullptr; // Global ref +static jmethodID g_onSignal = nullptr; +static std::mutex g_lock; + +static void loge(const char* msg) { __android_log_write(ANDROID_LOG_ERROR, "statusgo-service", msg); } + +static JNIEnv* getEnv() { + if (!g_vm) return nullptr; + JNIEnv* env = nullptr; + if (g_vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) { + if (g_vm->AttachCurrentThread(&env, nullptr) != JNI_OK) return nullptr; + } + return env; +} + +static void signalCb(const char* signalJson) { + std::lock_guard guard(g_lock); + if (!g_serviceObj || !g_onSignal) return; + JNIEnv* env = getEnv(); + if (!env) return; + jstring jSig = env->NewStringUTF(signalJson ? signalJson : ""); + env->CallVoidMethod(g_serviceObj, g_onSignal, jSig); + env->DeleteLocalRef(jSig); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } +} + +// Minimal JSON array-of-strings parser: +// Accepts the exact format produced by the UI stub runtime: +// ["str1","str2",...] +// with standard JSON escaping. +static bool parseJsonString(const char*& p, std::string& out) { + if (*p != '"') return false; + ++p; + while (*p) { + char c = *p++; + if (c == '"') return true; + if (c == '\\') { + char e = *p++; + switch (e) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + // Skip \uXXXX (best-effort; keep ASCII only for now) + for (int i = 0; i < 4 && *p; i++) ++p; + // Replace with '?' + out.push_back('?'); + break; + } + default: + out.push_back(e); + break; + } + } else { + out.push_back(c); + } + } + return false; +} + +static std::vector parseArgsJson(const char* argsJson) { + std::vector out; + if (!argsJson) return out; + const char* p = argsJson; + while (*p && (*p == ' ' || *p == '\n' || *p == '\t' || *p == '\r')) ++p; + if (*p != '[') return out; + ++p; + while (*p) { + while (*p && (*p == ' ' || *p == '\n' || *p == '\t' || *p == '\r')) ++p; + if (*p == ']') break; + std::string s; + if (!parseJsonString(p, s)) break; + out.push_back(std::move(s)); + while (*p && (*p == ' ' || *p == '\n' || *p == '\t' || *p == '\r')) ++p; + if (*p == ',') { ++p; continue; } + if (*p == ']') break; + } + return out; +} +} // namespace + +extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + g_vm = vm; + return JNI_VERSION_1_6; +} + +extern "C" JNIEXPORT void JNICALL +Java_app_status_mobile_ipc_StatusGoService_nativeInit(JNIEnv* env, jclass, jobject serviceObj) { + std::lock_guard guard(g_lock); + if (g_serviceObj) { + env->DeleteGlobalRef(g_serviceObj); + g_serviceObj = nullptr; + g_onSignal = nullptr; + } + g_serviceObj = env->NewGlobalRef(serviceObj); + jclass cls = env->GetObjectClass(serviceObj); + g_onSignal = env->GetMethodID(cls, "onNativeSignal", "(Ljava/lang/String;)V"); + if (!g_onSignal) { + loge("Failed to find StatusGoService.onNativeSignal(String)"); + } + env->DeleteLocalRef(cls); + + // Register callback into status-go. + SetSignalEventCallback(signalCb); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_app_status_mobile_ipc_StatusGoService_nativeCall(JNIEnv* env, jclass, jstring jMethod, jstring jArgsJson) { + const char* method = jMethod ? env->GetStringUTFChars(jMethod, nullptr) : nullptr; + const char* argsJson = jArgsJson ? env->GetStringUTFChars(jArgsJson, nullptr) : nullptr; + + std::vector args = parseArgsJson(argsJson); + std::vector argv; + argv.reserve(args.size()); + for (auto& s : args) argv.push_back(s.c_str()); + + char* out = statusgo_service_dispatch(method ? method : "", argv.empty() ? nullptr : argv.data(), argv.size()); + const bool shouldFree = (out != nullptr); + if (!out) out = (char*)"{\"error\":\"null return from dispatch\"}"; + + jstring jOut = env->NewStringUTF(out); + + if (shouldFree) Free(out); + + if (jMethod) env->ReleaseStringUTFChars(jMethod, method); + if (jArgsJson) env->ReleaseStringUTFChars(jArgsJson, argsJson); + + return jOut; +} + diff --git a/mobile/statusgo_stub/statusgo_stub.cpp b/mobile/statusgo_stub/statusgo_stub.cpp new file mode 100644 index 0000000000..6967951bb5 --- /dev/null +++ b/mobile/statusgo_stub/statusgo_stub.cpp @@ -0,0 +1,173 @@ +#include +#include +#include +#include +#include +#include +#include +// Tiny UI-process stub for status-go's exported C API. +// Instead of linking libstatus (real status-go) into the UI process, we export +// the same symbols and forward them to a separate Android service process via Java. +// +// Note: For now, the Java side can be a placeholder. This file focuses on: +// - providing the symbols required by the Nim glue (nim-status-go wrappers) +// - returning heap-allocated cstrings compatible with status-go's Free() +// +// The service-side implementation will be added next (Binder + separate process). +namespace { +static JavaVM* g_vm = nullptr; +static jclass g_bridgeClass = nullptr; +static jmethodID g_callMethod = nullptr; // static String call(String method, String argsJson) +static std::mutex g_lock; +using SignalCallback = void (*)(const char* signalJson); +static SignalCallback g_signalCb = nullptr; +static void loge(const char* msg) { + __android_log_write(ANDROID_LOG_ERROR, "statusgo-stub", msg); +} +static JNIEnv* getEnv() { + if (!g_vm) return nullptr; + JNIEnv* env = nullptr; + if (g_vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) { + if (g_vm->AttachCurrentThread(&env, nullptr) != JNI_OK) return nullptr; + } + return env; +} +static char* dupToMalloc(const char* s) { + if (!s) s = ""; + const size_t n = strlen(s); + char* out = static_cast(malloc(n + 1)); + if (!out) return nullptr; + memcpy(out, s, n); + out[n] = '\0'; + return out; +} + +static void appendJsonEscaped(std::string& out, const char* s) { + if (!s) return; + for (const unsigned char* p = (const unsigned char*)s; *p; ++p) { + const unsigned char c = *p; + switch (c) { + case '\\': out += "\\\\"; break; + case '"': out += "\\\""; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char buf[7]; + snprintf(buf, sizeof(buf), "\\u%04x", (unsigned)c); + out += buf; + } else { + out.push_back((char)c); + } + break; + } + } +} +static char* callJava(const char* method, const char* argsJson) { + JNIEnv* env = getEnv(); + if (!env) { + return dupToMalloc("{\"error\":\"status-go stub not initialized\"}"); + } + + // Keep lock scope minimal: copy references, then perform Binder call unlocked. + jclass bridgeClass = nullptr; + jmethodID callMethod = nullptr; + { + std::lock_guard guard(g_lock); + if (!g_bridgeClass || !g_callMethod) { + return dupToMalloc("{\"error\":\"status-go stub not initialized\"}"); + } + bridgeClass = (jclass)env->NewLocalRef(g_bridgeClass); + callMethod = g_callMethod; + } + if (!bridgeClass || !callMethod) { + if (bridgeClass) env->DeleteLocalRef(bridgeClass); + return dupToMalloc("{\"error\":\"status-go stub not initialized\"}"); + } + + jstring jMethod = env->NewStringUTF(method ? method : ""); + jstring jArgs = env->NewStringUTF(argsJson ? argsJson : "null"); + jstring jRet = (jstring)env->CallStaticObjectMethod(bridgeClass, callMethod, jMethod, jArgs); + env->DeleteLocalRef(jMethod); + env->DeleteLocalRef(jArgs); + env->DeleteLocalRef(bridgeClass); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + return dupToMalloc("{\"error\":\"java exception in status-go stub\"}"); + } + if (!jRet) return dupToMalloc(""); + const char* cRet = env->GetStringUTFChars(jRet, nullptr); + char* out = dupToMalloc(cRet); + env->ReleaseStringUTFChars(jRet, cRet); + env->DeleteLocalRef(jRet); + return out; +} +static char* buildArgsJson(const char** argv, size_t argc) { + std::string out; + out.reserve(64); + out.push_back('['); + for (size_t i = 0; i < argc; i++) { + if (i) out.push_back(','); + out.push_back('"'); + appendJsonEscaped(out, argv[i] ? argv[i] : ""); + out.push_back('"'); + } + out.push_back(']'); + return dupToMalloc(out.c_str()); +} +} // namespace + +extern "C" { + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + g_vm = vm; + return JNI_VERSION_1_6; +} + +// Called from Java to provide the bridge class/method. +JNIEXPORT void JNICALL +Java_app_status_mobile_StatusGoStub_nativeInit(JNIEnv* env, jclass, jclass bridgeClass) { + std::lock_guard guard(g_lock); + if (g_bridgeClass) { + env->DeleteGlobalRef(g_bridgeClass); + g_bridgeClass = nullptr; + g_callMethod = nullptr; + } + g_bridgeClass = (jclass)env->NewGlobalRef(bridgeClass); + g_callMethod = env->GetStaticMethodID(g_bridgeClass, "call", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); + if (!g_callMethod) { + loge("Failed to find StatusGoStub.call(String,String)"); + } +} + +// Called from Java (Binder listener) to deliver signals into the stored C callback. +JNIEXPORT void JNICALL +Java_app_status_mobile_StatusGoStub_nativeDeliverSignal(JNIEnv* env, jclass, jstring jsonSignal) { + (void)env; + if (!jsonSignal) return; + if (!g_signalCb) return; + const char* c = env->GetStringUTFChars(jsonSignal, nullptr); + g_signalCb(c); + env->ReleaseStringUTFChars(jsonSignal, c); +} + +void Free(void* p) { free(p); } + +void SetSignalEventCallback(SignalCallback cb) { g_signalCb = cb; } + +// Called by generated exports. +// - All arguments are passed as strings (even ints/bools) to simplify IPC. +// - The service side will interpret them based on the called method. +char* statusgo_stub_callv(const char* method, const char** argv, size_t argc) { + char* argsJson = buildArgsJson(argv, argc); + if (!argsJson) return dupToMalloc("{\"error\":\"oom\"}"); + char* out = callJava(method, argsJson); + free(argsJson); + return out; +} + +} // extern "C" + diff --git a/mobile/wrapperApp/Status.pro b/mobile/wrapperApp/Status.pro index 15d3eb1942..a7328dcc95 100644 --- a/mobile/wrapperApp/Status.pro +++ b/mobile/wrapperApp/Status.pro @@ -48,6 +48,8 @@ android { $$PWD/../lib/$$LIB_PREFIX/libnim_status_client.so \ $$PWD/../lib/$$LIB_PREFIX/libDOtherSide$$(LIB_SUFFIX)$$(LIB_EXT) \ $$PWD/../lib/$$LIB_PREFIX/libstatus.so \ + $$PWD/../lib/$$LIB_PREFIX/libstatus_stub.so \ + $$PWD/../lib/$$LIB_PREFIX/libstatus_service.so \ $$PWD/../lib/$$LIB_PREFIX/libsds.so \ $$PWD/../lib/$$LIB_PREFIX/libStatusQ$$(LIB_SUFFIX)$$(LIB_EXT) \ $$PWD/../lib/$$LIB_PREFIX/libMobileWebView$$(LIB_SUFFIX)$$(LIB_EXT) diff --git a/src/app/boot/app_controller.nim b/src/app/boot/app_controller.nim index 9b8d32b52c..8beb6de3a4 100644 --- a/src/app/boot/app_controller.nim +++ b/src/app/boot/app_controller.nim @@ -285,8 +285,11 @@ proc newAppController*(statusFoundation: StatusFoundation): AppController = result.connect() proc delete*(self: AppController) = - info "logging out..." - self.generalService.logout() + when defined(android): + info "Skipping logout on AppController.delete (Android keepalive enabled)" + else: + info "logging out..." + self.generalService.logout() singletonInstance.delete self.notificationsManager.delete @@ -344,6 +347,10 @@ proc initializeQmlContext(self: AppController) = singletonInstance.engine.setRootContextProperty("localAccountSettings", self.localAccountSettingsVariant) singletonInstance.engine.setRootContextProperty("globalUtils", self.globalUtilsVariant) + # Expose a lightweight login flag that is available as soon as `main.qml` starts evaluating. + let resumeLogin = self.accountsService.fetchLoggedInAccount().isValid() + singletonInstance.engine.setRootContextProperty("skipOnboardingContextProperty", newQVariant(resumeLogin)) + # Load keycard channel module (available before login for Session API) self.keycardChannelModule.load() diff --git a/src/app/modules/onboarding/module.nim b/src/app/modules/onboarding/module.nim index 3003903070..6e74a52a8b 100644 --- a/src/app/modules/onboarding/module.nim +++ b/src/app/modules/onboarding/module.nim @@ -41,6 +41,7 @@ type postLoginTasks: seq[PostOnboardingTask] accountsService: accounts_service.Service generalService: general_service.Service + resumeLogin: bool proc newModule*[T]( delegate: T, @@ -73,6 +74,9 @@ proc newModule*[T]( {.push warning[Deprecated]: off.} +# Forward declarations (needed because some methods call procs defined later). +proc finishAppLoading2*[T](self: Module[T]) + method delete*[T](self: Module[T]) = self.view.delete self.viewVariant.delete @@ -91,6 +95,8 @@ method onMainLoaded*[T](self: Module[T]) = self.viewVariant = nil self.controller.delete self.controller = nil + if self.resumeLogin: + self.delegate.onboardingDidLoad() method onMainFailedToLoad*[T](self: Module[T]) = self.view.accountLoginError("Failed to load main module, please restart the app and try again.", wrongPassword = false) @@ -98,6 +104,13 @@ method onMainFailedToLoad*[T](self: Module[T]) = method load*[T](self: Module[T]) = singletonInstance.engine.setRootContextProperty("onboardingModule", self.viewVariant) self.controller.init() + + let loggedInAccount = self.accountsService.fetchLoggedInAccount() + self.resumeLogin = loggedInAccount.isValid() + if (self.resumeLogin): + self.controller.setLoggedInAccount(loggedInAccount) + self.finishAppLoading2() + return let openedAccounts = self.controller.getOpenedAccounts() if openedAccounts.len > 0: diff --git a/src/app_service/service/accounts/service.nim b/src/app_service/service/accounts/service.nim index ba0a48c033..76f10d0707 100644 --- a/src/app_service/service/accounts/service.nim +++ b/src/app_service/service/accounts/service.nim @@ -408,6 +408,7 @@ QtObject: self.events.emit(SIGNAL_DERIVED_ADDRESSES_FROM_NOT_IMPORTED_MNEMONIC_FETCHED, data) proc doLogin(self: Service, account: AccountDto, passwordHash: string, chatPrivateKey: string = "", mnemonic: string = "") = + var request = LoginAccountRequest( keyUid: account.keyUid, kdfIterations: account.kdfIterations, @@ -522,3 +523,10 @@ QtObject: proc delete*(self: Service) = self.QObject.delete + proc fetchLoggedInAccount*(self: Service): AccountDto = + try: + let response = status_account.getActiveAccount() + result = toAccountDto(response.result) + except Exception as e: + error "fetchLoggedInAccount failed", procName="fetchLoggedInAccount", errName = e.name, errDesription = e.msg + result = AccountDto() diff --git a/src/backend/accounts.nim b/src/backend/accounts.nim index eababc66d5..22ea20747c 100644 --- a/src/backend/accounts.nim +++ b/src/backend/accounts.nim @@ -434,3 +434,19 @@ proc remainingKeypairCapacity*(): RpcResponse[JsonNode] = proc remainingWatchOnlyAccountCapacity*(): RpcResponse[JsonNode] = let payload = %* [] return core.callPrivateRPC("accounts_remainingWatchOnlyAccountCapacity", payload) + +proc getActiveAccount*(): RpcResponse[JsonNode] = + try: + let response = status_go.getActiveAccount() + result.result = Json.decode(response, JsonNode) + except RpcException as e: + error "getActiveAccount failed", exception=e.msg + raise newException(RpcException, e.msg) + +proc keyUID*(): RpcResponse[JsonNode] = + try: + let response = status_go.keyUID() + result.result = Json.decode(response, JsonNode) + except RpcException as e: + error "keyUID failed", exception=e.msg + raise newException(RpcException, e.msg) \ No newline at end of file diff --git a/ui/main.qml b/ui/main.qml index 5748bc384a..b99b21a35b 100644 --- a/ui/main.qml +++ b/ui/main.qml @@ -32,6 +32,11 @@ Window { Theme.style: Application.styleHints.colorScheme === Qt.ColorScheme.Dark ? Theme.Style.Dark : Theme.Style.Light + // Provided by Nim before `main.qml` starts (see AppController.initializeQmlContext()). + readonly property bool skipOnboarding: typeof skipOnboardingContextProperty !== "undefined" + ? skipOnboardingContextProperty + : false + property bool appIsReady: false readonly property AppStores.FeatureFlagsStore featureFlagsStore: AppStores.FeatureFlagsStore { @@ -330,7 +335,7 @@ Window { close.accepted = false // In case of android, we need to handle moveTaskToBackground explicitly if (SQUtils.Utils.isAndroid) - SystemUtils.androidMinimizeToBackground() + close.accepted = true else applicationWindow.showMinimized() // In case not logged in or loading, quit app @@ -451,6 +456,10 @@ Window { safeArea.additionalMargins.left = Qt.binding(() => MobileUI.safeAreaLeft) safeMarginsCleanupConnections.enabled = true + + if (applicationWindow.skipOnboarding) { + moveToAppMain() + } } signal navigateTo(string path) @@ -583,6 +592,7 @@ Window { anchors.leftMargin: parent.SafeArea.margins.left anchors.rightMargin: parent.SafeArea.margins.right anchors.bottomMargin: parent.SafeArea.margins.bottom + active: !applicationWindow.skipOnboarding sourceComponent: onboardingV2 } diff --git a/vendor/nim-status-go b/vendor/nim-status-go index 0ee0a0936d..8b7deb06e4 160000 --- a/vendor/nim-status-go +++ b/vendor/nim-status-go @@ -1 +1 @@ -Subproject commit 0ee0a0936d0b16e55322a1e9dd1ee99e319a25dd +Subproject commit 8b7deb06e4aa78176c120896db99914075dbe867 diff --git a/vendor/status-go b/vendor/status-go index 87ac03209c..cc7df45f84 160000 --- a/vendor/status-go +++ b/vendor/status-go @@ -1 +1 @@ -Subproject commit 87ac03209ce0a24b066753f4d67970d9975f943b +Subproject commit cc7df45f841b1fa9cadb49181018094e2e7ea017