/*
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 {
parseTalerUri,
TalerUri,
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 { Fragment, h, VNode } from "preact";
import { useRef, useState } from "preact/hooks";
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", (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 (!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) {
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
);
}