Mobile & WebView

Running Saphere Scan inside a React Native or Ionic/Capacitor shell, where the camera permission belongs to the native app rather than to the page.

Saphere Scan is a web widget. Inside a mobile app it runs in a WebView. That WebView needs three things a browser provides on its own: a camera permission granted by the app itself, a page to load, and a way to talk back to your native code.

Grant the camera before the WebView loads

getUserMedia inside a WebView does not prompt the user on its own. If the app itself has not been granted camera access, the widget receives a NotAllowedError and shows its refusal screen. From inside the page, the user has no way to fix it. Request the permission natively first, and only then mount the WebView.

Real-time mode inside a shell

Real-time mode rides on WebTransport, which not every WebView provides — WebKit-based ones, which is to say every WebView on iOS, do not interoperate with our server today. The widget detects it and continues with the certified measurement, reporting realtime:unavailable, so the integration still works; do not build a screen of your own around the real-time one being shown.

The page you ship

The same page works in both shells. Keep it local to the app bundle so the widget still mounts without a network round-trip to your own server.

Ship the two builds of your app against the two CDNs — the import below is the Production one, and your Test build differs by that line alone:

EnvironmentModule URL
Productionhttps://cdn.saphere.ai/saphere-scan/v2/main.js
Testhttps://cdn.test.saphere.ai/saphere-scan/v2/main.js

Read it from your build configuration rather than hardcoding it, exactly as you do for the token endpoint further down. Environments covers what the choice carries.

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=0">
    <style>
        html, body { margin: 0; padding: 0; overflow: hidden; }
        #scan { height: 100dvh; }
    </style>
</head>
<body>
    <div id="scan"></div>
    <script type="module">
        import SaphereScan from "https://cdn.saphere.ai/saphere-scan/v2/main.js"

        const instance = SaphereScan.create("#scan", {
            lang: "en",
            proxy: {
                retrieveAccessToken: {
                    strategy: "delegate",
                    url: "https://your-backend.example/api/saphere-token"
                }
            },
            onEvent: event => {
                // The bridge is one-way: the page speaks to native code, never the reverse.
                window.ReactNativeWebView?.postMessage(JSON.stringify(event))
            }
        })

        await instance.bootstrap()
    </script>
</body>
</html>

The viewport line matters more here than on the web. Without user-scalable=0, a pinch during the capture resizes the layout, and a resize mid-measurement is exactly the kind of disturbance the widget is trying to avoid.

React Native

import { WebView } from "react-native-webview"
import { Platform } from "react-native"

const uri = Platform.OS === "android"
    ? "file:///android_asset/scan.html"
    : "./assets/scan.html"

export function ScanScreen({ onEvent }: { onEvent: (event: unknown) => void }) {
    return (
        <WebView
            source={{ uri }}
            originWhitelist={["*"]}
            javaScriptEnabled
            allowFileAccess
            allowUniversalAccessFromFileURLs
            // without these two the camera never starts: iOS would open the stream full-screen
            // and wait for a user gesture
            allowsInlineMediaPlayback
            mediaPlaybackRequiresUserAction={false}
            textZoom={100}
            onMessage={({ nativeEvent }) => onEvent(JSON.parse(nativeEvent.data))}
        />
    )
}

Request the permission before rendering that component:

import { check, request, PERMISSIONS, RESULTS } from "react-native-permissions"
import { Platform } from "react-native"

const CAMERA = Platform.OS === "android" ? PERMISSIONS.ANDROID.CAMERA : PERMISSIONS.IOS.CAMERA

export async function ensureCamera(): Promise<boolean> {
    const status = await check(CAMERA)
    if (status === RESULTS.GRANTED)
        return true
    return (await request(CAMERA)) === RESULTS.GRANTED
}

iOS also needs the usage description in Info.plist, or the app is rejected at review and the prompt never appears:

<key>NSCameraUsageDescription</key>
<string>Used to measure your vital signs from a short video.</string>

Ionic / Capacitor

The widget mounts inside your Angular, React or Vue component like any other element. The container must have a real height — a Capacitor WebView gives 100dvh the full screen.

import { AfterViewInit, Component, ElementRef, OnDestroy, viewChild } from "@angular/core"

// a constant typed as a string: written as a literal in `import()`, the URL would send
// TypeScript and the bundler looking for a package of that name
const SAPHERE_SCAN: string = "https://cdn.saphere.ai/saphere-scan/v2/main.js"

@Component({ selector: "app-scan", template: `<div #host style="height: 100dvh"></div>` })
export class ScanComponent implements AfterViewInit, OnDestroy {
    private readonly host = viewChild.required<ElementRef<HTMLDivElement>>("host")
    private instance?: { destroy(): void }
    private destroyed = false

    async ngAfterViewInit() {
        const { default: SaphereScan } = await import(/* @vite-ignore */ SAPHERE_SCAN)
        if (this.destroyed)
            return // left before the module arrived: mounting now would light the camera for nobody
        const instance = SaphereScan.create(this.host().nativeElement, { /* options */ })
        this.instance = instance
        await instance.bootstrap()
    }

    // the widget holds the camera: not destroying it would leave it lit after leaving the screen
    ngOnDestroy() {
        this.destroyed = true
        this.instance?.destroy()
    }
}

iOS needs the same NSCameraUsageDescription as in the React Native section above, and Android needs the permission declared in AndroidManifest.xml:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />

Always destroy on unmount

destroy() is idempotent, so calling it twice is harmless — but not calling it at all leaves the camera stream open. In a mobile shell that shows as the camera indicator staying lit after the user has navigated away, which users report as a privacy bug.

Reading events natively

Every event described in Events crosses the bridge as JSON. Two are worth handling natively rather than in the page:

  • measure:result — the payload you came for. Persist it natively; a WebView can be discarded at any time by the OS.
  • camera:error with code: "NOT_ALLOWED" — the permission was refused or revoked. The page cannot re-prompt; only your native code can send the user to system settings.