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, et celle-ci a besoin de trois choses que le navigateur lui donne gratuitement : une autorisation caméra accordée nativement, une page à charger, et un moyen de parler à 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.

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 { 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()
    }

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

Android demande 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, en revanche, laisse le flux caméra ouvert — ce qui se voit, sur mobile, au témoin de caméra qui reste allumé après que l’utilisateur a quitté l’écran, et que les utilisateurs signalent comme une atteinte à leur vie privée.

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.