Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
21a7d2e
Add missing scripts
charleyf Nov 13, 2023
3db39bc
Add non-working selfie capture box with labels
charleyf Nov 13, 2023
ebdd725
Set CSP for testing
charleyf Nov 13, 2023
96186fc
Add selfie capture effect and container
charleyf Nov 13, 2023
5a111db
Handle successful selfie capture
charleyf Nov 13, 2023
6ac238c
Start fixing the types
charleyf Nov 13, 2023
27e44eb
Seperate image success function for selfie capture
charleyf Nov 13, 2023
8c47bcc
Draft in error handling
charleyf Nov 13, 2023
8e943e4
Correctly set state to reflect when the selfie cam is open/closed
charleyf Nov 15, 2023
472144a
Clean up open/close functions
charleyf Nov 15, 2023
92d3bf8
Remove security override, it was unnessecary
charleyf Nov 15, 2023
a36dcd2
Draft in loading spinner
charleyf Nov 15, 2023
9ad8da9
Add selfie upload to review page
charleyf Nov 15, 2023
a7ac604
Draft in loading spinner for when the selfie script is still loading
charleyf Nov 15, 2023
6734fd5
Remove console logging
charleyf Nov 15, 2023
563e85f
Extract selfie spinner into component
charleyf Nov 15, 2023
bfa1b91
Revise fullscreen close prop
charleyf Nov 15, 2023
a86e9ff
Remove conditional hook
charleyf Nov 15, 2023
ee25292
useContext rather than pass as a prop
charleyf Nov 15, 2023
ae8fdb7
Remove console log
charleyf Nov 15, 2023
3d3741e
Always use FullScreen component to avoid state bug
charleyf Nov 15, 2023
f820b91
Note bug
charleyf Nov 15, 2023
884b02a
Revert "Always use FullScreen component to avoid state bug"
charleyf Nov 15, 2023
8257519
Add translations
charleyf Nov 16, 2023
6a22934
Remove bug comment, see LG-11632
charleyf Nov 17, 2023
bd3cccd
Add comment
charleyf Nov 17, 2023
64c9de3
Remove Todo comment
charleyf Nov 17, 2023
2edddaf
Remove unused variables, will reintroduce them in logging ticket LG-1…
charleyf Nov 17, 2023
b62a92c
Improve types for selfie capture
charleyf Nov 17, 2023
81c8811
Merge branch 'main' into charley/selfie-try-three
charleyf Nov 17, 2023
7c2f578
Put work behind selfieCaptureEnabled feature flag
charleyf Nov 17, 2023
8989b94
Lint fixes
charleyf Nov 17, 2023
e020879
Remove unnessecary `React` import
charleyf Nov 20, 2023
efb9af4
Remove unnecessary fragment
charleyf Nov 20, 2023
cb289bb
Fix name of AcuantSelfieCamera component, move funtion into effect
charleyf Nov 20, 2023
f14748e
Add comment to div id
charleyf Nov 20, 2023
541d507
Change order of if/else to be clearer
charleyf Nov 20, 2023
0a66cee
Remove selfie capture from service provider context
charleyf Nov 20, 2023
62c65e5
Return children correctly
charleyf Nov 20, 2023
2f005cf
Re add fragment to make typescript happy
charleyf Nov 20, 2023
503940a
Add minimal testing to ensure the new field does not appear
charleyf Nov 20, 2023
d471255
Remove TODOs
charleyf Nov 21, 2023
9eeab88
Extract feature flag tests into a new file
charleyf Nov 21, 2023
f3dffb9
Draft in translations
charleyf Nov 22, 2023
170080a
Revise translations (still proof of concept)
charleyf Nov 22, 2023
94cf66b
Remove Todo
charleyf Nov 22, 2023
23d087d
Fix translation error by adding a photo ignore
charleyf Nov 22, 2023
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { useDidUpdateEffect } from '@18f/identity-react-hooks';
import { useI18n } from '@18f/identity-react-i18n';
import { removeUnloadProtection } from '@18f/identity-url';
import AcuantCamera, { AcuantDocumentType } from './acuant-camera';
import AcuantSelfieCamera from './acuant-selfie-camera';
import AcuantSelfieCaptureCanvas from './acuant-selfie-capture-canvas';
import type {
AcuantCaptureFailureError,
AcuantSuccessResponse,
Expand Down Expand Up @@ -335,6 +337,9 @@ function AcuantCapture(
const [attempt, incrementAttempt] = useCounter(1);
const [acuantFailureCookie, setAcuantFailureCookie, refreshAcuantFailureCookie] =
useCookie('AcuantCameraHasFailed');
// There's some pretty significant changes to this component when it's used for
// selfie capture vs document image capture. This controls those changes.
const selfieCapture = name === 'selfie';

const {
failedCaptureAttempts,
Expand Down Expand Up @@ -488,6 +493,30 @@ function AcuantCapture(
}
}

function onSelfieCaptureSuccess({ image }: { image: string }) {
onChangeAndResetError(image);
onResetFailedCaptureAttempts();
setIsCapturingEnvironment(false);
}

function onSelfieCaptureFailure() {
// Internally, Acuant sets a cookie to bail on guided capture if initialization had
// previously failed for any reason, including declined permission. Since the cookie
// never expires, and since we want to re-prompt even if the user had previously
// declined, unset the cookie value when failure occurs for permissions.
setAcuantFailureCookie(null);
onCameraAccessDeclined();

// Due to a bug with Safari on iOS we force the page to refresh on the third
// time a user denies permissions.
onFailedCameraPermissionAttempt();
if (failedCameraPermissionAttempts > 2) {
removeUnloadProtection();
window.location.reload();
}
setIsCapturingEnvironment(false);
}

function onAcuantImageCaptureSuccess(
nextCapture: AcuantSuccessResponse | LegacyAcuantSuccessResponse,
) {
Expand Down Expand Up @@ -598,7 +627,7 @@ function AcuantCapture(

return (
<div className={[className, 'document-capture-acuant-capture'].filter(Boolean).join(' ')}>
{isCapturingEnvironment && (
{isCapturingEnvironment && !selfieCapture && (
<AcuantCamera
onCropStart={() => setHasStartedCropping(true)}
onImageCaptureSuccess={onAcuantImageCaptureSuccess}
Expand All @@ -615,6 +644,20 @@ function AcuantCapture(
)}
</AcuantCamera>
)}
{isCapturingEnvironment && selfieCapture && (
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a little confusing for me to read, since "environment" refers to the rear-facing camera of a phone, which is the complete opposite of what we mean when we say selfie capture. It was originally introduced to differentiate between selfie and non-selfie, where selfie is capturing the user, not the environment.

https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode

Copy link
Contributor Author

@charleyf charleyf Nov 20, 2023

Choose a reason for hiding this comment

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

Oh, interesting.

  • I've been reading isCapturingEnvironment as "The full screen overlay is open, providing the correct environment for capturing an image from the Acuant SDK". This appears to be how this code functions now?
  • You're saying that isCapturingEnvironment should be read: "The camera facing the environment (back cam on phones) is open and we are potentially capturing an image from that."

I'm going to think about this. isCapturingEnvironment is pretty central to the capture code that users interact with today so I'd -really- prefer not to change it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting, then we need add some comment to indicate that.

Copy link
Contributor

Choose a reason for hiding this comment

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

I've been reading isCapturingEnvironment as "The full screen overlay is open, providing the correct environment for capturing an image from the Acuant SDK".

I might be affected by Monday brain fuzziness, but I thought this, too. I wonder if part of why we thought this was because in our previous work with this code, whenever the full screen overlay was open and providing the correct environment for capturing an image from the Acuant SDK, the user would also be using the back cam on phones? The conditions overlapped for id image capture in a way that they don't overlap for selfie capture.

(I agree with Dawei that it would be good to have comments/documentation about this.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After considering the code again, I do think that that the isCapturingEnvironment state currently means: "Is there a full screen div open and providing an environment to capture the image".

Since that's confusing, I'm writing a ticket to change:

  • isCapturingEnvironment and setIsCapturingEnvironment
  • To isCapturing and setIsCapturing.
  • As a simple rename, this ticket should be safe enough in it's own PR. I'd prefer not to do it here in this PR.

I'm not thrilled that I'm deferring the two major revisions requested on this PR, but I'm not seeing a great way to address them here either and this code is not intended for users in it's current "proof of concept" state.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here's the followup tickets to address the tasks I'm deferring from this PR (Slack of same list):

  1. Add testing: https://cm-jira.usa.gov/browse/LG-11667
  2. Rename isCapturingEnvironment: https://cm-jira.usa.gov/browse/LG-11653
  3. Add analytics: https://cm-jira.usa.gov/browse/LG-11631
  4. Research edge case: https://cm-jira.usa.gov/browse/LG-11632
  5. Research hooks linting: https://cm-jira.usa.gov/browse/LG-11654

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am hoping/planning to merge this ticket as is later today.

<AcuantSelfieCamera
onImageCaptureSuccess={onSelfieCaptureSuccess}
onImageCaptureFailure={onSelfieCaptureFailure}
onImageCaptureOpen={() => setIsCapturingEnvironment(true)}
onImageCaptureClose={() => setIsCapturingEnvironment(false)}
>
<AcuantSelfieCaptureCanvas
fullScreenRef={fullScreenRef}
fullScreenLabel={t('doc_auth.accessible_labels.document_capture_dialog')}
onRequestClose={() => setIsCapturingEnvironment(false)}
/>
</AcuantSelfieCamera>
)}
<FileInput
ref={inputRef}
label={label}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useContext, useEffect } from 'react';
import type { ReactNode } from 'react';
import AcuantContext from '../context/acuant';

declare global {
interface Window {
AcuantPassiveLiveness: AcuantPassiveLivenessInterface;
}
}

type AcuantPassiveLivenessStart = (
faceCaptureCallback: FaceCaptureCallback,
faceDetectionStates: FaceDetectionStates,
) => void;

interface AcuantPassiveLivenessInterface {
/**
* Start capture
*/
start: AcuantPassiveLivenessStart;
/**
* End capture
*/
end: () => void;
}

interface AcuantSelfieCameraContextProps {
/**
* Success callback
*/
onImageCaptureSuccess: ({ image }: { image: string }) => void;
/**
* Failure callback
*/
onImageCaptureFailure: any;
/**
* Capture open callback, tells the rest of the page
* when the fullscreen selfie capture page is open
*/
onImageCaptureOpen: () => void;
/**
* Capture close callback, tells the rest of the page
* when the fullscreen selfie capture page has been closed
*/
onImageCaptureClose: () => void;
/**
* React children node
*/
children: ReactNode;
}

interface FaceCaptureCallback {
onDetectorInitialized: () => void;
onDetection: (text) => void;
onOpened: () => void;
onClosed: () => void;
onError: (error) => void;
onPhotoTaken: () => void;
onPhotoRetake: () => void;
onCaptured: (base64Image: Blob) => void;
}

interface FaceDetectionStates {
FACE_NOT_FOUND: string;
TOO_MANY_FACES: string;
FACE_ANGLE_TOO_LARGE: string;
PROBABILITY_TOO_SMALL: string;
FACE_TOO_SMALL: string;
FACE_CLOSE_TO_BORDER: string;
}

function AcuantSelfieCamera({
onImageCaptureSuccess = () => {},
onImageCaptureFailure = () => {},
onImageCaptureOpen = () => {},
onImageCaptureClose = () => {},
children,
}: AcuantSelfieCameraContextProps) {
const { isReady, setIsActive } = useContext(AcuantContext);

useEffect(() => {
const faceCaptureCallback: FaceCaptureCallback = {
onDetectorInitialized: () => {
// This callback is triggered when the face detector is ready.
// Until then, no actions are executed and the user sees only the camera stream.
// You can opt to display an alert before the callback is triggered.
},
onDetection: () => {
// Triggered when the face does not pass the scan. The UI element
// should be updated here to provide guidence to the user
},
onOpened: () => {
// Camera has opened
onImageCaptureOpen();
},
onClosed: () => {
// Camera has closed
onImageCaptureClose();
},
onError: (error) => {
// Error occurred. Camera permission not granted will
// manifest here with 1 as error code. Unexpected errors will have 2 as error code.
onImageCaptureFailure({ error });
},
onPhotoTaken: () => {
// The photo has been taken and it's showing a preview with a button to accept or retake the image.
},
onPhotoRetake: () => {
// Triggered when retake button is tapped
},
onCaptured: (base64Image) => {
// Triggered when accept button is tapped
onImageCaptureSuccess({ image: `data:image/jpeg;base64,${base64Image}` });
},
};

const faceDetectionStates = {
FACE_NOT_FOUND: 'FACE NOT FOUND',
TOO_MANY_FACES: 'TOO MANY FACES',
FACE_ANGLE_TOO_LARGE: 'FACE ANGLE TOO LARGE',
PROBABILITY_TOO_SMALL: 'PROBABILITY TOO SMALL',
FACE_TOO_SMALL: 'FACE TOO SMALL',
FACE_CLOSE_TO_BORDER: 'TOO CLOSE TO THE FRAME',
};
const cleanupSelfieCamera = () => {
window.AcuantPassiveLiveness.end();
setIsActive(false);
};

const startSelfieCamera = () => {
window.AcuantPassiveLiveness.start(faceCaptureCallback, faceDetectionStates);
setIsActive(true);
};

if (isReady) {
startSelfieCamera();
}
// Cleanup when the AcuantSelfieCamera component is unmounted
return () => (isReady ? cleanupSelfieCamera() : undefined);
}, [isReady]);

return <>{children}</>;
}

export default AcuantSelfieCamera;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useContext } from 'react';
import { getAssetPath } from '@18f/identity-assets';
import { FullScreen } from '@18f/identity-components';
import AcuantContext from '../context/acuant';

function FullScreenLoadingSpinner({ fullScreenRef, onRequestClose, fullScreenLabel }) {
return (
<FullScreen ref={fullScreenRef} label={fullScreenLabel} onRequestClose={onRequestClose}>
<img
src={getAssetPath('loading-badge.gif')}
alt=""
width="144"
height="144"
className="acuant-capture-canvas__spinner"
/>
</FullScreen>
);
}

function AcuantSelfieCaptureCanvas({ fullScreenRef, onRequestClose, fullScreenLabel }) {
const { isReady } = useContext(AcuantContext);
// The Acuant SDK script AcuantPassiveLiveness attaches to whatever element has
// this id. It then uses that element as the root for the full screen selfie capture
const acuantCaptureContainerId = 'acuant-face-capture-container';
return isReady ? (
<div id={acuantCaptureContainerId} />
) : (
<FullScreenLoadingSpinner
fullScreenRef={fullScreenRef}
onRequestClose={onRequestClose}
fullScreenLabel={fullScreenLabel}
/>
);
}

export default AcuantSelfieCaptureCanvas;
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,8 @@ interface DocumentCaptureReviewIssuesProps {
hasDismissed: boolean;
}

type DocumentSide = 'front' | 'back';
type DocumentSide = 'front' | 'back' | 'selfie';

/**
* Sides of the document to present as file input.
*/
const DOCUMENT_SIDES: DocumentSide[] = ['front', 'back'];
function DocumentCaptureReviewIssues({
isFailedDocType,
remainingAttempts = Infinity,
Expand All @@ -47,7 +43,14 @@ function DocumentCaptureReviewIssues({
hasDismissed,
}: DocumentCaptureReviewIssuesProps) {
const { t } = useI18n();
const { notReadySectionEnabled, exitQuestionSectionEnabled } = useContext(FeatureFlagContext);
const { notReadySectionEnabled, exitQuestionSectionEnabled, selfieCaptureEnabled } =
useContext(FeatureFlagContext);

// Sides of document to present as file input.
const documentSides: DocumentSide[] = selfieCaptureEnabled
? ['front', 'back', 'selfie']
: ['front', 'back'];

return (
<>
<PageHeading>{t('doc_auth.headings.review_issues')}</PageHeading>
Expand All @@ -70,7 +73,7 @@ function DocumentCaptureReviewIssues({
]}
/>
)}
{DOCUMENT_SIDES.map((side) => (
{documentSides.map((side) => (
<DocumentSideAcuantCapture
key={side}
side={side}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import AcuantCapture from './acuant-capture';
/**
* @typedef DocumentSideAcuantCaptureProps
*
* @prop {'front'|'back'} side
* @prop {'front'|'back'|'selfie'} side
* @prop {RegisterFieldCallback} registerField
* @prop {Blob|string|null|undefined} value
* @prop {(nextValues:{[key:string]: Blob|string|null|undefined})=>void} onChange Update values,
Expand Down Expand Up @@ -50,9 +50,11 @@ function DocumentSideAcuantCapture({
ref={registerField(side, { isRequired: true })}
/* i18n-tasks-use t('doc_auth.headings.document_capture_back') */
/* i18n-tasks-use t('doc_auth.headings.document_capture_front') */
/* i18n-tasks-use t('doc_auth.headings.document_capture_selfie') */
label={t(`doc_auth.headings.document_capture_${side}`)}
/* i18n-tasks-use t('doc_auth.headings.back') */
/* i18n-tasks-use t('doc_auth.headings.front') */
/* i18n-tasks-use t('doc_auth.headings.selfie') */
bannerText={t(`doc_auth.headings.${side}`)}
value={value}
onChange={(nextValue, metadata) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { FeatureFlagContext } from '../context';
import DocumentCaptureAbandon from './document-capture-abandon';

/**
* @typedef {'front'|'back'} DocumentSide
* @typedef {'front'|'back'|'selfie'} DocumentSide
*/

/**
Expand All @@ -25,13 +25,6 @@ import DocumentCaptureAbandon from './document-capture-abandon';
* @prop {string=} back_image_metadata Back image metadata.
*/

/**
* Sides of document to present as file input.
*
* @type {DocumentSide[]}
*/
const DOCUMENT_SIDES = ['front', 'back'];

/**
* @param {import('@18f/identity-form-steps').FormStepComponentProps<DocumentsStepValue>} props Props object.
*/
Expand All @@ -46,7 +39,16 @@ function DocumentsStep({
const { isMobile } = useContext(DeviceContext);
const { isLastStep } = useContext(FormStepsContext);
const { flowPath } = useContext(UploadContext);
const { notReadySectionEnabled, exitQuestionSectionEnabled } = useContext(FeatureFlagContext);
const { notReadySectionEnabled, exitQuestionSectionEnabled, selfieCaptureEnabled } =
useContext(FeatureFlagContext);

/**
* Sides of document to present as file input.
*
* @type {DocumentSide[]}
*/
const documentSides = selfieCaptureEnabled ? ['front', 'back', 'selfie'] : ['front', 'back'];

return (
<>
{flowPath === 'hybrid' && <HybridDocCaptureWarning className="margin-bottom-4" />}
Expand All @@ -61,7 +63,7 @@ function DocumentsStep({
t('doc_auth.tips.document_capture_id_text3'),
].concat(!isMobile ? [t('doc_auth.tips.document_capture_id_text4')] : [])}
/>
{DOCUMENT_SIDES.map((side) => (
{documentSides.map((side) => (
<DocumentSideAcuantCapture
key={side}
side={side}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@ export interface FeatureFlagContextProps {
* Specify whether to show exit optional questions on doc capture screen.
*/
exitQuestionSectionEnabled: boolean;
/**
* Specify whether to show the selfie capture on the doc capture screen.
*/
selfieCaptureEnabled: boolean;
}

const FeatureFlagContext = createContext<FeatureFlagContextProps>({
notReadySectionEnabled: false,
exitQuestionSectionEnabled: false,
selfieCaptureEnabled: false,
});

FeatureFlagContext.displayName = 'FeatureFlagContext';
Expand Down
Loading