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, reportingrealtime: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:
| Environment | Module URL |
|---|---|
| Production | https://cdn.saphere.ai/saphere-scan/v2/main.js |
| Test | https://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:errorwithcode: "NOT_ALLOWED"— the permission was refused or revoked. The page cannot re-prompt; only your native code can send the user to system settings.