# Mobile apps

Build an iOS and Android client for a DeepSpace app with Expo and deepspace/native.

A DeepSpace app can have a native mobile client built with [Expo](https://expo.dev) (React Native). The client talks to the same Worker as the website, so it reads the same records under the same permissions, calls the same integrations and server actions, and signs in to the same accounts. Mobile support requires `deepspace` 0.39 or later and Expo SDK 57; use 0.39.2 or later so that signing out also ends the session on the server.

## Add a mobile client

From the app directory:

```bash
npx deepspace add mobile
```

This writes an Expo project to `mobile/`. Its `package.json` pins the same `deepspace` version as the app, and `mobile/app.config.ts` reads the app id and `app.space` name from `wrangler.toml`, so the client always points at the app it belongs to. Nothing in the website changes; the linter is told to skip `mobile/`.

The client signs in through a custom URL scheme: the app name with the dashes removed (`my-app` becomes `myapp`). Allowlist its callback in `wrangler.toml` and deploy the Worker:

```toml
[vars]
NATIVE_AUTH_REDIRECT_URIS = "myapp://auth/callback"
```

Until the callback is listed, `mobile/app.config.ts` stops the build and prints the exact line to add. List several callbacks separated by commas; the Worker accepts only exact matches.

Then run the app in the iOS Simulator or an Android emulator:

```bash
cd mobile
npm install
npx expo run:ios        # or: npx expo run:android
```

Use a development build (`expo run:ios`, `expo run:android`, or an EAS development build). Expo Go cannot receive the custom-scheme sign-in callback.

## Sign-in

Create one client at module scope and render one `DeepSpaceNativeProvider` at the root. `useDeepSpace` returns the auth state with `signIn` and `signOut`:

```tsx
import { createDeepSpaceExpoClient, DeepSpaceNativeProvider, useDeepSpace } from 'deepspace/native'

const deepSpace = createDeepSpaceExpoClient({ baseUrl: 'https://my-app.app.space' })

export default function App() {
  return (
    <DeepSpaceNativeProvider client={deepSpace}>
      <Root />
    </DeepSpaceNativeProvider>
  )
}

function Root() {
  const { isLoaded, isSignedIn, signIn } = useDeepSpace()
  if (!isLoaded) return null
  if (!isSignedIn) return <SignInButton onPress={() => signIn('google')} />
  return <Home />
}
```

`signIn` opens the provider's sign-in page in the system browser. The Worker returns a one-time code to the app's callback, and the app redeems it with a PKCE verifier that it sends straight to the Worker, never through the browser or the callback URL, so another app that intercepts the callback cannot use the code. The long-lived session is stored in the device keychain (Expo SecureStore); requests carry a short-lived token that the client refreshes shortly before it expires. A network failure during refresh keeps the session; only a definitive rejection signs the person out. `signOut` revokes the session on the server and clears it from the device.

If the person closes the sign-in sheet, `signIn` throws an error whose `code` is `sign_in_cancelled`. Treat it as a cancellation, not a failure, and check `code` rather than `instanceof`:

```ts
try {
  await signIn('google')
} catch (error) {
  if ((error as { code?: string }).code !== 'sign_in_cancelled') throw error
}
```

## Records

Records work as they do on the web. Wrap signed-in screens in `RecordProvider` and `RecordScope`, then use `useQuery` and `useMutations`. Import the schemas from the app's `src/` folder so the client and the Worker share one definition:

```tsx
import { RecordProvider, RecordScope, useMutations, useQuery } from 'deepspace/native'
import { schemas } from '../src/schemas'

function Home() {
  return (
    <RecordProvider>
      <RecordScope roomId={`app:${APP_ID}`} schemas={schemas}>
        <Items />
      </RecordScope>
    </RecordProvider>
  )
}
```

`RecordScope` connects to the client's `baseUrl` and reconnects when the app returns to the foreground. Phones lose connectivity more often than browsers: when a write must not be lost, use the confirmed mutations (`createConfirmed`, `putConfirmed`, `removeConfirmed`), which reject if the connection drops before the Worker accepts the write. See [data storage](/guides/data-storage) for queries, permissions, and write errors.

The user, presence, and messaging hooks are available too: `useUser`, `useUsers`, `useUserLookup`, `usePresence`, `useMessages`, `useChannels`, `useReactions`, `useChannelMembers`, and `useReadReceipts`.

## Integrations and server actions

`integration` calls the app's integration proxy exactly as on the web, and `callAction` posts to a [server action](/guides/server-actions):

```ts
import { callAction, integration } from 'deepspace/native'

const places = await integration.post('openweathermap/geocoding', { query: 'Brooklyn' })
const result = await callAction('archiveProject', { projectId })
if (!result.success) showError(result.error)
```

Per-user integrations such as Google answer with a consent URL until the person grants access (see [Google OAuth](/guides/google-oauth)). `consentUrlOf` reads that URL from a result, and `openIntegrationConsent` shows it in the system browser and resolves when the browser closes. The platform stores the grant, so retry the call afterwards:

```ts
import { consentUrlOf, integration, openIntegrationConsent } from 'deepspace/native'

let result = await integration.post('google/calendar-list-events', { maxResults: 10 })
const consentUrl = consentUrlOf(result)
if (consentUrl) {
  await openIntegrationConsent(consentUrl)
  result = await integration.post('google/calendar-list-events', { maxResults: 10 })
}
```

## What the mobile entry includes

`deepspace/native` exports the Expo client (`createDeepSpaceExpoClient` and its errors), `DeepSpaceNativeProvider`, `useDeepSpace`, `useAuth`, the record, user, presence, and messaging hooks above, `integration`, `callAction`, `consentUrlOf`, and `openIntegrationConsent`. Import everything from `deepspace/native` so the app holds one copy of the client.

The web-only surfaces are not part of it: `AuthOverlay`, `AuthGate`, theming, file uploads with `useR2Files`, and the canvas, cron, job, and collaborative-editing hooks.

## Build for devices

Device and store builds go through [EAS Build](https://docs.expo.dev/build/introduction/) and need an iOS bundle identifier and Android package registered to your developer accounts. `npx deepspace add mobile` fills in placeholders based on the app name; replace them in `mobile/app.config.ts` before the first device build. No native project is committed: `expo run:ios`, `expo run:android`, and EAS generate it.
