From 2288539a3c1eaa57c5241ba802bd53b75b045c0d Mon Sep 17 00:00:00 2001 From: Jason Dreyzehner Date: Wed, 29 Jun 2016 15:15:32 -0400 Subject: [PATCH] feat(browser): initial release of browser platform --- .jshintrc | 3 +- plugin.xml | 7 +- readme.md | 18 +- src/browser/QRScannerProxy.js | 542 ++++++++++++++++++++++++++++++++++ src/browser/worker.js | 11 + tests/tests.js | 8 +- 6 files changed, 576 insertions(+), 13 deletions(-) create mode 100644 src/browser/QRScannerProxy.js create mode 100644 src/browser/worker.js 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'); }); };