Intégration WebView

Faire tourner le module v1 dans une coquille React Native ou Ionic : la page, le pont, et les permissions.

Module hérité — déconseillé

Cette page documente le widget v1, conservé pour les intégrations déjà en production. Une nouvelle intégration doit utiliser Saphere Scan v2.

Une nouvelle intégration devrait utiliser Saphere Scan v2. Ce qui suit documente le module v1 à l’intérieur d’une coquille native.

Le module v1 est une application web. Dans une application mobile, il tourne dans une WebView, sur une petite page HTML que vous livrez avec le paquet, et parle au natif par le pont que fournit le cadriciel.

Trois choses décident s’il fonctionne, tout simplement :

  1. La permission caméra doit être accordée nativement, avant que la WebView ne charge. La couche web ne peut pas la demander à votre place.
  2. La WebView doit autoriser la lecture de média en ligne sans geste de l’utilisateur. Sans quoi le flux de la caméra ne démarre jamais.
  3. La page doit pouvoir charger le module depuis le CDN, ce qui suppose un accès réseau et, sur Android, les bons drapeaux d’accès aux fichiers.

React Native

La page que vous livrez

Placez un integration.html dans les répertoires de ressources de chaque plateforme — android/app/src/main/assets/ et le paquet iOS.

Les deux coquilles de cette page importent depuis la Production. Un build de Test de votre application vise l’autre CDN, et un point d’accès à jetons qui appelle l’api correspondante :

EnvironnementBase du CDN
Productionhttps://cdn.saphere.ai
Testhttps://cdn.test.saphere.ai

Voir Environnements — ce sont deux comptes distincts, donc les clés ne se transposent pas.

<!doctype html>
<html lang="fr">
<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: {
                // Pointe vers VOTRE serveur, qui détient la clé d'API.
                strategy: "delegate",
                url: "https://your-backend.example/api/saphere-measure"
            },
            // Tout ce que le widget rapporte est transmis au natif.
            onEvent: event => ReactNativeWebView.postMessage(JSON.stringify(event))
        }

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

N'incorporez pas la clé d'API ici

Ce fichier est livré dans le paquet de votre application. Tout ce qu’il contient peut être lu en décompressant l’APK ou l’IPA — y compris un en-tête Authorization écrit dans createMeasure.headers.

Pointez url vers votre propre serveur et laissez-le détenir la clé.

L’écran natif

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 }}
        // Sans ces deux-là, le flux de la caméra ne démarre jamais sur iOS.
        allowsInlineMediaPlayback
        mediaPlaybackRequiresUserAction={false}
        javaScriptEnabled
        // Nécessaires pour charger la page depuis le répertoire de ressources sur Android.
        allowFileAccess
        allowUniversalAccessFromFileURLs
        originWhitelist={["*"]}
        textZoom={100}
        startInLoadingState
        onMessage={({ nativeEvent: { data } }) => {
            const event = JSON.parse(data)
            if (event.type === "result")
                console.log("variables:", event.variables)
        }}
    />
)
PropriétéPourquoi elle compte
allowsInlineMediaPlaybackiOS lit la vidéo en plein écran par défaut, ce qui casse la mise en page
mediaPlaybackRequiresUserAction={false}Sans elle, le flux attend une tape qui ne viendra jamais
allowFileAccess, allowUniversalAccessFromFileURLsAndroid a besoin des deux pour charger depuis file:///android_asset et atteindre le réseau
textZoom={100}Empêche le réglage système de taille de police de casser la mise en page
onMessageReçoit ce que postMessage envoie

Le pont est unidirectionnel

ReactNativeWebView.postMessage fait sortir les événements de la WebView. Il n’existe aucun mécanisme v1 permettant au natif de piloter le widget — aucun moyen d’annuler une mesure ou de changer les options depuis React Native une fois qu’il est monté.

S’il vous faut cela, démontez la WebView.

Permission caméra

Demandez-la avant de rendre la WebView. Un appel à getUserMedia dans une WebView dont l’application hôte n’a pas la permission système échoue immédiatement, et l’utilisateur voit l’écran « caméra refusée » du widget sans aucune invite système pour l’expliquer.

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
}

Manifestes

iOSInfo.plist :

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

Écrivez une vraie phrase. La revue de l’App Store rejette un texte de remplissage, et l’utilisateur la lit au moment où il décide.

AndroidAndroidManifest.xml :

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

Ionic / Capacitor

La coquille Capacitor est elle-même une WebView, de sorte que le widget se monte directement dans l’application — sans page interne.

Chargez le build classique depuis le gabarit de l’application :

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

Puis montez-le depuis un composant :

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() {
        // La balise de script s'est déjà exécutée ; aucun pont n'est nécessaire,
        // le widget est déjà dans la WebView de l'application.
        window.Handler?.load("#container", this.options)
    }

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

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

AndroidManifest.xml a besoin des deux mêmes permissions que plus haut, ainsi que de la configuration d’activité qui empêche la WebView d’être recréée à la rotation :

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

Une activité recréée relance la mesure

Sans ces configChanges, tourner l’appareil détruit et recrée l’activité — et la WebView avec elle. Une mesure en cours est perdue, et l’utilisateur recommence.

Aide-mémoire

  • Permission caméra demandée nativement, avant que la WebView n’apparaisse
  • NSCameraUsageDescription rédigée comme une vraie phrase
  • CAMERA et INTERNET déclarées sur Android
  • Lecture de média en ligne autorisée, geste utilisateur non requis
  • Aucune clé d’API où que ce soit dans la page livrée
  • configChanges réglés pour que la rotation ne recrée pas la WebView
  • La page servie ou chargée depuis une origine sécurisée