Skip to content

Commit 0efabc2

Browse files
authored
Merge pull request #32326 from Expensify/marcaaron-forceAppUpgrade
Handle API errors to trigger force upgrades of the app
2 parents b31ea3a + c509c20 commit 0efabc2

File tree

24 files changed

+195
-39
lines changed

24 files changed

+195
-39
lines changed

assets/animations/Update.lottie

86.9 KB
Binary file not shown.

src/CONST.ts

+4
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,7 @@ const CONST = {
788788
EXP_ERROR: 666,
789789
MANY_WRITES_ERROR: 665,
790790
UNABLE_TO_RETRY: 'unableToRetry',
791+
UPDATE_REQUIRED: 426,
791792
},
792793
HTTP_STATUS: {
793794
// When Cloudflare throttles
@@ -818,6 +819,9 @@ const CONST = {
818819
GATEWAY_TIMEOUT: 'Gateway Timeout',
819820
EXPENSIFY_SERVICE_INTERRUPTED: 'Expensify service interrupted',
820821
DUPLICATE_RECORD: 'A record already exists with this ID',
822+
823+
// 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.
824+
UPDATE_REQUIRED: 'Upgrade Required',
821825
},
822826
ERROR_TYPE: {
823827
SOCKET: 'Expensify\\Auth\\Error\\Socket',

src/Expensify.js

+15-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import AppleAuthWrapper from './components/SignInButtons/AppleAuthWrapper';
1313
import SplashScreenHider from './components/SplashScreenHider';
1414
import UpdateAppModal from './components/UpdateAppModal';
1515
import withLocalize, {withLocalizePropTypes} from './components/withLocalize';
16+
import CONST from './CONST';
1617
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
1718
import * as Report from './libs/actions/Report';
1819
import * as User from './libs/actions/User';
@@ -76,6 +77,9 @@ const propTypes = {
7677
/** Whether the app is waiting for the server's response to determine if a room is public */
7778
isCheckingPublicRoom: PropTypes.bool,
7879

80+
/** True when the user must update to the latest minimum version of the app */
81+
updateRequired: PropTypes.bool,
82+
7983
/** Whether we should display the notification alerting the user that focus mode has been auto-enabled */
8084
focusModeNotification: PropTypes.bool,
8185

@@ -91,6 +95,7 @@ const defaultProps = {
9195
isSidebarLoaded: false,
9296
screenShareRequest: null,
9397
isCheckingPublicRoom: true,
98+
updateRequired: false,
9499
focusModeNotification: false,
95100
};
96101

@@ -204,6 +209,10 @@ function Expensify(props) {
204209
return null;
205210
}
206211

212+
if (props.updateRequired) {
213+
throw new Error(CONST.ERROR.UPDATE_REQUIRED);
214+
}
215+
207216
return (
208217
<DeeplinkWrapper
209218
isAuthenticated={isAuthenticated}
@@ -215,7 +224,8 @@ function Expensify(props) {
215224
<PopoverReportActionContextMenu ref={ReportActionContextMenu.contextMenuRef} />
216225
<EmojiPicker ref={EmojiPickerAction.emojiPickerRef} />
217226
{/* We include the modal for showing a new update at the top level so the option is always present. */}
218-
{props.updateAvailable ? <UpdateAppModal /> : null}
227+
{/* If the update is required we won't show this option since a full screen update view will be displayed instead. */}
228+
{props.updateAvailable && !props.updateRequired ? <UpdateAppModal /> : null}
219229
{props.screenShareRequest ? (
220230
<ConfirmModal
221231
title={props.translate('guides.screenShare')}
@@ -268,6 +278,10 @@ export default compose(
268278
screenShareRequest: {
269279
key: ONYXKEYS.SCREEN_SHARE_REQUEST,
270280
},
281+
updateRequired: {
282+
key: ONYXKEYS.UPDATE_REQUIRED,
283+
initWithStoredValues: false,
284+
},
271285
focusModeNotification: {
272286
key: ONYXKEYS.FOCUS_MODE_NOTIFICATION,
273287
initWithStoredValues: false,

src/ONYXKEYS.ts

+4
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ const ONYXKEYS = {
246246
// Max width supported for HTML <canvas> element
247247
MAX_CANVAS_WIDTH: 'maxCanvasWidth',
248248

249+
/** Indicates whether an forced upgrade is required */
250+
UPDATE_REQUIRED: 'updateRequired',
251+
249252
/** Collection Keys */
250253
COLLECTION: {
251254
DOWNLOAD: 'download_',
@@ -442,6 +445,7 @@ type OnyxValues = {
442445
[ONYXKEYS.MAX_CANVAS_AREA]: number;
443446
[ONYXKEYS.MAX_CANVAS_HEIGHT]: number;
444447
[ONYXKEYS.MAX_CANVAS_WIDTH]: number;
448+
[ONYXKEYS.UPDATE_REQUIRED]: boolean;
445449

446450
// Collections
447451
[ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download;

src/components/ErrorBoundary/BaseErrorBoundary.tsx

+10-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import React from 'react';
1+
import React, {useState} from 'react';
22
import {ErrorBoundary} from 'react-error-boundary';
33
import BootSplash from '@libs/BootSplash';
44
import GenericErrorPage from '@pages/ErrorPage/GenericErrorPage';
5+
import UpdateRequiredView from '@pages/ErrorPage/UpdateRequiredView';
6+
import CONST from '@src/CONST';
57
import type {BaseErrorBoundaryProps, LogError} from './types';
68

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

1315
function BaseErrorBoundary({logError = () => {}, errorMessage, children}: BaseErrorBoundaryProps) {
14-
const catchError = (error: Error, errorInfo: React.ErrorInfo) => {
15-
logError(errorMessage, error, JSON.stringify(errorInfo));
16+
const [errorContent, setErrorContent] = useState('');
17+
const catchError = (errorObject: Error, errorInfo: React.ErrorInfo) => {
18+
logError(errorMessage, errorObject, JSON.stringify(errorInfo));
1619
// We hide the splash screen since the error might happened during app init
1720
BootSplash.hide();
21+
setErrorContent(errorObject.message);
1822
};
1923

24+
const updateRequired = errorContent === CONST.ERROR.UPDATE_REQUIRED;
25+
2026
return (
2127
<ErrorBoundary
22-
fallback={<GenericErrorPage />}
28+
fallback={updateRequired ? <UpdateRequiredView /> : <GenericErrorPage />}
2329
onError={catchError}
2430
>
2531
{children}

src/components/LottieAnimations/index.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import variables from '@styles/variables';
12
import type DotLottieAnimation from './types';
23

34
const DotLottieAnimations: Record<string, DotLottieAnimation> = {
@@ -51,6 +52,11 @@ const DotLottieAnimations: Record<string, DotLottieAnimation> = {
5152
w: 853,
5253
h: 480,
5354
},
55+
Update: {
56+
file: require('@assets/animations/Update.lottie'),
57+
w: variables.updateAnimationW,
58+
h: variables.updateAnimationH,
59+
},
5460
Coin: {
5561
file: require('@assets/animations/Coin.lottie'),
5662
w: 375,

src/languages/en.ts

+6
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ export default {
299299
showing: 'Showing',
300300
of: 'of',
301301
default: 'Default',
302+
update: 'Update',
302303
},
303304
location: {
304305
useCurrent: 'Use current location',
@@ -772,6 +773,11 @@ export default {
772773
isShownOnProfile: 'Your timezone is shown on your profile.',
773774
getLocationAutomatically: 'Automatically determine your location.',
774775
},
776+
updateRequiredView: {
777+
updateRequired: 'Update required',
778+
pleaseInstall: 'Please update to the latest version of New Expensify',
779+
toGetLatestChanges: 'For mobile or desktop, download and install the latest version. For web, refresh your browser.',
780+
},
775781
initialSettingsPage: {
776782
about: 'About',
777783
aboutPage: {

src/languages/es.ts

+6
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ export default {
288288
showing: 'Mostrando',
289289
of: 'de',
290290
default: 'Predeterminado',
291+
update: 'Actualizar',
291292
},
292293
location: {
293294
useCurrent: 'Usar ubicación actual',
@@ -766,6 +767,11 @@ export default {
766767
isShownOnProfile: 'Tu zona horaria se muestra en tu perfil.',
767768
getLocationAutomatically: 'Detecta tu ubicación automáticamente.',
768769
},
770+
updateRequiredView: {
771+
updateRequired: 'Actualización requerida',
772+
pleaseInstall: 'Por favor, actualice la última versión de Nuevo Expensify',
773+
toGetLatestChanges: 'Para móvil o escritorio, descarga e instala la última versión. Para la web, actualiza tu navegador.',
774+
},
769775
initialSettingsPage: {
770776
about: 'Acerca de',
771777
aboutPage: {

src/libs/Environment/betaChecker/index.android.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Onyx from 'react-native-onyx';
22
import semver from 'semver';
3-
import * as AppUpdate from '@userActions/AppUpdate';
3+
import * as AppUpdate from '@libs/actions/AppUpdate';
44
import CONST from '@src/CONST';
55
import ONYXKEYS from '@src/ONYXKEYS';
66
import pkg from '../../../../package.json';

src/libs/HttpUtils.ts

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
66
import type {RequestType} from '@src/types/onyx/Request';
77
import type Response from '@src/types/onyx/Response';
88
import * as NetworkActions from './actions/Network';
9+
import * as UpdateRequired from './actions/UpdateRequired';
910
import * as ApiUtils from './ApiUtils';
1011
import HttpsError from './Errors/HttpsError';
1112

@@ -128,6 +129,10 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form
128129
alert('Too many auth writes', message);
129130
}
130131
}
132+
if (response.jsonCode === CONST.JSON_CODE.UPDATE_REQUIRED) {
133+
// Trigger a modal and disable the app as the user needs to upgrade to the latest minimum version to continue
134+
UpdateRequired.alertUser();
135+
}
131136
return response as Promise<Response>;
132137
});
133138
}

src/libs/Notification/LocalNotification/BrowserNotifications.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
import Str from 'expensify-common/lib/str';
33
import type {ImageSourcePropType} from 'react-native';
44
import EXPENSIFY_ICON_URL from '@assets/images/expensify-logo-round-clearspace.png';
5+
import * as AppUpdate from '@libs/actions/AppUpdate';
56
import ModifiedExpenseMessage from '@libs/ModifiedExpenseMessage';
67
import * as ReportUtils from '@libs/ReportUtils';
7-
import * as AppUpdate from '@userActions/AppUpdate';
88
import type {Report, ReportAction} from '@src/types/onyx';
99
import focusApp from './focusApp';
1010
import type {LocalNotificationClickHandler, LocalNotificationData} from './types';

src/libs/actions/AppUpdate.ts src/libs/actions/AppUpdate/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Onyx from 'react-native-onyx';
22
import ONYXKEYS from '@src/ONYXKEYS';
3+
import updateApp from './updateApp';
34

45
function triggerUpdateAvailable() {
56
Onyx.set(ONYXKEYS.UPDATE_AVAILABLE, true);
@@ -9,4 +10,4 @@ function setIsAppInBeta(isBeta: boolean) {
910
Onyx.set(ONYXKEYS.IS_BETA, isBeta);
1011
}
1112

12-
export {triggerUpdateAvailable, setIsAppInBeta};
13+
export {triggerUpdateAvailable, setIsAppInBeta, updateApp};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import * as Link from '@userActions/Link';
2+
import CONST from '@src/CONST';
3+
4+
export default function updateApp() {
5+
Link.openExternalLink(CONST.APP_DOWNLOAD_LINKS.ANDROID);
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {Linking} from 'react-native';
2+
import CONST from '@src/CONST';
3+
4+
export default function updateApp() {
5+
Linking.openURL(CONST.APP_DOWNLOAD_LINKS.DESKTOP);
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import * as Link from '@userActions/Link';
2+
import CONST from '@src/CONST';
3+
4+
export default function updateApp() {
5+
Link.openExternalLink(CONST.APP_DOWNLOAD_LINKS.IOS);
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* On web or mWeb we can simply refresh the page and the user should have the new version of the app downloaded.
3+
*/
4+
export default function updateApp() {
5+
window.location.reload();
6+
}

src/libs/actions/UpdateRequired.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Onyx from 'react-native-onyx';
2+
import getEnvironment from '@libs/Environment/getEnvironment';
3+
import CONST from '@src/CONST';
4+
import ONYXKEYS from '@src/ONYXKEYS';
5+
6+
function alertUser() {
7+
// For now, we will pretty much never have to do this on a platform other than production.
8+
// We should only update the minimum app version in the API after all platforms of a new version have been deployed to PRODUCTION.
9+
// As staging is always ahead of production there is no reason to "force update" those apps.
10+
getEnvironment().then((environment) => {
11+
if (environment !== CONST.ENVIRONMENT.PRODUCTION) {
12+
return;
13+
}
14+
15+
Onyx.set(ONYXKEYS.UPDATE_REQUIRED, true);
16+
});
17+
}
18+
19+
export {
20+
// eslint-disable-next-line import/prefer-default-export
21+
alertUser,
22+
};

src/libs/migrations/PersonalDetailsByAccountID.js

-6
Original file line numberDiff line numberDiff line change
@@ -251,12 +251,6 @@ export default function () {
251251
delete newReport.lastActorEmail;
252252
}
253253

254-
if (lodashHas(newReport, ['participants'])) {
255-
reportWasModified = true;
256-
Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing participants from report ${newReport.reportID}`);
257-
delete newReport.participants;
258-
}
259-
260254
if (lodashHas(newReport, ['ownerEmail'])) {
261255
reportWasModified = true;
262256
Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing ownerEmail from report ${newReport.reportID}`);
+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React from 'react';
2+
import {View} from 'react-native';
3+
import Button from '@components/Button';
4+
import Header from '@components/Header';
5+
import HeaderGap from '@components/HeaderGap';
6+
import Lottie from '@components/Lottie';
7+
import LottieAnimations from '@components/LottieAnimations';
8+
import Text from '@components/Text';
9+
import useLocalize from '@hooks/useLocalize';
10+
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
11+
import useStyleUtils from '@hooks/useStyleUtils';
12+
import useThemeStyles from '@hooks/useThemeStyles';
13+
import useWindowDimensions from '@hooks/useWindowDimensions';
14+
import * as AppUpdate from '@libs/actions/AppUpdate';
15+
16+
function UpdateRequiredView() {
17+
const insets = useSafeAreaInsets();
18+
const styles = useThemeStyles();
19+
const StyleUtils = useStyleUtils();
20+
const {translate} = useLocalize();
21+
const {isSmallScreenWidth} = useWindowDimensions();
22+
return (
23+
<View style={[styles.appBG, styles.h100, StyleUtils.getSafeAreaPadding(insets)]}>
24+
<HeaderGap />
25+
<View style={[styles.pt5, styles.ph5, styles.updateRequiredViewHeader]}>
26+
<Header title={translate('updateRequiredView.updateRequired')} />
27+
</View>
28+
<View style={[styles.flex1, StyleUtils.getUpdateRequiredViewStyles(isSmallScreenWidth)]}>
29+
<Lottie
30+
source={LottieAnimations.Update}
31+
// For small screens it looks better to have the arms from the animation come in from the edges of the screen.
32+
style={isSmallScreenWidth ? styles.w100 : styles.updateAnimation}
33+
webStyle={isSmallScreenWidth ? styles.w100 : styles.updateAnimation}
34+
autoPlay
35+
loop
36+
/>
37+
<View style={[styles.ph5, styles.alignItemsCenter, styles.mt5]}>
38+
<View style={styles.updateRequiredViewTextContainer}>
39+
<View style={[styles.mb3]}>
40+
<Text style={[styles.newKansasLarge, styles.textAlignCenter]}>{translate('updateRequiredView.pleaseInstall')}</Text>
41+
</View>
42+
<View style={styles.mb5}>
43+
<Text style={[styles.textAlignCenter, styles.textSupporting]}>{translate('updateRequiredView.toGetLatestChanges')}</Text>
44+
</View>
45+
</View>
46+
</View>
47+
<Button
48+
success
49+
large
50+
onPress={() => AppUpdate.updateApp()}
51+
text={translate('common.update')}
52+
style={styles.updateRequiredViewTextContainer}
53+
/>
54+
</View>
55+
</View>
56+
);
57+
}
58+
59+
UpdateRequiredView.displayName = 'UpdateRequiredView';
60+
export default UpdateRequiredView;

src/styles/index.ts

+13
Original file line numberDiff line numberDiff line change
@@ -4169,6 +4169,19 @@ const styles = (theme: ThemeColors) =>
41694169
},
41704170

41714171
colorSchemeStyle: (colorScheme: ColorScheme) => ({colorScheme}),
4172+
4173+
updateAnimation: {
4174+
width: variables.updateAnimationW,
4175+
height: variables.updateAnimationH,
4176+
},
4177+
4178+
updateRequiredViewHeader: {
4179+
height: variables.updateViewHeaderHeight,
4180+
},
4181+
4182+
updateRequiredViewTextContainer: {
4183+
width: variables.updateTextViewContainerWidth,
4184+
},
41724185
} satisfies Styles);
41734186

41744187
type ThemeStyles = ReturnType<typeof styles>;

src/styles/utils/index.ts

+8
Original file line numberDiff line numberDiff line change
@@ -1431,6 +1431,14 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
14311431
return containerStyles;
14321432
},
14331433

1434+
getUpdateRequiredViewStyles: (isSmallScreenWidth: boolean): ViewStyle[] => [
1435+
{
1436+
alignItems: 'center',
1437+
justifyContent: 'center',
1438+
...(isSmallScreenWidth ? {} : styles.pb40),
1439+
},
1440+
],
1441+
14341442
getFullscreenCenteredContentStyles: () => [StyleSheet.absoluteFill, styles.justifyContentCenter, styles.alignItemsCenter],
14351443
});
14361444

0 commit comments

Comments
 (0)