How to Build an Ionic App to Transfer Files via QR Codes
In the previous article, we talked about the possibility of transferring files via QR codes and built a demo web app.
In this article, we are going to build a cross-platform version using Ionic Vue which can run on Android, iOS and Web. The mechanism is also improved by adding a two-way communication which allows the receiver to tell the sender via a QR code which QR codes have been received so that the sender doesn’t have to show the QR codes that have already been scanned.
The capacitor plugin of Dynamsoft Barcode Reader is used to scan QR codes for its ease of use, performance and the ability to read a high-version QR code storing over 2KB’s data.
Getting started with Dynamsoft Barcode Reader
Demo video:
New Project
Create a new Ionic Vue project:
npm install -g @ionic/cli
ionic start myApp tabs --type vue
We can start a server to have a live test in the browser:
ionic serve
To run it on Android:
ionic capacitor add android
ionic capacitor copy android // sync files
ionic capacitor run android
To run it on iOS:
ionic capacitor add ios
ionic capacitor copy ios // sync files
ionic capacitor open ios // use XCode to open the project
Add Dependencies
-
Install the capacitor plugin of Dynamsoft Barcode Reader.
npm install capacitor-plugin-dynamsoft-barcode-reader@1.5.0
-
Install the capacitor plugins to save a file and share a file.
npm install @capacitor/filesystem @capacitor/share
-
Install
@capawesome/capacitor-file-picker
for picking a file to transfer.npm install @capawesome/capacitor-file-picker
-
Install
qrcode-generator
to generate a QR code.npm install qrcode-generator
-
Install
localforage
to store the scanned file in indexedDB.npm install localforage
Add Camera Permission
For iOS, add the following to ios\App\App\Info.plist
:
<key>NSCameraUsageDescription</key>
<string>For barcode scanning</string>
Write a QR Code Component to Generate QR Codes
We need to store the bytes of a file or the index of scanned QR codes in a QR code.
We can use qrcode-generator
to do this. Here is the code to create a QR code:
var typeNumber:TypeNumber = 0;
var errorCorrectionLevel:ErrorCorrectionLevel = 'L';
var qr = qrcode(typeNumber, errorCorrectionLevel);
qr.addData("text");
qr.make();
placeHolder.innerHTML = qr.createSvgTag();
If we need to store bytes, we need to override its stringToBytes
function:
qrcode.stringToBytes = function(data:any) { return data; }
Here is the complete Vue component we write to display a generated QR code:
<template>
<div ref="placeHolder"></div>
</template>
<script lang="ts" setup>
import qrcode from "qrcode-generator";
import { onMounted, ref, watch } from "vue";
const placeHolder = ref<HTMLElement|null>(null);
const props = defineProps<{
data:any
byteMode?: "on"|"off"
typeNumber?: TypeNumber
errorCorrectionLevel?:ErrorCorrectionLevel
}>()
const originalFunction = (qrcode as any).stringToBytes;
const toggleBytesMode = (on:boolean) => {
if (on) {
(qrcode as any).stringToBytes = function(data:any) { return data; };
}else{
(qrcode as any).stringToBytes = originalFunction;
}
}
onMounted(async () => {
if (props.byteMode === "on") {
toggleBytesMode(true);
}else{
toggleBytesMode(false);
}
makeQR();
})
const makeQR = () => {
if (placeHolder.value) {
var typeNumber:TypeNumber = props.typeNumber ?? 0;
var errorCorrectionLevel:ErrorCorrectionLevel = props.errorCorrectionLevel ?? 'L';
var qr = qrcode(typeNumber, errorCorrectionLevel);
qr.addData(props.data ?? "");
qr.make();
placeHolder.value.innerHTML = qr.createSvgTag();
placeHolder.value.getElementsByTagName("svg")[0].style.width = "100%";
placeHolder.value.getElementsByTagName("svg")[0].style.height = "100%";
}
}
watch(() => props.byteMode, (newVal, oldVal) => {
if (newVal === "on") {
toggleBytesMode(true);
}else{
toggleBytesMode(false);
}
makeQR();
});
watch(() => props.data, (newVal, oldVal) => {
if (newVal) {
makeQR();
}
});
watch(() => props.typeNumber, (newVal, oldVal) => {
if (newVal) {
makeQR();
}
});
watch(() => props.errorCorrectionLevel, (newVal, oldVal) => {
if (newVal) {
makeQR();
}
});
</script>
<style scoped>
img {
width: 100%;
height: 100%;
}
</style>
Write an Animated QR Code Component
We need to put the bytes of a file into segments, store them as QR codes, and display them in an animation loop for transfer.
The complete Vue component is like the following. The component has a scannedIndex
prop to filter out QR codes already scanned.
<template>
<QRCode v-if="chunk != null"
:data="chunk"
byteMode="on"
></QRCode>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from "vue";
import QRCode from "./QRCode.vue"
export interface SelectedFile {
unit8Array: Uint8Array,
filename: string,
type: string,
}
const props = defineProps<{
file:SelectedFile
chunkSize: number
interval: number
scannedIndex?: number[]
}>()
const emit = defineEmits<{
(e: 'onAnimated',index:number,chunksLeft:number,total:number): void
}>();
const chunk = ref<any>(null);
let chunks:any[] = [];
let chunksLeft:any[] = [];
let currentIndex = 0;
let interval:any;
onMounted(async () => {
if (props.file) {
loadArrayBufferToChunks(props.file.unit8Array,props.file.filename,props.file.type)
if (props.scannedIndex) {
filterOutScannedChunks(props.scannedIndex);
}
startLooping();
}
})
const loadArrayBufferToChunks = (bytes:Uint8Array,filename:string,type:string) => {
console.log(bytes);
var data = concatTypedArrays(stringToBytes(encodeURIComponent(filename)+"|"+type+"|"),bytes)
var chunkSize = props.chunkSize ?? 2000;
console.log("chunk size:"+chunkSize);
var num = Math.ceil(data.length / chunkSize)
chunks = [];
chunksLeft = [];
for (var i=0;i<num;i++){
var start = i*chunkSize;
var chunk = data.slice(start,start+chunkSize);
var meta = (i+1)+"/"+num+"|";
chunk = concatTypedArrays(stringToBytes(meta),chunk);
chunks.push(chunk);
chunksLeft.push(chunk);
}
}
const startLooping = () => {
stopLooping();
interval = setInterval(showAnimatedQRCode,props.interval ?? 400);
}
const stopLooping = () => {
if (interval) {
clearInterval(interval);
currentIndex = 0;
}
}
const showAnimatedQRCode = () => {
chunk.value = chunksLeft[currentIndex];
emit("onAnimated",currentIndex,chunksLeft.length,chunks.length);
currentIndex = currentIndex + 1
if (currentIndex >= chunksLeft.length){
currentIndex = 0;
}
}
const stringToBytes = (s:any) => {
var bytes = [];
for (var i = 0; i < s.length; i += 1) {
var c = s.charCodeAt(i);
bytes.push(c & 0xff);
}
return bytes;
}
//https://stackoverflow.com/questions/33702838/how-to-append-bytes-multi-bytes-and-buffer-to-arraybuffer-in-javascript
const concatTypedArrays = (a:any, b:any) => { //common array + unint8 array
var newLength = a.length + b.byteLength;
console.log(newLength);
var c = new Uint8Array(newLength);
c.set(a, 0);
c.set(b, a.length);
return c;
}
watch(() => props.file, (newVal, oldVal) => {
if (newVal) {
loadArrayBufferToChunks(newVal.unit8Array,newVal.filename,newVal.type);
startLooping();
}
});
watch(() => props.scannedIndex, (newVal, oldVal) => {
if (newVal) {
filterOutScannedChunks(newVal);
}
});
const filterOutScannedChunks = (scannedIndex:number[]) => {
chunksLeft = [];
for (let index = 0; index < chunks.length; index++) {
const chunk = chunks[index];
if (scannedIndex.indexOf(index) === -1) {
chunksLeft.push(chunk);
}
}
}
</script>
<style scoped>
</style>
Write a QR Code Scanner Component
The capacitor plugin of Dynamsoft Barcode Reader provides imperative functions to start the camera to scan barcodes. Here are the basic steps to use it.
-
Initiate the license.
import { DBR } from 'capacitor-plugin-dynamsoft-barcode-reader'; await DBR.initLicense({license:"DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ=="}); //one-day trial
You can apply for your license here.
-
Request camera permission.
await DBR.requestCameraPermission();
-
Start the scanning.
await DBR.startScan();
-
Add a listener to receive barcode results.
let frameReadListener = await DBR.addListener('onFrameRead', (scanResult:ScanResult) => { console.log(scanResult); });
-
Set the layout of the camera.
await DBR.setLayout({ top:"0px", left:"75%", width:"25%", height:"25%" })
-
Update the runtime settings. For example, we can specify the barcode format to QR code to improve speed.
await DBR.initRuntimeSettingsWithString({template:"{\"ImageParameter\":{\"BarcodeFormatIds\":[\"BF_QR_CODE\"],\"ExpectedBarcodesCount\":1,\"Name\":\"Settings\"},\"Version\":\"3.0\"}"});
The whole Vue component we write is like the following:
<template>
<div v-if="!initialized">Initializing...</div>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { DBR, Options, ScanRegion, ScanResult } from "capacitor-plugin-dynamsoft-barcode-reader";
import { PluginListenerHandle } from "@capacitor/core";
const props = defineProps<{
license?: string
scanRegion?: ScanRegion
dceLicense?: string
active?: boolean
desiredCamera?: string
interval?: number
torchOn?: boolean
runtimeSettings?: string
layout:{
top: string;
left: string;
width: string;
height: string;
}
}>()
const emit = defineEmits<{
(e: 'onScanned',result:ScanResult): void
(e: 'onPlayed',resolution:string): void
}>();
const initialized = ref(false);
let currentHeight = 0;
let currentWidth = 0;
let frameReadListener:PluginListenerHandle|undefined;
let onPlayedListener:PluginListenerHandle|undefined;
const handleRotation = (result:any, orientation: string, rotation:number) => {
let width,height;
if (orientation == "portrait") {
width = currentHeight;
height = currentWidth;
}else{
width = currentWidth;
height = currentHeight;
}
for (let i = 1; i < 5; i++) {
let x = result["x"+i];
let y = result["y"+i];
let rotatedX;
let rotatedY;
switch (rotation) {
case 0:
rotatedX = x;
rotatedY = y;
break;
case 90:
rotatedX = width - y;
rotatedY = x;
break;
case 180:
rotatedX = width - x;
rotatedY = height - y;
break;
case 270:
rotatedX = height - y;
rotatedY = width - x;
break;
default:
rotatedX = x;
rotatedY = y;
}
result["x"+i] = rotatedX;
result["y"+i] = rotatedY;
}
}
onMounted(async () => {
console.log(props);
let options:Options = {};
if (props.dceLicense) {
options.dceLicense = props.dceLicense;
}
if (props.layout) {
setLayout();
}
let result = await DBR.initialize(options);
if (result.success === true) {
if (frameReadListener) {
frameReadListener.remove();
}
if (onPlayedListener) {
onPlayedListener.remove();
}
frameReadListener = await DBR.addListener('onFrameRead', (scanResult:ScanResult) => {
for (let index = 0; index < scanResult.results.length; index++) {
const result = scanResult.results[index];
if (scanResult.deviceOrientation && scanResult.frameOrientation) {
handleRotation(result,scanResult.deviceOrientation,scanResult.frameOrientation);
}
}
emit("onScanned",scanResult);
});
onPlayedListener = await DBR.addListener("onPlayed", async (result:{resolution:string}) => {
currentWidth = parseInt(result.resolution.split("x")[0]);
currentHeight = parseInt(result.resolution.split("x")[1]);
setLayout();
emit("onPlayed",result.resolution);
});
if (props.runtimeSettings) {
console.log("update runtime settings");
console.log(props.runtimeSettings);
await DBR.initRuntimeSettingsWithString({template:props.runtimeSettings});
}
if (props.interval) {
await DBR.setInterval({interval:props.interval});
}
if (props.scanRegion){
await DBR.setScanRegion(props.scanRegion);
}
await selectDesiredCamera();
if (props.active === true) {
await DBR.startScan();
}
initialized.value = true;
console.log("QRCodeScanner mounted");
}
});
const setLayout = async () => {
if (props.layout) {
await DBR.setLayout({
top:props.layout.top,
left:props.layout.left,
width:props.layout.width,
height:props.layout.height
})
if (props.scanRegion) {
await DBR.setScanRegion(props.scanRegion);
}
}
}
onBeforeUnmount(async () => {
if (frameReadListener) {
frameReadListener.remove();
}
if (onPlayedListener) {
onPlayedListener.remove();
}
await DBR.stopScan();
console.log("QRCodeScanner unmount");
});
watch(() => props.torchOn, (newVal, oldVal) => {
if (initialized.value) {
if (newVal === true) {
DBR.toggleTorch({on:true});
}else{
DBR.toggleTorch({on:false});
}
}
});
watch(() => props.interval, (newVal, oldVal) => {
if (initialized.value) {
if (newVal) {
DBR.setInterval({interval:newVal});
}
}
});
watch(() => props.layout, (newVal, oldVal) => {
if (initialized.value) {
if (newVal) {
setLayout();
}
}
});
watch(() => props.active, (newVal, oldVal) => {
if (initialized.value) {
if (newVal === true) {
DBR.startScan();
}else if (newVal === false){
DBR.stopScan();
}
}
});
watch(() => props.desiredCamera, async (newVal, oldVal) => {
if (initialized.value) {
if (newVal) {
selectDesiredCamera();
}
}
});
const selectDesiredCamera = async () => {
let camerasResult = await DBR.getAllCameras();
if (camerasResult.cameras) {
for (let index = 0; index < camerasResult.cameras.length; index++) {
const cameraID = camerasResult.cameras[index];
let desiredCameraString = "founder"; //the USB camera's name of the developer
if (props.desiredCamera) {
desiredCameraString = props.desiredCamera;
}
if (cameraID.toLowerCase().indexOf(desiredCameraString) != -1 ){
await DBR.selectCamera({cameraID:cameraID});
break;
}
}
}
}
watch(() => props.scanRegion, async (newVal, oldVal) => {
if (initialized.value) {
if (newVal) {
await DBR.setScanRegion(newVal);
}else{
await DBR.setScanRegion({left:0,top:0,right:100,bottom:100,measuredByPercentage:1});
}
}
});
</script>
<style scoped>
</style>
Use the Components to Transfer Files via QR Codes
Next, we can use the components we just wrote to generate QR codes and scan QR codes on an Ionic page to transfer files.
Template:
<template>
<ion-page>
<ion-content :fullscreen="true" class="content">
<ion-modal :is-open="isOpen">
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button @click="cancel()">Cancel</ion-button>
</ion-buttons>
<ion-title>Received</ion-title>
<ion-buttons slot="end">
<ion-button :strong="true" @click="save()">Save</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<FileCard
:file="scannedFile"
></FileCard>
<div>
Speed: <span>{{ speed }}</span>
</div>
<div>
Elapsed time: <span>{{ seconds }}</span>
</div>
</ion-content>
</ion-modal>
<QRCodeScanner
:layout="layout"
:interval="scanInterval"
:active="scannerActive"
:runtimeSettings="runtimeSettings"
:desiredCamera="desiredCamera"
:scanRegion="scanRegionEnabled?scanRegion:undefined"
@onScanned="onScanned"
@onPlayed="onPlayed"
></QRCodeScanner>
<svg
ref="svg"
:viewBox="viewBox"
preserveAspectRatio="xMidYMid slice"
class="overlay"
>
<polygon v-bind:key="'polygon'+index" v-for="(barcodeResult,index) in barcodeResults"
:points="getPointsData(barcodeResult)"
class="barcode-polygon"
/>
</svg>
<div class="lower">
<div class="QRCode">
<div v-if="isSender && selectedFile">
<div>{{ (QRCodeCurrentIndex+1) + "/" + QRCodeChunksLeft }}</div>
<AnimatedQRCode
:file="selectedFile"
:chunkSize="chunkSize"
:interval="QRCodeInterval"
:scannedIndex="scannedIndex"
@on-animated="onAnimated"
></AnimatedQRCode>
</div>
<div>
<QRCode v-if="!isSender && twoWayCommunication"
:data="filesQR"
></QRCode>
</div>
</div>
</div>
<div class="status">
<div v-if="isSender">
<ion-button size="small" @click="pickAFile">Pick a file</ion-button>
<ion-input label="Chunk size:" @change="chunkSizeChanged($event.target.value)" :value="chunkSize"></ion-input>
<ion-input label="Interval:" @change="intervalChanged($event.target.value)" :value="QRCodeInterval"></ion-input>
</div>
<div class="scanningStatus" v-if="!isSender">
<pre>{{ scanningStatus }}</pre>
</div>
</div>
<ion-fab slot="fixed" vertical="bottom" horizontal="end">
<ion-fab-button id="open-action-sheet">
<ion-icon :icon="ellipsisHorizontalOutline"></ion-icon>
</ion-fab-button>
</ion-fab>
<ion-action-sheet
trigger="open-action-sheet"
header="Actions"
:buttons="actionSheetButtons"
@didDismiss="setActionResult($event)"
></ion-action-sheet>
</ion-content>
</ion-page>
</template>
Code to process the QR code results to get the data and know the index of QR codes scanned.
const twoWayCommunication = ref(false);
const scannedIndex = ref<number[]>();
let codeResults:any = {};
let total = 0;
const processRead = (result:TextResult) => {
let text = result["barcodeText"];
try {
let meta = text.split("|")[0];
let totalOfThisOne = parseInt(meta.split("/")[1]);
if (total!=0){
if (total != totalOfThisOne){
total = totalOfThisOne;
codeResults={};
return;
}
}
total = totalOfThisOne;
let index = parseInt(meta.split("/")[0]);
if (!(index in codeResults)) {
codeResults[index]=result;
if (twoWayCommunication.value === true) {
updateFilesQR(); //update the QR code containing the index of scanned QR codes
}
}
if (Object.keys(codeResults).length === total){
onCompleted(); //add the QR codes have been scanned
}
} catch(error) {
console.log(error);
}
}
const onCompleted = () => {
scannerActive.value = false;
let endTime = new Date().getTime();
let timeElapsed = endTime - startTime;
showResult(timeElapsed);
}
const showResult = async (timeElapsed:number) => {
let jointData:number[] = [];
let mimeType = "";
let filename = "";
for (let i=0;i<Object.keys(codeResults).length;i++){
let index = i+1;
let result:TextResult = codeResults[index];
let bytes = base64ToBytesArray(result.barcodeBytesBase64);
let text = result.barcodeText;
let data;
if (index === 1){
filename = text.split("|")[1]; //example of the first chunk: 1/11|example.webp|image/webp|bytes
mimeType = text.split("|")[2];
let firstSeparatorIndex = text.indexOf("|");
let secondSeparatorIndex = text.indexOf("|",firstSeparatorIndex+1);
let dataStart = text.indexOf("|",secondSeparatorIndex+1)+1;
data = bytes.slice(dataStart,bytes.length);
}else{
let dataStart = text.indexOf("|")+1; //example of the other chunks: 2/11|bytes
data = bytes.slice(dataStart,bytes.length);
}
jointData = jointData.concat(data);
}
let array = ConvertToUInt8Array(jointData);
let blob = new Blob([array],{type: mimeType}); //get the blob of the file
}
Source Code
All right, we’ve now talked about the key parts of the demo. Check out the source code to have a try on your Android or iOS devices or in the browser.