Files
lasuite-messages/docs/mobile.md
T
jbpenrath 31b51b5f67 (mobile) custom logout view to terminates the IdP session
ProConnect ignores prompt=login, so preserving the IdP session locked
mobile users into the same identity forever. The logout endpoint now
accepts a mobile_scheme and ends the RP-initiated round-trip on a new
logout-callback view that deep-links back to the app, so the system
browser — which holds both the Django session handed over at login and
the IdP SSO cookie — terminates both sessions.
Then, Proconnect login page's Content Security Policy blocks
the direct redirect: Chrome enforces its form-action on the whole
redirect chain of the credential form submission, and "*" only matches
network schemes — so our network mobile scheme violates it and the user
stays stuck on the identity provider during logout workflow.
The callback now serves a page that ends the form chain on a
network mobile scheme, then hands off to the app from our own page,
outside the IdP policy: automatically via script (iOS
interception, unchanged) with a button as the always-working fallback.
2026-08-13 12:08:41 +02:00

42 KiB

Mobile apps — technical architecture & onboarding

The Messages mobile apps (iOS + Android) are the existing web frontend wrapped in a Capacitor native shell. There is no second codebase: the same React/Vite bundle that serves localhost:8900 runs inside a WKWebView (iOS) / Android WebView, and a thin native layer supplies what a browser cannot — a shared-cookie login, a native HTTP stack, file sharing and over-the-air bundle updates.

This document is the onboarding reference for developers who need to work on the apps: the architecture, where the code lives, and the prerequisites to build and run on each platform.

This is the production-facing companion to mobile-poc.md, which records how the architecture was validated (smoke tests, cross-app SSO re-testing, negative controls). Read this file first; reach for the POC doc when you need the validation procedures.

Why Capacitor (and not React Native)

The whole product value — rendering arbitrary email HTML safely — depends on an iframe with srcDoc + sandbox + CSP. A previous React Native attempt broke on exactly that. Capacitor keeps a real browser engine in the app, so the web frontend renders identically to the desktop, and one team maintains one UI. The cost is a set of WebView limitations the native layer must paper over (session cookies, downloads, deep-link auth) — that layer is the interesting part of this codebase and the rest of this doc.

Architecture at a glance

┌─────────────────────────────────────────── native shell (iOS / Android) ──┐
│                                                                            │
│   ┌──────────────────────── WebView ───────────────────────┐              │
│   │  the web bundle (dist/) — React / TanStack / BlockNote  │              │
│   │                                                         │              │
│   │  window.fetch ──────────┐   (patched by CapacitorHttp)  │              │
│   └─────────────────────────┼───────────────────────────────┘             │
│                             ▼                                              │
│   ┌──────────────── native bridge (Capacitor plugins) ─────────────────┐  │
│   │  CapacitorHttp   → native HTTP stack, native cookie jar            │  │
│   │  WebAuthSession  → ASWebAuthenticationSession (iOS, app-local)     │  │
│   │  Browser         → Chrome Custom Tabs (Android)                    │  │
│   │  Filesystem/Share→ downloads to OS share sheet                     │  │
│   │  CapacitorUpdater→ OTA bundle download / swap                      │  │
│   └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────┘
        │ HTTPS (prod) / cleartext localhost (dev)      │ system browser
        ▼                                               ▼
   Django backend (confidential OIDC client)       Identity provider
   /api/v1.0/…                                     ProConnect (prod) / Keycloak (dev)

Four concerns make the shell more than a browser:

  1. Networking & sessionwindow.fetch is routed through the native HTTP layer so cookies live in the native jar, not the WebView.
  2. Authentication — the OIDC flow runs in the system browser, never the WebView, which is what enables cross-app SSO across all La Suite apps.
  3. File I/O — downloads and shares go through native plugins because an <a download> escapes the WebView and loses the session.
  4. OTA updates — the JS bundle can be replaced without a store release.

Each is detailed below.

Authentication architecture

This is the load-bearing design decision. The user who logs in on one La Suite app (mail, calendar, …) must not re-enter credentials on the others.

The OIDC flow runs in the system browserASWebAuthenticationSession on iOS, Chrome Custom Tabs on Android — following RFC 8252. The system browser shares its cookie jar across apps, so the IdP session cookie (ProConnect in production, Keycloak in development) provides the cross-app SSO: the second app's login completes silently.

The backend stays the confidential OIDC client (django-lasuite). The IdP only ever sees the ordinary web flow with the backend's HTTPS callback, so no IdP-side configuration is needed for mobile. The Django session is then handed to the app through a one-time token, bound to the app by PKCE:

