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
This commit is contained in:
Alex Jbanca
2026-03-20 09:22:26 +02:00
committed by Alex Jbanca
parent 432c0394cb
commit 4fdcf9ec1d
24 changed files with 1538 additions and 9 deletions
@@ -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 Androids 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)**
- Dont 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 wont 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 services 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.
+4
View File
@@ -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"])
+30 -3
View File
@@ -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) \
+24
View File
@@ -2,6 +2,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.status.mobile"
android:installLocation="auto">
<!--
Signature-level permission used to harden Binder IPC between the UI process and the
separate-process StatusGoService. This is defense-in-depth on top of android:exported="false".
-->
<permission
android:name="app.status.mobile.permission.STATUSGO_SERVICE"
android:label="Status status-go service access"
android:protectionLevel="signature" />
<uses-permission android:name="app.status.mobile.permission.STATUSGO_SERVICE" />
<!-- non-dangerous permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.NFC"/>
@@ -10,6 +20,8 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- features -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
@@ -116,5 +128,17 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/qtprovider_paths"/>
</provider>
<!-- Separate-process status-go host service -->
<service
android:name=".ipc.StatusGoService"
android:exported="false"
android:permission="app.status.mobile.permission.STATUSGO_SERVICE"
android:process=":statusgo"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="status-go background" />
</service>
</application>
</manifest>
@@ -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);
}
@@ -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);
}
+4
View File
@@ -61,6 +61,10 @@ android {
buildToolsVersion androidBuildToolsVersion
ndkVersion = androidNdkVersion
buildFeatures {
aidl true
}
sourceSets {
main {
manifest.srcFile 'AndroidManifest.xml'
@@ -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();
}
}
@@ -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();
}
@@ -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<IStatusGoSignalListener> 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();
}
}
@@ -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);
}
}
}
}
+5
View File
@@ -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)
+1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -0,0 +1,156 @@
#include <jni.h>
#include <android/log.h>
#include <string>
#include <vector>
#include <mutex>
#include <cstring>
// 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<void**>(&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<std::mutex> 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<std::string> parseArgsJson(const char* argsJson) {
std::vector<std::string> 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<std::mutex> 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<std::string> args = parseArgsJson(argsJson);
std::vector<const char*> 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;
}
+173
View File
@@ -0,0 +1,173 @@
#include <jni.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <mutex>
#include <string>
#include <android/log.h>
// 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<void**>(&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<char*>(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<std::mutex> 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<std::mutex> 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"
+2
View File
@@ -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)
+9 -2
View File
@@ -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()
+13
View File
@@ -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:
@@ -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()
+16
View File
@@ -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)
+11 -1
View File
@@ -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
}