Added QR Scan with device's camera

- Design changes regarding file selection / camera
This commit is contained in:
Hauke Tönjes 2021-06-30 03:13:01 +02:00
parent 98ca5b59bf
commit 57e08bacaf
No known key found for this signature in database
GPG Key ID: 0BF2BC96C9FAAE9E
5 changed files with 376 additions and 195 deletions

View File

@ -1,9 +1,10 @@
export default Card export default Card
function Card({ heading, content, step }) { function Card({heading, content, step}) {
return ( return (
<div className="rounded-md p-6 bg-white dark:bg-gray-800 space-y-4"> <div className="rounded-md p-6 bg-white dark:bg-gray-800 space-y-4">
{step && {
step &&
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<div className="rounded-full p-4 bg-green-600 h-5 w-5 flex items-center justify-center"> <div className="rounded-full p-4 bg-green-600 h-5 w-5 flex items-center justify-center">
<p className="text-white text-lg font-bold"> <p className="text-white text-lg font-bold">
@ -13,7 +14,8 @@ function Card({ heading, content, step }) {
<div className="ml-3 font-bold text-xl"> <div className="ml-3 font-bold text-xl">
{heading} {heading}
</div> </div>
</div>} </div>
}
<div className="text-lg"> <div className="text-lg">
{content} {content}
</div> </div>

View File

@ -1,11 +1,12 @@
import jsQR from "jsqr"
import {saveAs} from 'file-saver' import {saveAs} from 'file-saver'
import {BrowserQRCodeReader} from '@zxing/browser'
import React, {useEffect, useRef, useState} from "react"
import {decodeData} from "../src/decode" import {decodeData} from "../src/decode"
import { processJpeg, processPng, processPdf } from "../src/process" import {processPdf, processPng} from "../src/process"
import {createPass} from "../src/pass" import {createPass} from "../src/pass"
import Card from "../components/Card" import Card from "../components/Card"
import Alert from "../components/Alert" import Alert from "../components/Alert"
import jsQR from "jsqr";
export default Form export default Form
@ -25,7 +26,7 @@ function Form() {
}) })
} }
const error = function(heading, message) { const error = function (heading, message) {
const alert = document.getElementById('alert') const alert = document.getElementById('alert')
alert.setAttribute('style', null) alert.setAttribute('style', null)
@ -35,23 +36,27 @@ function Form() {
document.getElementById('spin').style.display = 'none' document.getElementById('spin').style.display = 'none'
} }
const processFile = async function() { const processFile = async function () {
console.log(qrCode)
console.log(file)
if (!qrCode && !file) {
error("Error", "Please capture a QR Code or select a file to scan");
return;
}
document.getElementById('spin').style.display = 'block' document.getElementById('spin').style.display = 'block'
const file = document.getElementById('file').files[0] let rawData;
const fileBuffer = await readFileAsync(file)
if (file) {
let imageData let imageData
const fileBuffer = await readFileAsync(file)
switch (file.type) { switch (file.type) {
case 'application/pdf': case 'application/pdf':
console.log('pdf') console.log('pdf')
imageData = await processPdf(fileBuffer) imageData = await processPdf(fileBuffer)
break break
case 'image/jpeg':
console.log('jpeg')
imageData = await processJpeg(fileBuffer)
break
case 'image/png': case 'image/png':
console.log('png') console.log('png')
imageData = await processPng(fileBuffer) imageData = await processPng(fileBuffer)
@ -65,31 +70,37 @@ function Form() {
inversionAttempts: 'dontInvert', inversionAttempts: 'dontInvert',
}) })
if (code) { rawData = code.data;
const rawData = code.data
} else {
rawData = qrCode.getText()
}
if (rawData) {
let decoded let decoded
try { try {
decoded = decodeData(rawData) decoded = decodeData(rawData)
} catch (error) { } catch (e) {
error('Invalid QR code found', 'Make sure that you picked the correct PDF') error('Invalid QR code found', 'Try another method to select your certificate')
return;
} }
return {decoded: decoded, raw: rawData} return {decoded: decoded, raw: rawData}
} else { } else {
error('No QR code found', 'Try scanning the PDF again') error('No QR code found', 'Try another method to select your certificate')
} }
} }
const addToWallet = async function(event) { const addToWallet = async function (event) {
event.preventDefault() event.preventDefault()
let result let result
try { try {
result = await processFile() result = await processFile()
} catch { } catch (e) {
error('Error:', 'Could not extract QR code data from PDF') error('Error:', 'Could not extract QR code data from certificate')
} }
if (typeof result === 'undefined') { if (typeof result === 'undefined') {
@ -120,30 +131,123 @@ function Form() {
} }
} }
const [isCameraOpen, setIsCameraOpen] = useState(false);
const [globalControls, setGlobalControls] = useState(undefined);
const [qrCode, setQrCode] = useState(undefined);
const [file, setFile] = useState(undefined);
const inputFile = useRef(undefined)
useEffect(() => {
if (inputFile && inputFile.current) {
inputFile.current.addEventListener('input', () => {
let selectedFile = inputFile.current.files[0];
if (selectedFile !== undefined) {
setQrCode(undefined);
setFile(selectedFile);
}
});
}
}, [inputFile])
async function showFileDialog() {
inputFile.current.click();
}
async function hideCameraView() {
if (globalControls !== undefined) {
globalControls.stop();
}
setIsCameraOpen(false);
}
async function showCameraView() {
const codeReader = new BrowserQRCodeReader();
// Needs to be called before any camera can be accessed
await BrowserQRCodeReader.listVideoInputDevices();
// Get preview Element to show camera stream
const previewElem = document.querySelector('#cameraPreview');
setGlobalControls(await codeReader.decodeFromVideoDevice(undefined, previewElem, (result, error, controls) => {
if (result !== undefined) {
setQrCode(result);
setFile(undefined);
controls.stop();
// Reset
setGlobalControls(undefined);
setIsCameraOpen(false);
}
}));
setIsCameraOpen(true);
}
return ( return (
<div> <div>
<form className="space-y-5" id="form" onSubmit={(e) => addToWallet(e)}> <form className="space-y-5" id="form" onSubmit={(e) => addToWallet(e)}>
<Card step={1} heading="Select Certificate" content={ <Card step={1} heading="Select Certificate" content={
<div className="space-y-5"> <div className="space-y-5">
<p> <p>
Please select the certificate screenshot or (scanned) PDF page, which you received from your doctor, pharmacy, vaccination centre or online. Note that taking a picture does not work on most devices yet. Please select the certificate screenshot or (scanned) PDF page, which you received from your
doctor, pharmacy, vaccination centre or online. Note that taking a picture does not work on
most devices yet.
</p> </p>
<input <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
className="w-full" <button
type="file" type="button"
id="file" onClick={isCameraOpen ? hideCameraView : showCameraView}
accept="application/pdf,image/jpeg,image/png" className="focus:outline-none h-20 bg-gray-500 hover:bg-gray-700 text-white font-semibold rounded-md">
required {isCameraOpen ? "Stop Camera" : "Start Camera"}
/> </button>
<button
type="button"
onClick={showFileDialog}
className="focus:outline-none h-20 bg-gray-500 hover:bg-gray-700 text-white font-semibold rounded-md">
Open File (PDF, PNG)
</button>
</div> </div>
} />
<video id="cameraPreview" className={`${isCameraOpen ? undefined : "hidden"} rounded-md w-full`}/>
<input type='file'
id='file'
accept="application/pdf,image/png"
ref={inputFile}
style={{display: 'none'}}
/>
{(qrCode || file) &&
<div className="flex items-center space-x-1">
<svg className="h-4 w-4 text-green-600" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"/>
</svg>
<span className="w-full">
{
qrCode && 'Found QR Code!'
}
{
file && file.name
}
</span>
</div>}
</div>
}/>
<Card step={2} heading="Pick a Color" content={ <Card step={2} heading="Pick a Color" content={
<div className="space-y-5"> <div className="space-y-5">
<p> <p>
Pick a background color for your pass. Pick a background color for your pass.
</p> </p>
<div className="relative inline-block w-full"> <div className="relative inline-block w-full">
<select name="color" id="color" className="bg-gray-200 dark:bg-gray-900 focus:outline-none w-full h-10 pl-3 pr-6 text-base rounded-md appearance-none cursor-pointer"> <select name="color" id="color"
className="bg-gray-200 dark:bg-gray-900 focus:outline-none w-full h-10 pl-3 pr-6 text-base rounded-md appearance-none cursor-pointer">
<option value="white">white</option> <option value="white">white</option>
<option value="black">black</option> <option value="black">black</option>
<option value="grey">grey</option> <option value="grey">grey</option>
@ -154,39 +258,47 @@ function Form() {
<option value="teal">teal</option> <option value="teal">teal</option>
</select> </select>
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none"> <div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
<svg className="w-5 h-5 fill-current" viewBox="0 0 20 20"><path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" fillRule="evenodd"/></svg> <svg className="w-5 h-5 fill-current" viewBox="0 0 20 20">
<path
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd" fillRule="evenodd"/>
</svg>
</div> </div>
</div> </div>
</div> </div>
} /> }/>
<Card step={3} heading="Add to Wallet" content={ <Card step={3} heading="Add to Wallet" content={
<div className="space-y-5"> <div className="space-y-5">
<p> <p>
Data privacy is of special importance when processing health-related data. Data privacy is of special importance when processing health-related data.
In order for you to make an informed decision, please read the <a href="/privacy">Privacy Policy</a>. In order for you to make an informed decision, please read the <a href="/privacy">Privacy
Policy</a>.
</p> </p>
<label htmlFor="privacy" className="flex flex-row space-x-4 items-center"> <label htmlFor="privacy" className="flex flex-row space-x-4 items-center">
<input type="checkbox" id="privacy" value="privacy" required className="h-4 w-4" /> <input type="checkbox" id="privacy" value="privacy" required className="h-4 w-4"/>
<p> <p>
I accept the <a href="/privacy" className="underline">Privacy Policy</a> I accept the <a href="/privacy" className="underline">Privacy Policy</a>
</p> </p>
</label> </label>
<div className="flex flex-row items-center justify-start"> <div className="flex flex-row items-center justify-start">
<button id="download" type="download" className="focus:outline-none bg-green-600 py-2 px-3 text-white font-semibold rounded-md disabled:bg-gray-400"> <button id="download" type="download"
className="focus:outline-none bg-green-600 py-2 px-3 text-white font-semibold rounded-md disabled:bg-gray-400">
Add to Wallet Add to Wallet
</button> </button>
<div id="spin" style={{ "display": "none" }}> <div id="spin" style={{"display": "none"}}>
<svg className="animate-spin h-5 w-5 ml-2" viewBox="0 0 24 24"> <svg className="animate-spin h-5 w-5 ml-2" viewBox="0 0 24 24">
<circle className="opacity-0" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/> <circle className="opacity-0" cx="12" cy="12" r="10" stroke="currentColor"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/> strokeWidth="4"/>
<path className="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg> </svg>
</div> </div>
</div> </div>
</div> </div>
} /> }/>
</form> </form>
<Alert /> <Alert/>
<canvas id="canvas" style={{ display: "none" }} /> <canvas id="canvas" style={{display: "none"}}/>
</div> </div>
) )
} }

