ts/screens/authentication/cie/CieCardReaderScreen.tsx
/**
* A screen to guide the user to proper read the CIE
* TODO: isolate cie event listener as saga
* TODO: when 100% is reached, the animation end
*/
import {
ButtonLink,
ButtonSolid,
ContentWrapper,
H3,
IOColors,
IOPictograms,
IOStyles,
VSpacer
} from "@pagopa/io-app-design-system";
import cieManager, { Event as CEvent } from "@pagopa/react-native-cie";
import { Millisecond } from "@pagopa/ts-commons/lib/units";
import * as O from "fp-ts/lib/Option";
import { pipe } from "fp-ts/lib/function";
import * as React from "react";
import {
AccessibilityInfo,
Platform,
ScrollView,
Text,
Vibration,
View,
StyleSheet
} from "react-native";
import { connect } from "react-redux";
import { SafeAreaView } from "react-native-safe-area-context";
import { useFocusEffect } from "@react-navigation/native";
import CieCardReadingAnimation, {
ReadingState
} from "../../../components/cie/CieCardReadingAnimation";
import { Body } from "../../../components/core/typography/Body";
import { isCieLoginUatEnabledSelector } from "../../../features/cieLogin/store/selectors";
import { getCieUatEndpoint } from "../../../features/cieLogin/utils/endpoints";
import I18n from "../../../i18n";
import { IOStackNavigationRouteProps } from "../../../navigation/params/AppParamsList";
import { AuthenticationParamsList } from "../../../navigation/params/AuthenticationParamsList";
import ROUTES from "../../../navigation/routes";
import {
CieAuthenticationErrorPayload,
CieAuthenticationErrorReason,
cieAuthenticationError
} from "../../../store/actions/cie";
import { ReduxProps } from "../../../store/actions/types";
import { assistanceToolConfigSelector } from "../../../store/reducers/backendStatus";
import { GlobalState } from "../../../store/reducers/types";
import {
isScreenReaderEnabled,
setAccessibilityFocus
} from "../../../utils/accessibility";
import { isDevEnv } from "../../../utils/environment";
import {
assistanceToolRemoteConfig,
handleSendAssistanceLog
} from "../../../utils/supportAssistance";
import {
trackLoginCieCardReaderScreen,
trackLoginCieCardReadingError,
trackLoginCieCardReadingSuccess
} from "../analytics/cieAnalytics";
export type CieCardReaderScreenNavigationParams = {
ciePin: string;
authorizationUri: string;
};
export type CieCardReaderNavigationProps = IOStackNavigationRouteProps<
AuthenticationParamsList,
"CIE_CARD_READER_SCREEN"
>;
type Props = CieCardReaderNavigationProps &
ReduxProps &
ReturnType<typeof mapStateToProps> & {
headerHeight: number;
blueColorName: string;
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: IOColors.white
},
centerText: {
textAlign: "center"
},
contentContainer: {
flexGrow: 1,
alignContent: "center",
justifyContent: "center"
}
});
type State = {
// Get the current status of the card reading
readingState: ReadingState;
title: string;
subtitle?: string;
content?: string;
errorMessage?: string;
isScreenReaderEnabled: boolean;
};
type setErrorParameter = {
eventReason: CieAuthenticationErrorReason;
errorDescription?: string;
navigation?: () => void;
};
const getPictogramName = (state: ReadingState): IOPictograms => {
switch (state) {
default:
case ReadingState.reading:
case ReadingState.waiting_card:
return Platform.select({
ios: "nfcScaniOS",
default: "nfcScanAndroid"
});
case ReadingState.error:
return "empty";
case ReadingState.completed:
return "success";
}
};
// A subset of Cie Events (errors) which is of interest to analytics
const analyticActions = new Map<CieAuthenticationErrorReason, string>([
// Reading interrupted before the sdk complete the reading
["Transmission Error", I18n.t("authentication.cie.card.error.onTagLost")],
["ON_TAG_LOST", I18n.t("authentication.cie.card.error.onTagLost")],
[
"TAG_ERROR_NFC_NOT_SUPPORTED",
I18n.t("authentication.cie.card.error.unknownCardContent")
],
[
"ON_TAG_DISCOVERED_NOT_CIE",
I18n.t("authentication.cie.card.error.unknownCardContent")
],
["PIN Locked", I18n.t("authentication.cie.card.error.generic")],
["ON_CARD_PIN_LOCKED", I18n.t("authentication.cie.card.error.generic")],
["ON_PIN_ERROR", I18n.t("authentication.cie.card.error.tryAgain")],
["PIN_INPUT_ERROR", ""],
["CERTIFICATE_EXPIRED", I18n.t("authentication.cie.card.error.generic")],
["CERTIFICATE_REVOKED", I18n.t("authentication.cie.card.error.generic")],
["AUTHENTICATION_ERROR", I18n.t("authentication.cie.card.error.generic")],
[
"EXTENDED_APDU_NOT_SUPPORTED",
I18n.t("authentication.cie.nfc.apduNotSupported")
],
[
"ON_NO_INTERNET_CONNECTION",
I18n.t("authentication.cie.card.error.tryAgain")
],
["STOP_NFC_ERROR", ""],
["START_NFC_ERROR", ""]
]);
// the timeout we sleep until move to consent form screen when authentication goes well
const WAIT_TIMEOUT_NAVIGATION = 1700 as Millisecond;
const WAIT_TIMEOUT_NAVIGATION_ACCESSIBILITY = 5000 as Millisecond;
const VIBRATION = 100 as Millisecond;
const accessibityTimeout = 100 as Millisecond;
type TextForState = {
title: string;
subtitle?: string;
content: string;
};
// some texts changes depending on current running Platform
const getTextForState = (
state: ReadingState.waiting_card | ReadingState.error,
errorMessage: string = ""
): TextForState => {
const texts: Record<
ReadingState.waiting_card | ReadingState.error,
TextForState
> = Platform.select({
ios: {
[ReadingState.waiting_card]: {
title: I18n.t("authentication.cie.card.titleiOS"),
subtitle: I18n.t("authentication.cie.card.layCardMessageHeaderiOS"),
// the native alert hides the screen content and shows a message it self
content: ""
},
[ReadingState.error]: {
title: I18n.t("authentication.cie.card.error.readerCardLostTitle"),
subtitle: "",
// the native alert hides the screen content and shows a message it self
content: ""
},
[ReadingState.reading]: {
title: I18n.t("authentication.cie.card.titleiOS"),
subtitle: I18n.t("authentication.cie.card.layCardMessageHeaderiOS"),
// the native alert hides the screen content and shows a message it self
content: ""
}
},
default: {
[ReadingState.waiting_card]: {
title: I18n.t("authentication.cie.card.title"),
subtitle: I18n.t("authentication.cie.card.layCardMessageHeader"),
content: I18n.t("authentication.cie.card.layCardMessageFooter")
},
[ReadingState.error]: {
title: I18n.t("authentication.cie.card.error.readerCardLostTitle"),
subtitle: I18n.t("authentication.cie.card.error.onTagLost"),
content: errorMessage
}
}
});
return texts[state];
};
/**
* This screen shown while reading the card
*/
class CieCardReaderScreen extends React.PureComponent<Props, State> {
private subTitleRef = React.createRef<Text>();
private choosenTool = assistanceToolRemoteConfig(
this.props.assistanceToolConfig
);
constructor(props: Props) {
super(props);
trackLoginCieCardReaderScreen();
this.state = {
/*
These are the states that can occur when reading the cie (from SDK)
- waiting_card (we are ready for read ->radar effect)
- reading (we are reading the card -> progress animation)
- error (the reading is interrupted -> progress animation stops and the progress circle becomes red)
- completed (the reading has been completed)
*/
readingState: ReadingState.waiting_card,
...getTextForState(ReadingState.waiting_card),
isScreenReaderEnabled: false
};
this.startCieiOS = this.startCieiOS.bind(this);
this.startCieAndroid = this.startCieAndroid.bind(this);
}
get ciePin(): string {
return this.props.route.params.ciePin;
}
get cieAuthorizationUri(): string {
return this.props.route.params.authorizationUri;
}
private setError = ({
eventReason,
errorDescription,
navigation
}: setErrorParameter) => {
const cieDescription =
errorDescription ??
pipe(
analyticActions.get(eventReason),
O.fromNullable,
O.getOrElse(() => "")
);
this.dispatchAnalyticEvent({
reason: eventReason,
cieDescription
});
this.setState(
{
readingState: ReadingState.error,
errorMessage: cieDescription
},
() => {
Vibration.vibrate(VIBRATION);
navigation?.();
}
);
};
private dispatchAnalyticEvent = (error: CieAuthenticationErrorPayload) => {
this.props.dispatch(cieAuthenticationError(error));
};
private handleCieEvent = async (event: CEvent) => {
handleSendAssistanceLog(this.choosenTool, event.event);
switch (event.event) {
// Reading starts
case "ON_TAG_DISCOVERED":
if (this.state.readingState !== ReadingState.reading) {
this.setState({ readingState: ReadingState.reading }, () => {
Vibration.vibrate(VIBRATION);
});
}
break;
// "Function not supported" seems to be TAG_ERROR_NFC_NOT_SUPPORTED
// for the iOS SDK
case "Function not supported" as unknown:
case "TAG_ERROR_NFC_NOT_SUPPORTED":
case "ON_TAG_DISCOVERED_NOT_CIE":
this.setError({
eventReason: event.event,
navigation: () =>
this.props.navigation.navigate(ROUTES.AUTHENTICATION, {
screen: ROUTES.CIE_WRONG_CARD_SCREEN
})
});
break;
case "AUTHENTICATION_ERROR":
case "ON_NO_INTERNET_CONNECTION":
this.setError({
eventReason: event.event,
navigation: () =>
this.props.navigation.navigate(ROUTES.AUTHENTICATION, {
screen: ROUTES.CIE_UNEXPECTED_ERROR
})
});
break;
case "EXTENDED_APDU_NOT_SUPPORTED":
this.setError({
eventReason: event.event,
navigation: () =>
this.props.navigation.navigate(ROUTES.AUTHENTICATION, {
screen: ROUTES.CIE_EXTENDED_APDU_NOT_SUPPORTED_SCREEN
})
});
break;
case "Transmission Error":
case "ON_TAG_LOST":
this.setError({ eventReason: event.event });
break;
// The card is temporarily locked. Unlock is available by CieID app
case "PIN Locked":
case "ON_CARD_PIN_LOCKED":
case "ON_PIN_ERROR":
this.setError({
eventReason: event.event,
navigation: () =>
this.props.navigation.navigate(ROUTES.AUTHENTICATION, {
screen: ROUTES.CIE_WRONG_PIN_SCREEN,
params: {
remainingCount:
event.event === "ON_CARD_PIN_LOCKED" ? 0 : event.attemptsLeft
}
})
});
break;
// CIE is Expired or Revoked
case "CERTIFICATE_EXPIRED":
case "CERTIFICATE_REVOKED":
this.setError({
eventReason: event.event,
navigation: () =>
this.props.navigation.navigate(ROUTES.AUTHENTICATION, {
screen: ROUTES.CIE_EXPIRED_SCREEN
})
});
break;
default:
break;
}
this.updateContent();
};
private announceUpdate = () => {
if (this.state.content) {
AccessibilityInfo.announceForAccessibility(this.state.content);
}
};
private updateContent = () => {
switch (this.state.readingState) {
case ReadingState.reading:
this.setState(
{
title: I18n.t("authentication.cie.card.readerCardTitle"),
subtitle: "",
content: I18n.t("authentication.cie.card.readerCardFooter")
},
this.announceUpdate
);
break;
case ReadingState.error:
trackLoginCieCardReadingError();
this.setState(
state => getTextForState(ReadingState.error, state.errorMessage),
this.announceUpdate
);
break;
case ReadingState.completed:
this.setState(
state => ({
title: I18n.t("authentication.cie.card.cieCardValid"),
subtitle: "",
// duplicate message so screen reader can read the updated message
content: state.isScreenReaderEnabled
? I18n.t("authentication.cie.card.cieCardValid")
: undefined
}),
this.announceUpdate
);
break;
// waiting_card state
default:
this.setState(
getTextForState(ReadingState.waiting_card),
this.announceUpdate
);
}
};
// TODO: It should reset authentication process
private handleCieError = (error: Error) => {
trackLoginCieCardReadingError();
handleSendAssistanceLog(this.choosenTool, error.message);
this.setError({ eventReason: "GENERIC", errorDescription: error.message });
};
private handleCieSuccess = (cieConsentUri: string) => {
if (this.state.readingState === ReadingState.completed) {
return;
}
handleSendAssistanceLog(this.choosenTool, "authentication SUCCESS");
this.setState({ readingState: ReadingState.completed }, () => {
this.updateContent();
setTimeout(
async () => {
trackLoginCieCardReadingSuccess();
this.props.navigation.navigate(ROUTES.AUTHENTICATION, {
screen: ROUTES.CIE_CONSENT_DATA_USAGE,
params: {
cieConsentUri
}
});
// if screen reader is enabled, give more time to read the success message
},
this.state.isScreenReaderEnabled
? WAIT_TIMEOUT_NAVIGATION_ACCESSIBILITY
: // if is iOS don't wait. The thank you page is shown natively
Platform.select({ ios: 0, default: WAIT_TIMEOUT_NAVIGATION })
);
});
};
public async startCieAndroid(useCieUat: boolean) {
cieManager
.start()
.then(async () => {
cieManager.onEvent(this.handleCieEvent);
cieManager.onError(this.handleCieError);
cieManager.onSuccess(this.handleCieSuccess);
await cieManager.setPin(this.ciePin);
cieManager.setAuthenticationUrl(this.cieAuthorizationUri);
cieManager.enableLog(isDevEnv);
cieManager.setCustomIdpUrl(useCieUat ? getCieUatEndpoint() : null);
await cieManager.startListeningNFC();
this.setState({ readingState: ReadingState.waiting_card });
})
.catch(() => {
this.setState({ readingState: ReadingState.error });
});
}
public async startCieiOS(useCieUat: boolean) {
cieManager.removeAllListeners();
cieManager.onEvent(this.handleCieEvent);
cieManager.onError(this.handleCieError);
cieManager.onSuccess(this.handleCieSuccess);
cieManager.enableLog(isDevEnv);
cieManager.setCustomIdpUrl(useCieUat ? getCieUatEndpoint() : null);
await cieManager.setPin(this.ciePin);
cieManager.setAuthenticationUrl(this.cieAuthorizationUri);
cieManager
.start({
readingInstructions: I18n.t(
"authentication.cie.card.iosAlert.readingInstructions"
),
moreTags: I18n.t("authentication.cie.card.iosAlert.moreTags"),
readingInProgress: I18n.t(
"authentication.cie.card.iosAlert.readingInProgress"
),
readingSuccess: I18n.t(
"authentication.cie.card.iosAlert.readingSuccess"
),
invalidCard: I18n.t("authentication.cie.card.iosAlert.invalidCard"),
tagLost: I18n.t("authentication.cie.card.iosAlert.tagLost"),
cardLocked: I18n.t("authentication.cie.card.iosAlert.cardLocked"),
wrongPin1AttemptLeft: I18n.t(
"authentication.cie.card.iosAlert.wrongPin1AttemptLeft"
),
wrongPin2AttemptLeft: I18n.t(
"authentication.cie.card.iosAlert.wrongPin2AttemptLeft"
),
genericError: I18n.t("authentication.cie.card.iosAlert.genericError")
})
.then(async () => {
await cieManager.startListeningNFC();
this.setState({ readingState: ReadingState.waiting_card });
this.updateContent();
})
.catch(() => {
this.setState({ readingState: ReadingState.error });
});
}
public async componentDidMount() {
const startCie = Platform.select({
ios: this.startCieiOS,
default: this.startCieAndroid
});
await startCie(this.props.isCieUatEnabled);
const srEnabled = await isScreenReaderEnabled();
this.setState({ isScreenReaderEnabled: srEnabled });
}
private handleCancel = () =>
this.props.navigation.reset({
index: 0,
routes: [{ name: ROUTES.AUTHENTICATION }]
});
private getFooter = () =>
Platform.select({
default: (
<View style={IOStyles.alignCenter}>
<View>
<ButtonLink
label={I18n.t("global.buttons.close")}
onPress={this.handleCancel}
/>
</View>
</View>
),
ios: (
<View style={IOStyles.alignCenter}>
<View>
<ButtonSolid
label={I18n.t("authentication.cie.nfc.retry")}
onPress={() => this.startCieiOS(this.props.isCieUatEnabled)}
/>
</View>
<VSpacer size={24} />
<View>
<ButtonLink
label={I18n.t("global.buttons.close")}
onPress={this.handleCancel}
/>
</View>
</View>
)
});
public render(): React.ReactNode {
return (
<SafeAreaView style={IOStyles.flex}>
<ScrollView
centerContent={true}
contentContainerStyle={styles.contentContainer}
>
<ContentWrapper>
<CieCardReadingAnimation
pictogramName={getPictogramName(this.state.readingState)}
readingState={this.state.readingState}
circleColor={this.props.blueColorName}
/>
<VSpacer size={32} />
<Title
text={this.state.title}
accessibilityLabel={
this.state.subtitle
? `${this.state.title}. ${this.state.subtitle}`
: this.state.title
}
/>
<VSpacer size={8} />
{this.state.subtitle && (
<Body style={styles.centerText} ref={this.subTitleRef}>
{this.state.subtitle}
</Body>
)}
<VSpacer size={24} />
{this.state.readingState !== ReadingState.completed &&
this.getFooter()}
</ContentWrapper>
</ScrollView>
</SafeAreaView>
);
}
}
const mapStateToProps = (state: GlobalState) => ({
assistanceToolConfig: assistanceToolConfigSelector(state),
isCieUatEnabled: isCieLoginUatEnabledSelector(state)
});
const ReaderScreen = (props: Props) => (
<View style={styles.container}>
<CieCardReaderScreen {...props} />
</View>
);
const Title = (props: { text: string; accessibilityLabel: string }) => {
const titleRef = React.useRef<View>(null);
useFocusEffect(
React.useCallback(() => {
if (!titleRef.current && Platform.OS === "android") {
setAccessibilityFocus(titleRef, accessibityTimeout);
}
}, [])
);
return (
<View accessible ref={titleRef}>
<H3 style={styles.centerText}>{props.text}</H3>
</View>
);
};
export default connect(mapStateToProps)(ReaderScreen);