diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx
index 7e9aa5aaba5..7568f3d044e 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,12 @@ function AcuantCapture(
*/
function startCaptureOrTriggerUpload(event) {
if (event.target === inputRef.current) {
+ if (forceNativeCamera) {
+ addPageAction(`IdV: Force native camera. Failed attempts: ${failedCaptureAttempts}`, {
+ field: name,
+ });
+ return forceUpload();
+ }
const isAcuantCaptureCapable = hasCapture && !acuantFailureCookie;
const shouldStartAcuantCapture =
isAcuantCaptureCapable && capture !== 'user' && !isForceUploading.current;
@@ -417,31 +453,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/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..4ec3510cf61 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 { useAcuant, 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 () {
+ const { initialize } = useAcuant();
+ initialize();
+ /**
+ * 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: Force native camera. Failed attempts: 0');
+ });
+
+ 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: Force native camera. Failed attempts: 2',
+ );
+ });
+ });
+});