72
package-lock.json generated
View File

@ -7,6 +7,7 @@
"": { "": {
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@zxing/browser": "^0.0.9",
"base45-js": "^1.0.1", "base45-js": "^1.0.1",
"cbor-js": "^0.1.0", "cbor-js": "^0.1.0",
"do-not-zip": "^1.0.0", "do-not-zip": "^1.0.0",
@ -435,6 +436,38 @@
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
}, },
"node_modules/@zxing/browser": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.0.9.tgz",
"integrity": "sha512-yqYz/FHQXbUnfSK+oeDXUa4ezC/qdXkVpqEFAnxM7LqIWvNWEQyUpdaTr0X6MGtIcP0Smakj5D8J7/l276plBw==",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.18.5"
}
},
"node_modules/@zxing/library": {
"version": "0.18.6",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.18.6.tgz",
"integrity": "sha512-bulZ9JHoLFd9W36pi+7e7DnEYNJhljYjZ1UTsKPOoLMU3qtC+REHITeCRNx40zTRJZx18W5TBRXt5pq2Uopjsw==",
"peer": true,
"dependencies": {
"ts-custom-error": "^3.0.0"
},
"engines": {
"node": ">= 10.4.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"optional": true
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
@ -4012,6 +4045,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/ts-custom-error": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.2.0.tgz",
"integrity": "sha512-cBvC2QjtvJ9JfWLvstVnI45Y46Y5dMxIaG1TDMGAD/R87hpvqFL+7LhvUDhnRCfOnx/xitollFWWvUKKKhbN0A==",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ts-pnp": { "node_modules/ts-pnp": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
@ -4730,6 +4772,30 @@
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
}, },
"@zxing/browser": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.0.9.tgz",
"integrity": "sha512-yqYz/FHQXbUnfSK+oeDXUa4ezC/qdXkVpqEFAnxM7LqIWvNWEQyUpdaTr0X6MGtIcP0Smakj5D8J7/l276plBw==",
"requires": {
"@zxing/text-encoding": "^0.9.0"
}
},
"@zxing/library": {
"version": "0.18.6",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.18.6.tgz",
"integrity": "sha512-bulZ9JHoLFd9W36pi+7e7DnEYNJhljYjZ1UTsKPOoLMU3qtC+REHITeCRNx40zTRJZx18W5TBRXt5pq2Uopjsw==",
"peer": true,
"requires": {
"@zxing/text-encoding": "~0.9.0",
"ts-custom-error": "^3.0.0"
}
},
"@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"optional": true
},
"acorn": { "acorn": {
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
@ -7485,6 +7551,12 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"ts-custom-error": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.2.0.tgz",
"integrity": "sha512-cBvC2QjtvJ9JfWLvstVnI45Y46Y5dMxIaG1TDMGAD/R87hpvqFL+7LhvUDhnRCfOnx/xitollFWWvUKKKhbN0A==",
"peer": true
},
"ts-pnp": { "ts-pnp": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",

View File

@ -9,6 +9,7 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
"@zxing/browser": "^0.0.9",
"base45-js": "^1.0.1", "base45-js": "^1.0.1",
"cbor-js": "^0.1.0", "cbor-js": "^0.1.0",
"do-not-zip": "^1.0.0", "do-not-zip": "^1.0.0",

View File

@ -1,4 +1,3 @@
import jpeg from 'jpeg-js'
import {PNG} from 'pngjs' import {PNG} from 'pngjs'
import * as PdfJS from 'pdfjs-dist' import * as PdfJS from 'pdfjs-dist'
@ -42,11 +41,6 @@ export async function processPdf(file) {
return ctx.getImageData(0, 0, canvas.width, canvas.height) return ctx.getImageData(0, 0, canvas.width, canvas.height)
} }
// Processes a JPEG File and returns it as ImageData
export async function processJpeg(file) {
return jpeg.decode(file)
}
// Processes a PNG File and returns it as ImageData // Processes a PNG File and returns it as ImageData
export async function processPng(file) { export async function processPng(file) {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {