diff --git a/app/controllers/frontend_log_controller.rb b/app/controllers/frontend_log_controller.rb index b68f9723ca2..7cc737acecb 100644 --- a/app/controllers/frontend_log_controller.rb +++ b/app/controllers/frontend_log_controller.rb @@ -17,6 +17,7 @@ class FrontendLogController < ApplicationController 'IdV: personal key confirm visited' => :idv_personal_key_confirm_visited, 'IdV: personal key confirm submitted' => :idv_personal_key_confirm_submitted, 'IdV: download personal key' => :idv_personal_key_downloaded, + 'IdV: Native camera forced after failed attempts' => :idv_native_camera_forced, 'Multi-Factor Authentication: download backup code' => :multi_factor_auth_backup_code_download, }.transform_values do |method| method.is_a?(Proc) ? method : AnalyticsEvents.instance_method(method) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index 7e9aa5aaba5..9fca4c40e51 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -282,9 +282,14 @@ function AcuantCapture( const [attempt, incrementAttempt] = useCounter(1); const [acuantFailureCookie, setAcuantFailureCookie, refreshAcuantFailureCookie] = useCookie('AcuantCameraHasFailed'); - const { onFailedCaptureAttempt, onResetFailedCaptureAttempts } = useContext( - FailedCaptureAttemptsContext, - ); + + const { + failedCaptureAttempts, + onFailedCaptureAttempt, + onResetFailedCaptureAttempts, + forceNativeCamera, + } = useContext(FailedCaptureAttemptsContext); + const hasCapture = !isError && (isReady ? isCameraSupported : isMobile); useEffect(() => { // If capture had started before Acuant was ready, stop capture if readiness reveals that no @@ -381,6 +386,31 @@ function AcuantCapture( isSuppressingClickLogging.current = false; } + /** + * Triggers upload to occur, regardless of support for direct capture. This is necessary since the + * default behavior for interacting with the file input is intercepted when capture is supported. + * Calling `forceUpload` will flag the click handling to skip intercepting the event as capture. + */ + function forceUpload() { + if (!inputRef.current) { + return; + } + + isForceUploading.current = true; + + const originalCapture = inputRef.current.getAttribute('capture'); + + if (originalCapture !== null) { + inputRef.current.removeAttribute('capture'); + } + + withoutClickLogging(() => inputRef.current?.click()); + + if (originalCapture !== null) { + inputRef.current.setAttribute('capture', originalCapture); + } + } + /** * Responds to a click by starting capture if supported in the environment, or triggering the * default file picker prompt. The click event may originate from the file input itself, or @@ -390,6 +420,13 @@ function AcuantCapture( */ function startCaptureOrTriggerUpload(event) { if (event.target === inputRef.current) { + if (forceNativeCamera) { + addPageAction('IdV: Native camera forced after failed attempts', { + field: name, + failed_attempts: failedCaptureAttempts, + }); + return forceUpload(); + } const isAcuantCaptureCapable = hasCapture && !acuantFailureCookie; const shouldStartAcuantCapture = isAcuantCaptureCapable && capture !== 'user' && !isForceUploading.current; @@ -417,31 +454,6 @@ function AcuantCapture( } } - /** - * Triggers upload to occur, regardless of support for direct capture. This is necessary since the - * default behavior for interacting with the file input is intercepted when capture is supported. - * Calling `forceUpload` will flag the click handling to skip intercepting the event as capture. - */ - function forceUpload() { - if (!inputRef.current) { - return; - } - - isForceUploading.current = true; - - const originalCapture = inputRef.current.getAttribute('capture'); - - if (originalCapture !== null) { - inputRef.current.removeAttribute('capture'); - } - - withoutClickLogging(() => inputRef.current?.click()); - - if (originalCapture !== null) { - inputRef.current.setAttribute('capture', originalCapture); - } - } - /** * @param {AcuantSuccessResponse} nextCapture */ diff --git a/app/javascript/packages/document-capture/context/failed-capture-attempts.jsx b/app/javascript/packages/document-capture/context/failed-capture-attempts.jsx index 18bd4734bbf..0ce5887c00e 100644 --- a/app/javascript/packages/document-capture/context/failed-capture-attempts.jsx +++ b/app/javascript/packages/document-capture/context/failed-capture-attempts.jsx @@ -18,7 +18,9 @@ import useCounter from '../hooks/use-counter'; * attempt, to increment attempts. * @prop {() => void} onResetFailedCaptureAttempts Callback to trigger a reset of attempts. * @prop {number} maxFailedAttemptsBeforeTips Number of failed attempts before showing tips. + * @prop {number} maxAttemptsBeforeNativeCamera Number of attempts before forcing the use of the native camera (if available) * @prop {CaptureAttemptMetadata} lastAttemptMetadata Metadata about the last attempt. + * @prop {boolean} forceNativeCamera Whether or not to force use of the native camera. Is set to true if the number of failedCaptureAttempts is equal to or greater than maxAttemptsBeforeNativeCamera */ /** @type {CaptureAttemptMetadata} */ @@ -32,8 +34,10 @@ const FailedCaptureAttemptsContext = createContext( failedCaptureAttempts: 0, onFailedCaptureAttempt: () => {}, onResetFailedCaptureAttempts: () => {}, + maxAttemptsBeforeNativeCamera: Infinity, maxFailedAttemptsBeforeTips: Infinity, lastAttemptMetadata: DEFAULT_LAST_ATTEMPT_METADATA, + forceNativeCamera: false, }), ); @@ -44,18 +48,25 @@ FailedCaptureAttemptsContext.displayName = 'FailedCaptureAttemptsContext'; * * @prop {ReactNode} children * @prop {number} maxFailedAttemptsBeforeTips + * @prop {number} maxAttemptsBeforeNativeCamera */ /** * @param {FailedCaptureAttemptsContextProviderProps} props */ -function FailedCaptureAttemptsContextProvider({ children, maxFailedAttemptsBeforeTips }) { +function FailedCaptureAttemptsContextProvider({ + children, + maxFailedAttemptsBeforeTips, + maxAttemptsBeforeNativeCamera, +}) { const [lastAttemptMetadata, setLastAttemptMetadata] = useState( /** @type {CaptureAttemptMetadata} */ (DEFAULT_LAST_ATTEMPT_METADATA), ); const [failedCaptureAttempts, incrementFailedCaptureAttempts, onResetFailedCaptureAttempts] = useCounter(); + const forceNativeCamera = failedCaptureAttempts >= maxAttemptsBeforeNativeCamera; + /** * @param {CaptureAttemptMetadata} metadata */ @@ -70,8 +81,10 @@ function FailedCaptureAttemptsContextProvider({ children, maxFailedAttemptsBefor failedCaptureAttempts, onFailedCaptureAttempt, onResetFailedCaptureAttempts, + maxAttemptsBeforeNativeCamera, maxFailedAttemptsBeforeTips, lastAttemptMetadata, + forceNativeCamera, }} > {children} diff --git a/app/javascript/packs/document-capture.jsx b/app/javascript/packs/document-capture.jsx index 2a8f1a8e6d0..f2ea46db5a4 100644 --- a/app/javascript/packs/document-capture.jsx +++ b/app/javascript/packs/document-capture.jsx @@ -40,6 +40,7 @@ import { trackEvent } from '@18f/identity-analytics'; * @prop {string} helpCenterRedirectUrl * @prop {string} appName * @prop {string} maxCaptureAttemptsBeforeTips + * @prop {string} maxAttemptsBeforeNativeCamera * @prop {FlowPath} flowPath * @prop {string} cancelUrl * @prop {string=} idvInPersonUrl @@ -131,6 +132,7 @@ function addPageAction(event, payload) { const { helpCenterRedirectUrl: helpCenterRedirectURL, maxCaptureAttemptsBeforeTips, + maxAttemptsBeforeNativeCamera, appName, flowPath, cancelUrl: cancelURL, @@ -180,6 +182,7 @@ function addPageAction(event, payload) { FailedCaptureAttemptsContextProvider, { maxFailedAttemptsBeforeTips: Number(maxCaptureAttemptsBeforeTips), + maxAttemptsBeforeNativeCamera: Number(maxAttemptsBeforeNativeCamera), }, ], [DocumentCapture, { isAsyncForm, onStepChange: keepAlive }], diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 45af378d4ee..7ecf141792f 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -467,6 +467,20 @@ def idv_cancellation_visited(step:, request_came_from:, **extra) ) end + # @param [String] name the name to prepend to analytics events + # @param [Number] failed_attempts the number of failed document capture attempts so far + # The number of acceptable failed attempts (maxFailedAttemptsBeforeNativeCamera) has been met + # or exceeded, and the system has forced the use of the native camera, rather than Acuant's + # camera, on mobile devices. + def idv_native_camera_forced(name:, failed_attempts:, **extra) + track_event( + 'IdV: Native camera forced after failed attempts', + name: name, + failed_attempts: failed_attempts, + **extra, + ) + end + # @param [String] step the step that the user was on when they clicked cancel # The user confirmed their choice to cancel going through IDV def idv_cancellation_confirmed(step:, **extra) diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 7333af9c835..4316d38eb20 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -29,6 +29,7 @@ sharpness_threshold: IdentityConfig.store.doc_auth_client_sharpness_threshold, status_poll_interval_ms: IdentityConfig.store.poll_rate_for_verify_in_seconds * 1000, max_capture_attempts_before_tips: IdentityConfig.store.doc_auth_max_capture_attempts_before_tips, + max_attempts_before_native_camera: IdentityConfig.store.doc_auth_max_attempts_before_native_camera, sp_name: sp_name, flow_path: flow_path, cancel_url: idv_cancel_path, diff --git a/config/application.yml.default b/config/application.yml.default index c8f835fddda..fe1ae6bb8ac 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -78,6 +78,7 @@ doc_auth_error_glare_threshold: 40 doc_auth_error_sharpness_threshold: 40 doc_auth_max_attempts: 20 doc_auth_max_capture_attempts_before_tips: 3 +doc_auth_max_attempts_before_native_camera: 2 doc_capture_request_valid_for_minutes: 15 email_from: no-reply@login.gov email_from_display_name: Login.gov diff --git a/lib/identity_config.rb b/lib/identity_config.rb index bbeeed761d2..a8a146bec30 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -142,6 +142,7 @@ def self.build_store(config_map) config.add(:doc_auth_error_sharpness_threshold, type: :integer) config.add(:doc_auth_extend_timeout_by_minutes, type: :integer) config.add(:doc_auth_max_attempts, type: :integer) + config.add(:doc_auth_max_attempts_before_native_camera, type: :integer) config.add(:doc_auth_max_capture_attempts_before_tips, type: :integer) config.add(:doc_auth_s3_request_timeout, type: :integer) config.add(:doc_auth_vendor, type: :string) diff --git a/spec/javascripts/packages/document-capture/context/failed-capture-attempts-spec.jsx b/spec/javascripts/packages/document-capture/context/failed-capture-attempts-spec.jsx index a32f59b10d3..392c4e731c7 100644 --- a/spec/javascripts/packages/document-capture/context/failed-capture-attempts-spec.jsx +++ b/spec/javascripts/packages/document-capture/context/failed-capture-attempts-spec.jsx @@ -1,8 +1,14 @@ import { useContext } from 'react'; import { renderHook } from '@testing-library/react-hooks'; +import userEvent from '@testing-library/user-event'; +import { DeviceContext, AnalyticsContext } from '@18f/identity-document-capture'; +import { Provider as AcuantContextProvider } from '@18f/identity-document-capture/context/acuant'; +import AcuantCapture from '@18f/identity-document-capture/components/acuant-capture'; import FailedCaptureAttemptsContext, { Provider, } from '@18f/identity-document-capture/context/failed-capture-attempts'; +import sinon from 'sinon'; +import { render } from '../../../support/document-capture'; describe('document-capture/context/failed-capture-attempts', () => { it('has expected default properties', () => { @@ -10,22 +16,29 @@ describe('document-capture/context/failed-capture-attempts', () => { expect(result.current).to.have.keys([ 'failedCaptureAttempts', + 'forceNativeCamera', 'onFailedCaptureAttempt', 'onResetFailedCaptureAttempts', 'maxFailedAttemptsBeforeTips', + 'maxAttemptsBeforeNativeCamera', 'lastAttemptMetadata', ]); expect(result.current.failedCaptureAttempts).to.equal(0); expect(result.current.onFailedCaptureAttempt).to.be.a('function'); expect(result.current.onResetFailedCaptureAttempts).to.be.a('function'); expect(result.current.maxFailedAttemptsBeforeTips).to.be.a('number'); + expect(result.current.maxAttemptsBeforeNativeCamera).to.be.a('number'); expect(result.current.lastAttemptMetadata).to.be.an('object'); }); describe('Provider', () => { it('sets increments on onFailedCaptureAttempt', () => { const { result } = renderHook(() => useContext(FailedCaptureAttemptsContext), { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => ( + + {children} + + ), }); result.current.onFailedCaptureAttempt({ isAssessedAsGlare: true, isAssessedAsBlurry: false }); @@ -46,3 +59,117 @@ describe('document-capture/context/failed-capture-attempts', () => { }); }); }); + +describe('FailedCaptureAttemptsContext testing of forceNativeCamera logic', () => { + it('Updating to a number of failed captures less than maxAttemptsBeforeNativeCamera will keep forceNativeCamera as false', () => { + const { result, rerender } = renderHook(() => useContext(FailedCaptureAttemptsContext), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + result.current.onFailedCaptureAttempt({ + isAssessedAsGlare: true, + isAssessedAsBlurry: false, + }); + rerender(true); + expect(result.current.failedCaptureAttempts).to.equal(1); + expect(result.current.forceNativeCamera).to.equal(false); + }); + it('Updating failed captures to a number gte the maxAttemptsBeforeNativeCamera will set forceNativeCamera to true', () => { + const { result, rerender } = renderHook(() => useContext(FailedCaptureAttemptsContext), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + result.current.onFailedCaptureAttempt({ + isAssessedAsGlare: true, + isAssessedAsBlurry: false, + }); + rerender(true); + expect(result.current.forceNativeCamera).to.equal(false); + result.current.onFailedCaptureAttempt({ + isAssessedAsGlare: true, + isAssessedAsBlurry: false, + }); + rerender(true); + expect(result.current.forceNativeCamera).to.equal(true); + result.current.onFailedCaptureAttempt({ + isAssessedAsGlare: true, + isAssessedAsBlurry: false, + }); + rerender({}); + expect(result.current.failedCaptureAttempts).to.equal(3); + expect(result.current.forceNativeCamera).to.equal(true); + }); +}); + +describe('maxAttemptsBeforeNativeCamera logging tests', () => { + context('failed acuant camera attempts', function () { + /** + * NOTE: We have to force maxAttemptsBeforeLogin to be 0 here + * in order to test this interactively. This is because the react + * testing library does not provide consistent ways to test using both + * a component's elements (for triggering clicks) and a component's + * subscribed context changes. You can use either render or renderHook, + * but not both. + */ + it('calls analytics with native camera message when failed attempts is greater than or equal to 0', async function () { + const addPageAction = sinon.spy(); + const acuantCaptureComponent = ; + function TestComponent({ children }) { + return ( + + + + + {acuantCaptureComponent} + {children} + + + + + ); + } + const result = render(); + const user = userEvent.setup(); + const fileInput = result.container.querySelector('input[type="file"]'); + expect(fileInput).to.exist(); + await user.click(fileInput); + expect(addPageAction).to.have.been.called(); + expect(addPageAction).to.have.been.calledWith( + 'IdV: Native camera forced after failed attempts', + ); + }); + + it('Does not call analytics with native camera message when failed attempts less than 2', async function () { + const addPageAction = sinon.spy(); + const acuantCaptureComponent = ; + function TestComponent({ children }) { + return ( + + + + + {acuantCaptureComponent} + {children} + + + + + ); + } + const result = render(); + const user = userEvent.setup(); + const fileInput = result.container.querySelector('input[type="file"]'); + expect(fileInput).to.exist(); + await user.click(fileInput); + expect(addPageAction).to.not.have.been.calledWith( + 'IdV: Native camera forced after failed attempts', + ); + }); + }); +});