Files
mattermost-mobile/app/mattermost.js
Jesse Hallam 4f1e3590f8 MM-8604: handle SERVER_VERSION_CHANGED and CONFIG_CHANGED events (#1469)
* MM-8604: handle SERVER_VERSION_CHANGED and CONFIG_CHANGED events

This pairs up with the corresponding mattermost-redux changes to split
apart these events. For now, we still assume a configuration change when
detecting a server version change, given that the app may run with older
mattermost servers. In the future, we could fine-tine or remove this duplicate
config/license loading.

TODO: Depend on mattermost-redux changes in yarn.lock.

* Update mattermost-redux
2018-03-07 09:04:39 +00:00

661 lines
25 KiB
JavaScript

// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import 'babel-polyfill';
import Orientation from 'react-native-orientation';
import {Provider} from 'react-redux';
import {Navigation, NativeEventsReceiver} from 'react-native-navigation';
import {IntlProvider} from 'react-intl';
import {
Alert,
AppState,
InteractionManager,
Keyboard,
NativeModules,
Platform,
} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import {setJSExceptionHandler, setNativeExceptionHandler} from 'react-native-exception-handler';
import StatusBarSizeIOS from 'react-native-status-bar-size';
import semver from 'semver';
import {General} from 'mattermost-redux/constants';
import {setAppState, setDeviceToken, setServerVersion} from 'mattermost-redux/actions/general';
import {markChannelAsRead} from 'mattermost-redux/actions/channels';
import {setSystemEmojis} from 'mattermost-redux/actions/emojis';
import {logError} from 'mattermost-redux/actions/errors';
import {loadMe, logout} from 'mattermost-redux/actions/users';
import {close as closeWebSocket} from 'mattermost-redux/actions/websocket';
import {Client4} from 'mattermost-redux/client';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {
calculateDeviceDimensions,
setDeviceOrientation,
setDeviceAsTablet,
setStatusBarHeight,
} from 'app/actions/device';
import {
createPost,
loadConfigAndLicense,
loadFromPushNotification,
purgeOfflineStore,
} from 'app/actions/views/root';
import {setChannelDisplayName} from 'app/actions/views/channel';
import {handleLoginIdChanged} from 'app/actions/views/login';
import {handleServerUrlChanged} from 'app/actions/views/select_server';
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';
import configureStore from 'app/store';
import mattermostBucket from 'app/mattermost_bucket';
import mattermostManaged from 'app/mattermost_managed';
import {deleteFileCache} from 'app/utils/file';
import {init as initAnalytics} from 'app/utils/segment';
import {captureException, initializeSentry, LOGGER_JAVASCRIPT, LOGGER_NATIVE} from 'app/utils/sentry';
import tracker from 'app/utils/time_tracker';
import {stripTrailingSlashes} from 'app/utils/url';
import {EmojiIndicesByAlias} from 'app/utils/emojis';
import LocalConfig from 'assets/config';
const {StatusBarManager} = NativeModules;
const AUTHENTICATION_TIMEOUT = 5 * 60 * 1000;
export default class Mattermost {
constructor() {
this.isConfigured = false;
this.allowOtherServers = true;
this.startAppFromPushNotification = false;
this.emmEnabled = false;
Client4.setUserAgent(DeviceInfo.getUserAgent());
Orientation.unlockAllOrientations();
initializeSentry();
this.store = configureStore(initialState);
registerScreens(this.store, Provider);
this.unsubscribeFromStore = this.store.subscribe(this.listenForHydration);
AppState.addEventListener('change', this.handleAppStateChange);
EventEmitter.on(General.SERVER_VERSION_CHANGED, this.handleServerVersionChanged);
EventEmitter.on(General.CONFIG_CHANGED, this.handleConfigChanged);
EventEmitter.on(NavigationTypes.NAVIGATION_RESET, this.handleLogout);
EventEmitter.on(General.DEFAULT_CHANNEL, this.handleResetChannelDisplayName);
EventEmitter.on(NavigationTypes.RESTART_APP, this.restartApp);
Orientation.addOrientationListener(this.orientationDidChange);
mattermostManaged.addEventListener('managedConfigDidChange', this.handleManagedConfig);
if (Platform.OS === 'ios') {
StatusBarSizeIOS.addEventListener('willChange', this.handleStatusBarHeightChange);
}
setJSExceptionHandler(this.errorHandler, false);
setNativeExceptionHandler(this.nativeErrorHandler, false);
setSystemEmojis(EmojiIndicesByAlias);
}
errorHandler = (e, isFatal) => {
if (!e) {
// This method gets called for propTypes errors in dev mode without an exception
return;
}
console.warn('Handling Javascript error ' + JSON.stringify(e)); // eslint-disable-line no-console
captureException(e, LOGGER_JAVASCRIPT, this.store);
const intl = this.getIntl();
closeWebSocket()(this.store.dispatch, this.store.getState);
if (Client4.getUrl()) {
logError(e)(this.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
this.store.dispatch(purgeOfflineStore());
},
}],
{cancelable: false}
);
}
};
nativeErrorHandler = (e) => {
console.warn('Handling native error ' + JSON.stringify(e)); // eslint-disable-line no-console
captureException(e, LOGGER_NATIVE, this.store);
};
getIntl = () => {
const state = this.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;
};
configureAnalytics = (config) => {
if (config && config.DiagnosticsEnabled === 'true' && config.DiagnosticId && LocalConfig.SegmentApiKey) {
initAnalytics(config);
} else {
global.analytics = null;
}
};
configurePushNotifications = () => {
PushNotifications.configure({
onRegister: this.onRegisterDevice,
onNotification: this.onPushNotification,
onReply: this.onPushNotificationReply,
popInitialNotification: true,
requestPermissions: true,
});
this.isConfigured = true;
};
handleAppStateChange = async (appState) => {
const {dispatch, getState} = this.store;
const isActive = appState === 'active';
setAppState(isActive)(dispatch, getState);
if (isActive && this.shouldRelaunchWhenActive) {
// This handles when the app was started in the background
// cause of an iOS push notification reply
this.launchApp();
this.shouldRelaunchWhenActive = false;
} else if (!isActive && !this.inBackgroundSince) {
// When the app is sent to the background we set the time when that happens
// and perform a data clean up to improve on performance
this.inBackgroundSince = Date.now();
dispatch({type: ViewTypes.DATA_CLEANUP, payload: getState()});
} else if (isActive && this.inBackgroundSince && (Date.now() - this.inBackgroundSince) >= AUTHENTICATION_TIMEOUT && this.emmEnabled) {
// Once the app becomes active after more than 5 minutes in the background and is controlled by an EMM
try {
const config = await mattermostManaged.getConfig();
const authNeeded = config.inAppPinCode && config.inAppPinCode === 'true';
if (authNeeded) {
const authenticated = await this.handleAuthentication(config.vendor);
if (!authenticated) {
mattermostManaged.quitApp();
}
}
} catch (error) {
// do nothing
}
}
if (isActive) {
this.inBackgroundSince = null;
Keyboard.dismiss();
}
};
handleAuthentication = async (vendor) => {
const isSecured = await mattermostManaged.isDeviceSecure();
const intl = this.getIntl();
if (isSecured) {
try {
mattermostBucket.setPreference('emm', vendor, LocalConfig.AppGroupId);
await mattermostManaged.authenticate({
reason: intl.formatMessage({
id: 'mobile.managed.secured_by',
defaultMessage: 'Secured by {vendor}',
}, {vendor}),
fallbackToPasscode: true,
suppressEnterPassword: true,
});
} catch (err) {
mattermostManaged.quitApp();
return false;
}
}
return true;
};
handleServerVersionChanged = async (serverVersion) => {
const {dispatch, getState} = this.store;
const version = serverVersion.match(/^[0-9]*.[0-9]*.[0-9]*(-[a-zA-Z0-9.-]*)?/g)[0];
const intl = this.getIntl();
const state = getState();
if (serverVersion) {
if (semver.valid(version) && semver.lt(version, LocalConfig.MinServerVersion)) {
Alert.alert(
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: intl.formatMessage({id: 'mobile.server_upgrade.button', defaultMessage: 'OK'}),
onPress: this.handleServerVersionUpgradeNeeded,
}],
{cancelable: false}
);
} else if (state.entities.users && state.entities.users.currentUserId) {
setServerVersion(serverVersion)(dispatch, getState);
// Note that license and config changes are now emitted as websocket events, but
// we might be connected to an older server. Loading the configuration multiple
// times isn't a high overhead at present, so there's no harm in potentially
// repeating the load and handling for now.
const data = await loadConfigAndLicense()(dispatch, getState);
this.handleConfigChanged(data.config);
}
}
};
handleConfigChanged = (config) => {
this.configureAnalytics(config);
}
handleManagedConfig = async (serverConfig) => {
const {dispatch, getState} = this.store;
const state = getState();
let authNeeded = false;
let blurApplicationScreen = false;
let jailbreakProtection = false;
let vendor = null;
let serverUrl = null;
let username = null;
if (LocalConfig.AutoSelectServerUrl) {
handleServerUrlChanged(LocalConfig.DefaultServerUrl)(dispatch, getState);
this.allowOtherServers = false;
}
try {
const config = await mattermostManaged.getConfig();
if (config && Object.keys(config).length) {
this.emmEnabled = true;
authNeeded = config.inAppPinCode && config.inAppPinCode === 'true';
blurApplicationScreen = config.blurApplicationScreen && config.blurApplicationScreen === 'true';
jailbreakProtection = config.jailbreakProtection && config.jailbreakProtection === 'true';
vendor = config.vendor || 'Mattermost';
if (!state.entities.general.credentials.token) {
serverUrl = config.serverUrl;
username = config.username;
if (config.allowOtherServers && config.allowOtherServers === 'false') {
this.allowOtherServers = false;
}
}
if (jailbreakProtection) {
const isTrusted = mattermostManaged.isTrustedDevice();
if (!isTrusted) {
const intl = this.getIntl();
Alert.alert(
intl.formatMessage({
id: 'mobile.managed.blocked_by',
defaultMessage: 'Blocked by {vendor}',
}, {vendor}),
intl.formatMessage({
id: 'mobile.managed.jailbreak',
defaultMessage: 'Jailbroken devices are not trusted by {vendor}, please exit the app.',
}, {vendor}),
[{
text: intl.formatMessage({id: 'mobile.managed.exit', defaultMessage: 'Exit'}),
style: 'destructive',
onPress: () => {
mattermostManaged.quitApp();
},
}],
{cancelable: false}
);
return false;
}
}
if (authNeeded && !serverConfig) {
if (Platform.OS === 'android') {
//Start a fake app as we need at least one activity for android
await this.startFakeApp();
}
const authenticated = await this.handleAuthentication(vendor);
if (!authenticated) {
return false;
}
}
if (blurApplicationScreen) {
mattermostManaged.blurAppScreen(true);
}
if (serverUrl) {
handleServerUrlChanged(serverUrl)(dispatch, getState);
}
if (username) {
handleLoginIdChanged(username)(dispatch, getState);
}
}
} catch (error) {
return true;
}
return true;
};
handleLogout = () => {
this.appStarted = false;
deleteFileCache();
this.resetBadgeAndVersion();
this.startApp('fade');
};
handleResetChannelDisplayName = (displayName) => {
this.store.dispatch(setChannelDisplayName(displayName));
};
handleStatusBarHeightChange = (nextStatusBarHeight) => {
this.store.dispatch(setStatusBarHeight(nextStatusBarHeight));
};
handleServerVersionUpgradeNeeded = async () => {
const {dispatch, getState} = this.store;
this.resetBadgeAndVersion();
if (getState().entities.general.credentials.token) {
InteractionManager.runAfterInteractions(() => {
logout()(dispatch, getState);
});
}
};
// We need to wait for hydration to occur before load the router.
listenForHydration = () => {
const {dispatch, getState} = this.store;
const state = getState();
if (!this.isConfigured) {
this.configurePushNotifications();
}
if (state.views.root.hydrationComplete) {
const orientation = Orientation.getInitialOrientation();
const {credentials, config} = state.entities.general;
const {currentUserId} = state.entities.users;
const isNotActive = AppState.currentState !== 'active';
const notification = PushNotifications.getNotification();
this.unsubscribeFromStore();
if (this.deviceToken) {
// If the deviceToken is set we need to dispatch it to the redux store
setDeviceToken(this.deviceToken)(dispatch, getState);
}
if (orientation) {
this.orientationDidChange(orientation);
}
if (config) {
this.configureAnalytics(config);
}
if (credentials.token && credentials.url) {
Client4.setToken(credentials.token);
Client4.setUrl(stripTrailingSlashes(credentials.url));
}
if (currentUserId) {
Client4.setUserId(currentUserId);
}
if (Platform.OS === 'ios') {
StatusBarManager.getHeight(
(data) => {
this.handleStatusBarHeightChange(data.height);
}
);
}
if (notification || this.replyNotificationData) {
// If we have a notification means that the app was started cause of a reply
// and the app was not sitting in the background nor opened
const notificationData = notification || this.replyNotificationData;
const {data, text, badge, completed} = notificationData;
this.onPushNotificationReply(data, text, badge, completed);
PushNotifications.resetNotification();
}
if (Platform.OS === 'android') {
// In case of Android we need to handle the bridge being initialized by HeadlessJS
Promise.resolve(Navigation.isAppLaunched()).then((appLaunched) => {
if (this.startAppFromPushNotification) {
return;
}
if (appLaunched) {
this.launchApp(); // App is launched -> show UI
} else {
new NativeEventsReceiver().appLaunched(this.launchApp); // App hasn't been launched yet -> show the UI only when needed.
}
});
} else if (isNotActive) {
// for IOS replying from push notification starts the app in the background
this.shouldRelaunchWhenActive = true;
this.startFakeApp();
} else {
this.launchApp();
}
}
};
onRegisterDevice = (data) => {
this.isConfigured = true;
const {dispatch, getState} = this.store;
let prefix;
if (Platform.OS === 'ios') {
prefix = General.PUSH_NOTIFY_APPLE_REACT_NATIVE;
if (DeviceInfo.getBundleId().includes('rnbeta')) {
prefix = `${prefix}beta`;
}
} else {
prefix = General.PUSH_NOTIFY_ANDROID_REACT_NATIVE;
}
const state = getState();
const token = `${prefix}:${data.token}`;
if (state.views.root.hydrationComplete) {
setDeviceToken(token)(dispatch, getState);
} else {
this.deviceToken = token;
}
};
onPushNotification = (deviceNotification) => {
const {dispatch, getState} = this.store;
const state = getState();
const {token, url} = state.entities.general.credentials;
// mark the app as started as soon as possible
if (!this.appStarted && Platform.OS !== 'ios') {
this.startAppFromPushNotification = true;
}
const {data, foreground, message, userInfo, userInteraction} = deviceNotification;
const notification = {
data,
message,
};
if (userInfo) {
notification.localNotification = userInfo.localNotification;
}
if (data.type === 'clear') {
markChannelAsRead(data.channel_id, null, false)(dispatch, getState);
} else if (foreground) {
EventEmitter.emit(ViewTypes.NOTIFICATION_IN_APP, notification);
} else if (userInteraction && !notification.localNotification) {
EventEmitter.emit('close_channel_drawer');
if (this.startAppFromPushNotification) {
Client4.setToken(token);
Client4.setUrl(stripTrailingSlashes(url));
}
InteractionManager.runAfterInteractions(async () => {
await loadFromPushNotification(notification)(dispatch, getState);
if (this.startAppFromPushNotification) {
this.launchApp();
} else {
EventEmitter.emit(ViewTypes.NOTIFICATION_TAPPED);
}
});
}
};
onPushNotificationReply = (data, text, badge, completed) => {
const {dispatch, getState} = this.store;
const state = getState();
const {currentUserId} = state.entities.users;
if (currentUserId) {
// one thing to note is that for android it will reply to the last post in the stack
const rootId = data.root_id || data.post_id;
const post = {
user_id: currentUserId,
channel_id: data.channel_id,
root_id: rootId,
parent_id: rootId,
message: text,
};
if (!Client4.getUrl()) {
// Make sure the Client has the server url set
Client4.setUrl(state.entities.general.credentials.url);
}
if (!Client4.getToken()) {
// Make sure the Client has the server token set
Client4.setToken(state.entities.general.credentials.token);
}
createPost(post)(dispatch, getState).then(() => {
markChannelAsRead(data.channel_id)(dispatch, getState);
if (badge >= 0) {
PushNotifications.setApplicationIconBadgeNumber(badge);
}
this.replyNotificationData = null;
}).then(completed);
} else {
this.replyNotificationData = {
data,
text,
badge,
completed,
};
}
};
orientationDidChange = (orientation) => {
const {dispatch} = this.store;
dispatch(setDeviceOrientation(orientation));
if (DeviceInfo.isTablet()) {
dispatch(setDeviceAsTablet());
}
setTimeout(() => {
dispatch(calculateDeviceDimensions());
}, 100);
};
resetBadgeAndVersion = () => {
const {dispatch, getState} = this.store;
Client4.serverVersion = '';
Client4.setUserId('');
PushNotifications.setApplicationIconBadgeNumber(0);
PushNotifications.cancelAllLocalNotifications();
setServerVersion('')(dispatch, getState);
};
restartApp = async () => {
Navigation.dismissModal({animationType: 'none'});
const {dispatch, getState} = this.store;
await loadConfigAndLicense()(dispatch, getState);
await loadMe()(dispatch, getState);
this.appStarted = false;
this.startApp('fade');
};
launchApp = () => {
this.handleManagedConfig().then((shouldStart) => {
if (shouldStart) {
this.startApp('fade');
}
});
};
startFakeApp = async () => {
return Navigation.startSingleScreenApp({
screen: {
screen: 'Root',
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
},
},
});
};
startApp = (animationType = 'none') => {
if (!this.appStarted) {
const {dispatch, getState} = this.store;
const {entities} = getState();
let screen = 'SelectServer';
if (entities) {
const {credentials} = entities.general;
if (credentials.token && credentials.url) {
screen = 'Channel';
tracker.initialLoad = Date.now();
loadMe()(dispatch, getState);
}
}
Navigation.startSingleScreenApp({
screen: {
screen,
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
screenBackgroundColor: 'transparent',
},
},
passProps: {
allowOtherServers: this.allowOtherServers,
},
appStyle: {
orientation: 'auto',
},
animationType,
});
this.appStarted = true;
this.startAppFromPushNotification = false;
}
};
}