Files
lasuite-messages/docs/mobile.md
T
jbpenrath deff190d58 🐛(mobile) fix the keyboard and composer focus glitches on touch devices
Fix several touch devices issues. On iOS, when the keyboard shows up,
view was flickering due to a timing issue between keyboard display and
keyboard resizing. Furthermore, taping on readonly blocknote blocks
prevents to keep focus on composer.
2026-09-24 16:17:32 +02:00

684 lines
44 KiB
Markdown

# Mobile apps (iOS / Android)
The Messages mobile apps are **the existing web frontend wrapped in a
[Capacitor](https://capacitorjs.com/) 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 (OTA) bundle updates.
- **Part 1 — Getting started**: what to install and run to work on the apps
today. Read this first, it is short.
- **Part 2 — Technical concepts**: how authentication, networking, OTA and
versioning actually work, and why. Read the section you touch.
- Shipping (OTA publishing, rollbacks, store builds, release checklist) lives in
[`mobile-release.md`](./mobile-release.md).
---
# Part 1 — Getting started
## What you need
**Everyone**
- The dev stack: `make bootstrap` once, then `make start` (backend on `:8901`,
Keycloak on `:8902`). `make start-full` additionally brings object storage on
`:8906`, only needed to test OTA locally.
- **No Node on the host.** The web bundle is built inside the `frontend-mobile`
container; the host only runs the native toolchains below. If you use a host
`npm` anyway, it must be **Node 22** (`>=22 <23`) — any other version
corrupts the lockfile.
- Nothing to configure on the backend: the default
`MOBILE_AUTH_CALLBACK_SCHEMES=["stmessages"]` already allows the dev app to
log in.
**Android** (any OS) — if you have never set up an Android toolchain, follow
[Capacitor's guide](https://capacitorjs.com/docs/getting-started/environment-setup#android-requirements)
first; this is what the project specifically needs:
- [Android Studio](https://developer.android.com/studio/install) (latest stable)
with **SDK 36** + build-tools (`compileSdk 36` / `targetSdk 36`, `minSdk 24`),
and JDK 17+ (bundled).
- [`adb`](https://developer.android.com/tools/adb) on the host `PATH`.
- An emulator image **with Play services** (Google Play / Google APIs). A bare
AOSP image has no Chrome, so login falls back to an isolated-cookie WebView and
cross-app SSO silently breaks. A physical device always ships Chrome.
**iOS** (macOS only) — same,
[Capacitor's guide](https://capacitorjs.com/docs/getting-started/environment-setup#ios-requirements)
first, then:
- [Xcode](https://developer.apple.com/xcode/) 16+ with the iOS 16+ SDK
(deployment target iOS 15). Dependencies come through Swift Package Manager
(`ios/App/CapApp-SPM/Package.swift`), resolved on first open — **no
CocoaPods**.
- For a physical iPhone: an Apple developer account and a signing team in Xcode.
## First run
Every `make mobile-*` target builds the web bundle **in the container** (so the
`NEXT_PUBLIC_*` / `MOBILE_*` vars from `deploy/env/frontend.{defaults,local}`
are inlined) and then runs the native step **on the host**.
**Android** — start an emulator (or plug a device with USB debugging on), then:
```bash
make mobile-android-run
```
It builds the bundle, runs `gradlew assembleDebug`, installs the APK and opens
the `adb reverse` tunnel (ports 8900, 8901, 8902, 8906) the WebView uses to
reach the dev stack. Prefer the IDE? `make mobile-android` builds and opens the
project in Android Studio instead.
**iOS**:
```bash
make mobile-ios
```
It builds the bundle and opens Xcode: run the `App` scheme on a simulator. No
tunnel needed, the simulator reaches the host's `localhost` directly.
Log in with any dev Keycloak account: the login opens in the system browser and
comes back to the app by deep link.
## Daily workflow
- **Hot reload is on by default.** The dev build loads the app straight from the
Vite dev server (`MOBILE_DEV_SERVER_URL=http://localhost:8900` in
`frontend.defaults`), so JS/CSS/SCSS changes apply through HMR without
rebuilding or reinstalling. The dev stack must be up or the app is blank.
- **Rebuild + reinstall** (`make mobile-android-run` / `make mobile-ios`) only
for native changes: a Capacitor plugin, `capacitor.config.ts`, anything under
`ios/` or `android/`, or a `MOBILE_*` variable.
- **After a fresh checkout or a pull that bumps a Capacitor plugin**, run
`make mobile-build`: `cap sync` regenerates the gitignored native scaffolding
Gradle needs and the plugin paths baked into the native projects.
- **Android tunnel**: `adb reverse` is dropped on every emulator reboot / adb
reconnection and Android Studio does not re-apply it. When the app suddenly
can't reach the backend, run `make mobile-android-reverse`. Several devices
attached? `export ANDROID_SERIAL=<serial>` (`adb devices` to list).
- **Physical iPhone**: no tunnel exists, so point the app at the Mac's LAN IP in
`deploy/env/frontend.local`: `MOBILE_DEV_SERVER_URL=http://<mac-ip>:8900`.
- **Tests**: the native layer is plain TypeScript covered by the frontend unit
tests (`make test-front`). `sso-invariants.test.ts` is a CI tripwire on the
native declarations (scheme, iOS plugin flag) — it fails when something in
`ios/` / `android/` diverges from `auth.ts`.
### Hot reload
Hot reload works because `cap sync` bakes `MOBILE_DEV_SERVER_URL` into the app
as Capacitor's `server.url`. Two consequences worth knowing:
- The startup OTA check is skipped in 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.
- To test the **embedded bundle** (what a store build ships) or the OTA chain
end to end, disable it by setting the variable **empty** in the gitignored
`deploy/env/frontend.local`, then rebuild and reinstall:
```bash
# deploy/env/frontend.local
MOBILE_DEV_SERVER_URL=
```
A leftover `server.url` fails Android **release** builds (gradle guard); iOS
has no such guard, see the release checklist in
[`mobile-release.md`](./mobile-release.md#release-checklist-manual).
## Command reference
| Command | What it does |
| --- | --- |
| `make mobile-build` | web build (container) + `cap sync` into `ios/` and `android/` |
| `make mobile-android-run` | `mobile-build` + `gradlew assembleDebug` + `adb install` + `adb reverse` (host) |
| `make mobile-android` | `mobile-build`, then open the Android project in Android Studio (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-assets` | regenerate native icons & splashscreens from the vector mark — see [`mobile-assets.md`](./mobile-assets.md) |
| `make mobile-android-release` | signed `.aab` for Play — see [`mobile-release.md`](./mobile-release.md#publishing-to-google-play) |
| `make mobile-ota-keygen` / `-bucket` / `-publish` / `-rollback` | OTA operations — see [`mobile-release.md`](./mobile-release.md#ota-releases) |
## Where the code lives
Everything native-specific on the web side sits in
`src/frontend/src/features/native/`; the single source of truth for "am I in the
app?" is `isNativePlatform()` (`platform.ts`). `main.tsx` also tags
`<html class="native">` so stylesheets can opt into mobile-only chrome.
| Concern | Location |
| --- | --- |
| Capacitor config (appId, `appVersion`, plugins, HTTP, SystemBars, OTA signing key) | `src/frontend/capacitor.config.ts` |
| Platform detection | `src/frontend/src/features/native/platform.ts` |
| Native login / logout, PKCE, system-browser session | `src/frontend/src/features/native/auth.ts`, `pkce.ts`, `auth-session.ts` |
| Deep-link dispatcher (single `appUrlOpen` owner) | `src/frontend/src/features/native/deep-link.ts` |
| Native CSRF token store | `src/frontend/src/features/native/csrf.ts` |
| Mutations bypassing the patched fetch | `src/frontend/src/features/native/fetch.ts` |
| Native download → share sheet | `src/frontend/src/features/native/download.ts` |
| Media subresources (`<img>`, `<video>`) carrying the session | `src/frontend/src/features/native/media-url.ts` |
| Native push client | `src/frontend/src/features/native/push.ts`, `src/frontend/src/features/push/shared.ts` |
| OTA client | `src/frontend/src/features/native/ota.ts`, `src/frontend/src/features/hooks/use-ota-update-toast.tsx` |
| Startup wiring (OTA, deep links, `native` html class) | `src/frontend/src/bootstrap.tsx` |
| CSRF / API origin headers | `src/frontend/src/features/api/utils.ts` |
| Login/logout routing | `src/frontend/src/features/auth/index.tsx` |
| Versions shown to users | `src/frontend/src/features/hooks/use-app-version.ts` |
| SSO invariants tripwire (CI) | `src/frontend/src/features/native/sso-invariants.test.ts` |
| iOS project, `ASWebAuthenticationSession` plugin + registration | `src/frontend/ios/`, `App/App/WebAuthSessionPlugin.swift`, `MainViewController.swift` |
| iOS push entitlement, APNs bridge, banner strings | `src/frontend/ios/App/App/App.entitlements`, `AppDelegate.swift`, `{en,fr}.lproj/Localizable.strings` |
| Android project (safe-area / keyboard insets in `MainActivity.java`) | `src/frontend/android/` |
| Android push banner strings | `src/frontend/android/app/src/main/res/values{,-fr}/strings.xml` |
| 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` |
| Scalingo OTA staging + flip | `deploy/paas/scalingo_stage_ota`, `deploy/paas/scalingo_ota_promote.py` |
## When things go wrong
| Symptom | Likely cause → fix |
| --- | --- |
| App is blank | Dev stack down (`make start`), or on Android the tunnel dropped (`make mobile-android-reverse`) |
| Requests fail after an emulator reboot | Tunnel dropped → `make mobile-android-reverse` |
| Gradle / Xcode can't find a Capacitor plugin | Plugin paths point into `src/frontend/node_modules/.store/…` → `make install-frozen-front` then `make mobile-build` (see *Why the plugin paths* below) |
| Login opens the browser and never comes back | `MOBILE_AUTH_SCHEME` mismatch between Vite and the native build (use the `make` targets, never a bare `./gradlew`), scheme missing from the backend `MOBILE_AUTH_CALLBACK_SCHEMES`, or not lowercase |
| Second La Suite app asks for credentials again (Android) | Emulator without Play services → use a Google Play / Google APIs image |
| Second La Suite app asks for credentials again (iOS) | IdP cookie is not persistent (tick "Remember me"), or the `eidas1` ACR mapping was removed from the dev realm |
| `localhost:8900` "logs in silently" so SSO must work | False positive: that is the Django session cookie, not IdP SSO. Only the mobile flow proves it |
| Mutations fail with "Referer checking failed" | Only against an HTTPS backend: they must go through `nativeFetch()` (see *Networking & session*) |
| Attachment preview / inline images blank on staging, fine in dev and on desktop | An `<img>` pointed straight at the API: it must go through `toNativeMediaUrl()` (see *Networking & session*) |
| No "Print" entry in the message menu | By design: the WebView has no print pipeline without a native plugin (see *Networking & session*) (To be done) |
| No archive upload in the Imports tab | By design: the chunked XHR upload to storage does not survive the bridge (a raw `Blob` body is serialized as `"{}"`); only the IMAP import is offered on native (see *Networking & session*) |
| OTA never triggers | By design in a hot-reload session; disable `MOBILE_DEV_SERVER_URL` to test it |
| Push registration fails on the emulator | Same Play-services requirement as SSO |
**Why the plugin paths.** The frontend dependencies live in
`src/frontend/node_modules` **on the host**, installed by the container through
the bind mount (`make bootstrap`, `make update`, …). Gradle and SPM resolve the
Capacitor plugins through relative paths into that tree, so it is deliberately
kept inside the bind mount rather than masked by a Docker volume (see
`compose.yaml`). npm installs with `install-strategy=linked`
(`src/frontend/.npmrc`), so those paths point at
`node_modules/.store/<pkg>@<ver>-<hash>/…`; the hash derives from the lockfile,
so the committed paths are valid on any machine with the same
`package-lock.json` and only change when a plugin dependency is bumped —
`make mobile-build` regenerates them. The binaries in that tree are the
container's (Linux) ones: Gradle and Xcode only read sources from them, never
point a host `npm`/`vite` at it.
## 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](./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.
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: a **Play-services image** is required — FCM registration fails on
a bare AOSP image (the UI then shows `registration_failed`, by design).
**iOS (APNs)**
1. **Physical iPhone required**: simulators never get a real APNs token
(`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;
the production gateway rejects them 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).
---
# Part 2 — Technical concepts
## 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 & session** — `window.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.
## Authentication
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 browser** — `ASWebAuthenticationSession` 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, **persists the
verifier** (see *Surviving the background* below), 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 return scheme (`MOBILE_AUTH_SCHEME`, default `stmessages`) must be
registered natively on **both** platforms — both substitute it at build time
from that one variable, and `sso-invariants.test.ts` pins the wiring (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`.
### Surviving the background
The whole flow runs while the app is **backgrounded** — the system browser is
on top — and a backgrounded WebView is not a safe place to keep state. Two
things can wipe the JS context mid-flow: the native updater installing a staged
OTA bundle (`appMovedToBackground()` → `installNext()` reloads the WebView,
whatever `autoUpdate` says), and Android reclaiming the process. Either one
used to strand the login *and* poison the following attempts, because
`@capacitor/app` notifies `appUrlOpen` with `retainUntilConsumed`: a callback
nobody was listening for is retained and replayed to the **next** listener that
subscribes, so the next attempt exchanged the previous attempt's expired token
— forever, until the app was killed. Three rules keep that shut:
- **The verifier outlives its context.** `nativeLogin()` persists
`{codeVerifier, startedAt}` before opening the browser, and the exchange
reads it back from storage. It is the only value the exchange cannot
re-derive. The record expires after 15 min (the user's time on the IdP, not
the 60 s life of the token) and is cleared on every exit path.
- **One deep-link listener, for the whole app lifetime.** `deep-link.ts` owns
the single `appUrlOpen` subscription, registered at boot in `bootstrap.tsx`;
flows *capture* routing from it while they run. Nothing is ever left
unconsumed for a later attempt to inherit — a link arriving outside a flow
either resumes a pending login (`resumeNativeLogin`, which is how a login
whose context died still completes) or is dropped.
- **OTA installs are held for the duration.** `openAuthSession()` brackets the
browser round-trip with `holdOtaInstall()` / `releaseOtaInstall()`, a Capgo
delay condition that makes `installNext()` return early. The staged bundle
applies at the next background *after* the flow instead.
### Cross-app SSO conditions
Both bit us during the initial validation:
- **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).
**CSRF `Origin` on HTTPS.** Against a secure backend (staging/prod), Django
additionally requires an `Origin` or `Referer` on every mutation ("Referer
checking failed - no Referer" otherwise) — headers the native HTTP client never
sends on its own. `getHeaders()` injects `Origin: <API origin>` on native
(same-origin for the backend, so no `CSRF_TRUSTED_ORIGINS` entry is needed),
but the bridge's patched `window.fetch` normalizes headers through
`new Request()`, whose browser "request" guard silently drops forbidden names —
`Origin` included. Mutations (POST/PUT/PATCH/DELETE) therefore bypass the patch
and call the `CapacitorHttp` plugin directly via `nativeFetch()`
(`src/features/native/fetch.ts`), which passes headers verbatim to the same
native stack; reads stay on the patched fetch and keep request cancellation.
The plain-HTTP dev backend never triggers the check, which is why this only
shows up outside dev.
**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 (attachment cards, raw `.eml`, calendar `.ics`).
**Media subresources (`<img>`, `<video>`, `<audio>`)** are the one thing the
patch does *not* cover: the WebView loads them with its own network stack, from
the shell origin (`capacitor://localhost` on iOS, `https://localhost` on
Android) — cross-site with the API. The Django session cookie is `SameSite=Lax`
(and WKWebView blocks third-party cookies outright), so those loads carry no
session and the API answers 401: the attachment preview, inline `cid:` images
and the external-image proxy all render blank. **Dev hides it**: the Vite dev
server and the backend both live on `localhost`, which is same-site, so the
cookie is sent; the bug only shows against a real deployment. The fix is
`toNativeMediaUrl()` (`src/features/native/media-url.ts`), which rewrites the
URL onto the bridge's own `/_capacitor_http_interceptor_?u=…` proxy — the same
same-origin URL the patched `fetch` uses for GETs — so the native side
intercepts the load and serves it from the native HTTP stack, cookie jar
included, streamed, nothing crossing the JS bridge. Any new `<img>`/`<video>`
pointed at the API must go through it. Do **not** apply it to URLs consumed by
`CapacitorHttp` itself (`nativeDownloadFile`, `nativeFetch`): those already
carry the session, and the proxy host is meaningless outside the WebView.
**Raw `Blob` bodies never cross the bridge.** The patched `XMLHttpRequest`
hands the body to `CapacitorHttp`, whose converter knows `File`, `FormData`,
`Uint8Array`, `ReadableStream` and `URLSearchParams` — a bare `Blob` (a
`file.slice()` chunk) falls through to the JSON case and is sent as `"{}"`,
silently. `nativeFetch()` rejects such a body loudly; `FormData` is safe
because `append()` turns a `Blob` into a `File`. The mailbox archive import
(chunked XHR PUT to storage) is the one caller affected, so `StepForm` hides
the archive upload on native and keeps only the IMAP import.
**Printing** (`usePrint`: `window.open('')` then `print()`) has no effect in
the shell: neither WKWebView nor the Android WebView wires `window.print()`
without native code (`UIPrintInteractionController` / `PrintManager`), which
an OTA bundle cannot add. The message menu hides the entry on native; a
native print plugin would be a store release.
## 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). OTA replaces the *web* bundle only: anything native (a new
Capacitor plugin, a permission, the Swift/Gradle side) still requires a store
release. How bundles are published, versioned and rolled back is covered in
[`mobile-release.md`](./mobile-release.md#ota-releases); this section is the
client side.
**Every bundle is encrypted and signed.** Because the bucket is world-readable,
bundles use Capgo v2 (RSA+AES) with a per-instance key: the public half is baked
into the app at `cap sync` time (`capacitor.config.ts`, `publicKey` ←
`MOBILE_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. `ota.ts` refuses to
apply a manifest when the build embeds no public key, so an OTA-enabled app can
never apply an unverified bundle.
**Consume** (`src/features/native/ota.ts`, called at startup and on app
foreground with a 30 min throttle):
1. `notifyOtaAppReady()` first — confirms the running bundle booted, so a broken
update auto-rolls-back on next launch.
2. `checkAndStageOtaUpdate()` polls the manifest URL served by the backend
(`MOBILE_OTA_MANIFEST_URL` setting, `/config` endpoint, resolved in
`bootstrap.tsx`) and accepts the advertised bundle only when it clears the
guards: different from `CapacitorUpdater.current()`, a *strictly greater*
`sequence` (a per-channel monotonic release counter the device persists —
which is what lets a deliberate rollback point at an older build while a
replayed manifest can never drag a device backward), never older than the
native builtin bundle, and not recorded as a prior failed boot (a bundle
that boot-looped is blacklisted on that device).
3. It downloads (passing `checksum` + `sessionKey`, verified against the
baked-in public key — a manifest missing either is refused outright, since
the Android plugin would otherwise fall back to the plain checksum the
manifest itself advertises) and **stages** it via `next()` — no mid-session
reload.
A persistent, non-dismissible toast (`use-ota-update-toast.tsx`, mounted by
the main layout) offers the single action "Update", which `reload()`s onto
the staged bundle; never tapping it is fine, Capgo applies the staged bundle
when the app next goes to the background or relaunches (except during a
login flow, see *Surviving the background*).
**Channels.** Each app follows exactly one channel through the manifest URL its
backend serves (`channels/<channel>/manifest.json`): `dev` locally, `staging`
and `prod` in the pipeline. The `NEXT_PUBLIC_*` vars are inlined into the bundle
at build time, so a bundle is never promoted across channels — each is rebuilt
for its environment.
## App versions
Two numbers coexist on a native platform, because OTA moves the web bundle ahead
of the installed app between two store releases — a single number could not
stand for both:
| Shown | Source | Read |
| --- | --- | --- |
| `Version 1.2.0` (native) | `appVersion` in `capacitor.config.ts`, bumped manually per store release | at runtime from the OS, via `@capacitor/app` |
| `Web interface 0.1.0` (native only) | `version` in `package.json` | baked in at build time (`__WEB_APP_VERSION__`) |
| `Version 0.1.0` (web) | `version` in `package.json` | idem |
The version is readable in-app, as the last entry of the help/support menu
(`SurveyButton`, `src/frontend/src/features/ui/components/feedback-button/`),
through `useAppVersion()`. Clicking the entry copies a one-line report — `app
1.2.0 (42) · web 0.1.0 (a1b2c3d) · ios` — including the build stamp of the
running bundle, so a bug report pins the exact code.
That stamp is the `SOURCE_VERSION` the build received: Scalingo's buildpack
sets it natively, the CI image build passes it as a build-arg, and the `make`
targets inject the host's `MOBILE_OTA_BUILD_ID` (the container itself can't
derive it — the bind mount carries no `.git` and the image no `git` binary).
Only a bare `npm run build` in the container gets the `t<timestamp>` fallback,
still unique per build but not traceable to a commit.
The store build number (`MOBILE_VERSION_CODE`) and how to bump the displayed
version are release concerns: see
[`mobile-release.md`](./mobile-release.md#app-versioning).
## Configuration
Mobile-specific environment variables (full reference in [env.md](./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 reads the host env (the `mobile-android-*` targets export it), Xcode reads the gitignored `ios/App/generated.xcconfig` written by `make mobile-build`. Release builds fail on a divergence from the synced config on both platforms (gradle guard / "Check synced Capacitor identity" build phase). Independent of the auth scheme (`MOBILE_AUTH_SCHEME`) |
| `MOBILE_APP_NAME` | Displayed application name (default `ST Messages`, a neutral placeholder an organisation overrides with its own). Reaches the native builds through the same two channels as `MOBILE_APP_ID` (gradle `resValue app_name` / iOS `PRODUCT_DISPLAY_NAME` in the generated xcconfig), with the same release-time divergence guards |
| `MOBILE_AUTH_SCHEME` | OIDC deep-link scheme (default `stmessages`). Read by Vite **and** the native builds (Android `manifestPlaceholders` from the host env, iOS `AUTH_CALLBACK_SCHEME` from the generated xcconfig). Give each environment its own so two builds can coexist on a device |
| `MOBILE_AUTH_CALLBACK_SCHEMES` | Backend allowlist: JSON list of accepted deep-link schemes (e.g. `["stmessages"]`); empty disables mobile login. Must contain every `MOBILE_AUTH_SCHEME` in use |
| `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 and absolute** 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 and on app foreground (30 min throttle) — 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 `mobile-ota-publish` targets (`dev` locally, `staging`/`prod` in the pipeline); must match the channel the build follows |
| `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), the
monotonic `sequence` floor persisted per device (anti-replay) and the
native-builtin floor (never below the store build) are in place, 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).
- **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)**.
- **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.
- **Session renewal.** 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). The same listener also
owns the keyboard resize on **Android 15+**: the OS draws every app edge to
edge there, and an edge-to-edge window is never resized by the keyboard, so
`windowSoftInputMode=adjustResize` does nothing and the composer toolbar would
hide behind the keyboard — the listener applies the `ime()` inset as padding,
and only above API 34 (below it the system resize still runs, and adding
padding is exactly what #8181 was). iOS resolves `env(safe-area-inset-*)`
natively; its keyboard resize is applied as soon as the keyboard starts
moving by `MainViewController.swift` (`@capacitor/keyboard` runs with
`resize: "none"`: its native mode snaps the web view to the new size
~200 ms after the keyboard animation, capacitor-keyboard#79). 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.
## See also
- [`mobile-release.md`](./mobile-release.md) — OTA publishing, channels,
signing keys, rollback, Scalingo integration, Google Play, release checklist.
- [`mobile-assets.md`](./mobile-assets.md) — app icons and splash screens: the
vector mark everything is derived from, the platform safe zones, and what each
OS actually reads at launch.
- [`push-notifications.md`](./push-notifications.md) — push architecture.
- [`env.md`](./env.md) — full environment-variable reference.