Skip to content

Commit

Permalink
Merge pull request Expensify#32326 from Expensify/marcaaron-forceAppU…
Browse files Browse the repository at this point in the history
…pgrade

Handle API errors to trigger force upgrades of the app
  • Loading branch information
puneetlath authored Jan 23, 2024
2 parents b31ea3a + c509c20 commit 0efabc2
Show file tree
Hide file tree
Showing 24 changed files with 195 additions and 39 deletions.
Binary file added assets/animations/Update.lottie
Binary file not shown.
4 changes: 4 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,7 @@ const CONST = {
EXP_ERROR: 666,
MANY_WRITES_ERROR: 665,
UNABLE_TO_RETRY: 'unableToRetry',
UPDATE_REQUIRED: 426,
},
HTTP_STATUS: {
// When Cloudflare throttles
Expand Down Expand Up @@ -818,6 +819,9 @@ const CONST = {
GATEWAY_TIMEOUT: 'Gateway Timeout',
EXPENSIFY_SERVICE_INTERRUPTED: 'Expensify service interrupted',
DUPLICATE_RECORD: 'A record already exists with this ID',

// The "Upgrade" is intentional as the 426 HTTP code means "Upgrade Required" and sent by the API. We use the "Update" language everywhere else in the front end when this gets returned.
UPDATE_REQUIRED: 'Upgrade Required',
},
ERROR_TYPE: {
SOCKET: 'Expensify\\Auth\\Error\\Socket',
Expand Down
16 changes: 15 additions & 1 deletion src/Expensify.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AppleAuthWrapper from './components/SignInButtons/AppleAuthWrapper';
import SplashScreenHider from './components/SplashScreenHider';
import UpdateAppModal from './components/UpdateAppModal';
import withLocalize, {withLocalizePropTypes} from './components/withLocalize';
import CONST from './CONST';
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
import * as Report from './libs/actions/Report';
import * as User from './libs/actions/User';
Expand Down Expand Up @@ -76,6 +77,9 @@ const propTypes = {
/** Whether the app is waiting for the server's response to determine if a room is public */
isCheckingPublicRoom: PropTypes.bool,

/** True when the user must update to the latest minimum version of the app */
updateRequired: PropTypes.bool,

/** Whether we should display the notification alerting the user that focus mode has been auto-enabled */
focusModeNotification: PropTypes.bool,

Expand All @@ -91,6 +95,7 @@ const defaultProps = {
isSidebarLoaded: false,
screenShareRequest: null,
isCheckingPublicRoom: true,
updateRequired: false,
focusModeNotification: false,
};

Expand Down Expand Up @@ -204,6 +209,10 @@ function Expensify(props) {
return null;
}

if (props.updateRequired) {
throw new Error(CONST.ERROR.UPDATE_REQUIRED);
}

return (
<DeeplinkWrapper
isAuthenticated={isAuthenticated}
Expand All @@ -215,7 +224,8 @@ function Expensify(props) {
<PopoverReportActionContextMenu ref={ReportActionContextMenu.contextMenuRef} />
<EmojiPicker ref={EmojiPickerAction.emojiPickerRef} />
{/* We include the modal for showing a new update at the top level so the option is always present. */}
{props.updateAvailable ? <UpdateAppModal /> : null}
{/* If the update is required we won't show this option since a full screen update view will be displayed instead. */}
{props.updateAvailable && !props.updateRequired ? <UpdateAppModal /> : null}
{props.screenShareRequest ? (
<ConfirmModal
title={props.translate('guides.screenShare')}
Expand Down Expand Up @@ -268,6 +278,10 @@ export default compose(
screenShareRequest: {
key: ONYXKEYS.SCREEN_SHARE_REQUEST,
},
updateRequired: {
key: ONYXKEYS.UPDATE_REQUIRED,
initWithStoredValues: false,
},
focusModeNotification: {
key: ONYXKEYS.FOCUS_MODE_NOTIFICATION,
initWithStoredValues: false,
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ const ONYXKEYS = {
// Max width supported for HTML <canvas> element
MAX_CANVAS_WIDTH: 'maxCanvasWidth',

/** Indicates whether an forced upgrade is required */
UPDATE_REQUIRED: 'updateRequired',

/** Collection Keys */
COLLECTION: {
DOWNLOAD: 'download_',
Expand Down Expand Up @@ -442,6 +445,7 @@ type OnyxValues = {
[ONYXKEYS.MAX_CANVAS_AREA]: number;
[ONYXKEYS.MAX_CANVAS_HEIGHT]: number;
[ONYXKEYS.MAX_CANVAS_WIDTH]: number;
[ONYXKEYS.UPDATE_REQUIRED]: boolean;

// Collections
[ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download;
Expand Down
14 changes: 10 additions & 4 deletions src/components/ErrorBoundary/BaseErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React from 'react';
import React, {useState} from 'react';
import {ErrorBoundary} from 'react-error-boundary';
import BootSplash from '@libs/BootSplash';
import GenericErrorPage from '@pages/ErrorPage/GenericErrorPage';
import UpdateRequiredView from '@pages/ErrorPage/UpdateRequiredView';
import CONST from '@src/CONST';
import type {BaseErrorBoundaryProps, LogError} from './types';

/**
Expand All @@ -11,15 +13,19 @@ import type {BaseErrorBoundaryProps, LogError} from './types';
*/

function BaseErrorBoundary({logError = () => {}, errorMessage, children}: BaseErrorBoundaryProps) {
const catchError = (error: Error, errorInfo: React.ErrorInfo) => {
logError(errorMessage, error, JSON.stringify(errorInfo));
const [errorContent, setErrorContent] = useState('');
const catchError = (errorObject: Error, errorInfo: React.ErrorInfo) => {
logError(errorMessage, errorObject, JSON.stringify(errorInfo));
// We hide the splash screen since the error might happened during app init
BootSplash.hide();
setErrorContent(errorObject.message);
};

const updateRequired = errorContent === CONST.ERROR.UPDATE_REQUIRED;

return (
<ErrorBoundary
fallback={<GenericErrorPage />}
fallback={updateRequired ? <UpdateRequiredView /> : <GenericErrorPage />}
onError={catchError}
>
{children}
Expand Down
6 changes: 6 additions & 0 deletions src/components/LottieAnimations/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import variables from '@styles/variables';
import type DotLottieAnimation from './types';

const DotLottieAnimations: Record<string, DotLottieAnimation> = {
Expand Down Expand Up @@ -51,6 +52,11 @@ const DotLottieAnimations: Record<string, DotLottieAnimation> = {
w: 853,
h: 480,
},
Update: {
file: require('@assets/animations/Update.lottie'),
w: variables.updateAnimationW,
h: variables.updateAnimationH,
},
Coin: {
file: require('@assets/animations/Coin.lottie'),
w: 375,
Expand Down
6 changes: 6 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ export default {
showing: 'Showing',
of: 'of',
default: 'Default',
update: 'Update',
},
location: {
useCurrent: 'Use current location',
Expand Down Expand Up @@ -772,6 +773,11 @@ export default {
isShownOnProfile: 'Your timezone is shown on your profile.',
getLocationAutomatically: 'Automatically determine your location.',
},
updateRequiredView: {
updateRequired: 'Update required',
pleaseInstall: 'Please update to the latest version of New Expensify',
toGetLatestChanges: 'For mobile or desktop, download and install the latest version. For web, refresh your browser.',
},
initialSettingsPage: {
about: 'About',
aboutPage: {
Expand Down
6 changes: 6 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ export default {
showing: 'Mostrando',
of: 'de',
default: 'Predeterminado',
update: 'Actualizar',
},
location: {
useCurrent: 'Usar ubicación actual',
Expand Down Expand Up @@ -766,6 +767,11 @@ export default {
isShownOnProfile: 'Tu zona horaria se muestra en tu perfil.',
getLocationAutomatically: 'Detecta tu ubicación automáticamente.',
},
updateRequiredView: {
updateRequired: 'Actualización requerida',
pleaseInstall: 'Por favor, actualice la última versión de Nuevo Expensify',
toGetLatestChanges: 'Para móvil o escritorio, descarga e instala la última versión. Para la web, actualiza tu navegador.',
},
initialSettingsPage: {
about: 'Acerca de',
aboutPage: {
Expand Down
2 changes: 1 addition & 1 deletion src/libs/Environment/betaChecker/index.android.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Onyx from 'react-native-onyx';
import semver from 'semver';
import * as AppUpdate from '@userActions/AppUpdate';
import * as AppUpdate from '@libs/actions/AppUpdate';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import pkg from '../../../../package.json';
Expand Down
5 changes: 5 additions & 0 deletions src/libs/HttpUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {RequestType} from '@src/types/onyx/Request';
import type Response from '@src/types/onyx/Response';
import * as NetworkActions from './actions/Network';
import * as UpdateRequired from './actions/UpdateRequired';
import * as ApiUtils from './ApiUtils';
import HttpsError from './Errors/HttpsError';

Expand Down Expand Up @@ -128,6 +129,10 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form
alert('Too many auth writes', message);
}
}
if (response.jsonCode === CONST.JSON_CODE.UPDATE_REQUIRED) {
// Trigger a modal and disable the app as the user needs to upgrade to the latest minimum version to continue
UpdateRequired.alertUser();
}
return response as Promise<Response>;
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
import Str from 'expensify-common/lib/str';
import type {ImageSourcePropType} from 'react-native';
import EXPENSIFY_ICON_URL from '@assets/images/expensify-logo-round-clearspace.png';
import * as AppUpdate from '@libs/actions/AppUpdate';
import ModifiedExpenseMessage from '@libs/ModifiedExpenseMessage';
import * as ReportUtils from '@libs/ReportUtils';
import * as AppUpdate from '@userActions/AppUpdate';
import type {Report, ReportAction} from '@src/types/onyx';
import focusApp from './focusApp';
import type {LocalNotificationClickHandler, LocalNotificationData} from './types';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Onyx from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';
import updateApp from './updateApp';

function triggerUpdateAvailable() {
Onyx.set(ONYXKEYS.UPDATE_AVAILABLE, true);
Expand All @@ -9,4 +10,4 @@ function setIsAppInBeta(isBeta: boolean) {
Onyx.set(ONYXKEYS.IS_BETA, isBeta);
}

export {triggerUpdateAvailable, setIsAppInBeta};
export {triggerUpdateAvailable, setIsAppInBeta, updateApp};
6 changes: 6 additions & 0 deletions src/libs/actions/AppUpdate/updateApp/index.android.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as Link from '@userActions/Link';
import CONST from '@src/CONST';

export default function updateApp() {
Link.openExternalLink(CONST.APP_DOWNLOAD_LINKS.ANDROID);
}
6 changes: 6 additions & 0 deletions src/libs/actions/AppUpdate/updateApp/index.desktop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {Linking} from 'react-native';
import CONST from '@src/CONST';

export default function updateApp() {
Linking.openURL(CONST.APP_DOWNLOAD_LINKS.DESKTOP);
}
6 changes: 6 additions & 0 deletions src/libs/actions/AppUpdate/updateApp/index.ios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as Link from '@userActions/Link';
import CONST from '@src/CONST';

export default function updateApp() {
Link.openExternalLink(CONST.APP_DOWNLOAD_LINKS.IOS);
}
6 changes: 6 additions & 0 deletions src/libs/actions/AppUpdate/updateApp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* On web or mWeb we can simply refresh the page and the user should have the new version of the app downloaded.
*/
export default function updateApp() {
window.location.reload();
}
22 changes: 22 additions & 0 deletions src/libs/actions/UpdateRequired.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Onyx from 'react-native-onyx';
import getEnvironment from '@libs/Environment/getEnvironment';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

function alertUser() {
// For now, we will pretty much never have to do this on a platform other than production.
// We should only update the minimum app version in the API after all platforms of a new version have been deployed to PRODUCTION.
// As staging is always ahead of production there is no reason to "force update" those apps.
getEnvironment().then((environment) => {
if (environment !== CONST.ENVIRONMENT.PRODUCTION) {
return;
}

Onyx.set(ONYXKEYS.UPDATE_REQUIRED, true);
});
}

export {
// eslint-disable-next-line import/prefer-default-export
alertUser,
};
6 changes: 0 additions & 6 deletions src/libs/migrations/PersonalDetailsByAccountID.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,6 @@ export default function () {
delete newReport.lastActorEmail;
}

if (lodashHas(newReport, ['participants'])) {
reportWasModified = true;
Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing participants from report ${newReport.reportID}`);
delete newReport.participants;
}

if (lodashHas(newReport, ['ownerEmail'])) {
reportWasModified = true;
Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing ownerEmail from report ${newReport.reportID}`);
Expand Down
60 changes: 60 additions & 0 deletions src/pages/ErrorPage/UpdateRequiredView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from 'react';
import {View} from 'react-native';
import Button from '@components/Button';
import Header from '@components/Header';
import HeaderGap from '@components/HeaderGap';
import Lottie from '@components/Lottie';
import LottieAnimations from '@components/LottieAnimations';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as AppUpdate from '@libs/actions/AppUpdate';

function UpdateRequiredView() {
const insets = useSafeAreaInsets();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
return (
<View style={[styles.appBG, styles.h100, StyleUtils.getSafeAreaPadding(insets)]}>
<HeaderGap />
<View style={[styles.pt5, styles.ph5, styles.updateRequiredViewHeader]}>
<Header title={translate('updateRequiredView.updateRequired')} />
</View>
<View style={[styles.flex1, StyleUtils.getUpdateRequiredViewStyles(isSmallScreenWidth)]}>
<Lottie
source={LottieAnimations.Update}
// For small screens it looks better to have the arms from the animation come in from the edges of the screen.
style={isSmallScreenWidth ? styles.w100 : styles.updateAnimation}
webStyle={isSmallScreenWidth ? styles.w100 : styles.updateAnimation}
autoPlay
loop
/>
<View style={[styles.ph5, styles.alignItemsCenter, styles.mt5]}>
<View style={styles.updateRequiredViewTextContainer}>
<View style={[styles.mb3]}>
<Text style={[styles.newKansasLarge, styles.textAlignCenter]}>{translate('updateRequiredView.pleaseInstall')}</Text>
</View>
<View style={styles.mb5}>
<Text style={[styles.textAlignCenter, styles.textSupporting]}>{translate('updateRequiredView.toGetLatestChanges')}</Text>
</View>
</View>
</View>
<Button
success
large
onPress={() => AppUpdate.updateApp()}
text={translate('common.update')}
style={styles.updateRequiredViewTextContainer}
/>
</View>
</View>
);
}

UpdateRequiredView.displayName = 'UpdateRequiredView';
export default UpdateRequiredView;
13 changes: 13 additions & 0 deletions src/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4169,6 +4169,19 @@ const styles = (theme: ThemeColors) =>
},

colorSchemeStyle: (colorScheme: ColorScheme) => ({colorScheme}),

updateAnimation: {
width: variables.updateAnimationW,
height: variables.updateAnimationH,
},

updateRequiredViewHeader: {
height: variables.updateViewHeaderHeight,
},

updateRequiredViewTextContainer: {
width: variables.updateTextViewContainerWidth,
},
} satisfies Styles);

type ThemeStyles = ReturnType<typeof styles>;
Expand Down
8 changes: 8 additions & 0 deletions src/styles/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1431,6 +1431,14 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
return containerStyles;
},

getUpdateRequiredViewStyles: (isSmallScreenWidth: boolean): ViewStyle[] => [
{
alignItems: 'center',
justifyContent: 'center',
...(isSmallScreenWidth ? {} : styles.pb40),
},
],

getFullscreenCenteredContentStyles: () => [StyleSheet.absoluteFill, styles.justifyContentCenter, styles.alignItemsCenter],
});

Expand Down
Loading

0 comments on commit 0efabc2

Please sign in to comment.