Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle API errors to trigger force upgrades of the app #35114

Merged
merged 9 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/animations/Update.lottie
Binary file not shown.
1,821 changes: 1,790 additions & 31 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1"
},
"dependencies": {
"@dotlottie/react-player": "^1.6.3",
"@dotlottie/react-player": "^1.6.15",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-getcanonicallocales": "^2.2.0",
"@formatjs/intl-listformat": "^7.2.2",
Expand All @@ -69,6 +69,7 @@
"@invertase/react-native-apple-authentication": "^2.2.2",
"@kie/act-js": "^2.0.1",
"@kie/mock-github": "^1.0.0",
"@lottiefiles/react-lottie-player": "^3.5.3",
"@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52",
"@onfido/react-native-sdk": "8.3.0",
"@react-native-async-storage/async-storage": "^1.19.5",
Expand Down
4 changes: 4 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,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 @@ -820,6 +821,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;
pecanoro marked this conversation as resolved.
Show resolved Hide resolved

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 @@ -301,6 +301,7 @@ export default {
showing: 'Showing',
of: 'of',
default: 'Default',
update: 'Update',
},
location: {
useCurrent: 'Use current location',
Expand Down Expand Up @@ -774,6 +775,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 @@ -290,6 +290,7 @@ export default {
showing: 'Mostrando',
of: 'de',
default: 'Predeterminado',
update: 'Actualizar',
},
location: {
useCurrent: 'Usar ubicación actual',
Expand Down Expand Up @@ -768,6 +769,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',
pecanoro marked this conversation as resolved.
Show resolved Hide resolved
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 {Linking} from 'react-native';
import CONST from '@src/CONST';

export default function updateApp() {
Linking.openURL(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 {Linking} from 'react-native';
import CONST from '@src/CONST';

export default function updateApp() {
Linking.openURL(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();
}
21 changes: 21 additions & 0 deletions src/libs/actions/UpdateRequired.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Config from 'react-native-config';
import Onyx from 'react-native-onyx';
import type Environment from '@libs/Environment/getEnvironment/types';
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.
if (((Config?.ENVIRONMENT as Environment) ?? CONST.ENVIRONMENT.DEV) !== 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;
}

pecanoro marked this conversation as resolved.
Show resolved Hide resolved
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
Loading
Loading