Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/screens/connection-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ export function ConnectionScreen({ onDone, onPair }: { onDone?: () => void; onPa
pressed && styles.secondaryButtonPressed,
]}
>
<Text style={styles.secondaryLabel}>PAIR SERVER + NOTIFICATIONS</Text>
<Text style={styles.secondaryLabel}>PAIR SERVER</Text>
</Pressable>
) : null}

Expand Down
130 changes: 130 additions & 0 deletions apps/mobile/src/screens/notification-pairing-screen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { act, fireEvent, render, screen } from "@testing-library/react-native";
import { NotificationPairingScreen } from "./notification-pairing-screen";

const mockSave = jest.fn(async () => "connection-1");
const mockHealth = jest.fn(async () => ({ pid: 42, version: "test" }));
const mockCreateClient = jest.fn((..._args: unknown[]) => ({
health: { get: mockHealth },
server: { get: jest.fn(async () => ({ urls: [] })) },
session: { list: jest.fn(async () => ({ data: [] })) },
}));
const mockRegisterPush = jest.fn(
async (): Promise<{ deviceName: string; expoPushToken: string; platform: string }> => {
throw new Error("NOTIFICATION_PERMISSION_DENIED");
},
);

jest.mock("expo-camera", () => ({
CameraView: () => null,
useCameraPermissions: () => [{ granted: true }, jest.fn()],
}));
jest.mock("expo-sqlite", () => ({ useSQLiteContext: () => ({}) }));
jest.mock("expo-crypto", () => ({ randomUUID: () => "test-id" }));
jest.mock("../connections/connections-context", () => ({
useConnections: () => ({ save: mockSave }),
}));
jest.mock("../expo-open-code-fetch", () => ({ boundedOpenCodeFetch: jest.fn() }));
jest.mock("@opencode2-mobile/opencode-adapter", () => ({
createOpenCodeClient: (...args: unknown[]) => mockCreateClient(...args),
normalizeOpenCodeBaseUrl: (value: string) => new URL(value).origin,
}));
jest.mock("../notifications/notification-registration", () => ({
registerForOpenCodePushNotifications: () => mockRegisterPush(),
}));
jest.mock("../notifications/notification-pairing-repository", () => ({}));

beforeEach(() => {
jest.clearAllMocks();
mockHealth.mockResolvedValue({ pid: 42, version: "test" });
});

async function inspectCode(url = "https://server.test") {
await fireEvent.changeText(
screen.getByPlaceholderText("Pairing code"),
JSON.stringify({ urls: [url], username: "opencode", password: "test-password" }),
);
await fireEvent.press(screen.getByText("CHECK CODE"));
}

test("pairs directly without push permission or a notification broker", async () => {
const done = jest.fn();
await render(<NotificationPairingScreen onDone={done} />);
await inspectCode();
await act(async () => {
fireEvent.press(screen.getByText("PAIR WITHOUT NOTIFICATIONS"));
});
expect(mockRegisterPush).not.toHaveBeenCalled();
expect(mockCreateClient).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: "https://server.test",
authorization: expect.stringMatching(/^Basic /),
}),
);
expect(mockSave).toHaveBeenCalledWith(
expect.objectContaining({
credential: {
mode: "basic",
username: "opencode",
password: "test-password",
schemaVersion: 1,
},
draft: expect.objectContaining({
baseUrl: "https://server.test",
allowDevelopmentHttp: false,
}),
}),
);
expect(done).toHaveBeenCalledTimes(1);
});

test("automatically pairs after notification registration fails", async () => {
await render(<NotificationPairingScreen onDone={jest.fn()} />);
await inspectCode();
await act(async () => {
fireEvent.press(screen.getByText("PAIR AND SAVE"));
});
expect(mockSave).toHaveBeenCalledTimes(1);
});

test("automatically pairs when the broker is unreachable", async () => {
mockRegisterPush.mockResolvedValueOnce({
deviceName: "Phone",
expoPushToken: "test-token",
platform: "ios",
});
const fetch = jest
.spyOn(globalThis, "fetch")
.mockRejectedValueOnce(new TypeError("Network request failed"));
const done = jest.fn();
try {
await render(<NotificationPairingScreen onDone={done} />);
await inspectCode("http://server.test:4096");
await act(async () => {
fireEvent.press(screen.getByText("APPROVE HTTP + PAIR"));
});
expect(fetch).toHaveBeenCalledWith(
"http://server.test:37100/v1/pair/opencode",
expect.anything(),
);
expect(mockSave).toHaveBeenCalledTimes(1);
expect(done).toHaveBeenCalledTimes(1);
} finally {
fetch.mockRestore();
}
});

