diff --git a/apps/mobile/src/screens/connection-screen.tsx b/apps/mobile/src/screens/connection-screen.tsx
index 923914e..2f36adb 100644
--- a/apps/mobile/src/screens/connection-screen.tsx
+++ b/apps/mobile/src/screens/connection-screen.tsx
@@ -506,7 +506,7 @@ export function ConnectionScreen({ onDone, onPair }: { onDone?: () => void; onPa
pressed && styles.secondaryButtonPressed,
]}
>
- PAIR SERVER + NOTIFICATIONS
+ PAIR SERVER
) : null}
diff --git a/apps/mobile/src/screens/notification-pairing-screen.test.tsx b/apps/mobile/src/screens/notification-pairing-screen.test.tsx
new file mode 100644
index 0000000..a001b6f
--- /dev/null
+++ b/apps/mobile/src/screens/notification-pairing-screen.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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();
+ 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();
+});
diff --git a/apps/mobile/src/screens/notification-pairing-screen.tsx b/apps/mobile/src/screens/notification-pairing-screen.tsx
index a1a4a47..4f5241c 100644
--- a/apps/mobile/src/screens/notification-pairing-screen.tsx
+++ b/apps/mobile/src/screens/notification-pairing-screen.tsx
@@ -9,6 +9,7 @@ import { StatusBar } from "expo-status-bar";
import { useRef, useState } from "react";
import {
ActivityIndicator,
+ Alert,
KeyboardAvoidingView,
Platform,
Pressable,
@@ -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,
+ 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);
@@ -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."
@@ -220,8 +287,8 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) {
- 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.
{scanning ? (
@@ -305,6 +372,20 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) {
)}
+ {preview.kind === "opencode" ? (
+ void pairWithoutNotifications()}
+ style={({ pressed }) => [styles.inspectButton, pressed && styles.pressed]}
+ >
+
+ {preview.prepared.allowDevelopmentHttp
+ ? "APPROVE HTTP + PAIR WITHOUT NOTIFICATIONS"
+ : "PAIR WITHOUT NOTIFICATIONS"}
+
+
+ ) : null}
) : null}
{error ? (