Skip to content

Commit a8e822f

Browse files
runway-github[bot]cryptodev-2smcmire
authored
chore(runway): cherry-pick feat: cp-7.62.0 Use ConnectivityController to suppress network banners and events when device is offline (#24844)
- feat: cp-7.62.0 Use `ConnectivityController` to suppress network banners and events when device is offline (#24554) ## Description Adds device connectivity detection to distinguish between "device offline" and "network/RPC degraded" states. ## Problem When a device goes offline, the mobile app incorrectly: - Shows "Network Degraded/Unavailable" banners (misleading - it's the device, not the network) - Tracks RPC endpoint failure metrics (false positives polluting analytics) ## Solution Introduces `ConnectivityController` that tracks device online/offline status using `@react-native-community/netinfo`. ### Implementation Details The mobile implementation uses `NetInfoConnectivityAdapter` which checks both connectivity indicators: - **`isInternetReachable`** (preferred): Detects actual internet connectivity - ensures the device can reach the internet, not just connected to a network - **`isConnected`** (fallback): Used when `isInternetReachable` is null (still determining connectivity) - checks if device has any network connection (WiFi, cellular, etc.) This dual-check approach is critical because: - A device can be `isConnected: true` (connected to WiFi/cellular) but `isInternetReachable: false` (no actual internet access) - We need to detect true offline state, not just network interface availability - The existing `OfflineMode` view already handles full-screen offline scenarios, and `ConnectivityController` ensures network banners don't show when internet is not reachable ### Key benefits: - **Suppressed false-positive metrics**: RPC endpoint unavailable/degraded events are no longer tracked when the device is offline - **Hidden misleading banners**: Network connection banners are hidden when internet is not reachable (even if device shows network connection) - **Accurate offline detection**: Checks both `isInternetReachable` and `isConnected` to properly distinguish between "connected to network" vs "has internet access" ### Integration with Existing Offline UI Mobile already has an `OfflineMode` view ([app/components/Views/OfflineMode/index.js](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/Views/OfflineMode/index.js#L61)) that handles device offline scenarios with a full-screen view. This view: - Uses `NetInfo.useNetInfo()` to detect when the device has no connectivity - Displays a dedicated offline screen with an astronaut image - Provides a "Try Again" button that checks connectivity before allowing navigation - Handles both regular offline scenarios and Infura blocked cases The `ConnectivityController` integration complements this existing UI by: - Hiding network connection banners when internet is not reachable (even if `isConnected` is true) - Ensuring analytics accurately distinguish between device offline vs. RPC endpoint issues - Providing a consistent connectivity state across the app that can be used by other components - Using the same dual-check logic (`isInternetReachable` + `isConnected`) to ensure accurate offline detection This ensures users see the appropriate `OfflineMode` screen for device connectivity issues, while network/RPC-specific banners only appear when the device has internet connectivity but is experiencing RPC endpoint problems. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Fixed misleading "Network Degraded" banners and false-positive RPC endpoint metrics when the device has no internet connection ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/WPC-210 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces device connectivity awareness and suppresses misleading network status UI/metrics when offline. > > - Adds `ConnectivityController` with `NetInfoConnectivityAdapter` and messenger; wires into `Engine` context/state/events and `BACKGROUND_STATE_CHANGE_EVENT_NAMES` > - Updates `NetworkController` init to query connectivity (`ConnectivityController:getState`) and pass `isOffline` to RPC service options > - Enhances `useNetworkConnectionBanner` to read `selectIsDeviceOffline`; cancels timers and hides/blocks `degraded`/`unavailable` banners while offline; resumes when back online > - New selectors `selectConnectivityStatus`/`selectIsDeviceOffline` and comprehensive unit tests for controller init, adapter, Engine, and the banner hook > - Bumps deps: adds `@metamask/connectivity-controller`, upgrades `@metamask/network-controller` to `^29.0.0` (and related middleware/block-tracker); updates fixtures and snapshots > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e5ad57a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [1930eec](1930eec) --------- Co-authored-by: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Co-authored-by: Elliot Winkler <elliot.winkler@gmail.com>
1 parent 57e20b6 commit a8e822f

21 files changed

Lines changed: 1045 additions & 11 deletions

app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx

Lines changed: 233 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { renderHook, act } from '@testing-library/react-native';
2+
import { renderHook, act, waitFor } from '@testing-library/react-native';
33
import configureMockStore from 'redux-mock-store';
44
import { Provider } from 'react-redux';
55
import { useNavigation } from '@react-navigation/native';
@@ -14,6 +14,7 @@ import useNetworkConnectionBanner from './useNetworkConnectionBanner';
1414
import Engine from '../../../core/Engine';
1515
import { MetaMetricsEvents, useMetrics } from '../useMetrics';
1616
import { selectNetworkConnectionBannerState } from '../../../selectors/networkConnectionBanner';
17+
import { selectIsDeviceOffline } from '../../../selectors/connectivityController';
1718
import { selectEVMEnabledNetworks } from '../../../selectors/networkEnablementController';
1819
import Routes from '../../../constants/navigation/Routes';
1920
import { isPublicEndpointUrl } from '../../../core/Engine/controllers/network-controller/utils';
@@ -24,6 +25,18 @@ jest.mock('../../../core/Engine');
2425
jest.mock('../../../selectors/networkEnablementController');
2526
jest.mock('../useMetrics');
2627
jest.mock('../../../selectors/networkConnectionBanner');
28+
jest.mock('../../../selectors/connectivityController');
29+
jest.mock('react-redux', () => {
30+
const actual = jest.requireActual('react-redux');
31+
return {
32+
...actual,
33+
useSelector: jest.fn(
34+
// Call the selector function directly to get the mocked return value
35+
// This ensures selectors are called on every render, not just when store changes
36+
(selector) => selector({} as unknown),
37+
),
38+
};
39+
});
2740
jest.mock('../../../core/Engine/controllers/network-controller/utils', () => ({
2841
...jest.requireActual(
2942
'../../../core/Engine/controllers/network-controller/utils',
@@ -134,6 +147,8 @@ describe('useNetworkConnectionBanner', () => {
134147
jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({
135148
visible: false,
136149
});
150+
// Default to online
151+
jest.mocked(selectIsDeviceOffline).mockReturnValue(false);
137152

138153
// Mock Engine methods directly (safer than spyOn after jest.mock)
139154
Engine.lookupEnabledNetworks = mockEngine.lookupEnabledNetworks;
@@ -163,6 +178,13 @@ describe('useNetworkConnectionBanner', () => {
163178
visible: false,
164179
chainId: undefined,
165180
},
181+
engine: {
182+
backgroundState: {
183+
ConnectivityController: {
184+
connectivityStatus: 'online',
185+
},
186+
},
187+
},
166188
});
167189
};
168190

@@ -1014,4 +1036,214 @@ describe('useNetworkConnectionBanner', () => {
10141036
expect(actions).toHaveLength(0);
10151037
});
10161038
});
1039+
1040+
describe('when device is offline', () => {
1041+
beforeEach(() => {
1042+
// Mock selector to return offline
1043+
jest.mocked(selectIsDeviceOffline).mockReturnValue(true);
1044+
});
1045+
1046+
it('hides banner when device is offline even if network is unavailable', () => {
1047+
jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({
1048+
visible: true,
1049+
chainId: '0x89',
1050+
status: 'degraded',
1051+
networkName: 'Polygon Mainnet',
1052+
rpcUrl: 'https://polygon-rpc.com',
1053+
isInfuraEndpoint: false,
1054+
});
1055+
1056+
renderHookWithProvider();
1057+
1058+
const actions = store.getActions();
1059+
expect(actions).toHaveLength(1);
1060+
expect(actions[0]).toStrictEqual({
1061+
type: 'HIDE_NETWORK_CONNECTION_BANNER',
1062+
});
1063+
});
1064+
1065+
it('does not show degraded banner when device is offline', () => {
1066+
jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({
1067+
visible: false,
1068+
});
1069+
1070+
renderHookWithProvider();
1071+
1072+
act(() => {
1073+
jest.advanceTimersByTime(5000);
1074+
});
1075+
1076+
const actions = store.getActions();
1077+
// Should not show degraded banner when offline
1078+
expect(actions).not.toContainEqual(
1079+
expect.objectContaining({
1080+
type: 'SHOW_NETWORK_CONNECTION_BANNER',
1081+
status: 'degraded',
1082+
}),
1083+
);
1084+
});
1085+
1086+
it('does not show unavailable banner when device is offline', () => {
1087+
jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({
1088+
visible: false,
1089+
});
1090+
1091+
renderHookWithProvider();
1092+
1093+
act(() => {
1094+
jest.advanceTimersByTime(30000);
1095+
});
1096+
1097+
const actions = store.getActions();
1098+
// Should not show unavailable banner when offline
1099+
expect(actions).not.toContainEqual(
1100+
expect.objectContaining({
1101+
type: 'SHOW_NETWORK_CONNECTION_BANNER',
1102+
status: 'unavailable',
1103+
}),
1104+
);
1105+
});
1106+
1107+
it('does not progress from degraded to unavailable when device goes offline', () => {
1108+
// Device is offline with degraded banner showing
1109+
jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({
1110+
visible: true,
1111+
chainId: '0x89',
1112+
status: 'degraded',
1113+
networkName: 'Polygon Mainnet',
1114+
rpcUrl: 'https://polygon-rpc.com',
1115+
isInfuraEndpoint: false,
1116+
});
1117+
1118+
renderHookWithProvider();
1119+
1120+
// Clear any calls from initial render (hiding banner)
1121+
store.clearActions();
1122+
1123+
// Wait for what would have been the unavailable timeout
1124+
act(() => {
1125+
jest.advanceTimersByTime(30000);
1126+
});
1127+
1128+
const actions = store.getActions();
1129+
// Should NOT progress to unavailable since device is offline
1130+
expect(actions).not.toContainEqual(
1131+
expect.objectContaining({
1132+
type: 'SHOW_NETWORK_CONNECTION_BANNER',
1133+
status: 'unavailable',
1134+
}),
1135+
);
1136+
});
1137+
1138+
it('does not update banner if already hidden when offline', () => {
1139+
jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({
1140+
visible: false,
1141+
});
1142+
1143+
renderHookWithProvider();
1144+
1145+
const actions = store.getActions();
1146+
// Should not dispatch any actions when banner is already hidden
1147+
expect(actions).toHaveLength(0);
1148+
});
1149+
1150+
it('resumes normal behavior when device comes back online', async () => {
1151+
// Start offline
1152+
jest.mocked(selectIsDeviceOffline).mockReturnValue(true);
1153+
jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({
1154+
visible: false,
1155+
});
1156+
1157+
const { rerender } = renderHookWithProvider();
1158+
1159+
// Clear any calls from initial render
1160+
store.clearActions();
1161+
1162+
// Device comes back online - update selector mock
1163+
jest.mocked(selectIsDeviceOffline).mockReturnValue(false);
1164+
1165+
await act(async () => {
1166+
rerender({});
1167+
});
1168+
1169+
// Advance timer to trigger degraded
1170+
act(() => {
1171+
jest.advanceTimersByTime(5000);
1172+
});
1173+
1174+
const actions = store.getActions();
1175+
// Should now show degraded banner
1176+
expect(actions).toContainEqual(
1177+
expect.objectContaining({
1178+
type: 'SHOW_NETWORK_CONNECTION_BANNER',
1179+
status: 'degraded',
1180+
chainId: '0x89',
1181+
}),
1182+
);
1183+
});
1184+
1185+
it('hides banner immediately when device goes offline while showing degraded', async () => {
1186+
// Start online with degraded banner
1187+
jest.mocked(selectIsDeviceOffline).mockReturnValue(false);
1188+
jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({
1189+
visible: true,
1190+
chainId: '0x89',
1191+
status: 'degraded',
1192+
networkName: 'Polygon Mainnet',
1193+
rpcUrl: 'https://polygon-rpc.com',
1194+
isInfuraEndpoint: false,
1195+
});
1196+
1197+
const { rerender } = renderHookWithProvider();
1198+
1199+
// Clear any calls from initial render
1200+
store.clearActions();
1201+
1202+
// Device goes offline - update selector mock
1203+
jest.mocked(selectIsDeviceOffline).mockReturnValue(true);
1204+
1205+
await act(async () => {
1206+
rerender({});
1207+
});
1208+
1209+
await waitFor(() => {
1210+
const actions = store.getActions();
1211+
expect(actions).toContainEqual({
1212+
type: 'HIDE_NETWORK_CONNECTION_BANNER',
1213+
});
1214+
});
1215+
});
1216+
1217+
it('hides banner immediately when device goes offline while showing unavailable', async () => {
1218+
// Start online with unavailable banner
1219+
jest.mocked(selectIsDeviceOffline).mockReturnValue(false);
1220+
jest.mocked(selectNetworkConnectionBannerState).mockReturnValue({
1221+
visible: true,
1222+
chainId: '0x89',
1223+
status: 'unavailable',
1224+
networkName: 'Polygon Mainnet',
1225+
rpcUrl: 'https://polygon-rpc.com',
1226+
isInfuraEndpoint: false,
1227+
});
1228+
1229+
const { rerender } = renderHookWithProvider();
1230+
1231+
// Clear any calls from initial render
1232+
store.clearActions();
1233+
1234+
// Device goes offline - update selector mock
1235+
jest.mocked(selectIsDeviceOffline).mockReturnValue(true);
1236+
1237+
await act(async () => {
1238+
rerender({});
1239+
});
1240+
1241+
await waitFor(() => {
1242+
const actions = store.getActions();
1243+
expect(actions).toContainEqual({
1244+
type: 'HIDE_NETWORK_CONNECTION_BANNER',
1245+
});
1246+
});
1247+
});
1248+
});
10171249
});

app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Hex, hexToNumber } from '@metamask/utils';
44
import { NetworkStatus } from '@metamask/network-controller';
55
import { useNavigation } from '@react-navigation/native';
66
import { selectNetworkConnectionBannerState } from '../../../selectors/networkConnectionBanner';
7+
import { selectIsDeviceOffline } from '../../../selectors/connectivityController';
78
import Engine from '../../../core/Engine';
89
import Routes from '../../../constants/navigation/Routes';
910
import { MetaMetricsEvents, useMetrics } from '../useMetrics';
@@ -46,6 +47,7 @@ const useNetworkConnectionBanner = (): {
4647
const networkConnectionBannerState = useSelector(
4748
selectNetworkConnectionBannerState,
4849
);
50+
const isOffline = useSelector(selectIsDeviceOffline);
4951

5052
// Use ref to access current banner state without causing timer effect to re-run
5153
const bannerStateRef = useRef(networkConnectionBannerState);
@@ -85,6 +87,17 @@ const useNetworkConnectionBanner = (): {
8587
}
8688

8789
useEffect(() => {
90+
// When device is offline, clear timers and reset banner state
91+
// We don't want to show network degraded/unavailable banners when the real issue
92+
// is the device's internet connectivity
93+
if (isOffline) {
94+
const currentBannerState = bannerStateRef.current;
95+
if (currentBannerState.visible) {
96+
dispatch(hideNetworkConnectionBanner());
97+
}
98+
return;
99+
}
100+
88101
const checkNetworkStatus = (timeoutType: NetworkConnectionBannerStatus) => {
89102
const currentBannerState = bannerStateRef.current;
90103
const networksMetadata =
@@ -192,7 +205,7 @@ const useNetworkConnectionBanner = (): {
192205
clearTimeout(degradedTimeout);
193206
clearTimeout(unavailableTimeout);
194207
};
195-
}, [evmEnabledNetworksChainIds, dispatch]);
208+
}, [isOffline, evmEnabledNetworksChainIds, dispatch]);
196209

197210
useEffect(() => {
198211
bannerStateRef.current = networkConnectionBannerState;

app/core/Engine/Engine.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ jest.mock('react-native-device-info', () => ({
3030
jest.mock('../BackupVault', () => ({
3131
backupVault: jest.fn().mockResolvedValue({ success: true, vault: 'vault' }),
3232
}));
33+
34+
jest.mock('@react-native-community/netinfo', () => ({
35+
__esModule: true,
36+
fetch: jest.fn().mockResolvedValue({
37+
isConnected: true,
38+
isInternetReachable: true,
39+
}),
40+
addEventListener: jest.fn().mockReturnValue(jest.fn()),
41+
}));
3342
jest.unmock('./Engine');
3443
jest.mock('../../store', () => ({
3544
store: {
@@ -147,6 +156,7 @@ describe('Engine', () => {
147156
expect(engine.context).toHaveProperty('GatorPermissionsController');
148157
expect(engine.context).toHaveProperty('RampsController');
149158
expect(engine.context).toHaveProperty('RampsService');
159+
expect(engine.context).toHaveProperty('ConnectivityController');
150160
});
151161

152162
it('calling Engine.init twice returns the same instance', () => {

app/core/Engine/Engine.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ import { loggingControllerInit } from './controllers/logging-controller-init';
174174
import { phishingControllerInit } from './controllers/phishing-controller-init';
175175
import { addressBookControllerInit } from './controllers/address-book-controller-init';
176176
import { analyticsControllerInit } from './controllers/analytics-controller/analytics-controller-init';
177+
import { connectivityControllerInit } from './controllers/connectivity/connectivity-controller-init';
177178
import { multichainRouterInit } from './controllers/multichain-router-init';
178179
import { profileMetricsControllerInit } from './controllers/profile-metrics-controller-init';
179180
import { profileMetricsServiceInit } from './controllers/profile-metrics-service-init';
@@ -367,6 +368,7 @@ export class Engine {
367368
RewardsDataService: rewardsDataServiceInit,
368369
DelegationController: DelegationControllerInit,
369370
AddressBookController: addressBookControllerInit,
371+
ConnectivityController: connectivityControllerInit,
370372
ProfileMetricsController: profileMetricsControllerInit,
371373
ProfileMetricsService: profileMetricsServiceInit,
372374
AnalyticsController: analyticsControllerInit,
@@ -405,6 +407,7 @@ export class Engine {
405407
const preferencesController = controllersByName.PreferencesController;
406408
const delegationController = controllersByName.DelegationController;
407409
const addressBookController = controllersByName.AddressBookController;
410+
const connectivityController = controllersByName.ConnectivityController;
408411
const profileMetricsController = controllersByName.ProfileMetricsController;
409412
const profileMetricsService = controllersByName.ProfileMetricsService;
410413
const rampsService = controllersByName.RampsService;
@@ -493,6 +496,7 @@ export class Engine {
493496
AccountTrackerController: accountTrackerController,
494497
AddressBookController: addressBookController,
495498
AppMetadataController: controllersByName.AppMetadataController,
499+
ConnectivityController: connectivityController,
496500
AssetsContractController: assetsContractController,
497501
NftController: nftController,
498502
TokensController: tokensController,
@@ -1304,6 +1308,7 @@ export default {
13041308
ApprovalController,
13051309
BridgeController,
13061310
BridgeStatusController,
1311+
ConnectivityController,
13071312
CurrencyRateController,
13081313
DeFiPositionsController,
13091314
DelegationController,
@@ -1370,6 +1375,7 @@ export default {
13701375
ApprovalController,
13711376
BridgeController,
13721377
BridgeStatusController,
1378+
ConnectivityController,
13731379
CurrencyRateController,
13741380
DeFiPositionsController,
13751381
DelegationController,

app/core/Engine/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [
2626
'AddressBookController:stateChange',
2727
'AnalyticsController:stateChange',
2828
'AppMetadataController:stateChange',
29+
'ConnectivityController:stateChange',
2930
'ApprovalController:stateChange',
3031
'CurrencyRateController:stateChange',
3132
'GasFeeController:stateChange',

0 commit comments

Comments
 (0)