/* This file is part of GNU Taler (C) 2022 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ import { assertUnreachable, parseTalerUri, TalerUri, TalerUriAction, TranslatedString, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { css } from "@linaria/core"; import { styled } from "@linaria/react"; import jsQR, * as pr from "jsqr"; import { h, VNode } from "preact"; import { useRef, useState } from "preact/hooks"; import { EnabledBySettings } from "../components/EnabledBySettings.js"; import { Alert } from "../mui/Alert.js"; import { Button } from "../mui/Button.js"; import { Grid } from "../mui/Grid.js"; import { InputFile } from "../mui/InputFile.js"; import { TextField } from "../mui/TextField.js"; const QrCanvas = css` width: 80%; margin-left: auto; margin-right: auto; padding: 8px; background-color: black; `; const LINE_COLOR = "#FF3B58"; const Container = styled.div` display: flex; flex-direction: column; & > * { margin-bottom: 20px; } `; export interface Props { onDetected: (url: TalerUri) => void; } type XY = { x: number; y: number }; function drawLine( canvas: CanvasRenderingContext2D, begin: XY, end: XY, color: string, ) { canvas.beginPath(); canvas.moveTo(begin.x, begin.y); canvas.lineTo(end.x, end.y); canvas.lineWidth = 4; canvas.strokeStyle = color; canvas.stroke(); } function drawBox(context: CanvasRenderingContext2D, code: pr.QRCode) { drawLine( context, code.location.topLeftCorner, code.location.topRightCorner, LINE_COLOR, ); drawLine( context, code.location.topRightCorner, code.location.bottomRightCorner, LINE_COLOR, ); drawLine( context, code.location.bottomRightCorner, code.location.bottomLeftCorner, LINE_COLOR, ); drawLine( context, code.location.bottomLeftCorner, code.location.topLeftCorner, LINE_COLOR, ); } const SCAN_PER_SECONDS = 3; const TIME_BETWEEN_FRAMES = 1000 / SCAN_PER_SECONDS; async function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function drawIntoCanvasAndGetQR( tag: HTMLVideoElement | HTMLImageElement, canvas: HTMLCanvasElement, ): string | undefined { const context = canvas.getContext("2d"); if (!context) { throw Error("no 2d canvas context"); } context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(tag, 0, 0, canvas.width, canvas.height); const imgData = context.getImageData(0, 0, canvas.width, canvas.height); const code = jsQR.default(imgData.data, canvas.width, canvas.height, { inversionAttempts: "attemptBoth", }); if (code) { drawBox(context, code); return code.data; } return undefined; } async function readNextFrame( video: HTMLVideoElement, canvas: HTMLCanvasElement, ): Promise { const requestFrame = "requestVideoFrameCallback" in video ? video.requestVideoFrameCallback.bind(video) : requestAnimationFrame; return new Promise((ok, bad) => { requestFrame(() => { try { const code = drawIntoCanvasAndGetQR(video, canvas); ok(code); } catch (error) { bad(error); } }); }); } async function createCanvasFromVideo( video: HTMLVideoElement, canvas: HTMLCanvasElement, ): Promise { const context = canvas.getContext("2d", { willReadFrequently: true, }); if (!context) { throw Error("no 2d canvas context"); } canvas.width = video.videoWidth; canvas.height = video.videoHeight; let last = Date.now(); let found: string | undefined = undefined; while (!found) { const timeSinceLast = Date.now() - last; if (timeSinceLast < TIME_BETWEEN_FRAMES) { await delay(TIME_BETWEEN_FRAMES - timeSinceLast); } last = Date.now(); found = await readNextFrame(video, canvas); } video.pause(); return found; } async function createCanvasFromFile( source: string, canvas: HTMLCanvasElement, ): Promise { const img = new Image(300, 300); img.src = source; canvas.width = img.width; canvas.height = img.height; return new Promise((ok, bad) => { img.addEventListener("load", () => { try { const code = drawIntoCanvasAndGetQR(img, canvas); ok(code); } catch (error) { bad(error); } }); }); } async function waitUntilReady(video: HTMLVideoElement): Promise { return new Promise((ok, _bad) => { if (video.readyState === video.HAVE_ENOUGH_DATA) { return ok(); } setTimeout(waitUntilReady, 100); }); } export function QrReaderPage({ onDetected }: Props): VNode { const videoRef = useRef(null); const canvasRef = useRef(null); const [error, setError] = useState(); const [value, setValue] = useState(""); const [show, setShow] = useState<"canvas" | "video" | "nothing">("nothing"); const { i18n } = useTranslationContext(); function onChangeDetect(str: string) { if (str) { const uri = parseTalerUri(str); if (!uri) { setError( i18n.str`URI is not valid. Taler URI should start with "taler://"`, ); } else { onDetected(uri); setError(undefined); } } else { setError(undefined); } setValue(str); } function onChange(str: string) { if (str) { if (!parseTalerUri(str)) { setError( i18n.str`URI is not valid. Taler URI should start with "taler://"`, ); } else { setError(undefined); } } else { setError(undefined); } setValue(str); } async function startVideo() { if (!videoRef.current || !canvasRef.current) { return; } const video = videoRef.current; if (!video || !video.played) return; const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, audio: false, }); setShow("video"); setError(undefined); video.srcObject = stream; await video.play(); await waitUntilReady(video); try { const code = await createCanvasFromVideo(video, canvasRef.current); if (code) { onChangeDetect(code); setShow("canvas"); } stream.getTracks().forEach((e) => { e.stop(); }); } catch (error) { setError(i18n.str`something unexpected happen: ${error}`); } } async function onFileRead(fileContent: string) { if (!canvasRef.current) { return; } setShow("nothing"); setError(undefined); try { const code = await createCanvasFromFile(fileContent, canvasRef.current); if (code) { onChangeDetect(code); setShow("canvas"); } else { setError(i18n.str`Could not found a QR code in the file`); } } catch (error) { setError(i18n.str`something unexpected happen: ${error}`); } } const uri = parseTalerUri(value); return (

Scan a QR code or enter taler:// URI below

{uri && ( )}

{error && {error}}

Read QR from file
); }