# React Native

`@octetproof/react-native` binds the native iOS and Android SDKs for React Native. Ask whether a device is inside or outside a region, and get back a signed, hardware-attested proof that your backend can verify.

The wrapper pins one exact native SDK version, the same on iOS and Android, so a proof from React Native means the same as a proof from the native SDK. Version 0.1.0 uses native SDK 3.1.0.

## Requirements

| | |
|---|---|
| React Native | 0.71 or later, with the New Architecture or the legacy bridge |
| iOS | 16.0 or later, on a physical device |
| Android | API 30 (Android 11) or later |
| Android ABI | `arm64-v8a` only |

The Android SDK ships `arm64-v8a` only. There is no `x86_64` build, so the standard Android emulator doesn't work: develop on a physical arm64 device. The iOS Simulator can't attest the app either, so use a real iPhone.

## Install

```bash
npm install @octetproof/react-native
```

With Expo, add the config plugin and you're done. In a bare React Native app, make the iOS and Android changes below.

### Expo

Add the plugin to `app.json`, then run `npx expo prebuild`:

```json
{ "expo": { "plugins": ["@octetproof/react-native"] } }
```

The plugin sets the `Info.plist` permission strings, the iOS deployment target, Octet's Maven repository and `minSdkVersion`.

For proofs while the app is in the background, turn on `backgroundLocation`. It adds the always-location key and the `location` background mode:

```json
{ "expo": { "plugins": [["@octetproof/react-native", { "backgroundLocation": true }]] } }
```

The plugin's permission strings are placeholders. Apple's reviewers read them and prefer a specific reason, so write your own. The plugin never replaces a value you have already set:

| Option | Sets |
|---|---|
| `locationWhenInUsePermission` | `NSLocationWhenInUseUsageDescription` |
| `motionPermission` | `NSMotionUsageDescription` |
| `locationAlwaysPermission` | `NSLocationAlwaysAndWhenInUseUsageDescription`, only when `backgroundLocation` is `true` |
| `backgroundLocation` | The always-location key and `UIBackgroundModes` |

### iOS

Set the deployment target to 16.0 first. React Native's default is lower, and CocoaPods then fails with a message that doesn't name the version. In `ios/Podfile`, replace `platform :ios, min_ios_version_supported` with:

```ruby
platform :ios, '16.0'
```

Then install the pods:

```bash
cd ios && pod install
```

`pod install` downloads the native framework and checks its checksum, so the first run needs network access. A checksum mismatch fails the install.

### Android

Make two changes to your app.

1. Set `minSdkVersion` to 30 in `android/build.gradle`.
2. Add Octet's Maven repository to your app. It has to be in your app, because a library can't add repositories for its own dependencies. Without it, the build fails with `Could not find com.octetproof:sdk:<version>`. In the default React Native template, that's `android/build.gradle`:

```groovy
allprojects {
  repositories {
    maven { url 'https://raw.githubusercontent.com/octetproof/octet-sdk-android/mvn-repo' }
  }
}
```

If your app declares repositories with `dependencyResolutionManagement` in `android/settings.gradle`, add it there instead.

### Permissions

Ask for location permission before `Octet.start`. The SDK needs a location permission to start, precise or approximate. With approximate location only, country proofs work, finer levels return `INSUFFICIENT_PRECISION`, and a proof is at most `PLAUSIBLE`. On iOS, the location and motion usage strings are required: without the motion string, iOS ends the app the first time the SDK reads motion data. The Expo plugin sets both strings. For a bare app, see [Prerequisites](/docs/mobile/getting-started/prerequisites/).

## Ask your first question

```ts
import { Octet, OctetRegion } from '@octetproof/react-native';

const sdk = await Octet.start();

const verdict = await sdk.loc.isWithin(OctetRegion.country('US'));

switch (verdict.result) {
  case 'YES':
  case 'NO':
    // A YES or NO always carries a proof.
    sendToVerifier(verdict.proof.proof_bytes_b64);
    break;
  case 'INDETERMINATE':
    // Not a NO: the SDK can't answer right now.
    console.log(verdict.reason, verdict.achievable_level);
    break;
}
```

