diff --git a/app/components/Views/ActivityView/index.js b/app/components/Views/ActivityView/index.js index 40472acbda2..d4fdd786816 100644 --- a/app/components/Views/ActivityView/index.js +++ b/app/components/Views/ActivityView/index.js @@ -11,6 +11,7 @@ import TransactionsView from '../TransactionsView'; import TabBar from '../../Base/TabBar'; import { strings } from '../../../../locales/i18n'; import FiatOrdersView from '../FiatOrdersView'; +import ErrorBoundary from '../ErrorBoundary'; const styles = StyleSheet.create({ wrapper: { @@ -30,12 +31,18 @@ function ActivityView({ hasOrders, ...props }) { ); return ( - - - - {hasOrders && } - - + + + + + {hasOrders && } + + + ); } diff --git a/app/components/Views/BrowserTab/__snapshots__/index.test.js.snap b/app/components/Views/BrowserTab/__snapshots__/index.test.js.snap index b54bf94b161..1471bab6acd 100644 --- a/app/components/Views/BrowserTab/__snapshots__/index.test.js.snap +++ b/app/components/Views/BrowserTab/__snapshots__/index.test.js.snap @@ -1,321 +1,325 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Browser should render correctly 1`] = ` - - - - - - - - + /> - + + + - + + - + - Cancel - - - - - - + Cancel + + + + + + - + + + - - - + + + - - - + + `; diff --git a/app/components/Views/BrowserTab/index.js b/app/components/Views/BrowserTab/index.js index a5067e7ef44..0e2c9ed3141 100644 --- a/app/components/Views/BrowserTab/index.js +++ b/app/components/Views/BrowserTab/index.js @@ -60,6 +60,7 @@ import { ethErrors } from 'eth-json-rpc-errors'; import EntryScriptWeb3 from '../../../core/EntryScriptWeb3'; import { getVersion } from 'react-native-device-info'; +import ErrorBoundary from '../ErrorBoundary'; const { HOMEPAGE_URL, USER_AGENT, NOTIFICATION_NAMES } = AppConstants; const HOMEPAGE_HOST = 'home.metamask.io'; @@ -1679,42 +1680,44 @@ export const BrowserTab = props => { * Main render */ return ( - - - {!!entryScriptWeb3 && firstUrlLoaded && ( - null} />} - source={{ uri: initialUrl }} - injectedJavaScriptBeforeContentLoaded={entryScriptWeb3} - style={styles.webview} - onLoadStart={onLoadStart} - onLoadEnd={onLoadEnd} - onLoadProgress={onLoadProgress} - onMessage={onMessage} - onError={onError} - onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} - userAgent={USER_AGENT} - sendCookies - javascriptEnabled - allowsInlineMediaPlayback - useWebkit - testID={'browser-webview'} - /> - )} + + + + {!!entryScriptWeb3 && firstUrlLoaded && ( + null} />} + source={{ uri: initialUrl }} + injectedJavaScriptBeforeContentLoaded={entryScriptWeb3} + style={styles.webview} + onLoadStart={onLoadStart} + onLoadEnd={onLoadEnd} + onLoadProgress={onLoadProgress} + onMessage={onMessage} + onError={onError} + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} + userAgent={USER_AGENT} + sendCookies + javascriptEnabled + allowsInlineMediaPlayback + useWebkit + testID={'browser-webview'} + /> + )} + + {renderProgressBar()} + {isTabActive() && renderPhishingModal()} + {isTabActive() && renderUrlModal()} + {isTabActive() && renderApprovalModal()} + {isTabActive() && renderWatchAssetModal()} + {isTabActive() && renderOptions()} + {isTabActive() && renderBottomBar()} + {isTabActive() && renderOnboardingWizard()} - {renderProgressBar()} - {isTabActive() && renderPhishingModal()} - {isTabActive() && renderUrlModal()} - {isTabActive() && renderApprovalModal()} - {isTabActive() && renderWatchAssetModal()} - {isTabActive() && renderOptions()} - {isTabActive() && renderBottomBar()} - {isTabActive() && renderOnboardingWizard()} - + ); }; diff --git a/app/components/Views/ErrorBoundary/index.js b/app/components/Views/ErrorBoundary/index.js new file mode 100644 index 00000000000..890da5cc18c --- /dev/null +++ b/app/components/Views/ErrorBoundary/index.js @@ -0,0 +1,222 @@ +import React, { Component } from 'react'; +import { SafeAreaView, Text, TouchableOpacity, View, StyleSheet, Image, Linking, Alert } from 'react-native'; +import PropTypes from 'prop-types'; +import RevealPrivateCredential from '../RevealPrivateCredential'; +import Logger from '../../../util/Logger'; +import { colors, fontStyles } from '../../../styles/common'; +import { ScrollView } from 'react-native-gesture-handler'; +import Clipboard from '@react-native-community/clipboard'; +import { strings } from '../../../../locales/i18n'; +import Icon from 'react-native-vector-icons/FontAwesome'; + +// eslint-disable-next-line import/no-commonjs +const metamaskErrorImage = require('../../../images/metamask-error.png'); + +const styles = StyleSheet.create({ + container: { + flex: 1 + }, + content: { + paddingHorizontal: 24, + flex: 1 + }, + header: { + alignItems: 'center' + }, + errorImage: { + width: 50, + height: 50, + marginTop: 24 + }, + title: { + color: colors.black, + fontSize: 24, + lineHeight: 34, + ...fontStyles.bold + }, + subtitle: { + fontSize: 14, + lineHeight: 20, + color: colors.grey500, + marginTop: 8, + textAlign: 'center', + ...fontStyles.normal + }, + errorContainer: { + backgroundColor: colors.red000, + borderRadius: 8, + marginTop: 24 + }, + error: { + color: colors.black, + padding: 8, + fontSize: 14, + lineHeight: 20, + ...fontStyles.normal + }, + button: { + marginTop: 24, + borderColor: colors.blue, + borderWidth: 1, + borderRadius: 50, + padding: 12, + paddingHorizontal: 34 + }, + buttonText: { + color: colors.blue, + textAlign: 'center', + ...fontStyles.normal, + fontWeight: '500' + }, + textContainer: { + marginTop: 24 + }, + text: { + color: colors.black, + fontSize: 14, + lineHeight: 20, + ...fontStyles.normal + }, + link: { + color: colors.blue + }, + reportTextContainer: { + paddingLeft: 14, + marginTop: 16, + marginBottom: 24 + }, + reportStep: { + marginTop: 14 + } +}); + +const Fallback = props => ( + + + + + {strings('error_screen.title')} + {strings('error_screen.subtitle')} + + + {props.errorMessage} + + + + + + {' '} + {strings('error_screen.try_again_button')} + + + + + + {strings('error_screen.submit_ticket_1')} + + + + + {' '} + {strings('error_screen.submit_ticket_2')} + + + + + {' '} + + {strings('error_screen.submit_ticket_3')} + {' '} + {strings('error_screen.submit_ticket_4')} + + + + + {' '} + {strings('error_screen.submit_ticket_5')}{' '} + + {strings('error_screen.submit_ticket_6')} + {' '} + {strings('error_screen.submit_ticket_7')} + + + + {strings('error_screen.save_seedphrase_1')}{' '} + + {strings('error_screen.save_seedphrase_2')} + {' '} + {strings('error_screen.save_seedphrase_3')} + + + + +); + +Fallback.propTypes = { + errorMessage: PropTypes.string, + resetError: PropTypes.func, + showExportSeedphrase: PropTypes.func, + copyErrorToClipboard: PropTypes.func, + openTicket: PropTypes.func +}; + +class ErrorBoundary extends Component { + state = { error: null }; + + static propTypes = { + children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]), + view: PropTypes.string.isRequired + }; + + static getDerivedStateFromError(error) { + return { error }; + } + + componentDidCatch(error, errorInfo) { + Logger.error(error, { View: this.props.view, ...errorInfo }); + } + + resetError = () => { + this.setState({ error: null }); + }; + + showExportSeedphrase = () => { + this.setState({ backupSeedphrase: true }); + }; + + cancelExportSeedphrase = () => { + this.setState({ backupSeedphrase: false }); + }; + + getErrorMessage = () => `View: ${this.props.view}\n${this.state.error.toString()}`; + + copyErrorToClipboard = async () => { + await Clipboard.setString(this.getErrorMessage()); + Alert.alert(strings('error_screen.copied_clipboard'), '', [{ text: strings('error_screen.ok') }], { + cancelable: true + }); + }; + + openTicket = () => { + const url = 'https://metamask.zendesk.com/hc/en-us/requests/new'; + Linking.openURL(url); + }; + + render() { + return this.state.backupSeedphrase ? ( + + ) : this.state.error ? ( + + ) : ( + this.props.children + ); + } +} + +export default ErrorBoundary; diff --git a/app/components/Views/Login/index.js b/app/components/Views/Login/index.js index 279d75d6252..6cdc3006ccc 100644 --- a/app/components/Views/Login/index.js +++ b/app/components/Views/Login/index.js @@ -30,6 +30,7 @@ import { ORIGINAL } from '../../../constants/storage'; import { passwordRequirementsMet } from '../../../util/password'; +import ErrorBoundary from '../ErrorBoundary'; const styles = StyleSheet.create({ mainWrapper: { @@ -335,78 +336,80 @@ class Login extends PureComponent { }; render = () => ( - - - - - {Device.isAndroid() ? ( - - ) : ( - - )} - - {strings('login.title')} - - {strings('login.password')} - ( - - - - + + + + ); } diff --git a/app/components/Views/RevealPrivateCredential/index.js b/app/components/Views/RevealPrivateCredential/index.js index 24ea18caa26..428c10a2a90 100644 --- a/app/components/Views/RevealPrivateCredential/index.js +++ b/app/components/Views/RevealPrivateCredential/index.js @@ -146,7 +146,6 @@ class RevealPrivateCredential extends PureComponent { strings(`reveal_credential.${navigation.getParam('privateCredentialName', '')}_title`), navigation ); - static propTypes = { /** /* navigation object required to push new views @@ -163,7 +162,15 @@ class RevealPrivateCredential extends PureComponent { /** * Boolean that determines if the user has set a password before */ - passwordSet: PropTypes.bool + passwordSet: PropTypes.bool, + /** + * String that determines whether to show the seedphrase or private key export screen + */ + privateCredentialName: PropTypes.string, + /** + * Cancel function to be called when cancel button is clicked. If not provided, we go to previous screen on cancel + */ + cancel: PropTypes.func }; async componentDidMount() { @@ -193,20 +200,17 @@ class RevealPrivateCredential extends PureComponent { }; cancel = () => { + if (this.props.cancel) return this.props.cancel(); const { navigation } = this.props; navigation.pop(); }; async tryUnlockWithPassword(password) { const { KeyringController } = Engine.context; - const { - selectedAddress, - navigation: { - state: { - params: { privateCredentialName } - } - } - } = this.props; + const { selectedAddress } = this.props; + + const privateCredentialName = + this.props.privateCredentialName || this.props.navigation.state.params.privateCredentialName; try { if (privateCredentialName === 'seed_phrase') { @@ -241,13 +245,9 @@ class RevealPrivateCredential extends PureComponent { copyPrivateCredentialToClipboard = async () => { const { privateCredential } = this.state; - const { - navigation: { - state: { - params: { privateCredentialName } - } - } - } = this.props; + const privateCredentialName = + this.props.privateCredentialName || this.props.navigation.state.params.privateCredentialName; + await Clipboard.setString(privateCredential); this.props.showAlert({ isVisible: true, @@ -272,13 +272,9 @@ class RevealPrivateCredential extends PureComponent { render = () => { const { unlocked, privateCredential } = this.state; - const { - navigation: { - state: { - params: { privateCredentialName } - } - } - } = this.props; + const privateCredentialName = + this.props.privateCredentialName || this.props.navigation.state.params.privateCredentialName; + return ( - + + + `; diff --git a/app/components/Views/Root/index.js b/app/components/Views/Root/index.js index 97df4fda47b..f20dbd4d001 100644 --- a/app/components/Views/Root/index.js +++ b/app/components/Views/Root/index.js @@ -9,6 +9,8 @@ import SplashScreen from 'react-native-splash-screen'; import App from '../../Nav/App'; import SecureKeychain from '../../../core/SecureKeychain'; import EntryScriptWeb3 from '../../../core/EntryScriptWeb3'; +import Logger from '../../../util/Logger'; +import ErrorBoundary from '../ErrorBoundary'; /** * Top level of the component hierarchy @@ -23,6 +25,10 @@ export default class Root extends PureComponent { foxCode: 'null' }; + errorHandler = (error, stackTrace) => { + Logger.error(error, stackTrace); + }; + constructor(props) { super(props); SecureKeychain.init(props.foxCode); @@ -34,7 +40,9 @@ export default class Root extends PureComponent { render = () => ( - + + + ); diff --git a/app/components/Views/Wallet/index.js b/app/components/Views/Wallet/index.js index 6c36aa4afde..0b94f0f6ac7 100644 --- a/app/components/Views/Wallet/index.js +++ b/app/components/Views/Wallet/index.js @@ -18,6 +18,7 @@ import { getTicker } from '../../../util/transactions'; import OnboardingWizard from '../../UI/OnboardingWizard'; import { showTransactionNotification, hideTransactionNotification } from '../../../actions/notification'; import DeeplinkManager from '../../../core/DeeplinkManager'; +import ErrorBoundary from '../ErrorBoundary'; const styles = StyleSheet.create({ wrapper: { @@ -238,15 +239,17 @@ class Wallet extends PureComponent { }; render = () => ( - - } - > - {this.props.selectedAddress ? this.renderContent() : this.renderLoader()} - - {this.renderOnboardingWizard()} - + + + } + > + {this.props.selectedAddress ? this.renderContent() : this.renderLoader()} + + {this.renderOnboardingWizard()} + + ); } diff --git a/app/images/metamask-error.png b/app/images/metamask-error.png new file mode 100644 index 00000000000..31eac551949 Binary files /dev/null and b/app/images/metamask-error.png differ diff --git a/locales/en.json b/locales/en.json index c152051e4df..c2640b4a886 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1325,5 +1325,22 @@ "text": "Don’t risk losing your funds. Protect your wallet by saving your seed phrase in a place you trust.", "text_bold": "It’s the only way to recover your wallet if you get locked out of the app or get a new device.", "action": "Learn more" + }, + "error_screen": { + "title": "An error occured", + "subtitle": "Your information can't be shown. Don’t worry, your wallet and funds are safe.", + "try_again_button": "Try again", + "submit_ticket_1": "Please report this issue so we can fix it:", + "submit_ticket_2": "Take a screenshot of this screen.", + "submit_ticket_3": "Copy", + "submit_ticket_4": "the error message to clipboard.", + "submit_ticket_5": "Submit a ticket", + "submit_ticket_6": "here.", + "submit_ticket_7": "Please include the error message and the screenshot.", + "save_seedphrase_1": "If this error persists,", + "save_seedphrase_2": "save your seed phrase", + "save_seedphrase_3": "& re-install the app. Note: you can NOT restore your wallet without your seed phrase.", + "copied_clipboard": "Copied to clipboard", + "ok": "OK" } }