test("requires explicit HTTP approval and does not save a failed connection", async () => {
mockHealth.mockRejectedValueOnce(new Error("unreachable"));
const done = jest.fn();
await render(<NotificationPairingScreen onDone={done} />);
await inspectCode("http://server.test:4096");
expect(screen.queryByText("PAIR WITHOUT NOTIFICATIONS")).toBeNull();
await act(async () => {
fireEvent.press(screen.getByText("APPROVE HTTP + PAIR WITHOUT NOTIFICATIONS"));
});
expect(mockSave).not.toHaveBeenCalled();
expect(done).not.toHaveBeenCalled();
expect(
screen.getByText("The paired OpenCode address or credentials could not be validated."),
).toBeTruthy();
});
85 changes: 83 additions & 2 deletions apps/mobile/src/screens/notification-pairing-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { StatusBar } from "expo-status-bar";
import { useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
Expand Down Expand Up @@ -91,6 +92,61 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) {
setScanning(true);
}

async function pairWithoutNotifications() {
if (preview?.kind !== "opencode" || busy) return;
await connectDirectly(preview.prepared);
}

async function connectDirectly(
prepared: ReturnType<typeof prepareOpenCodeDevicePairing>,
notificationFallback = false,
) {
setBusy(true);
setError(undefined);
let stage: PairingStage = "opencode-validation";
try {
const credential: ConnectionCredential = {
mode: "basic",
password: prepared.code.password,
schemaVersion: 1,
username: prepared.code.username,
};
const baseUrl = normalizeOpenCodeBaseUrl(prepared.openCodeOrigin);
const client = createOpenCodeClient({
authorization: connectionAuthorizationHeader(credential),
baseUrl,
fetch: boundedOpenCodeFetch,
});
const [health] = await Promise.all([
client.health.get(),
client.server.get(),
client.session.list({ limit: 1, order: "desc" }),
]);
stage = "connection-save";
await connections.save({
credential,
draft: {
allowDevelopmentHttp: prepared.allowDevelopmentHttp,
authMode: "basic",
baseUrl,
name: prepared.name,
},
health: { checkedAtMs: Date.now(), pid: health.pid, version: health.version },
});
if (notificationFallback) {
Alert.alert(
"Server connected",
"Notification setup was unavailable. Connected without notifications.",
);
}
onDone();
} catch (caught) {
setError(pairingErrorMessage(caught, stage));
} finally {
setBusy(false);
}
}

async function pair() {
if (!preview || busy) return;
setBusy(true);
Expand Down Expand Up @@ -190,6 +246,17 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) {
connectionRollbackFailed = true;
}
}
if (
preview.kind === "opencode" &&
!savedConnectionId &&
(stage === "push-registration" ||
stage === "broker-issue" ||
stage === "secure-storage" ||
stage === "broker-registration")
) {
await connectDirectly(preview.prepared, true);
return;
}
setError(
connectionRollbackFailed
? "Pairing failed after saving the connection. Remove it from Connections before retrying."
Expand Down Expand Up @@ -220,8 +287,8 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) {
</Pressable>
</View>
<Text style={styles.copy}>
The code configures one OpenCode server and its notification broker. Credentials are
decrypted on this phone and stored in the device keychain.
Scan an OpenCode /pair code to connect. Notifications are set up when available;
otherwise the app connects without them. Credentials are stored in the device keychain.
</Text>

{scanning ? (
Expand Down Expand Up @@ -305,6 +372,20 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) {
</Text>
)}
</Pressable>
{preview.kind === "opencode" ? (
<Pressable
accessibilityRole="button"
disabled={busy}
onPress={() => void pairWithoutNotifications()}
style={({ pressed }) => [styles.inspectButton, pressed && styles.pressed]}
>
<Text style={styles.inspectLabel}>
{preview.prepared.allowDevelopmentHttp
? "APPROVE HTTP + PAIR WITHOUT NOTIFICATIONS"
: "PAIR WITHOUT NOTIFICATIONS"}
</Text>
</Pressable>
) : null}
</View>
) : null}
{error ? (
Expand Down