From 9d9a88af010ac39f026299ebccea3e1164e5242e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 31 Jan 2023 10:21:08 -0300 Subject: [PATCH] fix #7535: fix qr implementation --- .../taler-wallet-webextension/package.json | 2 +- .../src/wallet/QrReader.tsx | 414 +++++++++++++----- pnpm-lock.yaml | 14 +- 3 files changed, 319 insertions(+), 111 deletions(-) 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; } -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 { i18n } = useTranslationContext(); +type XY = { x: number; y: number }; - function start(): void { - qrScanner.current!.start(); - onChange(""); - setActive(true); - } - function stop(): void { - qrScanner.current!.stop(); - setActive(false); - } +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 check(v: string) { - return ( - v.startsWith("taler://") && classifyTalerUri(v) !== TalerUriType.Unknown - ); - } - - useEffect(() => { - if (!videoRef.current) { - console.log("vide was not ready"); - 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); - 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://` - - - - - )} - +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, ); } -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.")); +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 canvasRef = useRef(null); + const [error, setError] = useState(); + const [value, setValue] = useState(""); + const [show, setShow] = useState<"canvas" | "video" | "nothing">("nothing"); + + const { i18n } = useTranslationContext(); + + 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); + } + + 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}`); + } + } + + async function onFileRead(fileContent: string) { + if (!canvasRef.current) { + return; + } + 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 ( + +
+

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

+ +

+ +

+ + +

{error && {error}}

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

+ +

+
+
+
+
+
+
+ ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebd352a8c..99c71d823 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -574,6 +574,7 @@ importers: date-fns: ^2.29.2 esbuild: ^0.15.13 history: 4.10.1 + jsqr: ^1.4.0 mocha: ^9.2.0 nyc: ^15.1.0 polished: ^4.1.4 @@ -581,7 +582,7 @@ importers: preact-cli: ^3.3.5 preact-render-to-string: ^5.1.19 preact-router: 3.2.1 - qr-scanner: ^1.4.1 + qr-scanner: 1.4.2 qrcode-generator: ^1.4.4 rimraf: ^3.0.2 tslib: ^2.4.0 @@ -591,9 +592,10 @@ importers: '@gnu-taler/taler-wallet-core': link:../taler-wallet-core date-fns: 2.29.3 history: 4.10.1 + jsqr: 1.4.0 preact: 10.11.3 preact-router: 3.2.1_preact@10.11.3 - qr-scanner: 1.4.1 + qr-scanner: 1.4.2 qrcode-generator: 1.4.4 tslib: 2.4.0 devDependencies: @@ -10683,6 +10685,10 @@ packages: verror: 1.10.0 dev: true + /jsqr/1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + dev: false + /jssha/3.3.0: resolution: {integrity: sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==} dev: true @@ -13210,8 +13216,8 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} dev: true - /qr-scanner/1.4.1: - resolution: {integrity: sha512-xiR90NONHTfTwaFgW/ihlqjGMIZg6ExHDOvGQRba1TvV+WVw7GoDArIOt21e+RO+9WiO4AJJq+mwc5f4BnGH3w==} + /qr-scanner/1.4.2: + resolution: {integrity: sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==} dependencies: '@types/offscreencanvas': 2019.7.0 dev: false