diff --git a/.jshintrc b/.jshintrc
index d140918d..e916e23a 100644
--- a/.jshintrc
+++ b/.jshintrc
@@ -9,6 +9,7 @@
"globals": {
"cordova": false,
"module": false,
- "require": true
+ "require": false,
+ "Promise": false
}
}
diff --git a/plugin.xml b/plugin.xml
index 387271e9..705a848e 100644
--- a/plugin.xml
+++ b/plugin.xml
@@ -34,12 +34,9 @@
-
-
-
-
+
+
-
diff --git a/readme.md b/readme.md
index c5784aa3..c35af722 100644
--- a/readme.md
+++ b/readme.md
@@ -252,7 +252,7 @@ Name | Description
`lightEnabled` | A boolean value which is true if the light is enabled.
`canOpenSettings` | A boolean value which is true only if the users' operating system is able to `QRScanner.openSettings()`.
`canEnableLight` | A boolean value which is true only if the users' device can enable a light in the direction of the currentCamera.
-`canChangeCamera` (TODO) | A boolean value which is true only if the current device "should" have a front camera. The camera may still not be capturable, which would emit error code 3, 4, or 5 when the switch is attempted.
+`canChangeCamera` | A boolean value which is true only if the current device "should" have a front camera. The camera may still not be capturable, which would emit error code 3, 4, or 5 when the switch is attempted.
`currentCamera` | A number representing the index of the currentCamera. `0` is the back camera, `1` is the front.
### Destroy
@@ -323,7 +323,9 @@ As a consequence, you should assume that your `
` element will be completel
### Privacy Lights
-Most devices now include a hardware-level "privacy light", which is enabled when the camera is being used. To prevent this light from being "always on" when the app is running, the browser platform disables/enables use of the camera with the `hide` and `show` methods. If your implementation works well on a mobile platform, you'll find that this addition provides a great head start for a solid `browser` implementation.
+Most devices now include a hardware-level "privacy light", which is enabled when the camera is being used. To prevent this light from being "always on" when the app is running, the browser platform disables/enables use of the camera with the `hide`, `show`, `pausePreview`, and `resumePreview` methods. If your implementation works well on a mobile platform, you'll find that this addition provides a great head start for a solid `browser` implementation.
+
+For this same reason, scanning requires the video preview to be active, and the `pausePreview` method will also pause scanning on the browser platform. (Calling `resumePreview` will continue the scan.)
### Camera Selection
@@ -351,11 +353,21 @@ Both Electron and NW.js automatically provide authorization to access the camera
On the `browser` platform, the `authorized` field is set to `true` if at least one camera is available **and** the user has granted the application access to at least one camera. On Electron and NW.js, this field can reliably be used to determine if a camera is available to the device.
+### Adjusting Scan Speed vs. CPU/Power Usage (uncommon)
+
+On the browser platform, it's possible to adjust the interval at which QR decode attempts occur – even while a scan is happening. This enables applications to intellegently adjust scanning speed in different application states. QRScanner will check for the presence of the global variable `window.QRScanner_SCAN_INTERVAL` before scheduling each next QR decode. If not set, the default of `130` (milliseconds) is used.
+
## Typescript
Type definitions for cordova-plugin-qrscanner are [available in the DefinitelyTyped project](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/cordova-plugin-qrscanner/cordova-plugin-qrscanner.d.ts).
## Contributing & Testing
-To setup the platform tests, run `npm run gen-tests`. This will create a new cordova project in the `cordova-plugin-test-projects` directory next to this repo, install `cordova-plugin-qrscanner`, and configure the [Cordova Plugin Test Framework](https://github.com/apache/cordova-plugin-test-framework). Once the platform tests are generated, the following commands are available:
+To setup the platform tests, run:
+
+```sh
+npm run gen-tests
+```
+
+This will create a new cordova project in the `cordova-plugin-test-projects` directory next to this repo, install `cordova-plugin-qrscanner`, and configure the [Cordova Plugin Test Framework](https://github.com/apache/cordova-plugin-test-framework). Once the platform tests are generated, the following commands are available:
- `npm run test:ios`
- `npm run test:browser`
diff --git a/src/browser/QRScannerProxy.js b/src/browser/QRScannerProxy.js
new file mode 100644
index 00000000..92d768f2
--- /dev/null
+++ b/src/browser/QRScannerProxy.js
@@ -0,0 +1,542 @@
+(function() {
+
+ var ELEMENTS = {
+ preview: 'cordova-plugin-qrscanner-video-preview',
+ still: 'cordova-plugin-qrscanner-still'
+ };
+ var ZINDEXES = {
+ preview: -100,
+ still: -99
+ };
+ var backCamera = null;
+ var frontCamera = null;
+ var currentCamera = 0;
+ var activeMediaStream = null;
+ var scanning = false;
+ var previewing = false;
+ var scanWorker = null;
+ var thisScanCycle = null;
+ var nextScan = null;
+
+ // standard screen widths/heights, from 4k down to 320x240
+ // widths and heights are each tested separately to account for screen rotation
+ var standardWidthsAndHeights = [
+ 5120, 4096, 3840, 3440, 3200, 3072, 3000, 2880, 2800, 2736, 2732, 2560,
+ 2538, 2400, 2304, 2160, 2100, 2048, 2000, 1920, 1856, 1824, 1800, 1792,
+ 1776, 1728, 1700, 1680, 1600, 1536, 1440, 1400, 1392, 1366, 1344, 1334,
+ 1280, 1200, 1152, 1136, 1120, 1080, 1050, 1024, 1000, 960, 900, 854, 848,
+ 832, 800, 768, 750, 720, 640, 624, 600, 576, 544, 540, 512, 480, 320, 240
+ ];
+
+ var facingModes = [
+ 'environment',
+ 'user'
+ ];
+
+ //utils
+ function killStream(mediaStream){
+ mediaStream.getTracks().forEach(function(track){
+ track.stop();
+ });
+ }
+
+ // For performance, we test best-to-worst constraints. Once we find a match,
+ // we move to the next test. Since `ConstraintNotSatisfiedError`s are thrown
+ // much faster than streams can be started and stopped, the scan is much
+ // faster, even though it may iterate through more constraint objects.
+ function getCameraSpecsById(deviceId){
+
+ // return a getUserMedia Constraints
+ function getConstraintObj(deviceId, facingMode, width, height){
+ var obj = { audio: false, video: {} };
+ obj.video.deviceId = {exact: deviceId};
+ if(facingMode) {
+ obj.video.facingMode = {exact: facingMode};
+ }
+ if(width) {
+ obj.video.width = {exact: width};
+ }
+ if(height) {
+ obj.video.height = {exact: height};
+ }
+ return obj;
+ }
+
+ var facingModeConstraints = facingModes.map(function(mode){
+ return getConstraintObj(deviceId, mode);
+ });
+ var widthConstraints = standardWidthsAndHeights.map(function(width){
+ return getConstraintObj(deviceId, null, width);
+ });
+ var heightConstraints = standardWidthsAndHeights.map(function(height){
+ return getConstraintObj(deviceId, null, null, height);
+ });
+
+ // create a promise which tries to resolve the best constraints for this deviceId
+ // rather than reject, failures return a value of `null`
+ function getFirstResolvingConstraint(constraintsBestToWorst){
+ return new Promise(function(resolveBestConstraints){
+ // build a chain of promises which either resolves or continues searching
+ return constraintsBestToWorst.reduce(function(chain, next){
+ return chain.then(function(searchState){
+ if(searchState.found){
+ // The best working constraint was found. Skip further tests.
+ return searchState;
+ } else {
+ searchState.nextConstraint = next;
+ return navigator.mediaDevices.getUserMedia(searchState.nextConstraint).then(function(mediaStream){
+ // We found the first working constraint object, now we can stop
+ // the stream and short-circuit the search.
+ killStream(mediaStream);
+ searchState.found = true;
+ return searchState;
+ }, function(){
+ // didn't get a media stream. The search continues:
+ return searchState;
+ });
+ }
+ });
+ }, Promise.resolve({
+ // kick off the search:
+ found: false,
+ nextConstraint: {}
+ })).then(function(searchState){
+ if(searchState.found){
+ resolveBestConstraints(searchState.nextConstraint);
+ } else {
+ resolveBestConstraints(null);
+ }
+ });
+ });
+ }
+
+ return getFirstResolvingConstraint(facingModeConstraints).then(function(facingModeSpecs){
+ return getFirstResolvingConstraint(widthConstraints).then(function(widthSpecs){
+ return getFirstResolvingConstraint(heightConstraints).then(function(heightSpecs){
+ return {
+ deviceId: deviceId,
+ facingMode: facingModeSpecs === null ? null : facingModeSpecs.video.facingMode.exact,
+ width: widthSpecs === null ? null : widthSpecs.video.width.exact,
+ height: heightSpecs === null ? null : heightSpecs.video.height.exact
+ };
+ });
+ });
+ });
+ }
+
+ function chooseCameras(){
+ var devices = navigator.mediaDevices.enumerateDevices();
+ return devices.then(function(mediaDeviceInfoList){
+ var videoDeviceIds = mediaDeviceInfoList.filter(function(elem){
+ return elem.kind === 'videoinput';
+ }).map(function(elem){
+ return elem.deviceId;
+ });
+ return videoDeviceIds;
+ }).then(function(videoDeviceIds){
+ // there is no standardized way for us to get the specs of each camera
+ // (due to concerns over user fingerprinting), so we're forced to
+ // iteratively test each camera for it's capabilities
+ var searches = [];
+ videoDeviceIds.forEach(function(id){
+ searches.push(getCameraSpecsById(id));
+ });
+ return Promise.all(searches);
+ }).then(function(cameraSpecsArray){
+ return cameraSpecsArray.filter(function(camera){
+ // filter out any cameras where width and height could not be captured
+ if(camera !== null && camera.width !== null && camera.height !== null){
+ return true;
+ }
+ }).sort(function(a, b){
+ // sort cameras from highest resolution (by width) to lowest
+ return b.width - a.width;
+ });
+ }).then(function(bestToWorstCameras){
+ var backCamera = null,
+ frontCamera = null;
+ // choose backCamera
+ for(var i = 0; i < bestToWorstCameras.length; i++){
+ if (bestToWorstCameras[i].facingMode === 'environment'){
+ backCamera = bestToWorstCameras[i];
+ // (shouldn't be used for frontCamera)
+ bestToWorstCameras.splice(i, 1);
+ break;
+ }
+ }
+ // if no back-facing cameras were found, choose the highest resolution
+ if(backCamera === null){
+ if(bestToWorstCameras.length > 0){
+ backCamera = bestToWorstCameras[0];
+ // (shouldn't be used for frontCamera)
+ bestToWorstCameras.splice(0, 1);
+ } else {
+ // user doesn't have any available cameras
+ backCamera = false;
+ }
+ }
+ if(bestToWorstCameras.length > 0){
+ // frontCamera should simply be the next-best resolution camera
+ frontCamera = bestToWorstCameras[0];
+ } else {
+ // user doesn't have any more cameras
+ frontCamera = false;
+ }
+ return {
+ backCamera: backCamera,
+ frontCamera: frontCamera
+ };
+ });
+ }
+
+ function mediaStreamIsActive(){
+ return activeMediaStream !== null;
+ }
+
+ function killActiveMediaStream(){
+ killStream(activeMediaStream);
+ activeMediaStream = null;
+ }
+
+ function getVideoPreview(){
+ return document.getElementById(ELEMENTS.preview);
+ }
+
+ function getImg(){
+ return document.getElementById(ELEMENTS.still);
+ }
+
+ function getCurrentCameraIndex(){
+ return currentCamera;
+ }
+
+ function getCurrentCamera(){
+ return currentCamera === 1 ? frontCamera : backCamera;
+ }
+
+ function bringStillToFront(){
+ getImg().style.visibility = 'visible';
+ previewing = false;
+ }
+
+ function bringPreviewToFront(){
+ getImg().style.visibility = 'hidden';
+ previewing = true;
+ }
+
+ function isInitialized(){
+ return backCamera !== null;
+ }
+
+ function canChangeCamera(){
+ return backCamera !== null && frontCamera !== null;
+ }
+
+ function calcStatus(){
+ return {
+ // !authorized means the user either has no camera or has denied access.
+ // This would leave a value of `null` before prepare(), and `false` after.
+ authorized: (backCamera !== null && backCamera !== false)? '1': '0',
+ // No applicable API
+ denied: '0',
+ // No applicable API
+ restricted: '0',
+ prepared: isInitialized() ? '1' : '0',
+ scanning: scanning? '1' : '0',
+ previewing: previewing? '1' : '0',
+ // We leave this true after prepare() to match the mobile experience as
+ // closely as possible. (Without additional covering, the preview will
+ // always be visible to the user).
+ showing: getVideoPreview()? '1' : '0',
+ // No applicable API
+ lightEnabled: '0',
+ // No applicable API
+ canOpenSettings: '0',
+ // No applicable API
+ canEnableLight: '0',
+ canChangeCamera: canChangeCamera() ? '1' : '0',
+ currentCamera: currentCamera.toString()
+ };
+ }
+
+ function startCamera(success, error){
+ var currentCameraIndex = getCurrentCameraIndex();
+ var currentCamera = getCurrentCamera();
+ navigator.mediaDevices.getUserMedia({
+ audio: false,
+ video: {
+ deviceId: {exact: currentCamera.deviceId},
+ width: {ideal: currentCamera.width},
+ height: {ideal: currentCamera.height}
+ }
+ }).then(function(mediaStream){
+ activeMediaStream = mediaStream;
+ var video = getVideoPreview();
+ video.src = URL.createObjectURL(mediaStream);
+ success(calcStatus());
+ }, function(err){
+ console.error(err);
+ var code = currentCameraIndex? 4 : 3;
+ error(code); // FRONT_CAMERA_UNAVAILABLE : BACK_CAMERA_UNAVAILABLE
+ });
+ }
+
+ function getTempCanvasAndContext(videoElement){
+ var tempCanvas = document.createElement('canvas');
+ var camera = getCurrentCamera();
+ tempCanvas.height = camera.height;
+ tempCanvas.width = camera.width;
+ var tempCanvasContext = tempCanvas.getContext('2d');
+ tempCanvasContext.drawImage(videoElement, 0, 0, camera.width, camera.height);
+ return {
+ canvas: tempCanvas,
+ context: tempCanvasContext
+ };
+ }
+
+ function getCurrentImageData(videoElement){
+ var snapshot = getTempCanvasAndContext(videoElement);
+ return snapshot.context.getImageData(0, 0, snapshot.canvas.width, snapshot.canvas.height);
+ }
+
+ // take a screenshot of the video preview with a temp canvas
+ function captureCurrentFrame(videoElement){
+ return getTempCanvasAndContext(videoElement).canvas.toDataURL('image/png');
+ }
+
+ function initialize(success, error){
+ if(scanWorker === null){
+ scanWorker = new Worker('/plugins/cordova-plugin-qrscanner/src/browser/worker.js');
+ }
+ if(!getVideoPreview()){
+ // prepare DOM (sync)
+ var videoPreview = document.createElement('video');
+ videoPreview.setAttribute('autoplay', 'autoplay');
+ videoPreview.setAttribute('id', ELEMENTS.preview);
+ videoPreview.setAttribute('style', 'display:block;position:fixed;top:50%;left:50%;' +
+ 'width:auto;height:auto;min-width:100%;min-height:100%;z-index:' + ZINDEXES.preview +
+ ';-moz-transform: translateX(-50%) translateY(-50%);-webkit-transform: ' +
+ 'translateX(-50%) translateY(-50%);transform:translateX(-50%) translateY(-50%);' +
+ 'background-size:cover;background-position:50% 50%;background-color:#FFF;');
+ videoPreview.addEventListener('loadeddata', function(){
+ bringPreviewToFront();
+ });
+
+ var stillImg = document.createElement('div');
+ stillImg.setAttribute('id', ELEMENTS.still);
+ stillImg.setAttribute('style', 'display:block;position:fixed;top:50%;left:50%;visibility: hidden;' +
+ 'width:auto;height:auto;min-width:100%;min-height:100%;z-index:' + ZINDEXES.still +
+ ';-moz-transform: translateX(-50%) translateY(-50%);-webkit-transform: ' +
+ 'translateX(-50%) translateY(-50%);transform:translateX(-50%) translateY(-50%);' +
+ 'background-size:cover;background-position:50% 50%;background-color:#FFF;');
+
+ document.body.appendChild(videoPreview);
+ document.body.appendChild(stillImg);
+ }
+ if(backCamera === null){
+ // set instance cameras
+ chooseCameras().then(function(cameras){
+ backCamera = cameras.backCamera;
+ frontCamera = cameras.frontCamera;
+ if(backCamera !== false){
+ success();
+ } else {
+ error(5); // CAMERA_UNAVAILABLE
+ }
+ }, function(err){
+ console.error(err);
+ error(0); // UNEXPECTED_ERROR
+ });
+ } else if (backCamera === false){
+ error(5); // CAMERA_UNAVAILABLE
+ } else {
+ success();
+ }
+ }
+
+ /*
+ * --- Begin Public API ---
+ */
+
+ function prepare(success, error){
+ initialize(function(){
+ // return status on success
+ success(calcStatus());
+ },
+ // pass errors through
+ error);
+ }
+
+ function show(success, error){
+ function showCamera(){
+ if(!mediaStreamIsActive()){
+ startCamera(success, error);
+ } else {
+ success(calcStatus());
+ }
+ }
+ if(!isInitialized()){
+ initialize(function(){
+ // on successful initialization, attempt to showCamera
+ showCamera();
+ },
+ // pass errors through
+ error);
+ } else {
+ showCamera();
+ }
+ }
+
+ function hide(success, error){
+ error = null; // should never error
+ if(mediaStreamIsActive()){
+ killActiveMediaStream();
+ }
+ var video = getVideoPreview();
+ if(video){
+ video.src = '';
+ }
+ success(calcStatus());
+ }
+
+ function scan(success, error) {
+ // initialize and start video preview if not already active
+ show(function(ignore){
+ // ignore success output – `scan` method callback should be passed the decoded data
+ ignore = null;
+ var video = getVideoPreview();
+ var returned = false;
+ scanning = true;
+ scanWorker.onmessage = function(event){
+ var obj = event.data;
+ if(obj.result && !returned){
+ returned = true;
+ thisScanCycle = null;
+ success(obj.result);
+ }
+ };
+ thisScanCycle = function(){
+ window.lastData = getCurrentImageData(video);
+ scanWorker.postMessage(getCurrentImageData(video));
+ // avoid race conditions, always clear before starting a cycle
+ window.clearTimeout(nextScan);
+ // interval in milliseconds at which to try decoding the QR code
+ var SCAN_INTERVAL = window.QRScanner_SCAN_INTERVAL || 130;
+ // this value can be adjusted on-the-fly (while a scan is active) to
+ // balance scan speed vs. CPU/power usage
+ nextScan = window.setTimeout(thisScanCycle, SCAN_INTERVAL);
+ };
+ thisScanCycle();
+ }, error);
+ }
+
+ function cancelScan(success, error){
+ error = null; // should never error
+ if(nextScan !== null){
+ window.clearTimeout(nextScan);
+ }
+ scanning = false;
+ success(calcStatus());
+ }
+
+ function pausePreview(success, error){
+ error = null; // should never error
+ if(mediaStreamIsActive()){
+ // pause scanning too
+ if(nextScan !== null){
+ window.clearTimeout(nextScan);
+ }
+ var video = getVideoPreview();
+ video.pause();
+ var img = new Image();
+ img.src = captureCurrentFrame(video);
+ window.lastImage = img.src;
+ getImg().style.backgroundImage = 'url(' + img.src + ')';
+ bringStillToFront();
+ // kill the active stream to turn off the privacy light (the screenshot
+ // in the stillImg will remain visible)
+ killActiveMediaStream();
+ success(calcStatus());
+ } else {
+ success(calcStatus());
+ }
+ }
+
+ function resumePreview(success, error){
+ // if a scan was happening, resume it
+ if(thisScanCycle !== null){
+ thisScanCycle();
+ }
+ show(success, error);
+ }
+
+ function enableLight(success, error){
+ error(7); //LIGHT_UNAVAILABLE
+ }
+
+ function disableLight(success, error){
+ error(7); //LIGHT_UNAVAILABLE
+ }
+
+ function useCamera(success, error, array){
+ var requestedCamera = array[0];
+ if(requestedCamera !== currentCamera){
+ currentCamera = requestedCamera;
+ hide(function(status){
+ // Don't need this one
+ status = null;
+ });
+ show(success, error);
+ } else {
+ success(calcStatus());
+ }
+ }
+
+ function openSettings(success, error){
+ error(8); //OPEN_SETTINGS_UNAVAILABLE
+ }
+
+ function getStatus(success, error){
+ error = null; // should never error
+ success(calcStatus());
+ }
+
+ // Reset all instance variables to their original state.
+ // This method might be useful in cases where a new camera is available, and
+ // the application needs to force the plugin to chooseCameras() again.
+ function destroy(success, error){
+ error = null; // should never error
+ if(mediaStreamIsActive()){
+ killActiveMediaStream();
+ }
+ backCamera = null;
+ frontCamera = null;
+ var preview = getVideoPreview();
+ var still = getImg();
+ if(preview){
+ preview.remove();
+ }
+ if(still){
+ still.remove();
+ }
+ success(calcStatus());
+ }
+
+ module.exports = {
+ prepare: prepare,
+ show: show,
+ hide: hide,
+ scan: scan,
+ cancelScan: cancelScan,
+ pausePreview: pausePreview,
+ resumePreview: resumePreview,
+ enableLight: enableLight,
+ disableLight: disableLight,
+ useCamera: useCamera,
+ openSettings: openSettings,
+ getStatus: getStatus,
+ destroy: destroy
+ };
+
+ require('cordova/exec/proxy').add('QRScanner', module.exports);
+})();
diff --git a/src/browser/worker.js b/src/browser/worker.js
new file mode 100644
index 00000000..1adb14cc
--- /dev/null
+++ b/src/browser/worker.js
@@ -0,0 +1,11 @@
+var module = {};
+importScripts('qrcode-reader.js');
+var QrCode = module.exports;
+var qr = new QrCode();
+qr.callback = function(result, err){
+ postMessage({result: result, err: err});
+};
+onmessage = function(event){
+ var imageData = event.data;
+ qr.decode(imageData);
+};
diff --git a/tests/tests.js b/tests/tests.js
index 8f273820..aadb8971 100644
--- a/tests/tests.js
+++ b/tests/tests.js
@@ -103,10 +103,10 @@ exports.defineManualTests = function(contentEl, createActionButton) {
var showBtn = 'QRScanner.show()';
qrscanner_tests += 'Show QRScanner
' +
'' +
- 'Expected result: Should clear background of the body and html elements (making the QRScanner layer visible through this webview).';
+ 'Expected result: Should make the video preview layer visible.';
var show = function() {
window.QRScanner.show(function(status) {
- log(showBtn, null, status, 'webviewBackgroundIsTransparent');
+ log(showBtn, null, status, 'showing');
});
};
@@ -114,10 +114,10 @@ exports.defineManualTests = function(contentEl, createActionButton) {
var hideBtn = 'QRScanner.hide()';
qrscanner_tests += 'Hide QRScanner
' +
'' +
- 'Expected result: Should reset the native webview background to white and opaque.';
+ 'Expected result: Should hide the video preview layer (returning the background to the default – opaque and white).';
var hide = function() {
window.QRScanner.hide(function(status) {
- log(hideBtn, null, status, 'webviewBackgroundIsTransparent');
+ log(hideBtn, null, status, 'showing');
});
};