Files
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

9.0 KiB
Raw Permalink Blame History

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 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).