App                         System browser                  Backend                IdP
 │  openAuthSession()            │                             │                    │
 │──────────────────────────────▶│ GET /api/v1.0/authenticate/ │                    │
 │   (+ mobile_scheme,           │────────────────────────────▶│  302 authorize     │
 │      code_challenge=S256)     │─────────────────────────────┼───────────────────▶│
 │                               │            login form (or silent SSO redirect)   │
 │                               │ GET /api/v1.0/callback/     │◀───────────────────│
 │   stmessages://auth?token=…   │◀────────────────────────────│ session + one-time │
 │◀──────────────────────────────│                             │ token (60 s TTL)   │
 │  POST /api/v1.0/mobile/auth/exchange/ {token, code_verifier}│                    │
 │────────────────────────────────────────────────────────────▶│                    │
 │◀── Set-Cookie sessionid + csrftoken, body {csrf_token} ─────│                    │

Step by step:

  1. App starts the flow. nativeLogin() generates a PKCE verifier (generateCodeVerifier), computes its S256 challenge, and opens /api/v1.0/authenticate/?mobile_scheme=stmessages&code_challenge=… in the system browser.
  2. Backend flags the session. OIDCAuthenticationRequestView checks the scheme against MOBILE_AUTH_CALLBACK_SCHEMES (rejects unknown schemes) and stashes {scheme, code_challenge, state, created_at} in the Django session. The state is the one generated for this OIDC round-trip: the callback only consumes the flag when its own state matches, and a flag older than 10 min is ignored, so an abandoned or overlapping mobile attempt can't hijack a web flow running in the same browser (same binding for the mobile logout).
  3. IdP authenticates — interactively the first time, silently afterwards (see Cross-app SSO conditions below).
  4. Callback mints a one-time token. OIDCAuthenticationCallbackView caches {session_key, code_challenge} under mobile-auth-token:<token> with a MOBILE_AUTH_TOKEN_TTL (60 s) timeout and hands the browser back to stmessages://auth?token=… — through a small hand-off page (auto-redirect + "open the app" button), not a plain 302 to the scheme. The direct redirect gets blocked by the CSP of the IdP login page: Chrome enforces its form-action on the whole redirect chain of the credential form submission, and * only matches network schemes, so the final custom-scheme hop violates it and the sheet stays stuck on the IdP. ProConnect sends such a CSP; the dev Keycloak does not, which hides the bug in dev. Ending the chain on a 200 page satisfies the policy, and the deep link then leaves from our own page. iOS is indifferent — ASWebAuthenticationSession intercepts the scheme navigation either way — and the logout keeps its direct scheme redirects: its round-trip involves no form submission, so no form-action ever applies.
  5. App exchanges the token. MobileSessionExchangeView (anonymous, single use) deletes the cache key before verifying it (a failed attempt can't be retried), checks S256(code_verifier) == code_challenge with secrets.compare_digest, rehydrates the session, and emits the Set-Cookie: sessionid header plus a csrf_token in the body.

The token is a bearer secret for ~60 s; PKCE is what makes a stolen deep link useless (the attacker lacks the verifier), which matters because custom URL schemes can be claimed by other apps.

The stmessages return scheme must be registered natively on both platforms (source of truth: AUTH_CALLBACK_SCHEME in auth.ts): an intent-filter in AndroidManifest.xml, and CFBundleURLTypes in the iOS Info.plist — the latter is required because ASWebAuthenticationSession runs with prefersEphemeralWebBrowserSession = false (needed to share the IdP cookie), and in that mode iOS only delivers the callback for an app-registered scheme. Both are independent of MOBILE_APP_ID.

Cross-app SSO conditions (both bit us during the POC)

  • Requested ACR must be satisfiable. The backend sends OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} (required by ProConnect). The IdP only skips the form if its existing session already meets that Level of Assurance. The dev Keycloak realm therefore maps eidas1 (acr.loa.map on the messages client in src/keycloak/realm.json); with an empty map Keycloak forces re-authentication on every flow and silently breaks cross-app SSO. Do not remove that mapping.
  • The IdP session cookie must be persistent on iOS. ASWebAuthenticationSession only shares Safari's persistent cookies; the Keycloak identity cookie is a session cookie unless "Remember me" is ticked.

False positive to avoid: visiting localhost:8900 may "log in silently" simply because the Django session cookie is still valid — that never hits /authorize and does not prove IdP SSO. Always exercise the mobile flow.

Logout ends the session everywhere (Django and IdP)