`Octet.start()` needs no config. It activates by attesting the app instance, as on native.

### Read the result, never its truthiness

`result` is `YES`, `NO` or `INDETERMINATE`. `INDETERMINATE` means the SDK can't answer right now, for example while it warms up or when the precision you asked for isn't available. Never treat it as `NO`. See [Verdicts](/docs/mobile/concepts/verdicts/).

TypeScript won't let you read `verdict.proof` until you narrow on `result`. It can't catch this, though: `result` is always a non-empty string, so `if (verdict.result)` is true for a `NO` too. Always compare:

```ts
if (verdict.result === 'YES') { grantAccess(); }
```

The lint rule `@typescript-eslint/strict-boolean-expressions` catches the mistake.

When `reason` is `INSUFFICIENT_PRECISION`, `achievable_level` names the best level the SDK can prove right now. The SDK never answers a coarser question than the one you asked.

## Regions

Build regions with the factories. The native SDK checks every region, and rejects a malformed one.

```ts
OctetRegion.country('US')
OctetRegion.subdivision('US-CA')
OctetRegion.usState('CA')              // same as subdivision('US-CA')
OctetRegion.city('San Francisco')
OctetRegion.disc({ latitude: 37.422, longitude: -122.084 }, 500)
OctetRegion.ellipse(center, 900, 400, 31.5)
OctetRegion.box3D([37, 38], [-122.5, -122], [0, 500])
OctetRegion.polygonSet(['8a2a1072b59ffff'])
OctetRegion.earth()
```

Pass H3 cells as hex strings. An H3 index is too large for a JavaScript number, so the wrapper rejects numeric cells. See [Regions](/docs/mobile/concepts/regions/).

## Predicate options

Every predicate takes an optional `PredicateOpts` object. It holds the parameters that Swift and Kotlin take as default arguments:

