diff --git a/android/app/build.gradle b/android/app/build.gradle index df00eebc59e..e91d0c2b65d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -161,6 +161,7 @@ android { } dependencies { + compile project(':react-native-background-timer') compile project(':instabug-reactnative') compile project(':react-native-add-calendar-event') compile project(':react-native-svg') diff --git a/android/app/src/main/java/it/teamdigitale/app/italiaapp/MainActivity.java b/android/app/src/main/java/it/teamdigitale/app/italiaapp/MainActivity.java index 723c9eb1077..e14e5582e01 100644 --- a/android/app/src/main/java/it/teamdigitale/app/italiaapp/MainActivity.java +++ b/android/app/src/main/java/it/teamdigitale/app/italiaapp/MainActivity.java @@ -1,6 +1,7 @@ package it.teamdigitale.app.italiaapp; import android.os.Bundle; +import android.view.WindowManager; import com.facebook.react.ReactActivity; import org.devio.rn.splashscreen.SplashScreen; @@ -18,6 +19,8 @@ protected String getMainComponentName() { // see https://github.com/crazycodeboy/react-native-splash-screen#third-stepplugin-configuration @Override protected void onCreate(Bundle savedInstanceState) { + // To avoid data leak disable preview of the window when application is in background + getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE,WindowManager.LayoutParams.FLAG_SECURE); SplashScreen.show(this, R.style.SplashScreenTheme); super.onCreate(savedInstanceState); } diff --git a/android/app/src/main/java/it/teamdigitale/app/italiaapp/MainApplication.java b/android/app/src/main/java/it/teamdigitale/app/italiaapp/MainApplication.java index 15f81a7e081..7600efc92d6 100644 --- a/android/app/src/main/java/it/teamdigitale/app/italiaapp/MainApplication.java +++ b/android/app/src/main/java/it/teamdigitale/app/italiaapp/MainApplication.java @@ -9,6 +9,7 @@ import com.learnium.RNDeviceInfo.RNDeviceInfo; import com.lugg.ReactNativeConfig.ReactNativeConfigPackage; import com.facebook.react.ReactApplication; +import com.ocetnik.timer.BackgroundTimerPackage; import com.instabug.reactlibrary.RNInstabugReactnativePackage; import com.vonovak.AddCalendarEventPackage; import com.horcrux.svg.SvgPackage; @@ -39,6 +40,7 @@ public boolean getUseDeveloperSupport() { protected List getPackages() { return Arrays.asList( new MainReactPackage(), + new BackgroundTimerPackage(), new RNInstabugReactnativePackage.Builder(BuildConfig.INSTABUG_TOKEN, MainApplication.this) .setInvocationEvent("none") .setPrimaryColor("#0073E6") diff --git a/android/settings.gradle b/android/settings.gradle index 619226b9d45..0c57b954c7e 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,4 +1,6 @@ rootProject.name = 'ItaliaApp' +include ':react-native-background-timer' +project(':react-native-background-timer').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-background-timer/android') include ':instabug-reactnative' project(':instabug-reactnative').projectDir = new File(rootProject.projectDir, '../node_modules/instabug-reactnative/android') include ':react-native-add-calendar-event' diff --git a/ios/ItaliaApp/AppDelegate.m b/ios/ItaliaApp/AppDelegate.m index 2e362c62666..231492e12d7 100644 --- a/ios/ItaliaApp/AppDelegate.m +++ b/ios/ItaliaApp/AppDelegate.m @@ -74,4 +74,34 @@ - (void)application:(UIApplication *)application didReceiveLocalNotification:(UI [RCTPushNotificationManager didReceiveLocalNotification:notification]; } +- (void)applicationWillResignActive:(UIApplication *)application { + + // Fill screen with our own colour + UIView *colourView = [[UIView alloc]initWithFrame:self.window.frame]; + colourView.backgroundColor = [UIColor whiteColor]; + colourView.tag = 1234; + colourView.alpha = 0; + [self.window addSubview:colourView]; + [self.window bringSubviewToFront:colourView]; + + // Fade in the view + [UIView animateWithDuration:0.5 animations:^{ + colourView.alpha = 1; + }]; +} + +- (void)applicationDidBecomeActive:(UIApplication *)application { + + // Grab a reference to our coloured view + UIView *colourView = [self.window viewWithTag:1234]; + + // Fade away colour view from main view + [UIView animateWithDuration:0.5 animations:^{ + colourView.alpha = 0; + } completion:^(BOOL finished) { + // Remove when finished fading + [colourView removeFromSuperview]; + }]; +} + @end diff --git a/ios/Podfile b/ios/Podfile index e0951d91742..4489423cf96 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -45,6 +45,8 @@ target 'ItaliaApp' do pod 'react-native-add-calendar-event', :path => '../node_modules/react-native-add-calendar-event' + pod 'react-native-background-timer', :path => '../node_modules/react-native-background-timer' + end # Fix a open bug in react-native-config diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 96f2db15f61..3b7a77e6378 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4,6 +4,8 @@ PODS: - React/Core (= 0.55.4) - react-native-add-calendar-event (2.1.0): - React + - react-native-background-timer (2.0.1): + - React - react-native-camera (1.2.0): - React - react-native-camera/RCT (= 1.2.0) @@ -71,6 +73,7 @@ PODS: DEPENDENCIES: - react-native-add-calendar-event (from `../node_modules/react-native-add-calendar-event`) + - react-native-background-timer (from `../node_modules/react-native-background-timer`) - react-native-camera (from `../node_modules/react-native-camera`) - react-native-config (from `../node_modules/react-native-config`) - react-native-mixpanel (from `../node_modules/react-native-mixpanel`) @@ -106,6 +109,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native" react-native-add-calendar-event: :path: "../node_modules/react-native-add-calendar-event" + react-native-background-timer: + :path: "../node_modules/react-native-background-timer" react-native-camera: :path: "../node_modules/react-native-camera" react-native-config: @@ -135,6 +140,7 @@ SPEC CHECKSUMS: Mixpanel: 3c3925c27f7a321d5978319a72f53f8f0885afc5 React: aa2040dbb6f317b95314968021bd2888816e03d5 react-native-add-calendar-event: e3415a2d8a782e06899be6fb59427199eba1ab82 + react-native-background-timer: bb7a98c8e97fc7c290de2d423dd09ddb73dcbcbb react-native-camera: 68ad5143d2d0636236d46c7de8d2a6455ca52a36 react-native-config: 408003951fd9d8b1dfd7ebf535f67c315f44d823 react-native-mixpanel: 0683bc3f3f8db8f9d79a8ba8584ee51030338a2f @@ -148,6 +154,6 @@ SPEC CHECKSUMS: RNVectorIcons: c0dbfbf6068fefa240c37b0f71bd03b45dddac44 yoga: a23273df0088bf7f2bb7e5d7b00044ea57a2a54a -PODFILE CHECKSUM: 3316adb921ccdc510910aca8601549f22fd3234e +PODFILE CHECKSUM: 7baf54ac28b13a0b19cb8d2964f16af6425333e3 COCOAPODS: 1.5.3 diff --git a/package.json b/package.json index 8302dd01b20..f53068c85ff 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "react": "16.3.1", "react-native": "0.55.4", "react-native-add-calendar-event": "^2.1.0", + "react-native-background-timer": "^2.0.1", "react-native-camera": "1.2.0", "react-native-config": "^0.11.2", "react-native-device-info": "^0.22", @@ -85,6 +86,7 @@ "@types/prettier": "^1.13.2", "@types/react": "16.3.16", "@types/react-native": "0.55.17", + "@types/react-native-background-timer": "^2.0.0", "@types/react-native-fs": "^2.8.2", "@types/react-native-i18n": "^2.0.0", "@types/react-native-loading-spinner-overlay": "^0.5.1", diff --git a/patches/react-native-background-timer+2.0.1.patch b/patches/react-native-background-timer+2.0.1.patch new file mode 100644 index 00000000000..f924b2d08b5 --- /dev/null +++ b/patches/react-native-background-timer+2.0.1.patch @@ -0,0 +1,10 @@ +patch-package +--- a/node_modules/react-native-background-timer/react-native-background-timer.podspec ++++ b/node_modules/react-native-background-timer/react-native-background-timer.podspec +@@ -19,4 +19,6 @@ Pod::Spec.new do |s| + s.preserve_paths = 'README.md', 'package.json', 'index.js' + s.source_files = 'ios/*.{h,m}' + ++ s.dependency 'React' ++ + end diff --git a/ts/IdentificationOverlay.tsx b/ts/IdentificationOverlay.tsx new file mode 100644 index 00000000000..4d4b2a83dc3 --- /dev/null +++ b/ts/IdentificationOverlay.tsx @@ -0,0 +1,201 @@ +import { Button, Content, Text, View } from "native-base"; +import * as React from "react"; +import { Dimensions, StatusBar, StyleSheet } from "react-native"; +import { connect } from "react-redux"; + +import Pinpad from "./components/Pinpad"; +import BaseScreenComponent from "./components/screens/BaseScreenComponent"; +import IconFont from "./components/ui/IconFont"; +import TextWithIcon from "./components/ui/TextWithIcon"; +import I18n from "./i18n"; +import { + identificationCancel, + identificationFailure, + identificationPinReset, + identificationSuccess +} from "./store/actions/identification"; +import { ReduxProps } from "./store/actions/types"; +import { IdentificationState } from "./store/reducers/identification"; +import { GlobalState } from "./store/reducers/types"; + +type ReduxMappedStateProps = { + identificationState: IdentificationState; +}; + +type Props = ReduxMappedStateProps & ReduxProps; + +/** + * Type used in the local state to save the result of Pinpad PIN matching. + * State is "unstarted" if the user still need to insert the PIN. + * State is "failure" when the PIN inserted by the user do not match the + * stored one. + */ +type IdentificationByPinState = "unstarted" | "failure"; + +type State = { + identificationByPinState: IdentificationByPinState; +}; + +const contextualHelp = { + title: I18n.t("pin_login.unlock_screen.help.title"), + body: () => I18n.t("pin_login.unlock_screen.help.content") +}; + +const renderIdentificationByPinState = ( + identificationByPinState: IdentificationByPinState +) => { + if (identificationByPinState === "failure") { + return ( + + + + + {I18n.t("pin_login.pin.confirmInvalid")} + + + ); + } + + return null; +}; + +const screenDimensions = Dimensions.get("screen"); + +const styles = StyleSheet.create({ + wrapper: { + width: screenDimensions.width, + height: screenDimensions.height + } +}); + +/** + * A component used to identify the the user. + * The identification process can be activated calling a saga or dispatching the + * requestIdentification redux action. + */ +class IdentificationOverlay extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + identificationByPinState: "unstarted" + }; + } + + public render() { + const { identificationState, dispatch } = this.props; + + if (identificationState.kind !== "started") { + return null; + } + + // The identification state is started we need to show the modal + const { + pin, + identificationCancelData, + identificationSuccessData + } = identificationState; + + const { identificationByPinState } = this.state; + + /** + * Create handlers merging default internal actions (to manage the identification state) + * with, if available, custom actions passed as props. + */ + const onIdentificationCancelHandler = () => { + if (identificationCancelData) { + dispatch(identificationCancelData.action); + } + dispatch(identificationCancel()); + }; + + const onIdentificationSuccessHandler = () => { + if (identificationSuccessData) { + dispatch(identificationSuccessData.action); + } + dispatch(identificationSuccess()); + }; + + const onIdentificationFailureHandler = () => { + dispatch(identificationFailure()); + }; + + const onPinResetHandler = () => { + dispatch(identificationPinReset()); + }; + + return ( + + + + + + + {I18n.t("pin_login.pin.pinInfo")} + + + this.onPinFullfill( + _, + __, + onIdentificationSuccessHandler, + onIdentificationFailureHandler + ) + } + clearOnInvalid={true} + /> + {renderIdentificationByPinState(identificationByPinState)} + + + {identificationCancelData !== undefined && ( + + )} + {identificationCancelData === undefined && ( + + )} + + {I18n.t("pin_login.pin.reset.tip")} + + + + + ); + } + + private onPinFullfill = ( + _: string, + isValid: boolean, + onIdentificationSuccessHandler: () => void, + onIdentificationFailureHandler: () => void + ) => { + if (isValid) { + this.setState({ + identificationByPinState: "unstarted" + }); + onIdentificationSuccessHandler(); + } else { + this.setState({ + identificationByPinState: "failure" + }); + + onIdentificationFailureHandler(); + } + }; +} + +const mapStateToProps = (state: GlobalState): ReduxMappedStateProps => ({ + identificationState: state.identification +}); + +export default connect(mapStateToProps)(IdentificationOverlay); diff --git a/ts/RootContainer.tsx b/ts/RootContainer.tsx index 89a5a1cf0a3..0f617f634e3 100644 --- a/ts/RootContainer.tsx +++ b/ts/RootContainer.tsx @@ -13,6 +13,7 @@ import { initialiseInstabug } from "./boot/configureInstabug"; import configurePushNotifications from "./boot/configurePushNotification"; import ConnectionBar from "./components/ConnectionBar"; import VersionInfoOverlay from "./components/VersionInfoOverlay"; +import IdentificationOverlay from "./IdentificationOverlay"; import Navigation from "./navigation"; import { applicationChangeState, @@ -140,6 +141,7 @@ class RootContainer extends React.PureComponent { + ); diff --git a/ts/components/Pinpad/index.tsx b/ts/components/Pinpad/index.tsx index e58e7ef39e5..d6253f16f69 100644 --- a/ts/components/Pinpad/index.tsx +++ b/ts/components/Pinpad/index.tsx @@ -19,9 +19,10 @@ const blurElement = (el: TextInput) => el.blur(); const current = (ref: React.RefObject) => ref.current; interface Props { + activeColor: string; + clearOnInvalid?: boolean; compareWithCode?: string; inactiveColor: string; - activeColor: string; onFulfill: (code: PinString, isValid: boolean) => void; } @@ -86,6 +87,10 @@ class Pinpad extends React.PureComponent { if (isValid) { this.foldInputRef(blurElement); + } else { + if (this.props.clearOnInvalid) { + this.clear(); + } } // Fire the callback asynchronously, otherwise this component diff --git a/ts/sagas/identification.ts b/ts/sagas/identification.ts new file mode 100644 index 00000000000..3e55ab2889d --- /dev/null +++ b/ts/sagas/identification.ts @@ -0,0 +1,138 @@ +import { Effect } from "redux-saga"; +import { call, put, select, take, takeLatest } from "redux-saga/effects"; +import { ActionType, getType } from "typesafe-actions"; + +import { startApplicationInitialization } from "../store/actions/application"; +import { sessionInvalid } from "../store/actions/authentication"; +import { + identificationCancel, + identificationPinReset, + identificationRequest, + identificationReset, + identificationStart, + identificationSuccess +} from "../store/actions/identification"; +import { navigateToMessageDetailScreenAction } from "../store/actions/navigation"; +import { clearNotificationPendingMessage } from "../store/actions/notifications"; +import { + IdentificationCancelData, + IdentificationResult, + IdentificationSuccessData +} from "../store/reducers/identification"; +import { pendingMessageStateSelector } from "../store/reducers/notifications/pendingMessage"; +import { GlobalState } from "../store/reducers/types"; +import { isPaymentOngoingSelector } from "../store/reducers/wallet/payment"; +import { PinString } from "../types/PinString"; +import { SagaCallReturnType } from "../types/utils"; +import { deletePin } from "../utils/keychain"; + +// Wait the identification and return the result +export function* waitIdentificationResult(): Iterator< + Effect | IdentificationResult +> { + const resultAction: + | ActionType + | ActionType + | ActionType = yield take([ + getType(identificationCancel), + getType(identificationPinReset), + getType(identificationSuccess) + ]); + + switch (resultAction.type) { + case getType(identificationCancel): + return IdentificationResult.cancel; + + case getType(identificationPinReset): { + // Invalidate the session + yield put(sessionInvalid()); + + // Delete the PIN + // tslint:disable-next-line:saga-yield-return-type + yield call(deletePin); + + // Hide the identification screen + yield put(identificationReset()); + + return IdentificationResult.pinreset; + } + + case getType(identificationSuccess): { + return IdentificationResult.success; + } + + default: { + ((): never => resultAction)(); + } + } +} + +/** + * If you need to start the identification process and wait the result in a "sync" way, + * like we do in the startup saga, use this generator + */ +export function* startAndReturnIdentificationResult( + pin: PinString, + identificationCancelData?: IdentificationCancelData, + identificationSuccessData?: IdentificationSuccessData +): Iterator> { + yield put( + identificationStart( + pin, + identificationCancelData, + identificationSuccessData + ) + ); + + return yield call(waitIdentificationResult); +} + +// Started by redux action +export function* startAndHandleIdentificationResult( + pin: PinString, + identificationRequestAction: ActionType +): IterableIterator { + yield put( + identificationStart( + pin, + identificationRequestAction.payload.identificationCancelData, + identificationRequestAction.payload.identificationSuccessData + ) + ); + const identificationResult = yield call(waitIdentificationResult); + + if (identificationResult === IdentificationResult.pinreset) { + yield put(startApplicationInitialization()); + } else if (identificationResult === IdentificationResult.success) { + // Check if we have a pending notification message + const pendingMessageState: ReturnType< + typeof pendingMessageStateSelector + > = yield select(pendingMessageStateSelector); + + // Check if there is a payment ongoing + const isPaymentOngoing: ReturnType< + typeof isPaymentOngoingSelector + > = yield select(isPaymentOngoingSelector); + + if (!isPaymentOngoing && pendingMessageState) { + // We have a pending notification message to handle + const messageId = pendingMessageState.id; + + // Remove the pending message from the notification state + yield put(clearNotificationPendingMessage()); + + // Navigate to message details screen + yield put(navigateToMessageDetailScreenAction({ messageId })); + } + } +} + +export function* watchIdentificationRequest( + pin: PinString +): IterableIterator { + yield takeLatest( + getType(identificationRequest), + startAndHandleIdentificationResult, + pin + ); +} diff --git a/ts/sagas/startup.ts b/ts/sagas/startup.ts index 3e62a901d53..fa397008841 100644 --- a/ts/sagas/startup.ts +++ b/ts/sagas/startup.ts @@ -24,6 +24,7 @@ import { sessionInfoSelector, sessionTokenSelector } from "../store/reducers/authentication"; +import { IdentificationResult } from "../store/reducers/identification"; import { navigationStateSelector } from "../store/reducers/navigation"; import { PendingMessageState, @@ -33,6 +34,10 @@ import { GlobalState } from "../store/reducers/types"; import { PinString } from "../types/PinString"; import { SagaCallReturnType } from "../types/utils"; import { getPin } from "../utils/keychain"; +import { + startAndReturnIdentificationResult, + watchIdentificationRequest +} from "./identification"; import { updateInstallationSaga } from "./notifications"; import { loadProfile, watchProfileUpsertRequestsSaga } from "./profile"; import { authenticationSaga } from "./startup/authenticationSaga"; @@ -40,19 +45,18 @@ import { checkAcceptedTosSaga } from "./startup/checkAcceptedTosSaga"; import { checkConfiguredPinSaga } from "./startup/checkConfiguredPinSaga"; import { checkProfileEnabledSaga } from "./startup/checkProfileEnabledSaga"; import { loadSessionInformationSaga } from "./startup/loadSessionInformationSaga"; -import { loginWithPinSaga } from "./startup/pinLoginSaga"; import { watchAbortOnboardingSaga } from "./startup/watchAbortOnboardingSaga"; import { watchApplicationActivitySaga } from "./startup/watchApplicationActivitySaga"; import { watchMessagesLoadOrCancelSaga } from "./startup/watchLoadMessagesSaga"; import { watchLoadMessageWithRelationsSaga } from "./startup/watchLoadMessageWithRelationsSaga"; import { watchLogoutSaga } from "./startup/watchLogoutSaga"; -import { watchPinResetSaga } from "./startup/watchPinResetSaga"; import { watchSessionExpiredSaga } from "./startup/watchSessionExpiredSaga"; import { watchWalletSaga } from "./wallet"; /** * Handles the application startup and the main application logic loop */ +// tslint:disable-next-line:cognitive-complexity function* initializeApplicationSaga(): IterableIterator { // Reset the profile cached in redux: at each startup we want to load a fresh // user profile. @@ -160,11 +164,15 @@ function* initializeApplicationSaga(): IterableIterator { if (!isSessionRefreshed) { // The user was previously logged in, so no onboarding is needed // The session was valid so the user didn't event had to do a full login, - // in this case we ask the user to provide the PIN as a "lighter" login - yield race({ - login: call(loginWithPinSaga, storedPin), - reset: call(watchPinResetSaga) - }); + // in this case we ask the user to identify using the PIN. + const identificationResult: SagaCallReturnType< + typeof startAndReturnIdentificationResult + > = yield call(startAndReturnIdentificationResult, storedPin); + if (identificationResult === IdentificationResult.pinreset) { + // If we are here the user had chosen to reset the PIN + yield put(startApplicationInitialization()); + return; + } } } @@ -216,8 +224,8 @@ function* initializeApplicationSaga(): IterableIterator { yield fork(watchSessionExpiredSaga); // Logout the user by expiring the session yield fork(watchLogoutSaga, backendClient.logout); - // Watch for requests to reset the PIN - yield fork(watchPinResetSaga); + // Watch for identification request + yield fork(watchIdentificationRequest, storedPin); // Check if we have a pending notification message const pendingMessageState: PendingMessageState = yield select( diff --git a/ts/sagas/startup/watchApplicationActivitySaga.ts b/ts/sagas/startup/watchApplicationActivitySaga.ts index 6706c6e8efb..4f198418670 100644 --- a/ts/sagas/startup/watchApplicationActivitySaga.ts +++ b/ts/sagas/startup/watchApplicationActivitySaga.ts @@ -1,34 +1,18 @@ -import { NavigationActions, NavigationState } from "react-navigation"; -import { Effect } from "redux-saga"; -import { call, put, select, takeEvery } from "redux-saga/effects"; +import { Effect, Task } from "redux-saga"; +import { call, cancel, fork, put, takeEvery } from "redux-saga/effects"; import { ActionType, getType } from "typesafe-actions"; import { backgroundActivityTimeout } from "../../config"; -import AppNavigator from "../../navigation/AppNavigator"; import { applicationChangeState, - ApplicationState, - startApplicationInitialization + ApplicationState } from "../../store/actions/application"; -import { - navigateToBackgroundScreen, - navigateToMessageDetailScreenAction -} from "../../store/actions/navigation"; -import { navigationHistoryPush } from "../../store/actions/navigationHistory"; -import { clearNotificationPendingMessage } from "../../store/actions/notifications"; -import { navigationStateSelector } from "../../store/reducers/navigation"; -import { - PendingMessageState, - pendingMessageStateSelector -} from "../../store/reducers/notifications/pendingMessage"; -import { GlobalState } from "../../store/reducers/types"; -import { saveNavigationStateSaga } from "../startup/saveNavigationStateSaga"; +import { identificationRequest } from "../../store/actions/identification"; +import { startTimer } from "../../utils/timer"; /** - * Listen to APP_STATE_CHANGE_ACTION and if needed force the user to provide - * the PIN + * Listen to APP_STATE_CHANGE_ACTION and if needed force the user to identify */ -// tslint:disable-next-line:cognitive-complexity export function* watchApplicationActivitySaga(): IterableIterator { const backgroundActivityTimeoutMillis = backgroundActivityTimeout * 1000; @@ -36,71 +20,31 @@ export function* watchApplicationActivitySaga(): IterableIterator { let lastState: ApplicationState = "active"; // tslint:disable-next-line:no-let - let lastUpdateAtMillis: number | undefined; + let identificationBackgroundTimer: Task | undefined; yield takeEvery(getType(applicationChangeState), function*( action: ActionType ) { - // listen for changes in application state + // Listen for changes in application state const newApplicationState: ApplicationState = action.payload; - // get the time elapsed from the last change in state - const nowMillis = new Date().getTime(); - const timeElapsedMillis = lastUpdateAtMillis - ? nowMillis - lastUpdateAtMillis - : nowMillis; - if (lastState !== "background" && newApplicationState === "background") { - // The app is going into background - // Save the navigation state so we can restore it later when the app come - // back to the active state - yield call(saveNavigationStateSaga); - - // Make sure that when the app come back active, the BackgrounScreen - // gets loaded first - // FIXME: not that this creates a quick blue flash in case after restoring - // the app we don't ask a PIN - yield put(navigateToBackgroundScreen); + // Start the background timer + identificationBackgroundTimer = yield fork(function*() { + // Start and wait the timer to fire + yield call(startTimer, backgroundActivityTimeoutMillis); + // Timer fired we need to identify the user + yield put(identificationRequest()); + }); } else if (lastState === "background" && newApplicationState === "active") { - // The app is coming back active after being in background - if (timeElapsedMillis > backgroundActivityTimeoutMillis) { - // If the app has been in background state for more than the timeout, - // re-initialize the app from scratch - yield put(startApplicationInitialization()); - } else { - // Check if we have a pending notification message - const pendingMessageState: PendingMessageState = yield select< - GlobalState - >(pendingMessageStateSelector); - if (pendingMessageState) { - // We have a pending notification message to handle - const messageId = pendingMessageState.id; - // Remove the pending message from the notification state - yield put(clearNotificationPendingMessage()); - // Navigate to message details screen - yield put(navigateToMessageDetailScreenAction({ messageId })); - // Push the MAIN navigator in the history to handle the back button - const navigationState: NavigationState = yield select( - navigationStateSelector - ); - yield put( - navigationHistoryPush( - AppNavigator.router.getStateForAction( - NavigationActions.back(), - navigationState - ) - ) - ); - } else { - // Or else, just navigate back to the screen we were at before - // going into background - yield put(NavigationActions.back()); - } + // Cancel the background timer if running + if (identificationBackgroundTimer) { + yield cancel(identificationBackgroundTimer); + identificationBackgroundTimer = undefined; } } - // Update the last state and update time + // Update the last state lastState = newApplicationState; - lastUpdateAtMillis = nowMillis; }); } diff --git a/ts/sagas/wallet.ts b/ts/sagas/wallet.ts index bce8063fe1a..9d9e082ebcb 100644 --- a/ts/sagas/wallet.ts +++ b/ts/sagas/wallet.ts @@ -14,7 +14,6 @@ import { Effect, fork, put, - race, select, take, takeLatest @@ -45,7 +44,6 @@ import { paymentRequestGoBack, paymentRequestPickPaymentMethod, paymentRequestPickPsp, - paymentRequestPinLogin, paymentRequestTransactionSummaryFromBanner, paymentRequestTransactionSummaryFromRptId, paymentResetLoadingState, @@ -82,8 +80,7 @@ import { getFavoriteWallet } from "../store/reducers/wallet/wallets"; import { paymentCancel, paymentFailure, - paymentRequestCompletion, - setPaymentStateToPinLogin + paymentRequestCompletion } from "./../store/actions/wallet/payment"; import { extractNodoError, extractPaymentManagerError } from "../types/errors"; @@ -94,15 +91,11 @@ import { Psp, Wallet } from "../types/pagopa"; -import { PinString } from "../types/PinString"; import { SessionToken } from "../types/SessionToken"; import { SagaCallReturnType } from "../types/utils"; import { constantPollingFetch, pagopaFetch } from "../utils/fetch"; -import { loginWithPinSaga } from "./startup/pinLoginSaga"; -import { watchPinResetSaga } from "./startup/watchPinResetSaga"; - import { fetchAndStorePagoPaToken, fetchWithTokenRefresh @@ -383,8 +376,7 @@ function* watchPaymentSaga( getVerificaRpt: TypeofApiCall, postAttivaRpt: TypeofApiCall, getPaymentIdApi: TypeofApiCall, - pagoPaClient: PagoPaClient, - storedPin: PinString + pagoPaClient: PagoPaClient ): Iterator { while (true) { const action: @@ -398,8 +390,7 @@ function* watchPaymentSaga( | ActionType | ActionType | ActionType - | ActionType - | ActionType = yield take([ + | ActionType = yield take([ getType(paymentRequestTransactionSummaryFromRptId), getType(paymentRequestTransactionSummaryFromBanner), getType(paymentRequestContinueWithPaymentMethods), @@ -410,7 +401,6 @@ function* watchPaymentSaga( getType(paymentRequestCompletion), getType(paymentRequestGoBack), getType(paymentRequestCancel), - getType(paymentRequestPinLogin), getType(resetPaymentState) ]); @@ -475,10 +465,6 @@ function* watchPaymentSaga( yield fork(cancelPaymentHandler, action); break; } - case getType(paymentRequestPinLogin): { - yield fork(pinLoginHandler, action, storedPin); - break; - } } } } @@ -866,25 +852,6 @@ function* updatePspHandler( } } -function* pinLoginHandler( - action: ActionType, - storedPin: PinString -) { - yield put(setPaymentStateToPinLogin()); - // Retrieve the configured PIN from the keychain - yield race({ - proceed: call(loginWithPinSaga, storedPin), - reset: call(watchPinResetSaga) - }); - - yield put( - paymentRequestCompletion({ - wallet: action.payload.wallet, - paymentId: action.payload.paymentId - }) - ); -} - function* completionHandler( action: ActionType, pagoPaClient: PagoPaClient @@ -945,8 +912,7 @@ function* completionHandler( */ export function* watchWalletSaga( sessionToken: SessionToken, - pagoPaClient: PagoPaClient, - storedPin: PinString + pagoPaClient: PagoPaClient ): Iterator { // Builds a backend client specifically for the pagopa-proxy endpoints that // need a fetch instance that doesn't retry requests and have longer timeout @@ -980,8 +946,7 @@ export function* watchWalletSaga( backendClient.getVerificaRpt, backendClient.postAttivaRpt, pollingBackendClient.getPaymentId, - pagoPaClient, - storedPin + pagoPaClient ); // Start listening for requests to fetch the transaction history diff --git a/ts/screens/wallet/payment/ConfirmPaymentMethodScreen.tsx b/ts/screens/wallet/payment/ConfirmPaymentMethodScreen.tsx index b978af7a5bf..82429fbf53a 100644 --- a/ts/screens/wallet/payment/ConfirmPaymentMethodScreen.tsx +++ b/ts/screens/wallet/payment/ConfirmPaymentMethodScreen.tsx @@ -38,15 +38,16 @@ import AppHeader from "../../../components/ui/AppHeader"; import CardComponent from "../../../components/wallet/card/CardComponent"; import PaymentBannerComponent from "../../../components/wallet/PaymentBannerComponent"; import I18n from "../../../i18n"; +import { identificationRequest } from "../../../store/actions/identification"; import { navigateToWalletTransactionsScreen } from "../../../store/actions/navigation"; import { Dispatch } from "../../../store/actions/types"; -import { paymentRequestTransactionSummaryFromBanner } from "../../../store/actions/wallet/payment"; import { paymentRequestCancel, + paymentRequestCompletion, paymentRequestGoBack, paymentRequestPickPaymentMethod, paymentRequestPickPsp, - paymentRequestPinLogin + paymentRequestTransactionSummaryFromBanner } from "../../../store/actions/wallet/payment"; import { createErrorSelector } from "../../../store/reducers/error"; import { createLoadingSelector } from "../../../store/reducers/loading"; @@ -97,11 +98,10 @@ type ReduxMappedDispatchProps = Readonly<{ pspList: ReadonlyArray, paymentId: string ) => void; - // requestCompletion: () => void; - requestPinLogin: (wallet: Wallet, paymentId: string) => void; goBack: () => void; showSummary: () => void; onCancel: () => void; + requestIdentification: (wallet: Wallet, paymentId: string) => void; }>; type Props = ReduxMappedStateProps & @@ -258,7 +258,7 @@ class ConfirmPaymentMethodScreen extends React.Component { @@ -324,13 +324,23 @@ const mapStateToProps = (state: GlobalState): ReduxMappedStateProps => { const mapDispatchToProps = (dispatch: Dispatch): ReduxMappedDispatchProps => ({ pickPaymentMethod: (paymentId: string) => dispatch(paymentRequestPickPaymentMethod({ paymentId })), - requestPinLogin: (wallet: Wallet, paymentId: string) => - dispatch(paymentRequestPinLogin({ wallet, paymentId })), goBack: () => dispatch(paymentRequestGoBack()), pickPsp: (wallet: Wallet, pspList: ReadonlyArray, paymentId: string) => dispatch(paymentRequestPickPsp({ wallet, pspList, paymentId })), showSummary: () => dispatch(paymentRequestTransactionSummaryFromBanner()), - onCancel: () => dispatch(paymentRequestCancel()) + onCancel: () => dispatch(paymentRequestCancel()), + requestIdentification: (wallet: Wallet, paymentId: string) => + dispatch( + identificationRequest( + { + action: paymentRequestCancel(), + label: I18n.t("wallet.ConfirmPayment.cancelPayment") + }, + { + action: paymentRequestCompletion({ wallet, paymentId }) + } + ) + ) }); export default connect( diff --git a/ts/store/actions/identification.ts b/ts/store/actions/identification.ts new file mode 100644 index 00000000000..7c4a2307a12 --- /dev/null +++ b/ts/store/actions/identification.ts @@ -0,0 +1,55 @@ +import { ActionType, createAction } from "typesafe-actions"; + +import { PinString } from "../../types/PinString"; +import { + IdentificationCancelData, + IdentificationSuccessData +} from "../reducers/identification"; + +/** + * An action dispatched by the screen. + * The identification saga will intercept it and enrich with the current pin. + */ +export const identificationRequest = createAction( + "IDENTIFICATION_REQUEST", + resolve => ( + identificationCancelData?: IdentificationCancelData, + identificationSuccessData?: IdentificationSuccessData + ) => + resolve({ + identificationCancelData, + identificationSuccessData + }) +); + +/** + * Action dispatched internally by the identification saga to start the process. + */ +export const identificationStart = createAction( + "IDENTIFICATION_START", + resolve => ( + pin: PinString, + identificationCancelData?: IdentificationCancelData, + identificationSuccessData?: IdentificationSuccessData + ) => + resolve({ + pin, + identificationCancelData, + identificationSuccessData + }) +); + +export const identificationCancel = createAction("IDENTIFICATION_CANCEL"); +export const identificationSuccess = createAction("IDENTIFICATION_SUCCESS"); +export const identificationFailure = createAction("IDENTIFICATION_FAILURE"); +export const identificationPinReset = createAction("IDENTIFICATION_PIN_RESET"); +export const identificationReset = createAction("IDENTIFICATION_RESET"); + +export type IdentificationActions = + | ActionType + | ActionType + | ActionType + | ActionType + | ActionType + | ActionType + | ActionType; diff --git a/ts/store/actions/types.ts b/ts/store/actions/types.ts index c2abb4e22a7..e86f4ed2ab4 100644 --- a/ts/store/actions/types.ts +++ b/ts/store/actions/types.ts @@ -1,7 +1,6 @@ /** * Defines types for the available actions and store related stuff. */ - import { Dispatch as DispatchAPI, MiddlewareAPI as ReduxMiddlewareAPI, @@ -17,6 +16,7 @@ import { BackendInfoActions } from "./backendInfo"; import { ContentActions } from "./content"; import { DeepLinkActions } from "./deepLink"; import { ErrorActions } from "./error"; +import { IdentificationActions } from "./identification"; import { MessagesActions } from "./messages"; import { NavigationActions } from "./navigation"; import { NavigationHistoryActions } from "./navigationHistory"; @@ -47,7 +47,8 @@ export type Action = | ServicesActions | WalletActions | ContentActions - | NavigationHistoryActions; + | NavigationHistoryActions + | IdentificationActions; export type Dispatch = DispatchAPI; diff --git a/ts/store/actions/wallet/payment.ts b/ts/store/actions/wallet/payment.ts index 217aea82ef5..350ca5c5e8c 100644 --- a/ts/store/actions/wallet/payment.ts +++ b/ts/store/actions/wallet/payment.ts @@ -139,19 +139,6 @@ export const paymentRequestCancel = createStandardAction( "PAYMENT_REQUEST_CANCEL" )(); -type PaymentRequestPinLoginPayload = Readonly<{ - wallet: Wallet; - paymentId: string; -}>; - -export const paymentRequestPinLogin = createStandardAction( - "PAYMENT_REQUEST_PIN_LOGIN" -)(); - -export const setPaymentStateToPinLogin = createStandardAction( - "PAYMENT_PIN_LOGIN" -)(); - export const paymentFailure = createStandardAction("PAYMENT_FAILURE")< PagoPaErrors >(); @@ -179,6 +166,4 @@ export type PaymentActions = | ActionType | ActionType | ActionType - | ActionType - | ActionType | ActionType; diff --git a/ts/store/reducers/identification.ts b/ts/store/reducers/identification.ts new file mode 100644 index 00000000000..a086795557f --- /dev/null +++ b/ts/store/reducers/identification.ts @@ -0,0 +1,80 @@ +import { getType } from "typesafe-actions"; + +import { PinString } from "../../types/PinString"; +import { + identificationCancel, + identificationReset, + identificationStart, + identificationSuccess +} from "../actions/identification"; +import { Action } from "../actions/types"; + +export enum IdentificationResult { + "cancel" = "cancel", + "pinreset" = "pinreset", + "success" = "success" +} + +export type IdentificationCancelData = { + action: Action; + label: string; +}; + +export type IdentificationSuccessData = { + action: Action; +}; + +type IdentificationUnidentifiedState = { + kind: "unidentified"; +}; + +type IdentificationStartedState = { + kind: "started"; + pin: PinString; + identificationCancelData?: IdentificationCancelData; + identificationSuccessData?: IdentificationSuccessData; +}; + +type IdentificationIdentifiedState = { + kind: "identified"; +}; + +export type IdentificationState = + | IdentificationUnidentifiedState + | IdentificationStartedState + | IdentificationIdentifiedState; + +export const INITIAL_STATE: IdentificationUnidentifiedState = { + kind: "unidentified" +}; + +const reducer = ( + state: IdentificationState = INITIAL_STATE, + action: Action +): IdentificationState => { + switch (action.type) { + case getType(identificationStart): + return { + kind: "started", + ...action.payload + }; + + case getType(identificationCancel): + return { + kind: "unidentified" + }; + + case getType(identificationSuccess): + return { + kind: "identified" + }; + + case getType(identificationReset): + return INITIAL_STATE; + + default: + return state; + } +}; + +export default reducer; diff --git a/ts/store/reducers/index.ts b/ts/store/reducers/index.ts index 3589d0bd761..7e0a6a7c57e 100644 --- a/ts/store/reducers/index.ts +++ b/ts/store/reducers/index.ts @@ -1,7 +1,6 @@ /** * Aggregates all defined reducers */ - import { reducer as networkReducer } from "react-native-offline"; import { combineReducers, Reducer } from "redux"; import { FormStateMap, reducer as formReducer } from "redux-form"; @@ -18,6 +17,7 @@ import contentReducer from "./content"; import deepLinkReducer from "./deepLink"; import entitiesReducer from "./entities"; import errorReducer from "./error"; +import identificationReducer from "./identification"; import loadingReducer from "./loading"; import navigationReducer from "./navigation"; import navigationHistoryReducer from "./navigationHistory"; @@ -65,6 +65,7 @@ const appReducer: Reducer = combineReducers< content: contentReducer, preferences: preferencesReducer, pinlogin: pinloginReducer, + identification: identificationReducer, // // persisted state diff --git a/ts/store/reducers/types.ts b/ts/store/reducers/types.ts index 7524d27d0a8..681558da273 100644 --- a/ts/store/reducers/types.ts +++ b/ts/store/reducers/types.ts @@ -10,6 +10,7 @@ import { ContentState } from "./content"; import { DeepLinkState } from "./deepLink"; import { EntitiesState } from "./entities"; import { ErrorState } from "./error"; +import { IdentificationState } from "./identification"; import { LoadingState } from "./loading"; import { NavigationHistoryState } from "./navigationHistory"; import { NotificationsState } from "./notifications"; @@ -48,6 +49,7 @@ export type GlobalState = Readonly<{ preferences: PreferencesState; content: ContentState; navigationHistory: NavigationHistoryState; + identification: IdentificationState; }>; /** diff --git a/ts/store/reducers/wallet/payment.ts b/ts/store/reducers/wallet/payment.ts index 2c6ee92beb6..7b54b00f455 100644 --- a/ts/store/reducers/wallet/payment.ts +++ b/ts/store/reducers/wallet/payment.ts @@ -17,11 +17,9 @@ import { Action } from "../../actions/types"; import { goBackOnePaymentState, paymentCancel, - resetPaymentState, setPaymentStateToConfirmPaymentMethod, setPaymentStateToPickPaymentMethod, setPaymentStateToPickPsp, - setPaymentStateToPinLogin, setPaymentStateToSummary, setPaymentStateToSummaryWithPaymentId } from "../../actions/wallet/payment"; @@ -124,6 +122,9 @@ export type PaymentStateWithVerificaResponse = Readonly<{ stack: NonEmptyArray; }>; +export const isPaymentOngoingSelector = (state: GlobalState): boolean => + state.wallet.payment.stack !== null; + export const isPaymentRequestingPinLogin = (wallet: WalletState) => wallet.payment.stack !== null && wallet.payment.stack.head.kind === "PaymentStatePinLogin"; @@ -524,40 +525,6 @@ const goBackReducer: PaymentReducer = ( return state; }; -/** - * Reducer for actions that terminate a payment - */ -const endPaymentReducer: PaymentReducer = ( - state: PaymentState = PAYMENT_INITIAL_STATE, - action: Action -) => { - if ( - isActionOf(setPaymentStateToPinLogin, action) && - isInAllowedOrigins(state, ["PaymentStateConfirmPaymentMethod"]) && - isPaymentStateWithSelectedPaymentMethod(state) - ) { - return { - stack: popToStateAndPush( - state.stack, - { - ...state.stack.head, - kind: "PaymentStatePinLogin" - }, - ["PaymentStatePinLogin"] - ) - }; - } - if ( - isActionOf(resetPaymentState, action) && - isInAllowedOrigins(state, ["PaymentStatePinLogin"]) - ) { - return { - stack: null // cleaning up - }; - } - return state; -}; - /** * Reducer for actions that cancel a payment */ @@ -583,8 +550,7 @@ const reducer = ( confirmMethodReducer, pickPspReducer, goBackReducer, - cancelPaymentReducer, - endPaymentReducer + cancelPaymentReducer ]; return reducers.reduce( (s: PaymentState, r: PaymentReducer) => r(s, action), diff --git a/ts/utils/timer.ts b/ts/utils/timer.ts new file mode 100644 index 00000000000..af83e1b36d1 --- /dev/null +++ b/ts/utils/timer.ts @@ -0,0 +1,8 @@ +import BackgroundTimer from "react-native-background-timer"; + +export function startTimer(t: number): Promise { + // tslint:disable-next-line:promise-must-complete + return new Promise(resolve => { + BackgroundTimer.setTimeout(resolve.bind(null), t); + }); +} diff --git a/yarn.lock b/yarn.lock index c665184f5e6..0992f968866 100644 --- a/yarn.lock +++ b/yarn.lock @@ -617,6 +617,10 @@ version "1.13.2" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-1.13.2.tgz#ffe96278e712a8d4e467e367a338b05e22872646" +"@types/react-native-background-timer@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/react-native-background-timer/-/react-native-background-timer-2.0.0.tgz#c44c57f8fbca9d9d5521fdd72a8f55232b79381e" + "@types/react-native-fs@^2.8.2": version "2.8.2" resolved "https://registry.yarnpkg.com/@types/react-native-fs/-/react-native-fs-2.8.2.tgz#c5c466ea898d433aa9b3a8fc06ee56392ae49e06" @@ -6387,6 +6391,10 @@ react-native-add-calendar-event@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/react-native-add-calendar-event/-/react-native-add-calendar-event-2.1.0.tgz#3374f9ce9e924980910caf15a9a4e2d3d19fd1aa" +react-native-background-timer@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/react-native-background-timer/-/react-native-background-timer-2.0.1.tgz#ef714a4bfd28fc47cbbd335631b581cf915604a2" + react-native-camera@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/react-native-camera/-/react-native-camera-1.2.0.tgz#fa93b39726d15bdbb02fac06f4a23957b0be403a"