nativeLogout() runs the RP-initiated logout (/api/v1.0/logout/ with mobile_scheme) in the system browser, which holds both the Django session cookie handed over at login and the IdP SSO cookie: the round-trip terminates both and ends on a scheme://logout deep link that closes the sheet. Keeping the IdP session alive is not an option — it silently signs the same identity back in on the next login and ProConnect ignores prompt=login, so tearing it down is the only way to let the user switch accounts. The app then POSTs to /api/v1.0/mobile/auth/logout/ as a safety net (the browser round-trip only ends the app-side session when the browser still holds the same session cookie), clears the native cookies and the cached CSRF token. By design this also ends the SSO session shared with other La Suite apps.

Networking & session

CapacitorHttp (enabled in capacitor.config.ts) patches window.fetch so every API call goes through the native HTTP stack, and the session cookies live in the native cookie jar:

  • No SameSite / ITP restriction, no WebView CORS.
  • The plain-HTTP dev backend works (server.cleartext on Android — gated by MOBILE_ALLOW_CLEARTEXT_FOR_DEV, set in frontend.defaults — and NSAllowsLocalNetworking on iOS; both dev-only).

The trade-off is that the WebView can no longer read the csrftoken cookie from document.cookie. So the CSRF token is delivered out-of-band by the session exchange and cached in localStorage:

  • csrf.ts stores/reads it under messages_native-csrf-token.
  • getCSRFToken() (src/features/api/utils.ts) returns the native token on native platforms and the web token otherwise; getHeaders() echoes it as X-CSRFToken. This works with the backend's CSRF_USE_SESSIONS (the secret lives in the session, replayed by the native cookie jar).

Downloads can't use <a download>: on native it escapes the WebView into the system browser, which has no session and gets a 401. nativeDownloadFile() fetches the bytes through CapacitorHttp (carrying the session), writes them to Directory.Cache and hands them to the OS share sheet. Used by the thread-view attachment components.

OTA (over-the-air) updates

The JS bundle can be replaced without a store release, driven entirely from JS against a public S3 bucket — no Capgo server (autoUpdate: false; the @capgo/capacitor-updater plugin is used only for its native download/set/reload primitives). Because the bucket is world-readable, every bundle is encrypted and signed (Capgo v2, RSA+AES) with a per-instance key: the public half is baked into the app at cap sync time (capacitor.config.ts, publicKeyMOBILE_OTA_SIGNING_PUBLIC_KEY_B64), the private half signs at publish time (MOBILE_OTA_SIGNING_PRIVATE_KEY_B64, CI-only). A substituted zip therefore fails native verification instead of running arbitrary code. The key is mandatory as soon as OTA is on: ota.ts refuses to apply a manifest when the build embeds no MOBILE_OTA_SIGNING_PUBLIC_KEY_B64 (and cap sync still fails when the deprecated baked NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL is set without it), so an OTA-enabled app can never apply an unverified bundle.

  • Publish (make ota-publish [VERSION=x] [CHANNEL=x], i.e. the frontend node script src/frontend/scripts/publish-ota.mjs — no Django involved): capgo bundle zip dist/ (with index.html at the zip root), capgo bundle encrypt it (→ encrypted zip + encrypted checksum + ivSessionKey), upload the encrypted zip to channels/<channel>/bundles/<version>.zip, and write channels/<channel>/manifest.json = {version, url, checksum, sessionKey} (the last two feed native verification). Publishing also refuses a version that does not order above the channel's current manifest (mirror of the client-side downgrade guard, see Bundle versioning; --force overrides).
  • Consume (src/features/native/ota.ts, called at startup): notifyOtaAppReady() first (confirms the running bundle booted, so a broken update auto-rolls-back on next launch), then checkAndApplyOtaUpdate() polls the manifest URL served by the backend MOBILE_OTA_MANIFEST_URL setting (/config endpoint, resolved in bootstrap.tsx) and applies the advertised bundle only if it is genuinely newer — it must differ from CapacitorUpdater.current(), carry a strictly greater version count (see Bundle versioning), and not be recorded as a prior failed boot (see Rollback). It then downloads (passing checksum + sessionKey, verified against the baked-in public key) and set()s it, which reloads the WebView.

OTA replaces the web bundle only. Anything native (a new Capacitor plugin, a permission, the Swift/Gradle side) still requires a store release.

Release channels

The bucket hosts one self-contained folder per channelchannels/<channel>/manifest.json plus its bundles/ — and each app follows exactly one channel: the channel segment lives in the MOBILE_OTA_MANIFEST_URL served by the backend the app talks to (/config endpoint). The deploy pipeline publishes to staging and prod; local development uses dev (commented default in deploy/env/backend.defaults), so experiments never look like a release.

