From 9d9a88af010ac39f026299ebccea3e1164e5242e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 31 Jan 2023 10:21:08 -0300 Subject: fix #7535: fix qr implementation --- packages/taler-wallet-webextension/package.json | 2 +- .../src/wallet/QrReader.tsx | 396 ++++++++++++++++----- 2 files changed, 300 insertions(+), 98 deletions(-) (limited to 'packages/taler-wallet-webextension') diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json index 076e43dc1..226ea757e 100644 --- a/packages/taler-wallet-webextension/package.json +++ b/packages/taler-wallet-webextension/package.json @@ -25,9 +25,9 @@ "@gnu-taler/taler-wallet-core": "workspace:*", "date-fns": "^2.29.2", "history": "4.10.1", + "jsqr": "^1.4.0", "preact": "10.11.3", "preact-router": "3.2.1", - "qr-scanner": "^1.4.1", "qrcode-generator": "^1.4.4", "tslib": "^2.4.0" }, diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx index 467f8bb7c..c1972823a 100644 --- a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx +++ b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx @@ -14,17 +14,25 @@ GNU Taler; see the file COPYING. If not, see */ -import { classifyTalerUri, TalerUriType } from "@gnu-taler/taler-util"; +import { + classifyTalerUri, + TalerUriType, + TranslatedString, +} from "@gnu-taler/taler-util"; import { styled } from "@linaria/react"; +import { css } from "@linaria/core"; import { Fragment, h, VNode } from "preact"; -import { Ref, useEffect, useRef, useState } from "preact/hooks"; -import QrScanner from "qr-scanner"; +import { Ref, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useTranslationContext } from "../context/translation.js"; import { Alert } from "../mui/Alert.js"; import { Button } from "../mui/Button.js"; import { TextField } from "../mui/TextField.js"; +import jsQR, * as pr from "jsqr"; +import { InputFile } from "../mui/InputFile.js"; +import { Grid } from "../mui/Grid.js"; +import { notDeepEqual } from "assert"; -const QrVideo = styled.video` +const QrCanvas = css` width: 80%; margin-left: auto; margin-right: auto; @@ -32,6 +40,8 @@ const QrVideo = styled.video` background-color: black; `; +const LINE_COLOR = "#FF3B58"; + const Container = styled.div` display: flex; flex-direction: column; @@ -44,111 +54,303 @@ interface Props { onDetected: (url: string) => 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(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", (e) => { + 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 imageRef = useRef(null); - const qrScanner = useRef(null); - const [value, onChange] = useState(""); - const [active, setActive] = useState(false); + const canvasRef = useRef(null); + const [error, setError] = useState(); + const [value, setValue] = useState(""); + const [show, setShow] = useState<"canvas" | "video" | "nothing">("nothing"); + const { i18n } = useTranslationContext(); - function start(): void { - qrScanner.current!.start(); - onChange(""); - setActive(true); - } - function stop(): void { - qrScanner.current!.stop(); - setActive(false); + function onChange(str: string) { + if (!!str) { + if (!str.startsWith("taler://")) { + setError( + i18n.str`URI is not valid. Taler URI should start with "taler://"`, + ); + } else if (classifyTalerUri(str) === TalerUriType.Unknown) { + setError(i18n.str`Unknown type of Taler URI`); + } else { + setError(undefined); + } + } else { + setError(undefined); + } + setValue(str); } - function check(v: string) { - return ( - v.startsWith("taler://") && classifyTalerUri(v) !== TalerUriType.Unknown - ); + 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) { + onChange(code); + setShow("canvas"); + } + stream.getTracks().forEach((e) => { + e.stop(); + }); + } catch (error) { + setError(i18n.str`something unexpected happen: ${error}`); + } } - useEffect(() => { - if (!videoRef.current) { - console.log("vide was not ready"); + async function onFileRead(fileContent: string) { + if (!canvasRef.current) { return; } - const elem = videoRef.current; - setTimeout(() => { - qrScanner.current = new QrScanner( - elem, - ({ data, cornerPoints }) => { - if (check(data)) { - onDetected(data); - return; - } - onChange(data); - stop(); - }, - { - maxScansPerSecond: 5, //default 25 - highlightScanRegion: true, - }, - ); - start(); - }, 1); - return () => { - qrScanner.current?.destroy(); - }; - }, []); - - const isValid = check(value); + setShow("nothing"); + setError(undefined); + try { + const code = await createCanvasFromFile(fileContent, canvasRef.current); + if (code) { + onChange(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 active = value === ""; return ( - {/* scanImage(imageRef, f)}> - Read QR from file - -
*/} -

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

- - - {isValid && ( - - )} - {!active && !isValid && ( - - - - URI is not valid. Taler URI should start with `taler://` - - - - - )} +
+

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

+ +

+ +

+ + +

{error && {error}}

+
+ + {!active && ( + + )} + + + {value && ( + + )} + + + Read QR from file + + +

+ +

+
+
+
+
+
); } - -async function scanImage( - imageRef: Ref, - image: string, -): Promise { - const imageEl = new Image(); - imageEl.src = image; - imageEl.width = 200; - imageRef.current!.appendChild(imageEl); - QrScanner.scanImage(image, { - alsoTryWithoutScanRegion: true, - }) - .then((result) => console.log(result)) - .catch((error) => console.log(error || "No QR code found.")); -} -- cgit v1.2.3