WebView integration

Running the v1 module inside a React Native or Ionic shell: the page, the bridge, and the permissions.

Legacy module — no longer recommended

This page documents the v1 widget, kept for integrations already in production. New integrations should use Saphere Scan v2.

New integrations should use Saphere Scan v2. What follows documents the v1 module inside a native shell.

The v1 module is a web application. In a mobile app it runs inside a WebView, on a small HTML page you ship with the bundle, and talks to the native side through whatever bridge the framework provides.

Three things decide whether it works at all:

  1. The camera permission must be granted natively, before the WebView loads. The web layer cannot request it for you.
  2. The WebView must allow inline media playback without a user gesture. Otherwise the camera stream never starts.
  3. The page must be able to load the module from the CDN, which means network access and, on Android, the right file-access flags.

React Native

The page you ship

Place an integration.html in the platform asset directories — android/app/src/main/assets/ and the iOS bundle.

Both shells on this page import from Production. A Test build of your app points at the other CDN, and at a token endpoint calling the matching API:

EnvironmentCDN base
Productionhttps://cdn.saphere.ai
Testhttps://cdn.test.saphere.ai

See Environments — the two are separate accounts, so the keys do not carry over.

<!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 { padding: 0; margin: 0; overflow: hidden; }
        #container { height: 100vh; }
    </style>
</head>
<body>
    <div id="container"></div>

    <script type="module">
        import { Handler } from "https://cdn.saphere.ai/saphere-scan/v1/mjs/main.min.js"

        const { ReactNativeWebView } = window

        const options = {
            createMeasure: {
                // Points at YOUR server, which holds the API key.
                strategy: "delegate",
                url: "https://your-backend.example/api/saphere-measure"
            },
            // Everything the widget reports is forwarded to the native side.
            onEvent: event => ReactNativeWebView.postMessage(JSON.stringify(event))
        }

        Handler.load("#container", options)
    </script>
</body>
</html>

Do not embed the API key here

This file ships inside your app bundle. Anything in it can be read by unpacking the APK or the IPA — including an Authorization header written into createMeasure.headers.

Point url at your own backend and let it hold the key.

The native screen

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

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

export const ScanScreen = () => (
    <WebView
        source={{ uri }}
        // Without these two the camera stream never starts on iOS.
        allowsInlineMediaPlayback
        mediaPlaybackRequiresUserAction={false}
        javaScriptEnabled
        // Needed to load the page from the asset directory on Android.
        allowFileAccess
        allowUniversalAccessFromFileURLs
        originWhitelist={["*"]}
        textZoom={100}
        startInLoadingState
        onMessage={({ nativeEvent: { data } }) => {
            const event = JSON.parse(data)
            if (event.type === "result")
                console.log("variables:", event.variables)
        }}
    />
)
PropWhy it matters
allowsInlineMediaPlaybackiOS plays video full-screen by default, which breaks the layout
mediaPlaybackRequiresUserAction={false}Without it the stream waits for a tap that never comes
allowFileAccess, allowUniversalAccessFromFileURLsAndroid needs both to load from file:///android_asset and reach the network
textZoom={100}Stops the system font-size setting from breaking the layout
onMessageReceives what postMessage sends

The bridge is one-way

ReactNativeWebView.postMessage carries events out of the WebView. There is no v1 mechanism for the native side to drive the widget — no way to cancel a measurement or change options from React Native once it is mounted.

If you need that, unmount the WebView.

Camera permission

Request it before rendering the WebView. A getUserMedia call inside a WebView whose host app lacks the OS permission fails immediately, and the user sees the widget’s “camera refused” screen with no system prompt to explain it.

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

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
}

Manifests

iOSInfo.plist:

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

Write a real sentence. App Store review rejects a placeholder, and the user reads it at the moment they decide.

AndroidAndroidManifest.xml:

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

Ionic / Capacitor

The Capacitor shell is itself a WebView, so the widget mounts directly into the app — no inner page.

Load the classic build from the app template:

<!-- index.html -->
<script src="https://cdn.saphere.ai/saphere-scan/v1/js/main.min.js"></script>

Then mount it from a component:

import { Component } from "react"

declare global {
    interface Window { Handler?: any }
}

export class ScanView extends Component {
    private readonly options = {
        createMeasure: {
            strategy: "delegate",
            url: "https://your-backend.example/api/saphere-measure"
        },
        onEvent: (event: { type: string }) => console.log(event.type, event)
    }

    componentDidMount() {
        // The script tag has run by now; no bridge is needed, the widget is
        // already inside the app's own WebView.
        window.Handler?.load("#container", this.options)
    }

    async componentWillUnmount() {
        await window.Handler?.destroy()
    }

    render() {
        return <div id="container" style={{ height: "100vh" }} />
    }
}

AndroidManifest.xml needs the same two permissions as above, plus the activity configuration that keeps the WebView from being recreated on rotation:

<activity
    android:name=".MainActivity"
    android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" />

A recreated activity restarts the measurement

Without those configChanges, rotating the device destroys and recreates the activity — and with it the WebView. A measurement in progress is lost, and the user starts over.

Checklist

  • Camera permission requested natively, before the WebView appears
  • NSCameraUsageDescription written as a real sentence
  • CAMERA and INTERNET declared on Android
  • Inline media playback allowed, user gesture not required
  • No API key anywhere in the shipped page
  • configChanges set so rotation does not recreate the WebView
  • The page served or loaded from a secure origin