A bundle is never copied or promoted across channels. The NEXT_PUBLIC_* vars (API origin, …) are inlined into the web bundle at build time, so a staging build is not a prod build pointed elsewhere — it is a different artifact targeting the staging backend, whose /config in turn pins the staging channel. Releasing to prod means rebuilding with the prod env and publishing to the prod channel. Keeping the zips under their channel also prevents two channels publishing the same commit (same <count>-<sha> id) from overwriting each other's bundle.

The publish target comes from MOBILE_OTA_CHANNEL (or --channel / make ota-publish CHANNEL=…), and must match the channel the apps follow through the backend MOBILE_OTA_MANIFEST_URL: publishing a bundle built for another environment would strand the fleet on that other backend's config.

Generating the signing key pair

Each deployment generates its own RSA-2048 pair once (the two halves must stay a matched set — the app rejects any bundle it can't verify):

make mobile-ota-keygen

It prints the two values ready to paste into an env file / CI secret store (the guidance goes to stderr, so stdout stays a clean pair):

MOBILE_OTA_SIGNING_PUBLIC_KEY_B64=…    # baked into the app build env (capacitor.config.ts)
MOBILE_OTA_SIGNING_PRIVATE_KEY_B64=…   # publish-time secret — CI only, never commit

Both are single-line base64 PEMs (PKCS1 — the format capgo bundle encrypt expects) so they survive Docker env_file and CI secret stores. The public half goes into the app build env; the private half signs bundles at publish time and must stay a CI secret. Rotating the pair requires shipping a new store build (the public key is baked in), so treat it as long-lived.

Bundle versioning

The manifest version (and the channels/<channel>/bundles/<version>.zip key) is a hybrid id, <count>-<sha> — e.g. 1234-a1b2c3d:

  • <count> = git rev-list --count HEAD, a monotonic commit count that orders releases;
  • <sha> = git rev-parse --short HEAD, tracing the exact source commit.

The Makefile derives it once as MOBILE_OTA_BUILD_ID; make ota-publish uses it as the default VERSION (override with VERSION=… to pin a release). The version field is a free-form string — Capgo treats it as a "version code/name" and does not require semver — so a commit-based id is fine. We use - (not the semver + build-metadata separator) to keep the id safe in the bundle URL/S3 key.

The count drives ordering, and the client enforces it: checkAndApplyOtaUpdate() applies a manifest only when its count is strictly greater than the running bundle's, so republishing an older build cannot downgrade the fleet (an accidental old publish or a replayed old bundle is refused). A bare SHA would carry no such order. Ids without the numeric prefix — the literal "builtin", or a manually pinned non-hybrid version — can't be ordered and fall back to a plain inequality check. Because the count comes from git rev-list --count HEAD, it is only monotonic along a single line of history: always publish OTA from the release branch, or two diverging branches can mint colliding counts.

Builtin stamping. make mobile-build passes MOBILE_OTA_BUILD_ID to cap sync, which stamps it as the store build's builtin bundle version (CapacitorUpdater.version in capacitor.config.ts). Without it the builtin reports the literal "builtin", so the first launch after a store install always re-downloads. With it, a first launch whose manifest points at the same commit skips the download; a newer manifest still updates — the normal case, since OTA runs ahead of the store.

Rollback

There are two kinds, plus a per-device safety net:

  • Automatic (a bundle that fails to boot). If the new bundle never calls notifyAppReady() (crash / white screen), the plugin reverts to the last good bundle — the builtin if there is none — on the next launch and records the version as its last failed update. checkAndApplyOtaUpdate() mirrors that record (which self-clears on read) into WebView storage and refuses to re-apply the version, so a broken publish can't trap the app in a download → crash → revert → re-download loop. The record is boot-specific: a transient download failure does not blacklist the version, it is simply retried on the next check.
  • Deliberate (a bundle that boots but is bad). You cannot point the manifest back at the older, lower-count bundle — the downgrade guard refuses it. Roll forward instead: git revert the bad commit(s) and make ota-publish. The revert has a higher count, so it passes the guard and the fleet converges onto the (restored) good code with a clean git trail. Escape hatch if you can't revert: make ota-publish VERSION=<count-above-current>-<oldsha> from the old build — but that breaks the count↔commit invariant, so prefer the revert.
  • Per-device safety net. CapacitorUpdater.reset() returns a single device to the builtin (store) bundle, which is always bootable. Not fleet-wide; useful to wire onto a support/debug action.

Never prune old bundles from the bucket — the plugin's fallback and any revert build may still reference them.

Codebase map

Concern Location
Capacitor config (appId via MOBILE_APP_ID, plugins, HTTP, SystemBars, OTA signing key) src/frontend/capacitor.config.ts
Platform detection src/frontend/src/features/native/platform.ts
PKCE helpers src/frontend/src/features/native/pkce.ts
System-browser session src/frontend/src/features/native/auth-session.ts
Native login / logout src/frontend/src/features/native/auth.ts
Native CSRF token store src/frontend/src/features/native/csrf.ts
Native download → share src/frontend/src/features/native/download.ts
Native push client (enable / on-launch refresh / tap deep-link) src/frontend/src/features/native/push.ts
Push opt-in marker + token-hash contract (shared web/native) src/frontend/src/features/push/shared.ts
OTA client src/frontend/src/features/native/ota.ts
Startup wiring (OTA, native html class) src/frontend/src/main.tsx
CSRF / API origin wiring src/frontend/src/features/api/utils.ts
Login/logout routing src/frontend/src/features/auth/index.tsx
iOS ASWebAuthenticationSession plugin src/frontend/ios/App/App/WebAuthSessionPlugin.swift
iOS plugin registration src/frontend/ios/App/App/MainViewController.swift
iOS push entitlement + APNs bridge + banner strings src/frontend/ios/App/App/App.entitlements, AppDelegate.swift, {en,fr}.lproj/Localizable.strings
Android push banner strings (FCM loc-keys) src/frontend/android/app/src/main/res/values{,-fr}/strings.xml
SSO invariants tripwire (CI guard on the native declarations) src/frontend/src/features/native/sso-invariants.test.ts
Android project src/frontend/android/
Backend mobile-aware OIDC views src/backend/core/authentication/views.py
Backend token → session exchange & mobile logout src/backend/core/api/viewsets/mobile_auth.py
OTA publish scripts src/frontend/scripts/publish-ota.mjs, create-ota-bucket.mjs, ota-lib.mjs

Native/web branching contract: the single source of truth is isNativePlatform(). main.tsx also tags <html class="native"> so stylesheets can opt into mobile-only chrome without every component re-deriving the platform.

Prerequisites

Common (all developers)

  • The dev stack running: make bootstrap once, then make start (or make start-minimal). Backend on :8901, Keycloak on :8902, object storage on :8906.
  • No host Node toolchain is required: the web bundle is built inside the frontend-mobile container — see Build & run workflow. The host only needs the native toolchains below. If you do run npm on the host anyway, it must be Node 22 (>=22 <23): any other version corrupts the lockfile and breaks the container build.
  • Backend settings must allowlist the scheme: MOBILE_AUTH_CALLBACK_SCHEMES=["stmessages"] (empty list = mobile login disabled). See env.md.

Android

If you have never set up an Android toolchain, follow Capacitor's environment setup guide end-to-end first; the list below is what this project specifically needs.

  • Android Studio (latest stable) with the Android SDK — install guide.
  • SDK levels: compileSdk 36 / targetSdk 36, minSdk 24. Install SDK 36
  • JDK 17+ (bundled with recent Android Studio).
  • adb on the PATH (host), for install + port forwarding. Heads-up: the adb reverse tunnel to the dev stack is dropped on every emulator reboot — rerun make mobile-android-reverse (details under Build & run workflow).
  • An emulator image with Play services (Google Play / Google APIs). Chrome Custom Tabs needs it; a bare AOSP image falls back to an isolated-cookie WebView and breaks cross-app SSO (a common false negative). A physical device always ships Chrome, so it can't hit this.

iOS

If you have never set up an iOS toolchain, follow Capacitor's environment setup guide end-to-end first; the list below is what this project specifically needs.

  • A Mac — non-negotiable for iOS builds.
  • Xcode 16+ with the iOS 16+ SDK. Deployment target is iOS 15.
  • Dependencies are managed by Swift Package Manager (pinned in ios/App/CapApp-SPM/Package.swift) — no CocoaPods / Podfile. Xcode resolves the packages on first open.
  • For a physical device: an Apple developer account and a signing team configured in Xcode — see running your app on a device.

Build & run workflow

The web bundle is built in a container (frontend-mobile) so the NEXT_PUBLIC_* vars from deploy/env/frontend.{defaults,local} are inlined at build time (Vite envPrefix: 'NEXT_PUBLIC_'). Building on the host with a bare npm run build would inline none of them. The native compile, IDE, adb and Xcode steps run on the host.

Always run make mobile-build after a fresh checkout. cap sync (not a bare copy) also regenerates the gitignored capacitor-cordova-android-plugins/ scaffolding that Gradle needs.

Command What it does
make mobile-build web build (container) + cap sync into ios/ and android/
make mobile-assets regenerate native icons & splashscreens from src/frontend/assets/
make mobile-android mobile-build, then open the Android project in Android Studio (host)
make mobile-android-run mobile-build + gradlew assembleDebug + adb install + adb reverse (host)
make mobile-android-reverse (re)apply the adb reverse port mapping
make mobile-ios mobile-build, then open the Xcode project (host, macOS)
make mobile-ota-keygen generate a per-instance OTA signing key pair (base64 PEMs)
make mobile-ota-bucket create the public messages-ota bucket
make ota-publish [VERSION=x] [CHANNEL=x] build + publish a signed OTA bundle and its channel manifest (VERSION defaults to <count>-<sha>, CHANNEL to MOBILE_OTA_CHANNEL)

Android port forwarding. The in-app WebView reaches the dev stack through an adb reverse tunnel for ports 8900, 8901, 8902, 8906 (frontend, backend, Keycloak, object storage). It is dropped on every emulator reboot / adb reconnection and is not re-applied by Android Studio — rerun make mobile-android-reverse whenever the app suddenly can't reach the backend. The same tunnel works over USB for a physical device (enable Developer options + USB debugging first). With several devices attached, pin one with export ANDROID_SERIAL=<serial> (adb devices to list).

iOS needs no tunnel: the simulator reaches the host's localhost directly. Run the App scheme after make mobile-ios.

Hot reload (on by default in dev)

MOBILE_DEV_SERVER_URL — set to http://localhost:8900 (the Vite dev server) in deploy/env/frontend.defaults — is baked by cap sync into the app as Capacitor's server.url: the WebView loads the app straight from Vite instead of the embedded dist/, so JS/CSS changes apply through HMR without rebuilding or reinstalling. Since every make mobile-* target runs in a container carrying the frontend env files, any dev build gets hot reload out of the box. Requirements and caveats:

  • The dev stack must be up (make run / the frontend-dev service): the app is blank otherwise. localhost:8900 is routed by the same adb reverse tunnel as the backend on Android, and by the shared loopback on the iOS simulator. For a physical iPhone (no tunnel), point it at the Mac's LAN IP in frontend.local: MOBILE_DEV_SERVER_URL=http://<mac-ip>:8900 (ATS exempts raw IP literals, so plain HTTP works).
  • Native changes (plugins, ios/, android/, capacitor.config.ts) still need a rebuild — hot reload only covers the web bundle.
  • The startup OTA check is skipped during a hot reload session (ota.ts skips when import.meta.env.DEV and MOBILE_DEV_SERVER_URL are set): applying a downloaded bundle would yank the WebView off the dev server mid-session.

Disabling it — to test the embedded bundle (what a store build ships), or the OTA chain end to end: set the variable empty in deploy/env/frontend.local (gitignored, overrides the defaults):

# deploy/env/frontend.local
MOBILE_DEV_SERVER_URL=

then rerun make mobile-build (or any target that wraps it) and reinstall the app. A leftover server.url fails Android release builds (gradle guard in android/app/build.gradle); see the release checklist for iOS.

Push notifications in dev (optional)

Push is off by default (PUSH_ENABLED=False): the apps build, run and hide the notification settings without any of this. Full architecture: push-notifications.md. What ships in the repo (entitlements, loc-key banner strings, permission, conditional google-services apply) needs no setup; what follows is the per-developer credential part.

The app self-configures per environment where it can — the client picks its transport at runtime (apns on iOS / fcm on Android), and a dev-signed iOS build automatically registers against Apple's sandbox gateway (aps-environment = development in App.entitlements; Xcode's distribution export rewrites it to production). What it cannot infer is the backend half: the gateway credentials and the sandbox flag below must match the build you install.