| Field | Meaning |
|---|---|
| `atTime` | The `Date` to answer for. Defaults to now. |
| `sessionNonceB64` | Your per-login challenge, base64, 1 to 512 bytes. See [Session binding](/docs/mobile/concepts/proof-binding/#session-binding-sessionnonce). |
| `decisionRef` | Your decision id, committed into the proof. See [Decision binding](/docs/mobile/concepts/proof-binding/#decision-binding-decisionref). |
| `content` | Content to commit to the proof. See [Content](#content). |

An empty or oversized `sessionNonceB64` returns an `INVALID_SESSION_NONCE` verdict with no proof. It doesn't throw.

## Content

`content` commits your content to the proof by digest. The content itself never enters the proof. Each item has one of:

- `valueB64`: bytes in memory, base64.
- `fileUri`: a file of up to 64 MiB.
- `digest`: a digest from `Octet.hashContent`, for larger files.

The SDK salts content by default. A `valueB64` or `fileUri` item without `saltB64` gets a fresh 32-byte salt, which the SDK writes onto your item object during the call. Read `item.saltB64` afterwards and keep it with the content, because a verifier needs it. Pass `saltB64: ''` to commit without a salt. `Octet.hashContent` returns its salt as `saltB64`.

Caller content must be enabled for your license. See [Content Commitments](/docs/mobile/concepts/content-commitments/).

### Uploading content to your storage

To have the SDK upload committed content to your own storage after the proof is made, set `upload: true` on the item (or `uploadFromUri` and `uploadSaltB64` on a `digest` item). Then register a provider that returns a fresh upload target for each attempt:

```ts
Octet.setContentUploadProvider(async (request) => {
  const url = await myBackend.presignUpload(request.proofId, request.key, request.size);
  return { url, headers: { 'Content-Type': request.contentType } };
});
Octet.setContentUploadListener((status) => console.log(status.key, status.state, status.failure));

const verdict = await sdk.loc.isWithin(OctetRegion.country('US'), {
  content: [{ key: 'photo', contentType: 'image/jpeg', fileUri, upload: true, locator: 'site-42/photo' }],
});
if (verdict.proof) {
  const statuses = await Octet.contentUploads(verdict.proof.id);
}
```

The proof comes back before any upload starts, and an upload never changes it. The upload request carries `saltB64`, so you can store it with the file. Each status is `pending`, `uploading`, `uploaded` or `failed`, and a failure names its reason: `contentChanged`, `contentMissing`, `providerFailed`, `rejected` (with `httpStatus`) or `exhausted`.

**Uploads run in the foreground only.** The provider is JavaScript, so it can answer only while the app's JavaScript runs. When the OS starts your app in the background, no provider is registered, and queued uploads wait until the app runs again, for up to 7 days. Register the provider early, for example when the app starts. A provider that throws, returns no `url`, or doesn't answer within 60 seconds counts as a failed attempt, and the SDK retries.

### Resumable uploads

For large files, return a resumable target. The SDK then sends the content in chunks and continues from the last confirmed chunk after an interruption:

| Target | What your backend supplies |
|---|---|
| `{ kind: 'gcsResumable', url, headers? }` | A Google Cloud Storage resumable session URL it has opened. |
| `{ kind: 'azureBlock', url, headers? }` | The blob's Azure SAS URL. The SDK uploads blocks, then commits the block list. |
| `{ kind: 's3Multipart', uploadId }` | An S3 multipart upload it has started. |

The SDK sends a resumable target's headers with every request. An `s3Multipart` target needs two more callbacks, passed as the second argument. `s3PartTarget` returns the pre-signed URL for one part (`{ url, method?, headers? }`), and `s3Complete` completes the upload with the parts (`partNumber` and `etag`), in order:

```ts
Octet.setContentUploadProvider(
  async (request) => ({ kind: 's3Multipart', uploadId: await myBackend.startMultipart(request) }),
  {
    s3PartTarget: async (request, uploadId, partNumber) =>
      ({ url: await myBackend.presignPart(uploadId, partNumber) }),
    s3Complete: async (request, uploadId, parts) => myBackend.completeMultipart(uploadId, parts),
  },
);
```

Each callback also has 60 seconds. A rejection or a timeout counts as a failed attempt, and a failed `s3Complete` is retried without sending the parts again. Without the S3 callbacks, the SDK refuses an `s3Multipart` target as a failed attempt.

## Errors

`Octet.start` rejects with a `LicenseError` that carries a `code`. Predicates return a verdict for everything the SDK decides, including `INDETERMINATE`. They reject only when the call can't reach the SDK: with `SDK_NOT_RUNNING` before `start` has completed, `INVALID_REGION` for a region the bridge can't decode, and `INVALID_CALLER_CONTENT` for content it can't decode. `Octet.hashContent` rejects with `INVALID_CALLER_CONTENT`.

The codes are `MALFORMED_KEY`, `VERIFICATION_FAILED`, `NO_ACTIVATION`, `EXPIRED`, `ACTIVATION_WINDOW_CLOSED`, `REVOKED`, `NETWORK`, `UPGRADE_REQUIRED`, `SERVER_REJECTED`, `NOT_YET_RELEASED`, `BOOTSTRAP_FAILED`, `NEW_DEVICES_BLOCKED` and `START_FAILED`.

- `UPGRADE_REQUIRED` carries `minVersion`, `SERVER_REJECTED` carries `httpStatus`, and `VERIFICATION_FAILED` carries `verificationReason`.
- `BOOTSTRAP_FAILED` means the attested bootstrap was refused. The message names the reason, for example `APP_NOT_REGISTERED` or `DEVICE_LIMIT_REACHED`. See [Attested bootstrap](/docs/mobile/concepts/attested-bootstrap/).
- `START_FAILED` is a start failure that isn't a license problem. The message says what failed.
- `NEW_DEVICES_BLOCKED` means your account accepts no new devices. Devices that already activated keep working. Send the user to `upgradeUrl`, opened exactly as received: the link is server-signed, so never build one yourself.

See [License Types](/docs/mobile/api-reference/license-types/#licenseerror).

```ts
try {
  await Octet.start();
} catch (e) {
  if (e instanceof LicenseError && e.code === 'UPGRADE_REQUIRED') {
    promptUpgrade(e.minVersion);
  }
}
```

## Proofs

`proof_bytes_b64` is the proof: base64 of the serialized proof, which only a verifier reads. Send it as it is, without parsing or re-encoding it. The other proof fields are there to inspect on the device.

### `proof_bytes_b64` doesn't show up when you log a verdict

You read `proof_bytes_b64` as usual, but it's hidden from anything that walks the object, such as `JSON.stringify(verdict)`, `console.log(verdict)` or `{ ...verdict.proof }`. Most React Native tools capture whole objects (a log line, a crash report breadcrumb, a store saved to `AsyncStorage`), so this keeps the proof out of them by accident.

It is a safeguard, not a guarantee. The proof crosses the bridge as a string, so it sits in JavaScript memory until garbage collection, and a tool that reads `Object.getOwnPropertyNames` still sees the field. If that matters to you, keep the proof off the device, as below.

When you forward a verdict, name the field, because a spread leaves it out:

```ts
await fetch(url, {
  method: 'POST',
  body: JSON.stringify({
    result: verdict.result,
    proof_bytes_b64: verdict.proof.proof_bytes_b64,   // a spread drops it
  }),
});
```

### Keeping proofs off the device

Your backend can fetch the proof itself, so the proof never enters JavaScript. Pass a `decisionRef`, send it to your backend instead of the proof, and have your backend fetch the proof by it:

```ts
const verdict = await sdk.loc.isWithin(OctetRegion.country('US'), { decisionRef });
await fetch('/my-backend/decide', {
  method: 'POST',
  body: JSON.stringify({ decisionRef, result: verdict.result }),   // no proof bytes
});
```

This needs `proofUploadUrl`, because proof upload is off by default. The verdict returns before the upload finishes, so your backend retries until the proof is there. That's fine for an audit trail or a decision that can wait. When a user is waiting on a screen, send the proof bytes instead.

## What the wrapper doesn't include

- `Octet.verify`. Check proofs on your backend with [`octet-verify`](/docs/mobile/getting-started/verifier-quickstart/), or in a native module with [`Octet.verify`](/docs/mobile/api-reference/octet-verify/).
- `acceptPlausibleContainment`. A disc query answers only from a `VERIFIED` proof. See [Predicates](/docs/mobile/api-reference/predicates/#plausible-containment).
- `TransportPolicy`, so the SDK's calls can't go through a gateway. See [Gateway routing](/docs/mobile/concepts/gateway-routing/).
- `AdvancedConfig.foregroundNotification` on Android. See [`AdvancedConfig`](/docs/mobile/api-reference/octet-start/#advancedconfig).
- Log sinks, the region-builder DSL, the canonical region-string helper and the feature-flag getters.

Usage reporting works as on native. See [Data collection](/docs/mobile/concepts/data-collection/#usage-counters).

## Versions

The wrapper has its own version and pins an exact native SDK version, the same on iOS and Android. From 1.0.0 its major version matches the native SDK's. While it's 0.x, the majors are independent. Minor and patch versions always move on their own, so a fix in JavaScript doesn't need a new native release.

| `@octetproof/react-native` | Native SDK |
|---|---|
| 0.1.x | 3.1.0 |

`Octet.sdkVersion` returns the native SDK version at runtime, not the wrapper's. The pinned versions are also in `node_modules/@octetproof/react-native/native-pin.json`.

## Next steps

- [React Native Sample](/docs/mobile/samples/react-native-sample/): a small app that runs a predicate on a device.
- [Verifier Quick Start](/docs/mobile/getting-started/verifier-quickstart/): check proofs on your backend.
- [Content Commitments](/docs/mobile/concepts/content-commitments/): what a commitment proves, and how to verify it.
