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:
iamdoubz committed 2026-10-26 17:45:18 -05:00
1 parent 1c58891c50
commit 6e4bc2b86b
22 files changed
+11676 -2

No files matched your search

+89
View File
@@ -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);
}
}
+20
View File
@@ -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>
);
}
+54
View File
@@ -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' },
});
+103
View File
@@ -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' },
});
+27
View File
@@ -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 }),
);