Android (FCM)

  1. Create a (free) dev Firebase project and register an Android app whose package name is exactly the applicationId of your build — the MOBILE_APP_ID default, local.suitenumerique.messages. A google-services.json for another package fails the Android build at the google-services step.
  2. Download google-services.json into src/frontend/android/app/ (gitignored, per-instance). Rebuild/reinstall.
  3. In Firebase console → project settings → service accounts, generate a service-account key and set in deploy/env/backend.local: PUSH_ENABLED=True, PUSH_FCM_CREDENTIALS (the JSON, single line), PUSH_FCM_PROJECT_ID. Restart the backend + celery worker.
  4. Emulator: use the same Play-services image the SSO setup already requires (see Prerequisites) — FCM registration fails on a bare AOSP image (the UI then shows the registration_failed message, by design).

iOS (APNs)

  1. Physical iPhone required for the end-to-end path: simulators never get a real APNs token, so registration against Apple's gateway can't be exercised there (xcrun simctl push only injects local payloads).
  2. Apple developer account: enable the Push Notifications capability on the App ID matching your bundle id, and create an APNs auth key (.p8).
  3. In deploy/env/backend.local: PUSH_ENABLED=True, PUSH_APNS_KEY (the .p8 PEM), PUSH_APNS_KEY_ID, PUSH_APNS_TEAM_ID, PUSH_APNS_BUNDLE_ID (= your MOBILE_APP_ID), and PUSH_APNS_USE_SANDBOX=True — dev-signed builds hold sandbox tokens; against the production gateway they are rejected as BadDeviceToken. Restart the backend + celery worker.

