Mobile et WebView

Faire tourner Saphere Scan dans une coquille React Native ou Ionic/Capacitor, où l’autorisation de la caméra appartient à l’application native et non à la page.

Saphere Scan est un widget web. Dans une application mobile, il tourne dans une WebView. Celle-ci a besoin de trois choses qu’un navigateur fournit de lui-même : une autorisation caméra accordée par l’application, une page à charger, et un moyen de reparler à votre code natif.

Accordez la caméra avant de charger la WebView

Dans une WebView, getUserMedia ne sollicite pas l’utilisateur de lui-même. Si l’application native n’a pas déjà obtenu l’accès à la caméra, le widget reçoit un NotAllowedError et affiche son écran de refus — sans que l’utilisateur puisse s’en sortir depuis la page. Demandez l’autorisation nativement d’abord, et ne montez la WebView qu’ensuite.

Le mode temps réel dans une coquille

Le mode temps réel repose sur WebTransport, que toutes les WebView ne fournissent pas — celles bâties sur WebKit, c’est-à-dire toutes les WebView d’iOS, n’interopèrent pas avec notre serveur aujourd’hui. Le widget s’en aperçoit et enchaîne sur la mesure certifiée en rapportant realtime:unavailable, si bien que l’intégration fonctionne tout de même ; n’échafaudez pas un écran à vous en supposant que l’écran temps réel sera montré.

La page que vous livrez

La même page convient aux deux coquilles. Gardez-la dans le paquet de l’application, pour que le widget se monte sans un aller-retour réseau vers votre propre serveur.

Livrez les deux builds de votre application contre les deux CDN — l’import ci-dessous est celui de la Production, et votre build de Test n’en diffère que par cette ligne :

EnvironnementURL du module
Productionhttps://cdn.saphere.ai/saphere-scan/v2/main.js
Testhttps://cdn.test.saphere.ai/saphere-scan/v2/main.js

Lisez-le depuis votre configuration de build plutôt que de l’écrire en dur, exactement comme pour le point d’accès à jetons plus bas. Environnements couvre ce que ce choix emporte.

<!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 { 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: "fr",
            proxy: {
                retrieveAccessToken: {
                    strategy: "delegate",
                    url: "https://votre-backend.exemple/api/saphere-token"
                }
            },
            onEvent: event => {
                // Le pont est unidirectionnel : la page parle au natif, jamais l'inverse.
                window.ReactNativeWebView?.postMessage(JSON.stringify(event))
            }
        })

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

La ligne viewport compte davantage ici que sur le web. Sans user-scalable=0, un pincement pendant la capture redimensionne la mise en page — et un redimensionnement en cours de mesure est exactement le genre de perturbation que le widget cherche à éviter.

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
            // sans ces deux-là, la caméra ne démarre pas : iOS ouvrirait le flux en plein écran
            // et attendrait un geste de l'utilisateur
            allowsInlineMediaPlayback
            mediaPlaybackRequiresUserAction={false}
            textZoom={100}
            onMessage={({ nativeEvent }) => onEvent(JSON.parse(nativeEvent.data))}
        />
    )
}

Demandez l’autorisation avant de rendre ce composant :

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 demande aussi la description d’usage dans Info.plist, faute de quoi l’application est refusée à la revue et la demande n’apparaît jamais :

<key>NSCameraUsageDescription</key>
<string>Utilisée pour mesurer vos signes vitaux à partir d'une courte vidéo.</string>

Ionic / Capacitor

Le widget se monte dans votre composant Angular, React ou Vue comme n’importe quel autre élément. Le conteneur doit avoir une hauteur réelle — dans une WebView Capacitor, 100dvh donne bien tout l’écran.

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

// une constante typée comme chaîne : écrite en littéral dans `import()`, l'URL ferait chercher
// à TypeScript et au bundler un paquet de ce nom
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 // écran quitté avant l'arrivée du module : monter maintenant allumerait la caméra pour personne
        const instance = SaphereScan.create(this.host().nativeElement, { /* options */ })
        this.instance = instance
        await instance.bootstrap()
    }

    // le widget tient la caméra : ne pas le détruire la laisserait allumée après la sortie d'écran
    ngOnDestroy() {
        this.destroyed = true
        this.instance?.destroy()
    }
}

iOS demande le même NSCameraUsageDescription que dans la section React Native ci-dessus, et Android l’autorisation déclarée dans AndroidManifest.xml :

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

Toujours détruire au démontage

destroy() est idempotent : l’appeler deux fois est sans effet. Ne pas l’appeler du tout laisse en revanche la caméra ouverte. Sur mobile, cela se voit : le témoin de caméra reste allumé après que l’utilisateur a quitté l’écran.

Lire les événements côté natif

Chacun des événements décrits dans Événements traverse le pont en JSON. Deux méritent d’être traités nativement plutôt que dans la page :

  • measure:result — la charge utile pour laquelle vous êtes venu. Conservez-la côté natif : une WebView peut être écartée à tout moment par le système.
  • camera:error avec code: "NOT_ALLOWED" — l’autorisation a été refusée ou retirée. La page ne peut pas la redemander ; seul votre code natif peut envoyer l’utilisateur dans les réglages du système.