Skip to content

Commit

Permalink
Merge pull request #38377 from tienifr/fix/37565
Browse files Browse the repository at this point in the history
Feature: Display backend unreachability message
  • Loading branch information
aldo-expensify authored May 14, 2024
2 parents b7dcc5e + ce38e99 commit 783c3de
Show file tree
Hide file tree
Showing 15 changed files with 162 additions and 67 deletions.
2 changes: 1 addition & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import './fonts.css';
Onyx.init({
keys: ONYXKEYS,
initialKeyStates: {
[ONYXKEYS.NETWORK]: {isOffline: false},
[ONYXKEYS.NETWORK]: {isOffline: false, isBackendReachable: true},
},
});

Expand Down
5 changes: 4 additions & 1 deletion src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,8 +555,10 @@ const CONST = {
EMPTY_ARRAY,
EMPTY_OBJECT,
USE_EXPENSIFY_URL,
STATUS_EXPENSIFY_URL: 'https://status.expensify.com',
GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com',
GOOGLE_DOC_IMAGE_LINK_MATCH: 'googleusercontent.com',
GOOGLE_CLOUD_URL: 'https://clients3.google.com/generate_204',
IMAGE_BASE64_MATCH: 'base64',
DEEPLINK_BASE_URL: 'new-expensify://',
PDF_VIEWER_URL: '/pdf/web/viewer.html',
Expand Down Expand Up @@ -1047,6 +1049,7 @@ const CONST = {
MAX_RETRY_WAIT_TIME_MS: 10 * 1000,
PROCESS_REQUEST_DELAY_MS: 1000,
MAX_PENDING_TIME_MS: 10 * 1000,
BACKEND_CHECK_INTERVAL_MS: 60 * 1000,
MAX_REQUEST_RETRIES: 10,
NETWORK_STATUS: {
ONLINE: 'online',
Expand All @@ -1058,7 +1061,7 @@ const CONST = {
DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'},
DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false},
DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false},
DEFAULT_NETWORK_DATA: {isOffline: false},
DEFAULT_NETWORK_DATA: {isOffline: false, isBackendReachable: true},
FORMS: {
LOGIN_FORM: 'LoginForm',
VALIDATE_CODE_FORM: 'ValidateCodeForm',
Expand Down
6 changes: 4 additions & 2 deletions src/Expensify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,10 @@ function Expensify({
// Initialize this client as being an active client
ActiveClientManager.init();

// Used for the offline indicator appearing when someone is offline
NetworkConnection.subscribeToNetInfo();
// Used for the offline indicator appearing when someone is offline or backend is unreachable
const unsubscribeNetworkStatus = NetworkConnection.subscribeToNetworkStatus();

return () => unsubscribeNetworkStatus();
}, []);

useEffect(() => {
Expand Down
23 changes: 20 additions & 3 deletions src/components/OfflineIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import Text from './Text';
import TextLink from './TextLink';

type OfflineIndicatorProps = {
/** Optional styles for container element that will override the default styling for the offline indicator */
Expand All @@ -23,7 +25,7 @@ function OfflineIndicator({style, containerStyles}: OfflineIndicatorProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const {isOffline, isBackendReachable} = useNetwork();
const {isSmallScreenWidth} = useWindowDimensions();

const computedStyles = useMemo((): StyleProp<ViewStyle> => {
Expand All @@ -34,7 +36,7 @@ function OfflineIndicator({style, containerStyles}: OfflineIndicatorProps) {
return isSmallScreenWidth ? styles.offlineIndicatorMobile : styles.offlineIndicator;
}, [containerStyles, isSmallScreenWidth, styles.offlineIndicatorMobile, styles.offlineIndicator]);

if (!isOffline) {
if (!isOffline && isBackendReachable) {
return null;
}

Expand All @@ -46,7 +48,22 @@ function OfflineIndicator({style, containerStyles}: OfflineIndicatorProps) {
width={variables.iconSizeSmall}
height={variables.iconSizeSmall}
/>
<Text style={[styles.ml3, styles.chatItemComposeSecondaryRowSubText]}>{translate('common.youAppearToBeOffline')}</Text>
<Text style={[styles.ml3, styles.chatItemComposeSecondaryRowSubText]}>
{isOffline ? (
translate('common.youAppearToBeOffline')
) : (
<>
{translate('common.weMightHaveProblem')}
<TextLink
href={CONST.STATUS_EXPENSIFY_URL}
style={[styles.chatItemComposeSecondaryRowSubText, styles.link]}
>
{new URL(CONST.STATUS_EXPENSIFY_URL).host}
</TextLink>
.
</>
)}
</Text>
</View>
);
}
Expand Down
9 changes: 5 additions & 4 deletions src/hooks/useNetwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ type UseNetworkProps = {
onReconnect?: () => void;
};

type UseNetwork = {isOffline: boolean};
type UseNetwork = {isOffline: boolean; isBackendReachable: boolean};

export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {}): UseNetwork {
const callback = useRef(onReconnect);
callback.current = onReconnect;

const {isOffline, networkStatus} = useContext(NetworkContext) ?? {...CONST.DEFAULT_NETWORK_DATA, networkStatus: CONST.NETWORK.NETWORK_STATUS.UNKNOWN};
const {isOffline, networkStatus, isBackendReachable} = useContext(NetworkContext) ?? {...CONST.DEFAULT_NETWORK_DATA, networkStatus: CONST.NETWORK.NETWORK_STATUS.UNKNOWN};
const isNetworkStatusUnknown = networkStatus === CONST.NETWORK.NETWORK_STATUS.UNKNOWN;
const prevOfflineStatusRef = useRef(isOffline);
useEffect(() => {
// If we were offline before and now we are not offline then we just reconnected
Expand All @@ -29,6 +30,6 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {
prevOfflineStatusRef.current = isOffline;
}, [isOffline]);

// If the network status is undefined, we don't treat it as offline. Otherwise, we utilize the isOffline prop.
return {isOffline: networkStatus === CONST.NETWORK.NETWORK_STATUS.UNKNOWN ? false : isOffline};
// If the network status is unknown, we fallback to default state, i.e. we're online and backend is reachable.
return isNetworkStatusUnknown ? CONST.DEFAULT_NETWORK_DATA : {isOffline, isBackendReachable};
}
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export default {
your: 'your',
conciergeHelp: 'Please reach out to Concierge for help.',
youAppearToBeOffline: 'You appear to be offline.',
weMightHaveProblem: 'We might have a problem. Check out ',
thisFeatureRequiresInternet: 'This feature requires an active internet connection to be used.',
attachementWillBeAvailableOnceBackOnline: 'Attachment will become available once back online.',
areYouSure: 'Are you sure?',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export default {
your: 'tu',
conciergeHelp: 'Por favor, contacta con Concierge para obtener ayuda.',
youAppearToBeOffline: 'Parece que estás desconectado.',
weMightHaveProblem: 'Peude que te tengamos un problema. Echa un vistazo a ',
thisFeatureRequiresInternet: 'Esta función requiere una conexión a Internet activa para ser utilizada.',
attachementWillBeAvailableOnceBackOnline: 'El archivo adjunto estará disponible cuando vuelvas a estar en línea.',
areYouSure: '¿Estás seguro?',
Expand Down
102 changes: 71 additions & 31 deletions src/libs/NetworkConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import * as NetworkActions from './actions/Network';
import AppStateMonitor from './AppStateMonitor';
import checkInternetReachability from './checkInternetReachability';
import Log from './Log';

let isOffline = false;
Expand Down Expand Up @@ -42,12 +43,23 @@ function setOfflineStatus(isCurrentlyOffline: boolean): void {
// When reconnecting, ie, going from offline to online, all the reconnection callbacks
// are triggered (this is usually Actions that need to re-download data from the server)
if (isOffline && !isCurrentlyOffline) {
NetworkActions.setIsBackendReachable(true);
triggerReconnectionCallbacks('offline status changed');
}

isOffline = isCurrentlyOffline;
}

function setNetWorkStatus(isInternetReachable: boolean | null): void {
let networkStatus;
if (!isBoolean(isInternetReachable)) {
networkStatus = CONST.NETWORK.NETWORK_STATUS.UNKNOWN;
} else {
networkStatus = isInternetReachable ? CONST.NETWORK.NETWORK_STATUS.ONLINE : CONST.NETWORK.NETWORK_STATUS.OFFLINE;
}
NetworkActions.setNetWorkStatus(networkStatus);
}

// Update the offline status in response to changes in shouldForceOffline
let shouldForceOffline = false;
Onyx.connect({
Expand All @@ -71,53 +83,81 @@ Onyx.connect({
});

/**
* Set up the event listener for NetInfo to tell whether the user has
* internet connectivity or not. This is more reliable than the Pusher
* `disconnected` event which takes about 10-15 seconds to emit.
* Set interval to periodically (re)check backend status.
* Because backend unreachability might imply lost internet connection, we need to check internet reachability.
* @returns clearInterval cleanup
*/
function subscribeToNetInfo(): void {
// Note: We are disabling the configuration for NetInfo when using the local web API since requests can get stuck in a 'Pending' state and are not reliable indicators for "offline".
// If you need to test the "recheck" feature then switch to the production API proxy server.
if (!CONFIG.IS_USING_LOCAL_WEB) {
// Calling NetInfo.configure (re)checks current state. We use it to force a recheck whenever we (re)subscribe
NetInfo.configure({
// By default, NetInfo uses `/` for `reachabilityUrl`
// When App is served locally (or from Electron) this address is always reachable - even offline
// Using the API url ensures reachability is tested over internet
reachabilityUrl: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/Ping`,
reachabilityMethod: 'GET',
reachabilityTest: (response) => {
function subscribeToBackendAndInternetReachability(): () => void {
const intervalID = setInterval(() => {
// Offline status also implies backend unreachability
if (isOffline) {
return;
}
// Using the API url ensures reachability is tested over internet
fetch(`${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/Ping`, {
method: 'GET',
cache: 'no-cache',
})
.then((response) => {
if (!response.ok) {
return Promise.resolve(false);
}
return response
.json()
.then((json) => Promise.resolve(json.jsonCode === 200))
.catch(() => Promise.resolve(false));
},
})
.then((isBackendReachable: boolean) => {
if (isBackendReachable) {
NetworkActions.setIsBackendReachable(true);
return;
}
checkInternetReachability().then((isInternetReachable: boolean) => {
setOfflineStatus(!isInternetReachable);
setNetWorkStatus(isInternetReachable);
NetworkActions.setIsBackendReachable(false);
});
})
.catch(() => {
checkInternetReachability().then((isInternetReachable: boolean) => {
setOfflineStatus(!isInternetReachable);
setNetWorkStatus(isInternetReachable);
NetworkActions.setIsBackendReachable(false);
});
});
}, CONST.NETWORK.BACKEND_CHECK_INTERVAL_MS);

return () => {
clearInterval(intervalID);
};
}

// If a check is taking longer than this time we're considered offline
reachabilityRequestTimeout: CONST.NETWORK.MAX_PENDING_TIME_MS,
});
}
/**
* Monitor internet connectivity and perform periodic backend reachability checks
* @returns unsubscribe method
*/
function subscribeToNetworkStatus(): () => void {
// Note: We are disabling the reachability check when using the local web API since requests can get stuck in a 'Pending' state and are not reliable indicators for reachability.
// If you need to test the "recheck" feature then switch to the production API proxy server.
const unsubscribeFromBackendReachability = !CONFIG.IS_USING_LOCAL_WEB ? subscribeToBackendAndInternetReachability() : undefined;

// Subscribe to the state change event via NetInfo so we can update
// whether a user has internet connectivity or not.
NetInfo.addEventListener((state) => {
// Set up the event listener for NetInfo to tell whether the user has
// internet connectivity or not. This is more reliable than the Pusher
// `disconnected` event which takes about 10-15 seconds to emit.
const unsubscribeNetInfo = NetInfo.addEventListener((state) => {
Log.info('[NetworkConnection] NetInfo state change', false, {...state});
if (shouldForceOffline) {
Log.info('[NetworkConnection] Not setting offline status because shouldForceOffline = true');
return;
}
setOfflineStatus(state.isInternetReachable === false);
let networkStatus;
if (!isBoolean(state.isInternetReachable)) {
networkStatus = CONST.NETWORK.NETWORK_STATUS.UNKNOWN;
} else {
networkStatus = state.isInternetReachable ? CONST.NETWORK.NETWORK_STATUS.ONLINE : CONST.NETWORK.NETWORK_STATUS.OFFLINE;
}
NetworkActions.setNetWorkStatus(networkStatus);
setNetWorkStatus(state.isInternetReachable);
});

return () => {
unsubscribeFromBackendReachability?.();
unsubscribeNetInfo();
};
}

function listenForReconnect() {
Expand Down Expand Up @@ -166,6 +206,6 @@ export default {
onReconnect,
triggerReconnectionCallbacks,
recheckNetworkConnection,
subscribeToNetInfo,
subscribeToNetworkStatus,
};
export type {NetworkStatus};
6 changes: 5 additions & 1 deletion src/libs/actions/Network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import Onyx from 'react-native-onyx';
import type {NetworkStatus} from '@libs/NetworkConnection';
import ONYXKEYS from '@src/ONYXKEYS';

function setIsBackendReachable(isBackendReachable: boolean) {
Onyx.merge(ONYXKEYS.NETWORK, {isBackendReachable});
}

function setIsOffline(isOffline: boolean) {
Onyx.merge(ONYXKEYS.NETWORK, {isOffline});
}
Expand All @@ -25,4 +29,4 @@ function setShouldFailAllRequests(shouldFailAllRequests: boolean) {
Onyx.merge(ONYXKEYS.NETWORK, {shouldFailAllRequests});
}

export {setIsOffline, setShouldForceOffline, setShouldFailAllRequests, setTimeSkew, setNetWorkStatus};
export {setIsBackendReachable, setIsOffline, setShouldForceOffline, setShouldFailAllRequests, setTimeSkew, setNetWorkStatus};
15 changes: 15 additions & 0 deletions src/libs/checkInternetReachability/index.android.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import CONST from '@src/CONST';
import type InternetReachabilityCheck from './types';

/**
* Although Android supports internet reachability check, it only does on initiating the connection.
* We need to implement a test for a highly-available endpoint in case of lost internet after initiation.
*/
export default function checkInternetReachability(): InternetReachabilityCheck {
return fetch(CONST.GOOGLE_CLOUD_URL, {
method: 'GET',
cache: 'no-cache',
})
.then((response) => Promise.resolve(response.status === 204))
.catch(() => Promise.resolve(false));
}
5 changes: 5 additions & 0 deletions src/libs/checkInternetReachability/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type InternetReachabilityCheck from './types';

export default function checkInternetReachability(): InternetReachabilityCheck {
return Promise.resolve(true);
}
3 changes: 3 additions & 0 deletions src/libs/checkInternetReachability/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type InternetReachabilityCheck = Promise<boolean>;

export default InternetReachabilityCheck;
3 changes: 3 additions & 0 deletions src/types/onyx/Network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ type Network = {
/** Is the network currently offline or not */
isOffline: boolean;

/** Is the backend reachable when online */
isBackendReachable: boolean;

/** Should the network be forced offline */
shouldForceOffline?: boolean;

Expand Down
Loading

0 comments on commit 783c3de

Please sign in to comment.