Smoke test (both platforms)

  1. In the app: account menu → Notifications → Enable notifications on this device → accept the OS prompt. The device must appear in the list.
  2. Kill the app, send the mailbox a message from another account: a content-free "New message / Nouveau message" banner must show (rendered by the OS from the loc-key strings — a blank banner means those strings are missing from the build).
  3. Tap it: the app must open on the thread (deep-link path).

Configuration

Mobile-specific environment variables (full reference in env.md):

Variable Purpose
MOBILE_APP_ID Store/OS bundle identifier (default local.suitenumerique.messages). Read by cap sync (container) and the native builds (gradle applicationId, iOS PRODUCT_BUNDLE_IDENTIFIER), so it must be exported in both contexts. Independent of the stmessages auth scheme
MOBILE_AUTH_CALLBACK_SCHEMES JSON list of allowlisted deep-link schemes (e.g. ["stmessages"]); empty disables mobile login
MOBILE_DEV_SERVER_URL Dev only: Vite dev server URL baked as Capacitor server.url at cap sync (hot reload). Set to http://localhost:8900 in frontend.defaults; disable with an empty value in frontend.local; never set for release builds (see Hot reload)
MOBILE_ALLOW_CLEARTEXT_FOR_DEV Dev only: baked as Capacitor server.cleartext at cap sync (android:usesCleartextTraffic), allowing plain HTTP to the dev backend / Vite / RustFS. Set to 1 in frontend.defaults; never set for release builds — the manifest then stays cleartext-free
MOBILE_AUTH_TOKEN_TTL Lifetime (s) of the one-time exchange token (default 60)
NEXT_PUBLIC_API_ORIGIN API base URL — must be set explicitly for mobile builds (no meaningful window.location.origin in the WebView)
MOBILE_OTA_MANIFEST_URL Backend setting served through /config: OTA channel manifest polled at startup — the followed channel changes without a new native build; unset disables OTA (deprecated build-time fallback: NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL)
MOBILE_OTA_CHANNEL Release channel ota-publish targets (dev locally, staging/prod in the pipeline); must match the channel the build follows (see Release channels)
MOBILE_OTA_S3_*, MOBILE_OTA_PUBLIC_BASE_URL OTA publish: S3 write credentials/endpoint (frontend env, not Django) and the device-reachable public base URL written into the manifest
MOBILE_OTA_SIGNING_PUBLIC_KEY_B64 Base64 PEM public key baked into the app (capacitor.config.ts, native verification) and inlined by Vite (ota.ts refuses a server-provided manifest URL without it); required for any OTA-enabled build
MOBILE_OTA_SIGNING_PRIVATE_KEY_B64 Base64 PEM private key that signs bundles at publish time (publish-ota.mjs, CI-only)

