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, and the WebView needs three things the browser gives it for free: a camera permission granted natively, 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 native app has not already been granted camera access, the widget receives a NotAllowedError and shows its refusal screen — with no way for the user to recover from inside the page. Request the permission natively first, and only then mount the WebView.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 { Component, ElementRef, OnDestroy, viewChild } from "@angular/core"
@Component({ selector: "app-scan", template: `<div #host style="height: 100dvh"></div>` })
export class ScanComponent implements OnDestroy {
private readonly host = viewChild.required<ElementRef<HTMLDivElement>>("host")
private instance?: { destroy(): void }
async ngAfterViewInit() {
const { default: SaphereScan } = await import("https://cdn.saphere.ai/saphere-scan/v2/main.js")
this.instance = SaphereScan.create(this.host().nativeElement, { /* options */ })
await this.instance.bootstrap()
}
// the widget holds the camera: not destroying it would leave it lit after leaving the screen
ngOnDestroy() {
this.instance?.destroy()
}
}
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.