# 13 — Mobile app (Android + iPhone) Owner, 2026-10-26: our own app for the user portal, on both phones from one codebase. Why this stack: [decision 0005](../decisions/0005-mobile-app.md). ## 1. What it is - Repo: `git.dou.bet/spng/mobile` (AGPL-3.0), checked out at `mobile/` beside this repo. It has this repo as a git submodule for `@selfphoneng/shared` (types and zod schemas). - Expo (React Native) + TypeScript, react-query and zustand, as the web client. Expo packages: expo-router (screens, and the `selfphoneng://` links), expo-camera (scanning the pairing QR), expo-secure-store (the device token), expo-device (the phone's name for the device list). Types from `@selfphoneng/shared` are `import type` only, so the app bundles none of this repo. - **User portal only** (owner): calls, call history, voicemail, contacts, My settings. No admin screens; admins use the web panel, which works in a phone's browser. - Calls: SIP.js + react-native-webrtc on the extension's `-web` endpoint (`GET /api/me/webrtc`), the same one the browser softphone uses (`max_contacts = 3`, so a browser and a phone fit). The extension needs the browser softphone turned on. Native call screen: expo-callkit-telecom (CallKit on iOS, Core-Telecom on Android); calls show in the phone's own recent calls (iOS Recents; Android `EXTRA_LOG_SELF_MANAGED_CALLS`). - **No push in v1** (owner): the app rings only while it's open. The app and the admin guide say so, as 07 §5 does, and suggest forwarding unanswered calls to the mobile number. - Built and sideloaded only for now (owner): an Android APK; iOS through ios-builder (macOS CI runner) with a free Apple ID re-sign. No store listings yet. ## 2. Pairing a phone The app signs in by pairing, not with a password: the person is already signed in on the web, so OpenID, LDAP and two-step sign-in all work without the app knowing about them. 1. My settings → **Mobile app** → **Pair a phone** (`POST /api/me/mobile-devices/pair`, a browser session only: 403 with any Bearer token). The api makes a one-time **pairing code**: 12 characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (no 0/O/1/I), good for 10 minutes, stored as sha256. The answer: `{ code, server, link, qr, expiresAt }`, where `server = https://PUBLIC_HOSTNAME`, `link = selfphoneng://pair?server=&code=` and `qr` is a PNG data URL of `link`. The page shows the QR, and the server and code to type by hand. 2. The app scans it (or takes them typed) and calls `POST /api/mobile/pair` `{ code, name, platform: 'android' | 'ios' }` (public, `SLOW`: 10 a minute per IP; it has no token yet, so it sends `X-Requested-With: selfphoneng` like the web, 04 §2.2). Case and anything but letters and digits are dropped from the code before the check. A code that is unknown, used or expired → 404 `pair_code_invalid`; the user disabled since → the same. Success → `{ token, user: { name, email } }`: the **device token**, shown this once. 3. The app keeps the token in the phone's secure storage (expo-secure-store) and sends it as `Authorization: Bearer spm_<40 base62>` on every request. Device tokens (`auth/devices.ts`): - `requireAuth` takes `spm_` tokens before `sp_` API tokens; the request acts as the device's user (`req.mobileDeviceId` set). Unknown, removed, or the user disabled → 401. `lastUsedAt` is written on every request. `must_change_password` → 403 `password_change_required` as for API tokens. - A phone is never an admin: `requireAuth` gives the request `role: 'user'` even for an admin's phone, so `requireAdmin` refuses it (403) and admin-only checks inside routes (recordings) don't pass. The app has no admin screens, and a lost phone shouldn't open the admin panel. - A device token can't make pairing codes (a stolen phone can't make itself a spare). - They don't expire (owner, 2026-10-26). They end when the person removes the device (on the web or Sign out in the app), and with every "sign out everywhere" (`deleteUserSessions`): the person changing their password (their current web session stays), an admin resetting their password or two-step sign-in, disabling them (also by the directory check), or deleting them. - An OpenID user disabled at the provider but not here keeps their phone until an admin disables them on Users: the provider's session check (12 §3.5) only ends web sessions (owner: fine). ## 3. Data (`mobile_devices`, migration 0013) The table is in 02 (`mobile_devices`). A row is made by **Pair a phone** (code only) and filled by the app's pair call (token, code cleared). Making a new code first deletes the user's rows that never paired. ## 4. API | Method | Path | Notes | |---|---|---| | POST | `/api/me/mobile-devices/pair` | browser session only. → `{ code, server, link, qr, expiresAt }` | | POST | `/api/mobile/pair` | public, `SLOW`. `{ code, name, platform }` → `{ token, user }` | | GET | `/api/me/mobile-devices` | paired devices: `{ id, name, platform, tokenPrefix, createdAt, lastUsedAt, current }` | | DELETE | `/api/me/mobile-devices/:id` | removes one of your devices (the app's Sign out removes its own) | Audit: `create` / `delete` on `mobile_device` (pairing code made, phone paired, device removed). What the app uses besides these (all exist): `GET|PATCH /api/me`, `GET /api/me/webrtc`, `GET /api/me/calls`, `GET|PATCH|DELETE /api/me/voicemail[/:id]`, `GET /api/me/voicemail/:id/audio`, `GET /api/me/contacts`, `GET|PUT|DELETE /api/me/greetings/:kind`. ## 5. Order of work | Task | Repo | What | Done when | |---|---|---|---| | MB1 | this | Decision 0005; this spec; migration 0013; device tokens in the guards; §4 routes; My settings → Mobile app (QR, code, device list, Remove) | `pnpm lint && typecheck && test` pass; pairing, device token, admin refusal and removal are tested. Done, see §5.1 | | MB2 | this | The live socket (`GET /api/live`, 04 §5.2) takes a device token as `Authorization: Bearer` (React Native's WebSocket can send headers; browsers keep the cookie) | the app gets `voicemail` and presence events | | MB3 | mobile | Expo skeleton, the submodule, pairing by QR, by hand and by `selfphoneng://pair` link, Sign out; Android APK builds; iOS steps for ios-builder (the tab bar comes with MB5, when there's a second screen) | the owner pairs an Android phone and an iPhone and sees their name and extension | | MB4 | mobile | Calls: SIP.js + react-native-webrtc, register while open, dial pad, in and out, hold, mute, DTMF, speaker; native call screen; the "rings only while open" notice | a call each way with the PBX, on both phones, shown on the native call screen and in the phone's recent calls | | MB5 | mobile | Call history (`/api/me/calls`), tap to call back | matches the web's Calls page | | MB6 | mobile | Voicemail: list, play, mark heard, delete | as the web's Voicemail page | | MB7 | mobile | Contacts: search, tap to call | as the web's Contacts page | | MB8 | mobile | My settings: DND, forwarding, voicemail PIN and email, greetings | as the web's My settings | ### 5.1 As built: MB1 - `auth/devices.ts` (codes, tokens, `pairDevice`: the code is cleared in the same `update` that sets the token, so one code pairs one phone), `routes/mobile.ts`, migration 0013. - My settings → **Mobile app**: Pair a phone shows the QR and the server and code; while it's up the page checks the list every 3 s and puts the code away when a new phone appears. Paired phones, with Remove. - `apps/api/test/mobile.test.ts`. Not tried with a real phone yet: that's MB3. ### 5.2 As built: MB3 (mobile `6e4bc2b`) - Expo SDK 58, React Native 0.88, Expo Router; app id `bet.dou.selfphoneng` (Android and iOS), confirmed by the owner (it can't change after a store listing). - Screens: `pair` (scan, type, or a `selfphoneng://pair` link filled in and waiting for a tap, so a link someone sends can't pair the phone to their server unnoticed) and home (name, server, extension from `GET /api/me`, the rings-only-while-open notice, Sign out). Sign out deletes the device on the server, then forgets the token even if the server can't be reached. - `npx expo export` bundles both platforms; a release APK (arm64) builds on Windows. That needed CMake 3.31 (`cmake.dir` in `android/local.properties`): the Android SDK's default CMake 3.22 has a `ninja` without long-path support, and React Native's codegen paths pass 260 characters. - **Open:** the owner pairs a real Android phone and an iPhone (ios-builder needs a GitHub mirror of the mobile repo). ## 6. Later (not in MB1–MB8) - Push, so the app rings in the background: FCM (Play build) and UnifiedPush (F-Droid build) on Android, Apple VoIP push through a relay the project runs (Apple's key belongs to the app's publisher, not to each PBX). The PBX sends the push and waits for the phone to register before ringing it. Needs a decision record (Play Services is closed source). Replaces M12's "Push gateway for mobile apps". - App Store and Play listings (and AGPL vs App Store terms). - TURN for strict mobile networks (`network.turn` already reaches `/api/me/webrtc`).