Production hardening / known gaps

The following are POC-scoped shortcuts that must be resolved before shipping. Treat this list as the "definition of ready for production".

  • OTA over HTTPS. Bundle signing/encryption (Capgo v2, RSA+AES) and a strictly-increasing version guard are in place (see the OTA section), so a substituted or replayed old zip is refused. What remains for production is to serve the bucket/CDN over HTTPS (dev uses cleartext RustFS). A hard minimum-version floor baked into the app — rejecting anything below a known release regardless of the running bundle — would further harden a device stuck on a very old build, but the monotonic guard already covers accidental downgrades.
  • Move off custom URL schemes. Custom schemes can be claimed by other apps (mitigated today by the one-time token + PKCE). Production should move to Universal Links (iOS) / App Links (Android).
  • CSRF Origin on HTTPS. Native requests carry no Origin/Referer, which Django requires on secure requests. The fetch wrapper must inject an Origin listed in CSRF_TRUSTED_ORIGINS.
  • Cleartext transport is dev-only, build-gated on both platforms. On Android, preReleaseBuild fails when the synced capacitor.config.json carries a dev server.url or server.cleartext (i.e. when MOBILE_DEV_SERVER_URL / MOBILE_ALLOW_CLEARTEXT_FOR_DEV was in the cap sync env). On iOS, the "Strip dev ATS exception" build phase deletes the NSAppTransportSecurity dict (NSAllowsLocalNetworking) from the built product in every non-Debug configuration, so it never ships in an Archive.
  • IdP logout & session renewal. Logout ends the Django session but not the IdP one; the 12 h Django session has no refresh-token renewal yet. Confirm ProConnect SSO session duration and persistent-cookie behaviour (esp. iOS) in production.
  • Safe-area insets. Disabling Capacitor's SystemBars inset handling (to fix the double keyboard inset, Capacitor #8181) means Android no longer receives the --safe-area-inset-* CSS variables; MainActivity.java re-injects them from the window insets (system bars + display cutout), without touching the keyboard behavior. iOS resolves env(safe-area-inset-*) natively. The app shell folds the top inset into --header-height (globals.scss), so anything laid out from it clears the status bar / notch automatically.
  • Iframe subresources. Inline images proxied through the API use the WebView network stack, not the native one, and may not load in dev; the HTML body itself renders.
  • App Store guideline 4.2. A pure web wrapper needs native-feeling differentiators (push notifications, share targets…) to pass review.

