Merge pull request #365 from bitjson/feature/new-qrscanner-permissions

Polish QR scanning for desktop, iOS, and Android
This commit is contained in:
Matias Alejo Garcia 2016-10-11 11:29:30 -03:00 committed by GitHub
commit 767b523499
9 changed files with 358 additions and 278 deletions

View file

@ -117,7 +117,6 @@ module.exports = function(grunt) {
angular: {
src: [
'bower_components/qrcode-generator/js/qrcode.js',
'bower_components/qrcode-decoder-js/lib/qrcode-decoder.js',
'bower_components/moment/min/moment-with-locales.js',
'bower_components/angular-moment/angular-moment.js',
'bower_components/ng-lodash/build/ng-lodash.js',

View file

@ -1,110 +0,0 @@
'use strict';
angular.module('copayApp.controllers').controller('scannerController', function($scope, $timeout, storageService, $ionicModal, platformInfo) {
// QR code Scanner
var video;
var canvas;
var $video;
var context;
var localMediaStream;
var prevResult;
var scanTimer;
var _scan = function(evt) {
if (localMediaStream) {
context.drawImage(video, 0, 0, 300, 225);
try {
qrcode.decode();
} catch (e) {
//qrcodeError(e);
}
}
scanTimer = $timeout(_scan, 800);
};
var _scanStop = function() {
$timeout.cancel(scanTimer);
if (localMediaStream && localMediaStream.active) {
var localMediaStreamTrack = localMediaStream.getTracks();
for (var i = 0; i < localMediaStreamTrack.length; i++) {
localMediaStreamTrack[i].stop();
}
} else {
try {
localMediaStream.stop();
} catch (e) {
// Older Chromium not support the STOP function
};
}
localMediaStream = null;
video.src = '';
};
qrcode.callback = function(data) {
if (prevResult != data) {
prevResult = data;
return;
}
_scanStop();
$scope.cancel();
$scope.onScan({
data: data
});
};
var _successCallback = function(stream) {
video.src = (window.URL && window.URL.createObjectURL(stream)) || stream;
localMediaStream = stream;
video.play();
$timeout(_scan, 1000);
};
var _videoError = function(err) {
$scope.cancel();
};
var setScanner = function() {
navigator.getUserMedia = navigator.getUserMedia ||
navigator.webkitGetUserMedia || navigator.mozGetUserMedia ||
navigator.msGetUserMedia;
window.URL = window.URL || window.webkitURL ||
window.mozURL || window.msURL;
};
$scope.init = function() {
scannerInit();
};
$scope.$on('TipsModalClosed', function(event) {
scannerInit();
});
function scannerInit() {
setScanner();
$timeout(function() {
if ($scope.beforeScan) {
$scope.beforeScan();
}
canvas = document.getElementById('qr-canvas');
context = canvas.getContext('2d');
video = document.getElementById('qrcode-scanner-video');
$video = angular.element(video);
canvas.width = 300;
canvas.height = 225;
context.clearRect(0, 0, 300, 225);
navigator.getUserMedia({
video: true
}, _successCallback, _videoError);
}, 500);
};
$scope.cancel = function() {
_scanStop();
$scope.scannerModal.hide();
$scope.scannerModal.remove();
};
});

View file

@ -1,29 +1,111 @@
'use strict';
angular.module('copayApp.controllers').controller('tabScanController', function($scope, $log, $timeout, scannerService, incomingData) {
angular.module('copayApp.controllers').controller('tabScanController', function($scope, $log, $timeout, scannerService, incomingData, $state, $ionicHistory, $rootScope) {
$scope.$on("$ionicView.beforeEnter", function() {
$log.debug('Preparing to display available controls.');
var scannerStates = {
unauthorized: 'unauthorized',
denied: 'denied',
unavailable: 'unavailable',
loading: 'loading',
visible: 'visible'
};
$scope.scannerStates = scannerStates;
function _updateCapabilities(){
var capabilities = scannerService.getCapabilities();
$scope.scannerIsAvailable = capabilities.isAvailable;
$scope.scannerHasPermission = capabilities.hasPermission;
$scope.scannerIsDenied = capabilities.isDenied;
$scope.scannerIsRestricted = capabilities.isRestricted;
$scope.canEnableLight = capabilities.canEnableLight;
$scope.canChangeCamera = capabilities.canChangeCamera;
$scope.canOpenSettings = capabilities.canOpenSettings;
}
function _handleCapabilities(){
// always update the view
$timeout(function(){
if(!scannerService.isInitialized()){
$scope.currentState = scannerStates.loading;
} else if(!$scope.scannerIsAvailable){
$scope.currentState = scannerStates.unavailable;
} else if($scope.scannerIsDenied){
$scope.currentState = scannerStates.denied;
} else if($scope.scannerIsRestricted){
$scope.currentState = scannerStates.denied;
} else if(!$scope.scannerHasPermission){
$scope.currentState = scannerStates.unauthorized;
}
$log.debug('Scan view state set to: ' + $scope.currentState);
});
}
function _refreshScanView(){
_updateCapabilities();
_handleCapabilities();
if($scope.scannerHasPermission){
activate();
}
}
// This could be much cleaner with a Promise API
// (needs a polyfill for some platforms)
$rootScope.$on('scannerServiceInitialized', function(){
$log.debug('Scanner initialization finished, reinitializing scan view...');
_refreshScanView();
});
$scope.$on("$ionicView.afterEnter", function() {
// try initializing and refreshing status any time the view is entered
scannerService.gentleInitialize();
});
function activate(){
scannerService.activate(function(){
_updateCapabilities();
_handleCapabilities();
$log.debug('Scanner activated, setting to visible...');
$scope.currentState = scannerStates.visible;
// pause to update the view
$timeout(function(){
scannerService.scan(function(err, contents){
if(err){
$log.debug('Scan canceled.');
} else if ($state.params.passthroughMode) {
$rootScope.scanResult = contents;
goBack();
} else {
incomingData.redir(contents);
handleSuccessfulScan(contents);
}
});
});
});
}
$scope.activate = activate;
$scope.authorize = function(){
scannerService.initialize(function(){
_refreshScanView();
});
};
$scope.$on("$ionicView.afterLeave", function() {
scannerService.deactivate();
});
function handleSuccessfulScan(contents){
$log.debug('Scan returned: "' + contents + '"');
incomingData.redir(contents);
}
$scope.openSettings = function(){
scannerService.openSettings();
};
$scope.attemptToReactivate = function(){
scannerService.reinitialize();
};
$scope.toggleLight = function(){
scannerService.toggleLight(function(lightEnabled){
$scope.lightActive = lightEnabled;
@ -42,4 +124,14 @@ angular.module('copayApp.controllers').controller('tabScanController', function(
});
};
$scope.canGoBack = function(){
return $state.params.passthroughMode;
}
function goBack(){
$ionicHistory.nextViewOptions({
disableAnimate: true
});
$ionicHistory.backView().go();
}
$scope.goBack = goBack;
});

View file

@ -1,77 +1,31 @@
'use strict';
angular.module('copayApp.directives')
.directive('qrScanner', function($rootScope, $timeout, $ionicModal, gettextCatalog, platformInfo) {
var isCordova = platformInfo.isCordova;
var isWP = platformInfo.isWP;
var isIOS = platformInfo.isIOS;
var controller = function($scope) {
var onSuccess = function(result) {
$timeout(function() {
window.plugins.spinnerDialog.hide();
}, 100);
if (isWP && result.cancelled) return;
$timeout(function() {
var data = isIOS ? result : result.text;
$scope.onScan({
data: data
});
}, 1000);
};
var onError = function(error) {
$timeout(function() {
window.plugins.spinnerDialog.hide();
}, 100);
};
$scope.cordovaOpenScanner = function() {
window.plugins.spinnerDialog.show(null, gettextCatalog.getString('Preparing camera...'), true);
$timeout(function() {
if (isIOS) {
cloudSky.zBar.scan({}, onSuccess, onError);
} else {
cordova.plugins.barcodeScanner.scan(onSuccess, onError);
}
if ($scope.beforeScan) {
$scope.beforeScan();
}
}, 100);
};
$scope.modalOpenScanner = function() {
$ionicModal.fromTemplateUrl('views/modals/scanner.html', {
scope: $scope,
animation: 'slide-in-up'
}).then(function(modal) {
$scope.scannerModal = modal;
$scope.scannerModal.show();
});
};
$scope.openScanner = function() {
if (isCordova) {
$scope.cordovaOpenScanner();
} else {
$scope.modalOpenScanner();
}
};
$scope.setFn({theScanFn: $scope.openScanner});
};
.directive('qrScanner', function($state, $rootScope, $log, $ionicHistory) {
return {
restrict: 'E',
scope: {
onScan: "&",
setFn: "&",
beforeScan: "&"
onScan: "&"
},
controller: controller,
replace: true,
template: '<a on-tap="openScanner()"><i class="icon ion-qr-scanner"></i></a>'
template: '<a on-tap="openScanner()" nav-transition="none"><i class="icon ion-qr-scanner"></i></a>',
link: function(scope, el, attrs) {
scope.openScanner = function() {
$log.debug('Opening scanner by directive...');
$ionicHistory.nextViewOptions({
disableAnimate: true
});
$state.go('scanner', { passthroughMode: 1 });
};
$rootScope.$on('$ionicView.afterEnter', function() {
if($rootScope.scanResult) {
scope.onScan({ data: $rootScope.scanResult });
$rootScope.scanResult = null;
}
});
}
}
});

View file

@ -217,6 +217,14 @@ angular.module('copayApp').config(function(historicLogProvider, $provide, $logPr
}
}
})
.state('scanner', {
url: '/scanner',
params: {
passthroughMode: null,
},
controller: 'tabScanController',
templateUrl: 'views/tab-scan.html'
})
.state('tabs.send', {
url: '/send',
views: {

View file

@ -1,6 +1,6 @@
'use strict';
angular.module('copayApp.services').service('scannerService', function($log, $timeout, platformInfo) {
angular.module('copayApp.services').service('scannerService', function($log, $timeout, platformInfo, $rootScope) {
var isDesktop = !platformInfo.isCordova;
var QRScanner = window.QRScanner;
@ -8,21 +8,39 @@ angular.module('copayApp.services').service('scannerService', function($log, $ti
var backCamera = true; // the plugin defaults to the back camera
// Initalize known capabilities
var hasPermission = isDesktop? true: false;
var isAvailable = isDesktop? false: true; // assume camera exists on mobile
var hasPermission = isDesktop? true: false; // assume desktop has permission
var isDenied = false;
var isRestricted = false;
var canEnableLight = false;
var canChangeCamera = false;
var canOpenSettings = false;
function _checkCapabilities(status){
$log.debug('scannerService is reviewing platform capabilities...');
// Permission can be assumed on the desktop builds
hasPermission = (isDesktop || status.authorized)? true: false;
isDenied = status.denied? true : false;
isRestricted = status.restricted? true : false;
canEnableLight = status.canEnableLight? true : false;
canChangeCamera = status.canChangeCamera? true : false;
function orIsNot(bool){
canOpenSettings = status.canOpenSettings? true : false;
_logCapabilities();
}
function _logCapabilities(){
function _orIsNot(bool){
return bool? '' : 'not ';
}
$log.debug('A light is ' + orIsNot(canEnableLight) + 'available on this platform.');
$log.debug('A second camera is ' + orIsNot(canChangeCamera) + 'available on this platform.');
$log.debug('A camera is ' + _orIsNot(isAvailable) + 'available to this app.');
var access = 'not authorized';
if(hasPermission) access = 'authorized';
if(isDenied) access = 'denied';
if(isRestricted) access = 'restricted';
$log.debug('Camera access is ' + access + '.');
$log.debug('Support for opening device settings is ' + _orIsNot(canOpenSettings) + 'available on this platform.');
$log.debug('A light is ' + _orIsNot(canEnableLight) + 'available on this platform.');
$log.debug('A second camera is ' + _orIsNot(canChangeCamera) + 'available on this platform.');
}
/**
@ -30,12 +48,17 @@ angular.module('copayApp.services').service('scannerService', function($log, $ti
*/
this.getCapabilities = function(){
return {
isAvailable: isAvailable,
hasPermission: hasPermission,
isDenied: isDenied,
isRestricted: isRestricted,
canEnableLight: canEnableLight,
canChangeCamera: canChangeCamera
canChangeCamera: canChangeCamera,
canOpenSettings: canOpenSettings
}
}
var initializeStarted = false;
/**
* If camera access has been granted, pre-initialize the QRScanner. This method
* can be safely called before the scanner is visible to improve perceived
@ -44,39 +67,70 @@ angular.module('copayApp.services').service('scannerService', function($log, $ti
* The `status` of QRScanner is returned to the callback.
*/
this.gentleInitialize = function(callback) {
if(initializeStarted){
QRScanner.getStatus(function(status){
_completeInitialization(status, callback);
});
return;
}
initializeStarted = true;
$log.debug('Trying to pre-initialize QRScanner.');
if(!isDesktop){
QRScanner.getStatus(function(status){
_checkCapabilities(status);
if(status.authorized){
$log.debug('Camera permission already granted.');
_initalize();
initialize(callback);
} else {
$log.debug('QRScanner not authorized, waiting to initalize.');
if(typeof callback === "function"){
callback && callback(status);
}
_completeInitialization(status, callback);
}
});
} else {
$log.debug('Camera permission assumed on desktop.');
_initalize();
}
function _initalize(){
$log.debug('Preparing scanner...');
QRScanner.prepare(function(err, status){
if(err){
$log.error(err);
}
_checkCapabilities(status);
callback && callback(status);
});
initialize(callback);
}
};
function initialize(callback){
$log.debug('Initializing scanner...');
QRScanner.prepare(function(err, status){
if(err){
$log.error(err);
// does not return `status` if there is an error
QRScanner.getStatus(function(status){
_completeInitialization(status, callback);
});
} else {
isAvailable = true;
_completeInitialization(status, callback);
}
});
}
this.initialize = initialize;
// This could be much cleaner with a Promise API
// (needs a polyfill for some platforms)
var initializeCompleted = false;
function _completeInitialization(status, callback){
_checkCapabilities(status);
initializeCompleted = true;
$rootScope.$emit('scannerServiceInitialized');
if(typeof callback === "function"){
callback(status);
}
}
this.isInitialized = function(){
return initializeCompleted;
}
this.initializeStarted = function(){
return initializeStarted;
}
var nextHide = null;
var nextDestroy = null;
var hideAfterSeconds = 15;
var hideAfterSeconds = 10;
var destroyAfterSeconds = 5 * 60;
/**
@ -89,7 +143,9 @@ angular.module('copayApp.services').service('scannerService', function($log, $ti
$log.debug('Activating scanner...');
QRScanner.show(function(status){
_checkCapabilities(status);
if(typeof callback === "function"){
callback(status);
}
});
if(nextHide !== null){
$timeout.cancel(nextHide);
@ -121,7 +177,7 @@ angular.module('copayApp.services').service('scannerService', function($log, $ti
*/
this.deactivate = function(callback) {
$log.debug('Deactivating scanner...');
QRScanner.cancelScan();
// QRScanner.cancelScan();
nextHide = $timeout(_hide, hideAfterSeconds * 1000);
nextDestroy = $timeout(_destroy, destroyAfterSeconds * 1000);
};
@ -140,6 +196,12 @@ angular.module('copayApp.services').service('scannerService', function($log, $ti
QRScanner.destroy();
}
this.reinitialize = function(callback){
initializeCompleted = false;
QRScanner.destroy();
initialize(callback);
}
/**
* Toggle the device light (if available).
*
@ -185,4 +247,9 @@ angular.module('copayApp.services').service('scannerService', function($log, $ti
callback(status);
});
};
this.openSettings = function() {
$log.debug('Attempting to open device settings...');
QRScanner.openSettings();
};
});

View file

@ -1,4 +1,62 @@
$scannerBackgroundColor: #060d2d;
#tab-scan {
color: #fff;
text-align: center;
background: transparent none;
.bar-header {
opacity: .9;
}
&-has-problems,
&-loading-camera {
background-color: $scannerBackgroundColor;
}
&-has-problems {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.zero-state {
&-icon {
display: inline-block;
width: 50px;
height: 50px;
border-radius: 50%;
padding: 13px;
box-shadow: $subtle-box-shadow;
background-color: #fff;
}
&-heading {
font-size: 20px;
margin: 1rem;
}
&-description {
margin: 0 2rem 120px;
opacity: .6;
max-width: 300px;
}
&-tldr {
margin: 1rem auto;
}
&-description,
&-tldr {
max-width: 300px;
}
&-cta {
position: absolute;
bottom: 0;
width: 100%;
padding-bottom: 6vh;
}
}
}
&-loading-camera {
height: 100%;
width: 100%
}
&-camera-ready {
// view background is transparent to show video preview
background: none transparent;
.scanner-controls {
@ -48,8 +106,9 @@
.icon-camera-toggle {
background-image: url("../img/icon-camera-toggle.svg");
}
}
}
#cordova-plugin-qrscanner-still, #cordova-plugin-qrscanner-video-preview {
background-color: #060d2d !important;
background-color: $scannerBackgroundColor !important;
}

View file

@ -1,12 +0,0 @@
<ion-modal-view ng-controller="scannerController" ng-init="init()">
<ion-header-bar align-title="center" class="bar-royal">
<button ng-click="cancel()" class="button button-back button-clear" translate>
Close
</button>
<h1 class="title ellipsis" translate>QR-Scanner</h1>
</ion-header-bar>
<ion-content >
<canvas id="qr-canvas" width="200" height="150"></canvas>
<video id="qrcode-scanner-video" width="300" height="225"></video>
</ion-content>
</ion-modal-view>

View file

@ -1,8 +1,30 @@
<ion-view id="tab-scan">
<ion-nav-bar class="bar-royal">
<ion-nav-title>{{'Scan' | translate}}</ion-nav-title>
<ion-nav-buttons side="primary">
<button class="button back-button button-clear ng-hide" ng-click="goBack()" ng-show="canGoBack()">
<i class="icon ion-ios-close-empty"></i>
</button>
</ion-nav-buttons>
</ion-nav-bar>
<ion-content scroll="false">
<div class="ng-hide" id="tab-scan-has-problems" ng-show="currentState === scannerStates.unauthorized || currentState === scannerStates.denied || currentState === scannerStates.unavailable">
<i class="icon zero-state-icon">
<img src="img/tab-icons/ico-receive.svg"/>
</i>
<div class="zero-state-heading" translate>Scan QR Codes</div>
<div class="zero-state-description" translate>You can scan bitcoin addresses, payment requests, paper wallets, and more.</div>
<div class="zero-state-cta">
<div class="ng-hide zero-state-tldr" ng-show="currentState === scannerStates.unauthorized" translate>Enable the camera to get started.</div>
<div class="ng-hide zero-state-tldr" ng-show="currentState === scannerStates.denied" translate>Enable camera access in your device settings to get started.</div>
<div class="ng-hide zero-state-tldr" ng-show="currentState === scannerStates.unavailable" translate>Please connect a camera to get started.</div>
<button ng-show="currentState === scannerStates.unauthorized" class="ng-hide button button-standard button-primary" ng-click="authorize()">Allow Camera Access</button>
<button ng-show="currentState === scannerStates.denied && canOpenSettings" class="ng-hide button button-standard button-primary" ng-click="openSettings()">Open Settings</button>
<button ng-show="currentState === scannerStates.unavailable" class="ng-hide button button-standard button-primary" ng-click="attemptToReactivate()">Retry Camera</button>
</div>
</div>
<div class="ng-show" id="tab-scan-loading-camera" ng-show="currentState === scannerStates.loading"></div>
<div class="ng-hide" id="tab-scan-camera-ready" ng-show="currentState === scannerStates.visible">
<div class="guides">
<img class="qr-scan-guides" src="img/bitpay-wallet-qr-scan-guides.svg">
</div>
@ -18,5 +40,6 @@
</i>
</a>
</div>
</div>
</ion-content>
</ion-view>