Files
selfphoneng/docs/spec/13-mobile.md
T
iamdoubz 431ada51aa
ci / check (push) Successful in 1m30s
ci / images (push) Successful in 22s
docs: confirm the mobile app id
MB3
2026-10-26 20:54:54 -05:00

128 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<server>&code=<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`).