Commit a29662cd by Will Daly

ECOM-859: Add support for an input image capture.

Add support for HTML Media Capture (file input)
so that iOS users can use the camera.
parent d0c9daa4
......@@ -1129,6 +1129,7 @@ verify_student_js = [
'js/src/string_utils.js',
'js/verify_student/models/verification_model.js',
'js/verify_student/views/error_view.js',
'js/verify_student/views/image_input_view.js',
'js/verify_student/views/webcam_photo_view.js',
'js/verify_student/views/step_view.js',
'js/verify_student/views/intro_step_view.js',
......
......@@ -423,6 +423,16 @@
},
'js/verify_student/views/webcam_photo_view': {
exports: 'edx.verify_student.WebcamPhotoView',
deps: [
'jquery',
'underscore',
'backbone',
'gettext',
'js/verify_student/views/image_input_view'
]
},
'js/verify_student/views/image_input_view': {
exports: 'edx.verify_student.ImageInputView',
deps: [ 'jquery', 'underscore', 'backbone', 'gettext' ]
},
'js/verify_student/views/step_view': {
......@@ -540,6 +550,7 @@
'lms/include/js/spec/student_profile/profile_spec.js',
'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js',
'lms/include/js/spec/verify_student/webcam_photo_view_spec.js',
'lms/include/js/spec/verify_student/image_input_spec.js',
'lms/include/js/spec/verify_student/review_photos_step_view_spec.js',
'lms/include/js/spec/verify_student/make_payment_step_view_spec.js',
'lms/include/js/spec/edxnotes/utils/logger_spec.js',
......
define([
'jquery',
'backbone',
'js/common_helpers/template_helpers',
'js/common_helpers/ajax_helpers',
'js/verify_student/views/image_input_view',
'js/verify_student/models/verification_model'
], function( $, Backbone, TemplateHelpers, AjaxHelpers, ImageInputView, VerificationModel ) {
'use strict';
describe( 'edx.verify_student.ImageInputView', function() {
var IMAGE_DATA = 'abcd1234';
var createView = function() {
return new ImageInputView({
el: $( '#current-step-container' ),
model: new VerificationModel({}),
modelAttribute: 'faceImage',
errorModel: new ( Backbone.Model.extend({}) )(),
submitButton: $( '#submit_button' ),
}).render();
};
var uploadImage = function( view, fileType, callback ) {
var imageCapturedEvent = false,
errorEvent = false;
// Since image upload is an asynchronous process,
// we need to wait for the upload to complete
// before checking the outcome.
runs(function() {
var fakeFile,
fakeEvent = { target: { files: [] } };
// If no file type is specified, don't add any files.
// This simulates what happens when the user clicks
// "cancel" after clicking the input.
if ( fileType !== null) {
fakeFile = new Blob(
[ IMAGE_DATA ],
{ type: 'image/' + fileType }
);
fakeEvent.target.files = [ fakeFile ];
}
// Wait for either a successful upload or an error
view.on( 'imageCaptured', function() {
imageCapturedEvent = true;
});
view.on( 'error', function() {
errorEvent = true;
});
// Trigger the file input change
// It's impossible to trigger this directly due
// to browser security restrictions, so we call
// the handler instead.
view.handleInputChange( fakeEvent );
});
// Check that the image upload has completed,
// either successfully or with an error.
waitsFor(function() {
return ( imageCapturedEvent || errorEvent );
});
// Execute the callback to check expectations.
runs( callback );
};
var expectPreview = function( view, fileType ) {
var previewImage = view.$preview.attr('src');
if ( fileType ) {
expect( previewImage ).toContain( 'data:image/' + fileType );
} else {
expect( previewImage ).toEqual( '' );
}
};
var expectSubmitEnabled = function( isEnabled ) {
var appearsDisabled = $( '#submit_button' ).hasClass( 'is-disabled' ),
isDisabled = $( '#submit_button' ).prop( 'disabled' );
expect( !appearsDisabled ).toEqual( isEnabled );
expect( !isDisabled ).toEqual( isEnabled );
};
var expectImageData = function( view, fileType ) {
var imageData = view.model.get( view.modelAttribute );
if ( fileType ) {
expect( imageData ).toContain( 'data:image/' + fileType );
} else {
expect( imageData ).toEqual( '' );
}
};
var expectError = function( view ) {
expect( view.errorModel.get('shown') ).toBe(true);
};
beforeEach(function() {
setFixtures(
'<div id="current-step-container"></div>' +
'<input type="button" id="submit_button"></input>'
);
TemplateHelpers.installTemplate( 'templates/verify_student/image_input' );
});
it( 'initially disables the submit button', function() {
createView();
expectSubmitEnabled( false );
});
it( 'uploads a png image', function() {
var view = createView();
uploadImage( view, 'png', function() {
expectPreview( view, 'png' );
expectSubmitEnabled( true );
expectImageData( view, 'png' );
});
});
it( 'uploads a jpeg image', function() {
var view = createView();
uploadImage( view, 'jpeg', function() {
expectPreview( view, 'jpeg' );
expectSubmitEnabled( true );
expectImageData( view, 'jpeg' );
} );
});
it( 'hides the preview when the user cancels the upload', function() {
var view = createView();
uploadImage( view, null, function() {
expectPreview( view, null );
expectSubmitEnabled( false );
expectImageData( view, null );
} );
});
it( 'shows an error if the file type is not supported', function() {
var view = createView();
uploadImage( view, 'txt', function() {
expectPreview( view, null );
expectError( view );
expectSubmitEnabled( false );
expectImageData( view, null );
} );
});
});
});
......@@ -13,7 +13,8 @@ define(['jquery', 'js/common_helpers/template_helpers', 'js/verify_student/views
'make_payment_step',
'payment_confirmation_step',
'review_photos_step',
'webcam_photo'
'webcam_photo',
'image_input'
];
var INTRO_STEP = {
......
......@@ -45,14 +45,14 @@ define([
};
};
var createView = function( backends ) {
var createView = function( backendStub ) {
return new WebcamPhotoView({
el: $( '#current-step-container' ),
model: new VerificationModel({}),
modelAttribute: 'faceImage',
errorModel: new ( Backbone.Model.extend({}) )(),
submitButton: $( '#submit_button' ),
backends: backends
backend: backendStub
}).render();
};
......@@ -91,7 +91,7 @@ define([
});
it( 'takes a snapshot', function() {
var view = createView( [ StubBackend( "html5" ) ] );
var view = createView( new StubBackend( "html5" ) );
// Spy on the backend
spyOn( view.backend, 'snapshot' ).andCallThrough();
......@@ -122,7 +122,7 @@ define([
});
it( 'resets the camera', function() {
var view = createView( [ StubBackend( "html5" ) ]);
var view = createView( new StubBackend( "html5" ) );
// Spy on the backend
spyOn( view.backend, 'reset' ).andCallThrough();
......@@ -145,30 +145,8 @@ define([
expect( view.model.get( 'faceImage' ) ).toEqual( "" );
});
it( 'falls back to a second video capture backend', function() {
var backends = [ StubBackend( "html5", false ), StubBackend( "flash", true ) ],
view = createView( backends );
// Expect that the second backend is chosen
expect( view.backend.name ).toEqual( backends[1].name );
});
it( 'displays an error if no video backend is supported', function() {
var backends = [ StubBackend( "html5", false ), StubBackend( "flash", false ) ],
view = createView( backends );
// Expect an error
expect( view.errorModel.get( 'errorTitle' ) ).toEqual( 'Flash Not Detected' );
expect( view.errorModel.get( 'errorMsg' ) ).toContain( 'Get Flash' );
expect( view.errorModel.get( 'shown' ) ).toBe( true );
// Expect that submission is disabled
expectSubmitEnabled( false );
});
it( 'displays an error if the snapshot fails', function() {
var backends = [ StubBackend( "html5", true, false ) ],
view = createView( backends );
var view = createView( new StubBackend( "html5", true, false ) );
// Take a snapshot
takeSnapshot();
......@@ -189,7 +167,7 @@ define([
});
it( 'displays an error triggered by the backend', function() {
var view = createView( [ StubBackend( "html5") ] );
var view = createView( new StubBackend( "html5") );
// Simulate an error triggered by the backend
// This could occur at any point, including
......
......@@ -17,7 +17,7 @@
errorMsg: "",
shown: false
});
this.listenToOnce( this.model, 'change', this.render );
this.listenTo( this.model, 'change', this.render );
},
render: function() {
......
......@@ -17,7 +17,7 @@ var edx = edx || {};
},
postRender: function() {
var webcam = new edx.verify_student.WebcamPhotoView({
var webcam = edx.verify_student.getSupportedWebcamView({
el: $( '#facecam' ),
model: this.model,
modelAttribute: 'faceImage',
......
......@@ -17,7 +17,7 @@ var edx = edx || {};
},
postRender: function() {
var webcam = new edx.verify_student.WebcamPhotoView({
var webcam = edx.verify_student.getSupportedWebcamView({
el: $( '#idcam' ),
model: this.model,
modelAttribute: 'identificationImage',
......
/**
* Allow users to upload an image using a file input.
*
* This uses HTML Media Capture so that iOS will
* allow users to use their camera instead of choosing
* a file.
*/
var edx = edx || {};
(function( $, _, Backbone, gettext ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.ImageInputView = Backbone.View.extend({
template: '#image_input-tpl',
initialize: function( obj ) {
this.$submitButton = obj.submitButton ? $( obj.submitButton ) : '';
this.modelAttribute = obj.modelAttribute || '';
this.errorModel = obj.errorModel || null;
},
render: function() {
var renderedHtml = _.template( $( this.template ).html(), {} );
$( this.el ).html( renderedHtml );
// Set the submit button to disabled by default
this.setSubmitButtonEnabled( false );
this.$input = $( 'input.image-upload' );
this.$preview = $( 'img.preview' );
this.$input.on('change', _.bind( this.handleInputChange, this ) );
// Initially hide the preview
this.displayImage( false );
return this;
},
handleInputChange: function( event ) {
var files = event.target.files,
reader = new FileReader();
if ( files[0] && files[0].type.match( 'image.[png|jpg|jpeg]' ) ) {
reader.onload = _.bind( this.handleImageUpload, this );
reader.onerror = _.bind( this.handleUploadError, this );
reader.readAsDataURL( files[0] );
} else if ( files.length === 0 ) {
this.handleUploadError( false );
} else {
this.handleUploadError( true );
}
},
handleImageUpload: function( event ) {
var imageData = event.target.result;
this.model.set( this.modelAttribute, imageData );
this.displayImage( imageData );
this.setSubmitButtonEnabled( true );
// Hide any errors we may have displayed previously
if ( this.errorModel ) {
this.errorModel.set({ shown: false });
}
this.trigger( 'imageCaptured' );
},
displayImage: function( imageData ) {
if ( imageData ) {
this.$preview
.attr( 'src', imageData )
.removeClass('is-hidden')
.attr('aria-hidden', 'false');
} else {
this.$preview
.attr( 'src', '' )
.addClass('is-hidden')
.attr('aria-hidden', 'true');
}
},
handleUploadError: function( displayError ) {
this.displayImage( null );
this.setSubmitButtonEnabled( false );
if ( this.errorModel ) {
if ( displayError ) {
this.errorModel.set({
errorTitle: gettext( 'Image Upload Error' ),
errorMsg: gettext( 'Please verify that you have uploaded a valid image (PNG and JPEG).' ),
shown: true
});
} else {
this.errorModel.set({
shown: false
});
}
}
this.trigger( 'error' );
},
setSubmitButtonEnabled: function( isEnabled ) {
this.$submitButton
.toggleClass( 'is-disabled', !isEnabled )
.prop( 'disabled', !isEnabled )
.attr('aria-disabled', !isEnabled);
}
});
})( jQuery, _, Backbone, gettext );
/**
* Interface for retrieving webcam photos.
* Supports both HTML5 and Flash.
* Supports HTML5 and Flash.
*/
var edx = edx || {};
......@@ -13,8 +13,8 @@
template: "#webcam_photo-tpl",
backends: [
{
backends: {
"html5": {
name: "html5",
initialize: function( obj ) {
......@@ -24,18 +24,21 @@
this.stream = null;
// Start the capture
this.getUserMediaFunc()(
{
video: true,
// Specify the `fake` constraint if we detect we are running in a test
// environment. In Chrome, this will do nothing, but in Firefox, it will
// instruct the browser to use a fake video device.
fake: window.location.hostname === 'localhost'
},
_.bind( this.getUserMediaCallback, this ),
_.bind( this.handleVideoFailure, this )
);
var getUserMedia = this.getUserMediaFunc();
if ( getUserMedia ) {
getUserMedia(
{
video: true,
// Specify the `fake` constraint if we detect we are running in a test
// environment. In Chrome, this will do nothing, but in Firefox, it will
// instruct the browser to use a fake video device.
fake: window.location.hostname === 'localhost'
},
_.bind( this.getUserMediaCallback, this ),
_.bind( this.handleVideoFailure, this )
);
}
},
isSupported: function() {
......@@ -98,16 +101,14 @@
}
},
{
"flash": {
name: "flash",
initialize: function( obj ) {
this.wrapper = obj.wrapper || "";
this.imageData = "";
// Replace the camera section with the flash object
$( this.wrapper ).html( this.flashObjectTag() );
// Wait for the player to load, then verify camera support
// Trigger an error if no camera is available.
this.checkCameraSupported();
......@@ -203,36 +204,26 @@
// so we don't need to keep checking.
}
}
],
},
initialize: function( obj ) {
this.submitButton = obj.submitButton || "";
this.modelAttribute = obj.modelAttribute || "";
this.errorModel = obj.errorModel || null;
this.backend = _.find(
obj.backends || this.backends,
function( backend ) {
return backend.isSupported();
}
);
this.backend = this.backends[obj.backendName] || obj.backend;
if ( !this.backend ) {
this.handleError(
gettext( "Flash Not Detected" ),
gettext( "You don't seem to have Flash installed." ) + " " +
_.sprintf(
gettext( "%(a_start)s Get Flash %(a_end)s to continue your enrollment." ),
{
a_start: '<a rel="external" href="http://get.adobe.com/flashplayer/">',
a_end: '</a>'
}
)
);
}
else {
_.extend( this.backend, Backbone.Events );
this.listenTo( this.backend, 'error', this.handleError );
}
this.backend.initialize({
wrapper: "#camera",
video: '#photo_id_video',
canvas: '#photo_id_canvas'
});
_.extend( this.backend, Backbone.Events );
this.listenTo( this.backend, 'error', this.handleError );
},
isSupported: function() {
return this.backend.isSupported();
},
render: function() {
......@@ -242,26 +233,18 @@
this.setSubmitButtonEnabled( false );
// Load the template for the webcam into the DOM
renderedHtml = _.template( $( this.template ).html(), {} );
renderedHtml = _.template(
$( this.template ).html(),
{ backendName: this.backend.name }
);
$( this.el ).html( renderedHtml );
// Initialize the video capture backend
// We need to do this after rendering the template
// so that the backend has the opportunity to modify the DOM.
if ( this.backend ) {
this.backend.initialize({
wrapper: "#camera",
video: '#photo_id_video',
canvas: '#photo_id_canvas'
});
// Install event handlers
$( "#webcam_reset_button", this.el ).on( 'click', _.bind( this.reset, this ) );
$( "#webcam_capture_button", this.el ).on( 'click', _.bind( this.capture, this ) );
// Install event handlers
$( "#webcam_reset_button", this.el ).on( 'click', _.bind( this.reset, this ) );
$( "#webcam_capture_button", this.el ).on( 'click', _.bind( this.capture, this ) );
// Show the capture button
$( "#webcam_capture_button", this.el ).removeClass('is-hidden');
}
// Show the capture button
$( "#webcam_capture_button", this.el ).removeClass('is-hidden');
return this;
},
......@@ -325,4 +308,38 @@
}
});
/**
* Retrieve a supported webcam view implementation.
*
* The priority order from most to least preferable is:
* 1) HTML5
* 2) Flash
* 3) File input
*
* @param {Object} obj Parameters to the webcam view.
* @return {Object} A Backbone view.
*/
edx.verify_student.getSupportedWebcamView = function( obj ) {
var view = null;
// First choice is HTML5, supported by most web browsers
obj.backendName = "html5";
view = new edx.verify_student.WebcamPhotoView( obj );
if ( view.isSupported() ) {
return view;
}
// Second choice is Flash, required for older versions of IE
obj.backendName = "flash";
view = new edx.verify_student.WebcamPhotoView( obj );
if ( view.isSupported() ) {
return view;
}
// Last resort is HTML file input with image capture.
// This will work everywhere, and on iOS it will
// allow users to take a photo with the camera.
return new edx.verify_student.ImageInputView( obj );
};
})( jQuery, _, Backbone, gettext );
......@@ -973,6 +973,11 @@
.controls {
height: ($baseline*4);
}
.preview {
width: 100%;
height: 100%;
}
}
// ====================
......
<img class="preview" alt="<%- gettext("Preview of uploaded image") %>"/>
<label>
<span class="sr"><%- gettext("Upload an image or capture one with your web or phone camera.") %></span>
<input class="image-upload" type="file" accept="image/*;capture=camera">
</label>
......@@ -23,7 +23,7 @@ from verify_student.views import PayAndVerifyView
<%block name="header_extras">
<%
template_names = (
["webcam_photo", "error"] +
["webcam_photo", "image_input", "error"] +
[step['templateName'] for step in display_steps]
)
%>
......
<div class="placeholder-cam" id="camera">
<div class="placeholder-art">
<p class="copy"><%- gettext( "Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission." ) %></p>
</div>
<% if ( backendName === 'html5' ) { %>
<div class="placeholder-art">
<p class="copy"><%- gettext( "Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission." ) %></p>
</div>
<video id="photo_id_video" aria-label="<%- gettext( 'Live view of webcam' ) %>" autoplay></video><br/>
<canvas id="photo_id_canvas" style="display:none;" width="640" height="480"></canvas>
<video id="photo_id_video" aria-label="<%- gettext( 'Live view of webcam' ) %>" autoplay></video><br/>
<canvas id="photo_id_canvas" style="display:none;" width="640" height="480"></canvas>
<% } else if ( backendName === 'flash' ) { %>
<object type="application/x-shockwave-flash"
id="flash_video"
name="flash_video"
data="/static/js/verify_student/CameraCapture.swf"
width="500"
height="375">
<param name="quality" value="high">
<param name="allowscriptaccess" value="sameDomain">
</object>
<% } %>
</div>
<div class="controls photo-controls">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment