diff --git a/packages/mobile/locales/en-US/accountScreen10.json b/packages/mobile/locales/en-US/accountScreen10.json index ec7598f25b3..ffd3bfa0485 100644 --- a/packages/mobile/locales/en-US/accountScreen10.json +++ b/packages/mobile/locales/en-US/accountScreen10.json @@ -7,12 +7,10 @@ "sendIssueReport": "Send an Issue Report", "analytics": "Analytics", "shareAnalytics": "Share Analytics", - "shareAnalytics_detail": - "We collect anonymized data about how you use Celo to help improve the application for everyone.", + "shareAnalytics_detail": "We collect anonymized data about how you use Celo to help improve the application for everyone.", "dataSaver": "Data Saver", "enableDataSaver": "Enable Data Saver", - "dataSaverDetail": - "Data Saver mode allows you to communicate with the Celo Network through a trusted node. You can always change this mode in app settings.", + "dataSaverDetail": "Data Saver mode allows you to communicate with the Celo Network through a trusted node. You can always change this mode in app settings.", "restartModalSwitchOff": { "header": "Restart To Switch Off Data Saver", "body": "To switch Data Saver on and off repeatedly, you will need to restart the app.", @@ -23,6 +21,11 @@ "body": "In case of incorrect PIN, you will need to restart the app.", "understand": "I understand" }, + "promptZeroSyncModal": { + "header": "Switch Connection Mode?", + "body": "We’ve noticed you’re having some trouble connecting. We recommend enabling Data Saver mode to allow you to keep using the Celo Wallet with intermittent connection.", + "switchToDataSaver": "Switch To Data Saver" + }, "testFaqLink": "Celo Wallet FAQ", "termsOfServiceLink": "Terms of service", "editProfile": "Edit Profile", diff --git a/packages/mobile/locales/es-419/accountScreen10.json b/packages/mobile/locales/es-419/accountScreen10.json index 5babac84263..9293fbf81aa 100755 --- a/packages/mobile/locales/es-419/accountScreen10.json +++ b/packages/mobile/locales/es-419/accountScreen10.json @@ -23,6 +23,11 @@ "body": "En caso de ingresar un PIN incorrecto, la aplicación se reiniciará.", "understand": "Entendido" }, + "promptZeroSyncModal": { + "header": "Cambiar modo de conexión?", + "body": "Hemos notado que tienes problemas para conectarte. Recomendamos habilitar el modo de ahorro de datos para que pueda seguir usando Celo Wallet con una conexión intermitente.", + "switchToDataSaver": "Habilitar Ahorro de Datos" + }, "testFaqLink": "Las Preguntas Frecuentes del Monedero Celo", "termsOfServiceLink": "Las Condiciones de Servicio", "editProfile": "Editar perfil", diff --git a/packages/mobile/src/account/DataSaver.test.tsx b/packages/mobile/src/account/DataSaver.test.tsx index cccc4e9d215..348499f9f44 100644 --- a/packages/mobile/src/account/DataSaver.test.tsx +++ b/packages/mobile/src/account/DataSaver.test.tsx @@ -4,12 +4,13 @@ import { Provider } from 'react-redux' import * as renderer from 'react-test-renderer' import DataSaver from 'src/account/DataSaver' import { createMockStore } from 'test/utils' +import { mockNavigation } from 'test/values' describe('DataSaver', () => { it('renders correctly', () => { const tree = renderer.create( - + ) expect(tree).toMatchSnapshot() diff --git a/packages/mobile/src/account/DataSaver.tsx b/packages/mobile/src/account/DataSaver.tsx index 2ef1ebf66f2..2df824acb57 100644 --- a/packages/mobile/src/account/DataSaver.tsx +++ b/packages/mobile/src/account/DataSaver.tsx @@ -6,9 +6,11 @@ import * as React from 'react' import { WithTranslation } from 'react-i18next' import { ScrollView, StyleSheet, Text, View } from 'react-native' import Modal from 'react-native-modal' +import { NavigationInjectedProps } from 'react-navigation' import { connect } from 'react-redux' import i18n, { Namespaces, withTranslation } from 'src/i18n' import { headerWithBackButton } from 'src/navigator/Headers' +import { navigateBack } from 'src/navigator/NavigationService' import { RootState } from 'src/redux/reducers' import { toggleZeroSyncMode } from 'src/web3/actions' @@ -21,7 +23,7 @@ interface DispatchProps { toggleZeroSyncMode: typeof toggleZeroSyncMode } -type Props = StateProps & DispatchProps & WithTranslation +type Props = StateProps & DispatchProps & WithTranslation & NavigationInjectedProps const mapDispatchToProps = { toggleZeroSyncMode, @@ -37,6 +39,7 @@ const mapStateToProps = (state: RootState): StateProps => { interface State { switchOffModalVisible: boolean switchOnModalVisible: boolean + promptModalVisible: boolean } interface ModalProps { @@ -85,6 +88,16 @@ export class DataSaver extends React.Component { state = { switchOffModalVisible: false, switchOnModalVisible: false, + promptModalVisible: false, + } + + componentDidMount() { + const promptModalVisible = this.props.navigation.getParam('promptModalVisible') + if (promptModalVisible) { + this.setState({ + promptModalVisible, + }) + } } showSwitchOffModal = () => { @@ -113,6 +126,16 @@ export class DataSaver extends React.Component { this.hideSwitchOnModal() } + onPressPromptModal = () => { + this.props.toggleZeroSyncMode(true) + navigateBack() + } + + hidePromptModal = () => { + this.props.toggleZeroSyncMode(false) + navigateBack() + } + handleZeroSyncToggle = (zeroSyncMode: boolean) => { if (!zeroSyncMode && this.props.gethStartedThisSession) { // Starting geth a second time this app session which will @@ -136,6 +159,15 @@ export class DataSaver extends React.Component { > {t('enableDataSaver')} + + + + + + + promptZeroSyncModal.header + + + promptZeroSyncModal.body + + + + + global:goBack + + + + + promptZeroSyncModal.switchToDataSaver + + + + + + ({ +export const setInitState = (state: InitializationState): SetInitStateAction => ({ type: Actions.SET_INIT_STATE, state, }) -interface SetGethConnected { +export const cancelGethSaga = () => ({ + type: Actions.CANCEL_GETH_SAGA, +}) + +interface SetPromptZeroSyncAction { + type: Actions.SET_PROMPT_ZERO_SYNC + promptIfNeeded: boolean +} + +export const setPromptZeroSync = (promptIfNeeded: boolean): SetPromptZeroSyncAction => ({ + type: Actions.SET_PROMPT_ZERO_SYNC, + promptIfNeeded, +}) + +interface SetGethConnectedAction { type: Actions.SET_GETH_CONNECTED connected: boolean } -export const setGethConnected = (connected: boolean): SetGethConnected => ({ +export const setGethConnected = (connected: boolean): SetGethConnectedAction => ({ type: Actions.SET_GETH_CONNECTED, connected, }) -export type ActionTypes = SetInitState | SetGethConnected +export type ActionTypes = SetInitStateAction | SetGethConnectedAction | SetPromptZeroSyncAction diff --git a/packages/mobile/src/geth/reducer.ts b/packages/mobile/src/geth/reducer.ts index caca73e3316..ee1703fbafe 100644 --- a/packages/mobile/src/geth/reducer.ts +++ b/packages/mobile/src/geth/reducer.ts @@ -1,5 +1,4 @@ import { Actions, ActionTypes } from 'src/geth/actions' -import { RootState } from 'src/redux/reducers' export enum InitializationState { NOT_YET_INITIALIZED = 'NOT_YET_INITIALIZED', @@ -12,11 +11,13 @@ export enum InitializationState { export interface State { initialized: InitializationState connected: boolean + promptZeroSyncIfNeeded: boolean } const initialState: State = { initialized: InitializationState.NOT_YET_INITIALIZED, connected: false, + promptZeroSyncIfNeeded: false, } export function gethReducer(state: State = initialState, action: ActionTypes) { @@ -28,10 +29,13 @@ export function gethReducer(state: State = initialState, action: ActionTypes) { ...state, connected: action.connected, } + case Actions.SET_PROMPT_ZERO_SYNC: + return { + ...state, + promptZeroSyncIfNeeded: action.promptIfNeeded, + } + default: return state } } - -export const isGethConnectedSelector = (state: RootState) => - state.geth.initialized === InitializationState.INITIALIZED && state.geth.connected diff --git a/packages/mobile/src/geth/saga.ts b/packages/mobile/src/geth/saga.ts index 6a983c155aa..6a02f42ee9f 100644 --- a/packages/mobile/src/geth/saga.ts +++ b/packages/mobile/src/geth/saga.ts @@ -1,17 +1,29 @@ import { AppState, NativeEventEmitter, NativeModules } from 'react-native' import { eventChannel } from 'redux-saga' -import { call, cancelled, delay, fork, put, race, select, take } from 'redux-saga/effects' -import { Actions, setGethConnected, setInitState } from 'src/geth/actions' +import { + all, + call, + cancel, + cancelled, + delay, + fork, + put, + race, + select, + take, +} from 'redux-saga/effects' +import { Actions, setGethConnected, setInitState, setPromptZeroSync } from 'src/geth/actions' import { FailedToFetchGenesisBlockError, FailedToFetchStaticNodesError, getGeth, } from 'src/geth/geth' -import { InitializationState, isGethConnectedSelector } from 'src/geth/reducer' -import { navigateToError } from 'src/navigator/NavigationService' +import { InitializationState } from 'src/geth/reducer' +import { isGethConnectedSelector, promptZeroSyncIfNeededSelector } from 'src/geth/selectors' +import { navigate, navigateToError } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' import { deleteChainDataAndRestartApp } from 'src/utils/AppRestart' import Logger from 'src/utils/Logger' -import { zeroSyncSelector } from 'src/web3/selectors' const gethEmitter = new NativeEventEmitter(NativeModules.RNGeth) @@ -42,10 +54,6 @@ export function* waitForGethConnectivity() { } function* waitForGethInstance() { - const zeroSyncMode = yield select(zeroSyncSelector) - if (zeroSyncMode) { - return GethInitOutcomes.SUCCESS - } try { const gethInstance = yield call(getGeth) if (gethInstance == null) { @@ -120,7 +128,13 @@ export function* initGethSaga() { Logger.error(TAG, 'Geth initialization failed, restarting the app.') deleteChainDataAndRestartApp() } else { - navigateToError('networkConnectionFailed') + // Suggest switch to forno for network-related errors + if (yield select(promptZeroSyncIfNeededSelector)) { + yield put(setPromptZeroSync(false)) + navigate(Screens.DataSaver, { promptModalVisible: true }) + } else { + navigateToError('networkConnectionFailed') + } } } @@ -134,13 +148,6 @@ function createNewBlockChannel() { function* monitorGeth() { const newBlockChannel = yield createNewBlockChannel() - const zeroSyncMode = yield select(zeroSyncSelector) - if (zeroSyncMode) { - yield put(setGethConnected(true)) - yield delay(GETH_MONITOR_DELAY) - return - } - while (true) { try { const { newBlock } = yield race({ @@ -152,24 +159,25 @@ function* monitorGeth() { yield put(setGethConnected(true)) yield delay(GETH_MONITOR_DELAY) } else { - // Check whether reason for no new blocks is switch to zeroSync mode - const switchedToZeroSync = yield select(zeroSyncSelector) - if (switchedToZeroSync) { - yield put(setGethConnected(true)) - return - } else { - Logger.error( - `${TAG}@monitorGeth`, - `Did not receive a block in ${NEW_BLOCK_TIMEOUT} milliseconds` - ) - yield put(setGethConnected(false)) - } + Logger.error( + `${TAG}@monitorGeth`, + `Did not receive a block in ${NEW_BLOCK_TIMEOUT} milliseconds` + ) + yield put(setGethConnected(false)) } } catch (error) { Logger.error(`${TAG}@monitorGeth`, error) } finally { if (yield cancelled()) { - newBlockChannel.close() + try { + newBlockChannel.close() + } catch (error) { + Logger.debug( + `${TAG}@monitorGeth`, + 'Could not close newBlockChannel. May already be closed.', + error + ) + } } } } @@ -205,6 +213,8 @@ function* monitorAppState() { export function* gethSaga() { yield call(initGethSaga) - yield fork(monitorAppState) - yield fork(monitorGeth) + const gethRelatedSagas = yield all([fork(monitorAppState), fork(monitorGeth)]) + yield take(Actions.CANCEL_GETH_SAGA) + yield cancel(gethRelatedSagas) + yield put(setGethConnected(true)) } diff --git a/packages/mobile/src/geth/selectors.ts b/packages/mobile/src/geth/selectors.ts new file mode 100644 index 00000000000..f020af4ac15 --- /dev/null +++ b/packages/mobile/src/geth/selectors.ts @@ -0,0 +1,7 @@ +import { InitializationState } from 'src/geth/reducer' +import { RootState } from 'src/redux/reducers' + +export const isGethConnectedSelector = (state: RootState) => + state.geth.initialized === InitializationState.INITIALIZED && state.geth.connected +export const promptZeroSyncIfNeededSelector = (state: RootState) => + state.geth.promptZeroSyncIfNeeded diff --git a/packages/mobile/src/invite/JoinCelo.test.tsx b/packages/mobile/src/invite/JoinCelo.test.tsx index b3632d07343..73a6c2df812 100644 --- a/packages/mobile/src/invite/JoinCelo.test.tsx +++ b/packages/mobile/src/invite/JoinCelo.test.tsx @@ -37,6 +37,7 @@ describe('JoinCeloScreen', () => { { { showError={error} hideAlert={jest.fn()} setPhoneNumber={jest.fn()} + setPromptZeroSync={jest.fn()} setName={jest.fn()} language={'en-us'} cachedName={''} diff --git a/packages/mobile/src/invite/JoinCelo.tsx b/packages/mobile/src/invite/JoinCelo.tsx index 8c95af3a9f4..4ca4407b3cb 100644 --- a/packages/mobile/src/invite/JoinCelo.tsx +++ b/packages/mobile/src/invite/JoinCelo.tsx @@ -16,6 +16,7 @@ import { componentWithAnalytics } from 'src/analytics/wrapper' import { ErrorMessages } from 'src/app/ErrorMessages' import DevSkipButton from 'src/components/DevSkipButton' import { CELO_TERMS_LINK } from 'src/config' +import { setPromptZeroSync } from 'src/geth/actions' import { Namespaces, withTranslation } from 'src/i18n' import NuxLogo from 'src/icons/NuxLogo' import { nuxNavigationOptions } from 'src/navigator/Headers' @@ -33,6 +34,7 @@ interface StateProps { } interface DispatchProps { + setPromptZeroSync: typeof setPromptZeroSync showError: typeof showError hideAlert: typeof hideAlert setPhoneNumber: typeof setPhoneNumber @@ -49,6 +51,7 @@ interface State { } const mapDispatchToProps = { + setPromptZeroSync, setPhoneNumber, setName, showError, @@ -136,6 +139,7 @@ export class JoinCelo extends React.Component { return } + this.props.setPromptZeroSync(true) // Allow zero sync prompt after Welcome screen this.props.setPhoneNumber(e164Number, countryCode) this.props.setName(name) this.goToNextScreen() diff --git a/packages/mobile/src/navigator/Navigator.tsx b/packages/mobile/src/navigator/Navigator.tsx index 2edff9d53d3..31b22ddd2d6 100644 --- a/packages/mobile/src/navigator/Navigator.tsx +++ b/packages/mobile/src/navigator/Navigator.tsx @@ -102,6 +102,7 @@ export const commonScreens = { [Screens.DappKitSignTxScreen]: { screen: DappKitSignTxScreen }, [Screens.DappKitTxDataScreen]: { screen: DappKitTxDataScreen }, [Screens.Debug]: { screen: Debug }, + [Screens.DataSaver]: { screen: DataSaver }, } const verificationScreens = { diff --git a/packages/mobile/src/redux/selectors.ts b/packages/mobile/src/redux/selectors.ts index 1581544d5cb..12caaa1935b 100644 --- a/packages/mobile/src/redux/selectors.ts +++ b/packages/mobile/src/redux/selectors.ts @@ -2,7 +2,7 @@ import { createSelector } from 'reselect' import { getIncomingPaymentRequests } from 'src/account/selectors' import { DAYS_TO_BACKUP, DAYS_TO_DELAY } from 'src/backup/utils' import { BALANCE_OUT_OF_SYNC_THRESHOLD } from 'src/config' -import { isGethConnectedSelector } from 'src/geth/reducer' +import { isGethConnectedSelector } from 'src/geth/selectors' import { RootState } from 'src/redux/reducers' import { timeDeltaInDays, timeDeltaInSeconds } from 'src/utils/time' diff --git a/packages/mobile/src/web3/saga.ts b/packages/mobile/src/web3/saga.ts index 4085fadfb23..e0a5e8d97fe 100644 --- a/packages/mobile/src/web3/saga.ts +++ b/packages/mobile/src/web3/saga.ts @@ -14,10 +14,13 @@ import { CustomEventNames } from 'src/analytics/constants' import { ErrorMessages } from 'src/app/ErrorMessages' import { currentLanguageSelector } from 'src/app/reducers' import { getWordlist } from 'src/backup/utils' +import { cancelGethSaga, setPromptZeroSync } from 'src/geth/actions' import { UNLOCK_DURATION } from 'src/geth/consts' import { deleteChainData, stopGethIfInitialized } from 'src/geth/geth' -import { initGethSaga, waitForGethConnectivity } from 'src/geth/saga' -import { navigateToError } from 'src/navigator/NavigationService' +import { gethSaga, waitForGethConnectivity } from 'src/geth/saga' +import { promptZeroSyncIfNeededSelector } from 'src/geth/selectors' +import { navigate, navigateToError } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' import { setCachedPincode } from 'src/pincode/PincodeCache' import { restartApp } from 'src/utils/AppRestart' import { setKey } from 'src/utils/keyStore' @@ -50,24 +53,20 @@ const TAG = 'web3/saga' // The timeout for web3 to complete syncing and the latestBlock to be > 0 export const SYNC_TIMEOUT = 2 * 60 * 1000 // 2 minutes const BLOCK_CHAIN_CORRUPTION_ERROR = "Error: CONNECTION ERROR: Couldn't connect to node on IPC." +const SWITCH_TO_ZERO_SYNC_TIMEOUT = 15000 // if syncing takes >15 secs, suggest switch to zeroSync +const WEB3_MONITOR_DELAY = 100 // checks if web3 claims it is currently syncing and attempts to wait for it to complete export function* checkWeb3SyncProgress() { Logger.debug(TAG, 'checkWeb3SyncProgress', 'Checking sync progress') + let syncLoops = 0 while (true) { try { let syncProgress: boolean | Web3SyncProgress - const zeroSyncMode = yield select(zeroSyncSelector) - // tslint:disable-next-line: prefer-conditional-expression - if (zeroSyncMode) { - // In this mode, the check seems to fail with - // web3/saga/checking web3 sync progress: Error: Invalid JSON RPC response: "": - syncProgress = false - } else { - // isSyncing returns a syncProgress object when it's still syncing, false otherwise - syncProgress = yield call(web3.eth.isSyncing) - } + + // isSyncing returns a syncProgress object when it's still syncing, false otherwise + syncProgress = yield call(web3.eth.isSyncing) if (typeof syncProgress === 'boolean' && !syncProgress) { Logger.debug(TAG, 'checkWeb3SyncProgress', 'Sync maybe complete, checking') @@ -85,15 +84,16 @@ export function* checkWeb3SyncProgress() { } else { throw new Error('Invalid syncProgress type') } - - yield delay(100) // wait 100ms while web3 syncs then check again - } catch (error) { - // Check if error caused by switch to zeroSyncMode - // as if it is in zeroSyncMode it should have returned above - const switchedToZeroSyncMode = yield select(zeroSyncSelector) - if (switchedToZeroSyncMode) { - return true + yield delay(WEB3_MONITOR_DELAY) // wait 100ms while web3 syncs then check again + syncLoops += 1 + if (syncLoops * WEB3_MONITOR_DELAY > SWITCH_TO_ZERO_SYNC_TIMEOUT) { + if (yield select(promptZeroSyncIfNeededSelector)) { + yield put(setPromptZeroSync(false)) + navigate(Screens.DataSaver, { promptModalVisible: true }) + return true + } } + } catch (error) { if (error.toString().toLowerCase() === BLOCK_CHAIN_CORRUPTION_ERROR.toLowerCase()) { CeloAnalytics.track(CustomEventNames.blockChainCorruption, {}, true) const deleted = yield call(deleteChainData) @@ -129,8 +129,10 @@ export function* waitForWeb3Sync() { } export function* waitWeb3LastBlock() { - yield call(waitForGethConnectivity) - yield call(waitForWeb3Sync) + if (!(yield select(zeroSyncSelector))) { + yield call(waitForGethConnectivity) + yield call(waitForWeb3Sync) + } } export function* getOrCreateAccount() { @@ -452,7 +454,8 @@ export function* switchToGethFromZeroSync() { return } - yield call(initGethSaga) + yield spawn(gethSaga) + switchWeb3ProviderForSyncMode(false) // Ensure web3 is fully synced using new provider yield call(waitForWeb3Sync) @@ -471,9 +474,9 @@ export function* switchToZeroSyncFromGeth() { Logger.debug(TAG, 'Switching to zeroSync from geth..') try { yield put(setZeroSyncMode(true)) - yield call(stopGethIfInitialized) - switchWeb3ProviderForSyncMode(true) + yield put(cancelGethSaga()) + yield call(stopGethIfInitialized) // Ensure web3 sync state is updated with new zeroSync state. // This prevents a false positive "geth disconnected" @@ -486,26 +489,30 @@ export function* switchToZeroSyncFromGeth() { } export function* toggleZeroSyncMode(action: SetIsZeroSyncAction) { - Logger.debug(TAG + '@toggleZeroSyncMode', ` to: ${action.zeroSyncMode}`) - if (action.zeroSyncMode) { - yield call(switchToZeroSyncFromGeth) - } else { - yield call(switchToGethFromZeroSync) - } - // Unlock account to ensure private keys are accessible in new mode - try { - const account = yield call(getConnectedUnlockedAccount) - Logger.debug( - TAG + '@toggleZeroSyncMode', - `Switched to ${action.zeroSyncMode} and able to unlock account ${account}` - ) - } catch (e) { - // Rollback if private keys aren't accessible in new mode + if ((yield select(zeroSyncSelector)) !== action.zeroSyncMode) { + Logger.debug(TAG + '@toggleZeroSyncMode', ` to: ${action.zeroSyncMode}`) if (action.zeroSyncMode) { - yield call(switchToGethFromZeroSync) - } else { yield call(switchToZeroSyncFromGeth) + } else { + yield call(switchToGethFromZeroSync) } + // Unlock account to ensure private keys are accessible in new mode + try { + const account = yield call(getConnectedUnlockedAccount) + Logger.debug( + TAG + '@toggleZeroSyncMode', + `Switched to ${action.zeroSyncMode} and able to unlock account ${account}` + ) + } catch (e) { + // Rollback if private keys aren't accessible in new mode + if (action.zeroSyncMode) { + yield call(switchToGethFromZeroSync) + } else { + yield call(switchToZeroSyncFromGeth) + } + } + } else { + Logger.debug(TAG + '@toggleZeroSyncMode', ` already in desired state: ${action.zeroSyncMode}`) } } export function* watchZeroSyncMode() { diff --git a/packages/mobile/test/schemas.ts b/packages/mobile/test/schemas.ts index 90fb476850d..d0cf39d8cad 100644 --- a/packages/mobile/test/schemas.ts +++ b/packages/mobile/test/schemas.ts @@ -236,6 +236,10 @@ export const v5Schema = { ...v4Schema.localCurrency, exchangeRate: '1.33', }, + geth: { + ...v4Schema.geth, + promptZeroSyncIfNeeded: false, + }, } export function getLatestSchema(): Partial {