From 3a233455a6474d744c7fbf322b819ebd929c9c10 Mon Sep 17 00:00:00 2001 From: enahum Date: Wed, 28 Jun 2017 10:22:57 -0400 Subject: [PATCH] RN-235 Report errors to email and restart app if needed (#683) * RN-235 Report errors to email and restart app if needed * Feedback review --- NOTICE.txt | 33 +++++++++++++++++++++ app/constants/navigation.js | 3 +- app/initial_state.js | 7 ++--- app/mattermost.js | 50 ++++++++++++++++++++++++++++++-- app/reducers/views/root.js | 12 +++++++- app/screens/settings/settings.js | 11 +++++-- app/store/index.js | 49 +++++++++++++++++++++++++++---- assets/base/i18n/en.json | 6 ++++ package.json | 1 + yarn.lock | 6 +++- 10 files changed, 159 insertions(+), 19 deletions(-) diff --git a/NOTICE.txt b/NOTICE.txt index d3621c3f6..43e1be967 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1057,3 +1057,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- + +## react-native-exception-handler + +This product contains 'react-native-exception-handler', A react native module that lets you to register a global error handler that can capture fatal/non fatal uncaught exceptions. + +* HOMEPAGE: + * https://github.com/master-atul/react-native-exception-handler + +* LICENSE: + +The MIT License (MIT) + +Copyright (c) 2015 Max + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- diff --git a/app/constants/navigation.js b/app/constants/navigation.js index cc5899560..eefe57a63 100644 --- a/app/constants/navigation.js +++ b/app/constants/navigation.js @@ -6,7 +6,8 @@ import keyMirror from 'mattermost-redux/utils/key_mirror'; const NavigationTypes = keyMirror({ NAVIGATION_RESET: null, NAVIGATION_CLOSE_MODAL: null, - NAVIGATION_NO_TEAMS: null + NAVIGATION_NO_TEAMS: null, + RESTART_APP: null }); export default NavigationTypes; diff --git a/app/initial_state.js b/app/initial_state.js index 5a33b9c2f..5416e0a94 100644 --- a/app/initial_state.js +++ b/app/initial_state.js @@ -74,10 +74,6 @@ const state = { status: 'not_started', error: null }, - leaveChannel: { - status: 'not_started', - error: null - }, joinChannel: { status: 'not_started', error: null @@ -270,7 +266,8 @@ const state = { notification: null, root: { appInitializing: false, - hydrationComplete: false + hydrationComplete: false, + purge: false }, selectServer: { serverUrl: Config.DefaultServerUrl diff --git a/app/mattermost.js b/app/mattermost.js index 90325d53a..46407223a 100644 --- a/app/mattermost.js +++ b/app/mattermost.js @@ -5,6 +5,7 @@ import 'babel-polyfill'; import Orientation from 'react-native-orientation'; import {Provider} from 'react-redux'; import {Navigation} from 'react-native-navigation'; +import {IntlProvider} from 'react-intl'; import { Alert, AppState, @@ -12,10 +13,12 @@ import { Platform } from 'react-native'; import DeviceInfo from 'react-native-device-info'; +import {setJSExceptionHandler} from 'react-native-exception-handler'; import semver from 'semver'; import {setAppState, setDeviceToken, setServerVersion} from 'mattermost-redux/actions/general'; import {markChannelAsRead} from 'mattermost-redux/actions/channels'; +import {logError} from 'mattermost-redux/actions/errors'; import {logout} from 'mattermost-redux/actions/users'; import {Client4} from 'mattermost-redux/client'; import {General} from 'mattermost-redux/constants'; @@ -24,6 +27,7 @@ import EventEmitter from 'mattermost-redux/utils/event_emitter'; import {goToNotification, loadConfigAndLicense, queueNotification} from 'app/actions/views/root'; import {setChannelDisplayName} from 'app/actions/views/channel'; import {NavigationTypes, ViewTypes} from 'app/constants'; +import {getTranslations} from 'app/i18n'; import initialState from 'app/initial_state'; import PushNotifications from 'app/push_notifications'; import {registerScreens} from 'app/screens'; @@ -37,17 +41,50 @@ registerScreens(store, Provider); export default class Mattermost { constructor() { this.isConfigured = false; + setJSExceptionHandler(this.errorHandler, true); Orientation.lockToPortrait(); this.unsubscribeFromStore = store.subscribe(this.listenForHydration); AppState.addEventListener('change', this.handleAppStateChange); EventEmitter.on(General.CONFIG_CHANGED, this.handleConfigChanged); EventEmitter.on(NavigationTypes.NAVIGATION_RESET, this.handleReset); EventEmitter.on(General.DEFAULT_CHANNEL, this.handleResetDisplayName); + EventEmitter.on(NavigationTypes.RESTART_APP, this.restartApp); this.handleAppStateChange(AppState.currentState); Client4.setUserAgent(DeviceInfo.getUserAgent()); } + errorHandler = (e, isFatal) => { + const intl = this.getIntl(); + logError(e)(store.dispatch); + + if (isFatal) { + Alert.alert( + intl.formatMessage({id: 'mobile.error_handler.title', defaultMessage: 'Unexpected error occurred'}), + intl.formatMessage({id: 'mobile.error_handler.description', defaultMessage: '\nClick relaunch to open the app again. After restart, you can report the problem from the settings menu.\n'}), + [{ + text: intl.formatMessage({id: 'mobile.error_handler.button', defaultMessage: 'Relaunch'}), + onPress: () => { + // purge the store + store.dispatch({type: General.OFFLINE_STORE_PURGE}); + } + }] + ); + } + }; + + getIntl = () => { + const state = store.getState(); + let locale = DeviceInfo.getDeviceLocale().split('-')[0]; + if (state.views.i18n.locale) { + locale = state.views.i18n.locale; + } + + const intlProvider = new IntlProvider({locale, messages: getTranslations(locale)}, {}); + const {intl} = intlProvider.getChildContext(); + return intl; + }; + handleAppStateChange = (appState) => { const {dispatch, getState} = store; setAppState(appState === 'active')(dispatch, getState); @@ -56,13 +93,15 @@ export default class Mattermost { handleConfigChanged = (serverVersion) => { const {dispatch, getState} = store; const version = serverVersion.match(/^[0-9]*.[0-9]*.[0-9]*(-[a-zA-Z0-9.-]*)?/g)[0]; + const intl = this.getIntl(); + if (serverVersion) { if (semver.valid(version) && semver.lt(version, Config.MinServerVersion)) { Alert.alert( - 'Server upgrade required', - 'A server upgrade is required to use the Mattermost app. Please ask your System Administrator for details.', + intl.formatMessage({id: 'mobile.server_upgrade.title', defaultMessage: 'Server upgrade required'}), + intl.formatMessage({id: 'mobile.server_upgrade.description', defaultMessage: '\nA server upgrade is required to use the Mattermost app. Please ask your System Administrator for details.\n'}), [{ - text: 'OK', + text: intl.formatMessage({id: 'mobile.server_upgrade.button', defaultMessage: 'OK'}), onPress: this.handleVersionUpgrade }] ); @@ -154,6 +193,11 @@ export default class Mattermost { } }; + restartApp = () => { + Navigation.dismissModal({animationType: 'none'}); + this.startApp('fade'); + }; + startApp = (animationType = 'none') => { if (!this.isConfigured) { this.configurePushNotifications(); diff --git a/app/reducers/views/root.js b/app/reducers/views/root.js index 802575b07..bd24bdba7 100644 --- a/app/reducers/views/root.js +++ b/app/reducers/views/root.js @@ -28,7 +28,17 @@ function hydrationComplete(state = false, action) { } } +function purge(state = false, action) { + switch (action.type) { + case General.OFFLINE_STORE_PURGE: + return true; + default: + return state; + } +} + export default combineReducers({ appInitializing, - hydrationComplete + hydrationComplete, + purge }); diff --git a/app/screens/settings/settings.js b/app/screens/settings/settings.js index 934bf5498..607649d4f 100644 --- a/app/screens/settings/settings.js +++ b/app/screens/settings/settings.js @@ -52,10 +52,16 @@ class Settings extends PureComponent { `App Platform: ${Platform.OS}` ]; if (errors.length) { + const errorArray = errors.map((e) => { + const {error} = e; + const stack = error.stack || ''; + return `Date: ${e.date}\nMessage: ${error.message}\nStack trace:\n${stack}\n\n`; + }).join(''); + contents = contents.concat([ '', 'Errors:', - JSON.stringify(errors.map((e) => e.error)) + errorArray ]); } return contents.join('\n'); @@ -135,8 +141,9 @@ class Settings extends PureComponent { openErrorEmail = () => { const recipient = 'feedback@mattermost.com'; const subject = 'Problem with Mattermost React Native app'; + const body = this.errorEmailBody(); Linking.openURL( - `mailto:${recipient}?subject=${subject}&body=${this.errorEmailBody()}` + `mailto:${recipient}?subject=${subject}&body=${body}` ); this.props.actions.clearErrors(); }; diff --git a/app/store/index.js b/app/store/index.js index 49a890439..38ca515e9 100644 --- a/app/store/index.js +++ b/app/store/index.js @@ -3,13 +3,15 @@ import {batchActions} from 'redux-batched-actions'; import {AsyncStorage} from 'react-native'; -import configureStore from 'mattermost-redux/store'; -import {GeneralTypes} from 'mattermost-redux/action_types'; -import {General, RequestStatus} from 'mattermost-redux/constants'; import {createBlacklistFilter} from 'redux-persist-transform-filter'; import {createTransform, persistStore} from 'redux-persist'; -import {ViewTypes} from 'app/constants'; +import {ErrorTypes, GeneralTypes} from 'mattermost-redux/action_types'; +import {General, RequestStatus} from 'mattermost-redux/constants'; +import configureStore from 'mattermost-redux/store'; +import EventEmitter from 'mattermost-redux/utils/event_emitter'; + +import {NavigationTypes, ViewTypes} from 'app/constants'; import appReducer from 'app/reducers'; import {transformSet} from './utils'; @@ -108,6 +110,41 @@ export default function configureAppStore(initialState) { setTimeout(() => { purging = false; }, 500); + } else if (state.views.root.purge && !purging) { + purging = true; + + await persistor.purge(); + + store.dispatch(batchActions([ + { + type: General.OFFLINE_STORE_RESET, + data: initialState + }, + { + type: ErrorTypes.RESTORE_ERRORS, + data: [...state.errors] + }, + { + type: GeneralTypes.RECEIVED_APP_DEVICE_TOKEN, + data: state.entities.general.deviceToken + }, + { + type: GeneralTypes.RECEIVED_APP_CREDENTIALS, + data: { + url: state.entities.general.credentials.url, + token: state.entities.general.credentials.token + } + }, + { + type: ViewTypes.SERVER_URL_CHANGED, + serverUrl: state.entities.general.credentials.url || state.views.selectServer.serverUrl + } + ], 'BATCH_FOR_RESTART')); + + setTimeout(() => { + purging = false; + EventEmitter.emit(NavigationTypes.RESTART_APP); + }, 500); } }); @@ -117,7 +154,7 @@ export default function configureAppStore(initialState) { autoRehydrate: { log: false }, - blacklist: ['errors', 'navigation', 'offline', 'requests'], + blacklist: ['navigation', 'offline', 'requests'], debounce: 500, transforms: [ setTransformer, @@ -126,5 +163,5 @@ export default function configureAppStore(initialState) { } }; - return configureStore({}, appReducer, offlineOptions, getAppReducer); + return configureStore(initialState, appReducer, offlineOptions, getAppReducer); } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index ff5bf83fc..1071a1888 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1718,6 +1718,9 @@ "mobile.custom_list.no_results": "No Results", "mobile.drawer.teamsTitle": "Teams", "mobile.edit_post.title": "Editing Message", + "mobile.error_handler.button": "Relaunch", + "mobile.error_handler.description": "\nClick relaunch to open the app again. After restart, you can report the problem from the settings menu.\n", + "mobile.error_handler.title": "Unexpected error occurred", "mobile.file_upload.camera": "Take Photo or Video", "mobile.file_upload.library": "Photo Library", "mobile.file_upload.more": "More", @@ -1769,6 +1772,9 @@ "mobile.select_team.join_open": "Open teams you can join", "mobile.select_team.no_teams": "There are no available teams for you to join.", "mobile.server_ping_failed": "Cannot connect to the server. Please check your server URL and internet connection.", + "mobile.server_upgrade.button": "OK", + "mobile.server_upgrade.description": "\nA server upgrade is required to use the Mattermost app. Please ask your System Administrator for details.\n", + "mobile.server_upgrade.title": "Server upgrade required", "mobile.server_url.invalid_format": "URL must start with http:// or https://", "mobile.session_expired": "Session Expired: Please log in to continue receiving notifications.", "mobile.settings.team_selection": "Team Selection", diff --git a/package.json b/package.json index b846c4f84..6abac80fa 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-native-cookies": "3.1.0", "react-native-device-info": "0.10.2", "react-native-drawer": "2.3.0", + "react-native-exception-handler": "1.1.0", "react-native-image-picker": "jp928/react-native-image-picker#6ee35b69f3dbd6c7c66f580fd4d9eabf398703d4", "react-native-keyboard-aware-scroll-view": "0.2.8", "react-native-linear-gradient": "2.0.0", diff --git a/yarn.lock b/yarn.lock index 3476837e7..4af5a66d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3645,7 +3645,7 @@ makeerror@1.0.x: mattermost-redux@mattermost/mattermost-redux#master: version "0.0.1" - resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/caf81fd083fb4f883c787e4ca11bf17e5898e031" + resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/6f9a8c71aa857ef043cc0794957917ac6f304f6a" dependencies: deep-equal "1.0.1" harmony-reflect "1.5.1" @@ -4476,6 +4476,10 @@ react-native-drawer@2.3.0: dependencies: tween-functions "^1.0.1" +react-native-exception-handler@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/react-native-exception-handler/-/react-native-exception-handler-1.1.0.tgz#320b7fc9c104e11d66173a2b0daeac97831a1a37" + react-native-image-picker@jp928/react-native-image-picker#6ee35b69f3dbd6c7c66f580fd4d9eabf398703d4: version "0.26.2" resolved "https://codeload.github.com/jp928/react-native-image-picker/tar.gz/6ee35b69f3dbd6c7c66f580fd4d9eabf398703d4"