9.0 KiB
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.
1. What it is
- Repo:
git.dou.bet/spng/mobile(AGPL-3.0), checked out atmobile/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/sharedareimport typeonly, 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
-webendpoint (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; AndroidEXTRA_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.
- 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 fromABCDEFGHJKLMNPQRSTUVWXYZ23456789(no 0/O/1/I), good for 10 minutes, stored as sha256. The answer:{ code, server, link, qr, expiresAt }, whereserver = https://PUBLIC_HOSTNAME,link = selfphoneng://pair?server=<server>&code=<code>andqris a PNG data URL oflink. The page shows the QR, and the server and code to type by hand. - 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 sendsX-Requested-With: selfphonenglike 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 → 404pair_code_invalid; the user disabled since → the same. Success →{ token, user: { name, email } }: the device token, shown this once. - 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):
requireAuthtakesspm_tokens beforesp_API tokens; the request acts as the device's user (req.mobileDeviceIdset). Unknown, removed, or the user disabled → 401.lastUsedAtis written on every request.must_change_password→ 403password_change_requiredas for API tokens.- A phone is never an admin:
requireAuthgives the requestrole: 'user'even for an admin's phone, sorequireAdminrefuses 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 sameupdatethat 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 aselfphoneng://pairlink 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 fromGET /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 exportbundles both platforms; a release APK (arm64) builds on Windows. That needed CMake 3.31 (cmake.dirinandroid/local.properties): the Android SDK's default CMake 3.22 has aninjawithout 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.turnalready reaches/api/me/webrtc).