Release checklist (manual)

Some load-bearing behaviors cannot fail loudly: when they regress, login still works and only the invisible part disappears, so no error ever surfaces in development. Run this checklist before every store release, and after any change to the native projects (ios/, android/), the auth plumbing or the Capacitor version.

  1. iOS cross-app SSOthe critical, silent one. It rests on WebAuthSessionPlugin.swift using ASWebAuthenticationSession with prefersEphemeralWebBrowserSession = false, its registration in MainViewController.swift, and a persistent IdP cookie. Regenerating the iOS project or "simplifying" back to the default Browser plugin (SFSafariViewController — cookie store isolated from Safari) silently turns the second app's silent login back into a credential prompt. Run the two-app procedure and its objective proofs (Keycloak events, negative control). The sso-invariants.test.ts tripwire pins the files (flag, registration, schemes) so the most likely mechanical regressions turn CI red, but it cannot prove the runtime behavior — this manual test stays mandatory. For a store release, run it against the production IdP: the IdP-side half of the contract (ProConnect silently reusing its session for acr_values=eidas1, persistent cookie) lives outside this repo and no CI or dev-realm check can stand in for it.
  2. Android cross-app SSO — same two-app procedure through Chrome Custom Tabs. Beware the false negative: an emulator without Play services falls back to an isolated-cookie WebView (see Prerequisites).
  3. Thread rendering — open a thread: the message body iframe (srcDoc + sandbox + CSP) must render. This is what killed the previous React Native attempt; a WebView/Capacitor upgrade can regress it.
  4. OTA chain on the release channel — publish to the channel the build follows, relaunch, verify the new bundle applies; then confirm a lower-count manifest is refused (downgrade guard).
  5. Native file paths — download/share an attachment and a raw .eml (native HTTP session), upload an attachment (CSRF token path).
  6. Logout → re-login — logout ends both the Django session and the IdP session (RP-initiated logout in the system browser); the following login must stop on the IdP login form, allowing an account switch. A silent re-login means the IdP session survived: that is a regression.
  7. No dev server baked in — in dev, MOBILE_DEV_SERVER_URL bakes the Vite dev server URL into capacitor.config.json (hot reload, see Build & run workflow). Before archiving, set it empty in frontend.local and rerun make mobile-build. Android release builds fail on a leftover server.url (gradle guard in android/app/build.gradle); Xcode has no equivalent guard, so check manually for iOS (no server.url in ios/App/App/capacitor.config.json).
  8. Push environment pairing — nothing fails loudly on a mismatch, pushes just never arrive (or hit BadDeviceToken in the sender logs). For a store release: the backend serving those users must run PUSH_APNS_USE_SANDBOX=False (a distribution-signed build holds production APNs tokens — Xcode rewrites aps-environment at export, no manual step); the bundled google-services.json must come from the production Firebase project and contain a client for the release MOBILE_APP_ID; PUSH_APNS_BUNDLE_ID must equal that same id. Then run the smoke test of Push notifications in dev against the release build.

See also

  • mobile-poc.md — validation procedures: backend-only smoke test with curl, running on emulators/devices, re-testing cross-app SSO with a throwaway second app, and objective SSO proofs (Keycloak events, negative controls).
  • env.md — full environment-variable reference.