Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/frontend_log_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand All @@ -32,8 +34,10 @@ const FailedCaptureAttemptsContext = createContext(
failedCaptureAttempts: 0,
onFailedCaptureAttempt: () => {},
onResetFailedCaptureAttempts: () => {},
maxAttemptsBeforeNativeCamera: Infinity,
maxFailedAttemptsBeforeTips: Infinity,
lastAttemptMetadata: DEFAULT_LAST_ATTEMPT_METADATA,
forceNativeCamera: false,
}),
);

Expand All @@ -44,18 +48,25 @@ FailedCaptureAttemptsContext.displayName = 'FailedCaptureAttemptsContext';
*
* @prop {ReactNode} children
* @prop {number} maxFailedAttemptsBeforeTips
* @prop {number} maxAttemptsBeforeNativeCamera
Comment on lines 50 to +51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will a user ever see these tips anymore? If not, is this a feature we'd want to remove?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aduth good catch -- we are currently all discussing this and will get back to you / push changes if needed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. We have decided to open a separate ticket for dealing with the tips issue. It's going to take us a bit more time to sort that all out. As it stands with this branch, the tips should never be triggered (the default is 3 failed attempts vs our 2).

*/

/**
* @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
*/
Expand All @@ -70,8 +81,10 @@ function FailedCaptureAttemptsContextProvider({ children, maxFailedAttemptsBefor
failedCaptureAttempts,
onFailedCaptureAttempt,
onResetFailedCaptureAttempts,
maxAttemptsBeforeNativeCamera,
maxFailedAttemptsBeforeTips,
lastAttemptMetadata,
forceNativeCamera,
}}
>
{children}
Expand Down
3 changes: 3 additions & 0 deletions app/javascript/packs/document-capture.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -131,6 +132,7 @@ function addPageAction(event, payload) {
const {
helpCenterRedirectUrl: helpCenterRedirectURL,
maxCaptureAttemptsBeforeTips,
maxAttemptsBeforeNativeCamera,
appName,
flowPath,
cancelUrl: cancelURL,
Expand Down Expand Up @@ -180,6 +182,7 @@ function addPageAction(event, payload) {
FailedCaptureAttemptsContextProvider,
{
maxFailedAttemptsBeforeTips: Number(maxCaptureAttemptsBeforeTips),
maxAttemptsBeforeNativeCamera: Number(maxAttemptsBeforeNativeCamera),
},
],
[DocumentCapture, { isAsyncForm, onStepChange: keepAlive }],
Expand Down
14 changes: 14 additions & 0 deletions app/services/analytics_events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/views/idv/shared/_document_capture.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions config/application.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/identity_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,44 @@
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', () => {
const { result } = renderHook(() => useContext(FailedCaptureAttemptsContext));

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 }) => <Provider maxFailedAttemptsBeforeTips={1}>{children}</Provider>,
wrapper: ({ children }) => (
<Provider maxAttemptsBeforeNativeCamera={2} maxFailedAttemptsBeforeTips={10}>
{children}
</Provider>
),
});

result.current.onFailedCaptureAttempt({ isAssessedAsGlare: true, isAssessedAsBlurry: false });
Expand All @@ -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 }) => (
<Provider maxAttemptsBeforeNativeCamera={2} maxFailedAttemptsBeforeTips={10}>
{children}
</Provider>
),
});
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 }) => (
<Provider maxAttemptsBeforeNativeCamera={2} maxFailedAttemptsBeforeTips={10}>
{children}
</Provider>
),
});
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 = <AcuantCapture />;
function TestComponent({ children }) {
return (
<AnalyticsContext.Provider value={{ addPageAction }}>
<DeviceContext.Provider value={{ isMobile: true }}>
<AcuantContextProvider sdkSrc="about:blank" cameraSrc="about:blank">
<Provider maxAttemptsBeforeNativeCamera={0} maxFailedAttemptsBeforeTips={10}>
{acuantCaptureComponent}
{children}
</Provider>
</AcuantContextProvider>
</DeviceContext.Provider>
</AnalyticsContext.Provider>
);
}
const result = render(<TestComponent />);
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 = <AcuantCapture />;
function TestComponent({ children }) {
return (
<AnalyticsContext.Provider value={{ addPageAction }}>
<DeviceContext.Provider value={{ isMobile: true }}>
<AcuantContextProvider sdkSrc="about:blank" cameraSrc="about:blank">
<Provider maxAttemptsBeforeNativeCamera={2} maxFailedAttemptsBeforeTips={10}>
{acuantCaptureComponent}
{children}
</Provider>
</AcuantContextProvider>
</DeviceContext.Provider>
</AnalyticsContext.Provider>
);
}
const result = render(<TestComponent />);
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',
);
});
});
});