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:

Online demo

Android apk

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

  1. Install the capacitor plugin of Dynamsoft Barcode Reader.

    npm install capacitor-plugin-dynamsoft-barcode-reader
    
  2. Install the capacitor plugins to save a file and share a file.

    npm install @capacitor/filesystem @capacitor/share
    
  3. Install @capawesome/capacitor-file-picker for picking a file to transfer.

    npm install @capawesome/capacitor-file-picker
    
  4. Install qrcode-generator to generate a QR code.

    npm install qrcode-generator
    
  5. 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.

  1. 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.

  2. Request camera permission.

    await DBR.requestCameraPermission();
    
  3. Start the scanning.

    await DBR.startScan();
    
  4. Add a listener to receive barcode results.

    let frameReadListener = await DBR.addListener('onFrameRead', (scanResult:ScanResult) => {
      console.log(scanResult);
     });
    
  5. Set the layout of the camera.

    await DBR.setLayout({
      top:"0px",
      left:"75%",
      width:"25%",
      height:"25%"
    })
    
  6. 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.

https://github.com/tony-xlh/QRTransfer