app: Expo skeleton and pairing with a PBX
MB3 (PBX spec 13 §5). Expo SDK 58 + Expo Router, with the PBX repo as a submodule for @selfphoneng/shared types (import type only). - pair by scanning the QR from My settings → Mobile app, by typing the server and code, or from a selfphoneng://pair link (waits for a tap) - the device token lives in expo-secure-store; a 401 goes back to pairing - home shows who and which extension the phone is paired as; Sign out removes the phone on the server too - README: Android APK build (and the Windows long-path fix), iPhone builds with ios-builder
This commit is contained in:
1 parent
1c58891c50
commit
6e4bc2b86b
22 files changed
+11676
-2
No files matched your search
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vendor/selfphoneng"]
|
||||||
|
path = vendor/selfphoneng
|
||||||
|
url = https://git.dou.bet/spng/selfphoneng.git
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
vendor/
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
|
assets/
|
||||||
|
package-lock.json
|
||||||
|
LICENSE
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# AGENTS.md — Rules for AI coding agents working on the SelfPhoneNG mobile app
|
||||||
|
|
||||||
|
The spec lives in the PBX repo: read `vendor/selfphoneng/AGENTS.md`, then
|
||||||
|
`vendor/selfphoneng/docs/spec/13-mobile.md` (this app), and `04-backend-api.md` for the API.
|
||||||
|
Tasks are MB3–MB8 in 13 §5: one at a time, as the PBX's hard rules say.
|
||||||
|
|
||||||
|
## Expo changes fast: don't trust memory
|
||||||
|
|
||||||
|
Read the `expo` major in `package.json`, then the matching docs
|
||||||
|
(`https://docs.expo.dev/versions/v<major>.0.0/`, or Context7). Add packages with
|
||||||
|
`npx expo install <package>` (SDK-compatible versions), never `npm install` for Expo or React
|
||||||
|
Native packages.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Routes are in `src/app/` (Expo Router); everything else in `src/`.
|
||||||
|
- `android/` and `ios/` are generated (`npx expo prebuild`): never edit or commit them. Native
|
||||||
|
settings go in `app.json` and config plugins.
|
||||||
|
- API types come from `@selfphoneng/shared` (the submodule) with `import type` only, so Metro never
|
||||||
|
bundles the PBX's code. Don't redefine a type the PBX already exports.
|
||||||
|
- Every source file starts with `// SPDX-License-Identifier: AGPL-3.0-or-later`.
|
||||||
|
- Open source only (PBX hard rule 4). New dependencies are listed in 13 §1 with the reason.
|
||||||
|
- Never log the device token, SIP password or voicemail audio.
|
||||||
|
|
||||||
|
## Before saying a task is done
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck && npm run lint && npx expo export --platform android --platform ios
|
||||||
|
```
|
||||||
@@ -1,3 +1,78 @@
|
|||||||
# mobile
|
# SelfPhoneNG mobile
|
||||||
|
|
||||||
Mobile apps for SelfPhoneNG
|
The Android and iPhone app for [SelfPhoneNG](https://git.dou.bet/spng/selfphoneng), the
|
||||||
|
self-hosted business phone system. One Expo (React Native) codebase for both. What it does and
|
||||||
|
the order of work: the PBX repo's `docs/spec/13-mobile.md`.
|
||||||
|
|
||||||
|
Status: pairing with a PBX (MB3). Calls, history, voicemail, contacts and settings come next.
|
||||||
|
|
||||||
|
## Get the code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recurse-submodules https://git.dou.bet/spng/mobile.git
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
`vendor/selfphoneng` is the PBX repo as a git submodule, for the shared types
|
||||||
|
(`@selfphoneng/shared`). After the PBX changes its API, move it forward:
|
||||||
|
`git -C vendor/selfphoneng pull origin main && git add vendor/selfphoneng`.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npx expo start # needs a development build on the phone (the app has native modules)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pair a phone
|
||||||
|
|
||||||
|
On a computer, open the PBX's web page → My settings → **Mobile app** → **Pair a phone**. In the
|
||||||
|
app, tap **Scan the QR code** (or type the server and code). The code works once, for 10 minutes.
|
||||||
|
|
||||||
|
## Build and sideload
|
||||||
|
|
||||||
|
### Android (on Windows, Linux or macOS)
|
||||||
|
|
||||||
|
Needs JDK 17+ and the Android SDK (`ANDROID_HOME`). The first build downloads the NDK and SDK
|
||||||
|
platforms it's missing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo prebuild --platform android
|
||||||
|
cd android && ./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `-PreactNativeArchitectures=arm64-v8a` for a faster build that runs on any recent phone.
|
||||||
|
|
||||||
|
On Windows the C++ build fails with `ninja: error: mkdir(...): No such file or directory`: the
|
||||||
|
paths pass 260 characters, and the CMake 3.22 that Gradle installs has a `ninja` that can't use
|
||||||
|
long paths. Turn on Windows long paths (`LongPathsEnabled`), install a newer CMake
|
||||||
|
(`sdkmanager "cmake;3.31.6"`), and after each `expo prebuild` point the build at it in
|
||||||
|
`android/local.properties`:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
sdk.dir=C:/Users/<you>/AppData/Local/Android/Sdk
|
||||||
|
cmake.dir=C:/Users/<you>/AppData/Local/Android/Sdk/cmake/3.31.6
|
||||||
|
```
|
||||||
|
|
||||||
|
The APK is `android/app/build/outputs/apk/release/app-release.apk`, signed with the debug key:
|
||||||
|
copy it to the phone and open it (allow installing from unknown sources), or `adb install` it.
|
||||||
|
|
||||||
|
### iPhone (no Mac needed)
|
||||||
|
|
||||||
|
[ios-builder](https://github.com/MobAI-App/ios-builder) runs the Xcode build on a GitHub Actions
|
||||||
|
macOS runner, so it needs this repo mirrored to GitHub.
|
||||||
|
|
||||||
|
1. Download `builder-windows-amd64.exe` from its releases, rename it to `builder.exe`, put it on
|
||||||
|
your PATH, then `builder auth github`.
|
||||||
|
2. In this repo: `builder init`. It adds `.github/workflows/ios-build.yml` and `builder.json`. Set
|
||||||
|
`builder.json` to `{ "ios": { "path": "ios", "configuration": "Release" } }` (Debug loads the
|
||||||
|
JavaScript from a dev server).
|
||||||
|
3. `builder ios build --unsigned`: the IPA lands in `./dist/`.
|
||||||
|
4. Install [MobAI](https://mobai.run/download), connect the iPhone by USB, `builder mobai ping`,
|
||||||
|
then `builder mobai install dist/<file>.ipa`. It re-signs with a free Apple ID (use a spare one):
|
||||||
|
the app works for 7 days, then install it again.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3.0-or-later, as the PBX.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "SelfPhoneNG",
|
||||||
|
"slug": "selfphoneng",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scheme": "selfphoneng",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"ios": {
|
||||||
|
"bundleIdentifier": "bet.dou.selfphoneng",
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "bet.dou.selfphoneng",
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "$(PRODUCT_NAME) uses the camera to scan the pairing QR code.",
|
||||||
|
"microphonePermission": false,
|
||||||
|
"recordAudioAndroid": false,
|
||||||
|
"barcodeScannerEnabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require('eslint-config-expo/flat');
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{ ignores: ['dist/*', 'vendor/*', 'android/*', 'ios/*'] },
|
||||||
|
]);
|
||||||
Generated
+11126
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@selfphoneng/mobile",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"dependencies": {
|
||||||
|
"@selfphoneng/shared": "file:vendor/selfphoneng/packages/shared",
|
||||||
|
"@tanstack/react-query": "^5.105.0",
|
||||||
|
"eslint-config-expo": "~58.0.4",
|
||||||
|
"expo": "~58.0.19",
|
||||||
|
"expo-camera": "~58.0.12",
|
||||||
|
"expo-constants": "~58.0.13",
|
||||||
|
"expo-device": "~58.0.5",
|
||||||
|
"expo-linking": "~58.0.14",
|
||||||
|
"expo-router": "~58.0.26",
|
||||||
|
"expo-secure-store": "~58.0.2",
|
||||||
|
"expo-status-bar": "~58.0.4",
|
||||||
|
"react": "19.3.0",
|
||||||
|
"react-dom": "19.3.0",
|
||||||
|
"react-native": "0.88.0",
|
||||||
|
"react-native-safe-area-context": "~5.10.1",
|
||||||
|
"react-native-screens": "~4.30.0",
|
||||||
|
"zod": "^4.6.7",
|
||||||
|
"zustand": "^5.0.16"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.3.0",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"prettier": "^3.9.10",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "expo lint && prettier --check .",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"license": "AGPL-3.0-or-later"
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// The PBX's REST API (spec 04 §7, 13 §4) with this phone's device token.
|
||||||
|
import type {
|
||||||
|
ListResponse,
|
||||||
|
MobileDeviceDto,
|
||||||
|
MobilePair,
|
||||||
|
MobilePairResult,
|
||||||
|
} from '@selfphoneng/shared';
|
||||||
|
import * as Device from 'expo-device';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { useSession } from './session';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
server: string,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<T> {
|
||||||
|
const r = await fetch(server + path, {
|
||||||
|
method,
|
||||||
|
headers: body === undefined ? headers : { ...headers, 'content-type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await r.json().catch(() => null);
|
||||||
|
if (!r.ok)
|
||||||
|
throw new ApiError(
|
||||||
|
r.status,
|
||||||
|
data?.error?.code ?? 'error',
|
||||||
|
data?.error?.message ?? `The server answered ${r.status}`,
|
||||||
|
);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const { session, set } = useSession.getState();
|
||||||
|
if (!session) throw new ApiError(401, 'unauthorized', 'Not paired');
|
||||||
|
try {
|
||||||
|
return await request<T>(session.server, method, path, body, {
|
||||||
|
authorization: `Bearer ${session.token}`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Removed on the web, a password change, or the user disabled: back to pairing.
|
||||||
|
if (e instanceof ApiError && e.status === 401) await set(null);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "pbx.example.com" or "https://pbx.example.com/" → "https://pbx.example.com". */
|
||||||
|
export function serverUrl(input: string): string {
|
||||||
|
const s = input.trim().replace(/\/+$/, '');
|
||||||
|
if (/^http:\/\//i.test(s)) throw new Error('The server must use https://');
|
||||||
|
return /^https:\/\//i.test(s) ? s : `https://${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pair(server: string, code: string): Promise<void> {
|
||||||
|
const url = serverUrl(server);
|
||||||
|
const body: MobilePair = {
|
||||||
|
code,
|
||||||
|
name: Device.modelName ?? (Platform.OS === 'ios' ? 'iPhone' : 'Android phone'),
|
||||||
|
platform: Platform.OS === 'ios' ? 'ios' : 'android',
|
||||||
|
};
|
||||||
|
// No token yet: the CSRF header instead, like the web (spec 04 §2.2).
|
||||||
|
const r = await request<MobilePairResult>(url, 'POST', '/api/mobile/pair', body, {
|
||||||
|
'x-requested-with': 'selfphoneng',
|
||||||
|
});
|
||||||
|
await useSession.getState().set({ server: url, token: r.token, ...r.user });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes this phone on the server too, so it leaves the list on the web. */
|
||||||
|
export async function signOut(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { items } = await api<ListResponse<MobileDeviceDto>>('GET', '/api/me/mobile-devices');
|
||||||
|
const me = items.find((d) => d.current);
|
||||||
|
if (me) await api('DELETE', `/api/me/mobile-devices/${me.id}`);
|
||||||
|
} finally {
|
||||||
|
await useSession.getState().set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { useSession } from '../session';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const loaded = useSession((s) => s.loaded);
|
||||||
|
// The splash screen stays up while secure storage is read.
|
||||||
|
if (!loaded) return null;
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="index" options={{ title: 'SelfPhoneNG' }} />
|
||||||
|
<Stack.Screen name="pair" options={{ title: 'Pair this phone' }} />
|
||||||
|
</Stack>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// Home: who this phone is paired as. Calls, voicemail, contacts and settings come in MB4–MB8.
|
||||||
|
import type { MeDto } from '@selfphoneng/shared';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { api, signOut } from '../api';
|
||||||
|
import { useSession } from '../session';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const session = useSession((s) => s.session);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
const me = useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: () => api<MeDto>('GET', '/api/me'),
|
||||||
|
enabled: !!session,
|
||||||
|
});
|
||||||
|
if (!session) return <Redirect href="/pair" />;
|
||||||
|
return (
|
||||||
|
<View style={styles.page}>
|
||||||
|
<Text style={styles.name}>{session.name}</Text>
|
||||||
|
<Text>{session.email}</Text>
|
||||||
|
<Text style={styles.muted}>{session.server}</Text>
|
||||||
|
{me.data?.extension && (
|
||||||
|
<Text>
|
||||||
|
Extension {me.data.extension.number} ({me.data.extension.name})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{me.error && <Text style={styles.error}>{me.error.message}</Text>}
|
||||||
|
<Text style={styles.muted}>
|
||||||
|
Calls ring here only while the app is open. For calls you can’t miss, forward unanswered
|
||||||
|
calls to your mobile number in My settings.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title={leaving ? 'Signing out…' : 'Sign out'}
|
||||||
|
disabled={leaving}
|
||||||
|
onPress={async () => {
|
||||||
|
setLeaving(true);
|
||||||
|
await signOut().catch(() => {});
|
||||||
|
qc.clear();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { padding: 24, gap: 12 },
|
||||||
|
name: { fontSize: 22, fontWeight: '600' },
|
||||||
|
muted: { color: '#71717a' },
|
||||||
|
error: { color: '#b91c1c' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// Pairing (spec 13 §2): scan the QR code from My settings → Mobile app, or type the server and
|
||||||
|
// code. A selfphoneng://pair?server=…&code=… link opens here filled in, and waits for a tap.
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import * as Linking from 'expo-linking';
|
||||||
|
import { Redirect, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
import { pair } from '../api';
|
||||||
|
import { useSession } from '../session';
|
||||||
|
|
||||||
|
export default function Pair() {
|
||||||
|
const session = useSession((s) => s.session);
|
||||||
|
const params = useLocalSearchParams<{ server?: string; code?: string }>();
|
||||||
|
const [server, setServer] = useState(params.server ?? '');
|
||||||
|
const [code, setCode] = useState(params.code ?? '');
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
|
||||||
|
if (session) return <Redirect href="/" />;
|
||||||
|
|
||||||
|
if (scanning)
|
||||||
|
return (
|
||||||
|
<View style={styles.full}>
|
||||||
|
<CameraView
|
||||||
|
style={styles.full}
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={({ data }) => {
|
||||||
|
if (!data.startsWith('selfphoneng://pair?')) return;
|
||||||
|
const { queryParams } = Linking.parse(data);
|
||||||
|
if (typeof queryParams?.code !== 'string') return;
|
||||||
|
setServer(String(queryParams.server ?? ''));
|
||||||
|
setCode(queryParams.code);
|
||||||
|
setScanning(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button title="Cancel" onPress={() => setScanning(false)} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.page}>
|
||||||
|
<Text>
|
||||||
|
On a computer, open SelfPhoneNG → My settings → Mobile app → Pair a phone, then scan the
|
||||||
|
code it shows.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Scan the QR code"
|
||||||
|
onPress={async () => {
|
||||||
|
setError('');
|
||||||
|
if ((permission?.granted ?? false) || (await requestPermission()).granted)
|
||||||
|
setScanning(true);
|
||||||
|
else setError('The camera is needed to scan. You can type the server and code instead.');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={styles.label}>Server</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={server}
|
||||||
|
onChangeText={setServer}
|
||||||
|
placeholder="pbx.example.com"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
/>
|
||||||
|
<Text style={styles.label}>Code</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={code}
|
||||||
|
onChangeText={setCode}
|
||||||
|
placeholder="ABCD EFGH JKLM"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title={busy ? 'Pairing…' : 'Pair'}
|
||||||
|
disabled={busy || !server.trim() || !code.trim()}
|
||||||
|
onPress={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await pair(server, code);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!!error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { padding: 24, gap: 12 },
|
||||||
|
full: { flex: 1 },
|
||||||
|
label: { fontWeight: '600' },
|
||||||
|
input: { borderWidth: 1, borderColor: '#d4d4d8', borderRadius: 6, padding: 10, fontSize: 16 },
|
||||||
|
error: { color: '#b91c1c' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// The paired server and this phone's device token (spec 13 §2), kept in the phone's secure storage.
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export type Session = { server: string; token: string; name: string; email: string };
|
||||||
|
|
||||||
|
const KEY = 'session';
|
||||||
|
|
||||||
|
export const useSession = create<{
|
||||||
|
loaded: boolean;
|
||||||
|
session: Session | null;
|
||||||
|
set: (s: Session | null) => Promise<void>;
|
||||||
|
}>((setState) => ({
|
||||||
|
loaded: false,
|
||||||
|
session: null,
|
||||||
|
set: async (session) => {
|
||||||
|
if (session) await SecureStore.setItemAsync(KEY, JSON.stringify(session));
|
||||||
|
else await SecureStore.deleteItemAsync(KEY);
|
||||||
|
setState({ session });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
SecureStore.getItemAsync(KEY).then(
|
||||||
|
(v) => useSession.setState({ loaded: true, session: v ? (JSON.parse(v) as Session) : null }),
|
||||||
|
() => useSession.setState({ loaded: true }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||||
|
}
|
||||||
+1
Submodule vendor/selfphoneng added at 0cdac0bcdc.
Reference in new issue
Block a user