feat(MM-65625): low connectivity feature toggle in advance settings (#9191)

This commit is contained in:
Rahim Rahman 2025-10-12 23:21:11 -06:00 committed by GitHub
parent 0b7820c2b9
commit c872e2e2b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1294 additions and 158 deletions

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {Tutorial} from '@constants';
import {GLOBAL_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {
getDeviceToken,
@ -33,6 +34,7 @@ import {
removePushDisabledInServerAcknowledged,
storeScheduledPostTutorial,
storeScheduledPostsListTutorial,
storeLowConnectivityMonitor,
} from './global';
const serverUrl = 'server.test.com';
@ -203,4 +205,17 @@ describe('/app/actions/app/global', () => {
records = await queryGlobalValue(Tutorial.SCHEDULED_POSTS_LIST)?.fetch();
expect(records?.[0]?.value).toBe(true);
});
test('storeLowConnectivityMonitor', async () => {
let records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch();
expect(records?.[0]?.value).toBeUndefined();
await storeLowConnectivityMonitor(true);
records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch();
expect(records?.[0]?.value).toBe(true);
await storeLowConnectivityMonitor(false);
records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch();
expect(records?.[0]?.value).toBe(false);
});
});

View file

@ -97,3 +97,7 @@ export const storePushDisabledInServerAcknowledged = async (serverUrl: string) =
export const removePushDisabledInServerAcknowledged = async (serverUrl: string) => {
return storeGlobal(`${GLOBAL_IDENTIFIERS.PUSH_DISABLED_ACK}${serverUrl}`, null, false);
};
export const storeLowConnectivityMonitor = async (enabled: boolean) => {
return storeGlobal(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR, enabled, false);
};

View file

@ -31,7 +31,13 @@ jest.mock('@actions/remote/user');
jest.mock('@actions/remote/post');
jest.mock('@actions/remote/groups');
jest.mock('@actions/remote/thread');
jest.mock('@queries/app/global');
jest.mock('@queries/app/global', () => {
const {of: mockOf} = require('rxjs');
return {
getDeviceToken: jest.fn(),
observeLowConnectivityMonitor: jest.fn(() => mockOf(true)),
};
});
jest.mock('@queries/servers/system');
jest.mock('@queries/servers/entry');
jest.mock('@queries/servers/system', () => {

View file

@ -920,16 +920,11 @@ describe('ClientTracking', () => {
groupLabel: 'Cold Start' as RequestGroupLabel,
};
beforeEach(() => {
LocalConfig.MonitorNetworkPerformance = true;
});
afterEach(() => {
LocalConfig.MonitorNetworkPerformance = false;
jest.clearAllMocks();
});
it('should call full tracking lifecycle when MonitorNetworkPerformance is enabled', async () => {
it('should call full tracking lifecycle', async () => {
const mockMetrics = createMockMetrics();
apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics));
@ -939,16 +934,6 @@ describe('ClientTracking', () => {
expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123', mockMetrics);
});
it('should not call tracking methods when MonitorNetworkPerformance is disabled', async () => {
LocalConfig.MonitorNetworkPerformance = false;
apiClientMock.get.mockResolvedValue(mockSuccessResponse());
await client.doFetchWithTracking('https://example.com/api', requestOptions);
expect(mockedNPM.startRequestTracking).not.toHaveBeenCalled();
expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled();
});
it('should not call completeRequestTracking when response.metrics is undefined', async () => {
apiClientMock.get.mockResolvedValue({
ok: true,
@ -1008,17 +993,6 @@ describe('ClientTracking', () => {
expect(mockedNPM.cancelRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123');
expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled();
});
it('should not call cancelRequestTracking when MonitorNetworkPerformance is disabled and request fails', async () => {
LocalConfig.MonitorNetworkPerformance = false;
apiClientMock.get.mockRejectedValue(new Error('Request failed'));
await expect(client.doFetchWithTracking('https://example.com/api', requestOptions)).rejects.toThrow('Received invalid response from the server.');
expect(mockedNPM.startRequestTracking).not.toHaveBeenCalled();
expect(mockedNPM.cancelRequestTracking).not.toHaveBeenCalled();
expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled();
});
});
});
/* eslint-enable max-lines */

View file

@ -4,7 +4,7 @@
import {defineMessage} from 'react-intl';
import {DeviceEventEmitter, Platform} from 'react-native';
import {CollectNetworkMetrics, MonitorNetworkPerformance} from '@assets/config.json';
import {CollectNetworkMetrics} from '@assets/config.json';
import {Events} from '@constants';
import {setServerCredentials} from '@init/credentials';
import NetworkPerformanceManager from '@managers/network_performance_manager';
@ -133,21 +133,18 @@ export default class ClientTracking {
}
startNetworkPerformanceTracking(url: string): string | undefined {
if (!MonitorNetworkPerformance) {
return undefined;
}
return NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url);
}
completeNetworkPerformanceTracking(requestId: string | undefined, url: string, metrics: ClientResponseMetrics | undefined) {
if (!MonitorNetworkPerformance || !requestId || !metrics) {
if (!requestId || !metrics) {
return;
}
NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, requestId, metrics);
}
cancelNetworkPerformanceTracking(requestId: string | undefined) {
if (!MonitorNetworkPerformance || !requestId) {
if (!requestId) {
return;
}
NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, requestId);

View file

@ -48,15 +48,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
actionContainer: {
flexDirection: 'row',
alignItems: 'center',
alignItems: 'flex-start',
justifyContent: 'flex-end',
marginTop: 2,
},
container: {
flexDirection: 'row',
alignItems: 'center',
alignItems: 'flex-start',
minHeight: ITEM_HEIGHT,
gap: 12,
justifyContent: 'space-between',
paddingVertical: 12,
},
destructive: {
color: theme.dndIndicator,

View file

@ -84,9 +84,10 @@ export const GLOBAL_IDENTIFIERS = {
DONT_ASK_FOR_REVIEW: 'dontAskForReview',
FIRST_LAUNCH: 'firstLaunch',
LAST_ASK_FOR_REVIEW: 'lastAskForReview',
ONBOARDING: 'onboarding',
LAST_VIEWED_CHANNEL: 'lastViewedChannel',
LAST_VIEWED_THREAD: 'lastViewedThread',
LOW_CONNECTIVITY_MONITOR: 'lowConnectivityMonitor',
ONBOARDING: 'onboarding',
PUSH_DISABLED_ACK: 'pushDisabledAck',
};

View file

@ -53,7 +53,15 @@ jest.mock('@database/manager', () => ({
serverDatabases: {},
}));
jest.mock('@init/credentials');
jest.mock('@queries/app/global');
jest.mock('@queries/app/global', () => {
const {of: mockOf} = require('rxjs');
return {
getLastViewedChannelIdAndServer: jest.fn(),
getOnboardingViewed: jest.fn(),
getLastViewedThreadIdAndServer: jest.fn(),
observeLowConnectivityMonitor: jest.fn(() => mockOf(true)),
};
});
jest.mock('@queries/app/servers');
jest.mock('@queries/servers/post');
jest.mock('@queries/servers/preference');

View file

@ -105,7 +105,7 @@ describe('NetworkConnectivityManager', () => {
expect((manager as any).netInfo).toBeNull();
expect((manager as any).appState).toBeNull();
expect((manager as any).currentPerformanceState).toBeNull();
expect((manager as any).performanceSuppressedUntilNormal).toBe(false);
expect((manager as any).suppressSlowPerformanceBanner).toBe(false);
expect((manager as any).isOnAppStart).toBe(true);
});
@ -560,16 +560,16 @@ describe('NetworkConnectivityManager', () => {
expect(mockBannerManager.showBanner).not.toHaveBeenCalled();
});
it('should reset performance suppression when performance returns to normal after manual dismiss', () => {
(manager as any).performanceSuppressedUntilNormal = true;
expect((manager as any).performanceSuppressedUntilNormal).toBe(true);
it('should reset suppression when reset is called', () => {
(manager as any).suppressSlowPerformanceBanner = true;
expect((manager as any).suppressSlowPerformanceBanner).toBe(true);
manager.updatePerformanceState('normal');
manager.reset();
expect((manager as any).performanceSuppressedUntilNormal).toBe(false);
expect((manager as any).suppressSlowPerformanceBanner).toBe(false);
});
it('should show reconnection banner when performance returns to normal', () => {
it('should hide banner when performance returns to normal', () => {
setupConnectedState(manager);
manager.updatePerformanceState('slow');
@ -580,8 +580,8 @@ describe('NetworkConnectivityManager', () => {
mockBannerManager.showBannerWithAutoHide.mockClear();
manager.updatePerformanceState('normal');
const reconnectionBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0];
expect((reconnectionBannerCall.customComponent as any).props.message).toBe('Connection restored');
expect(mockBannerManager.hideBanner).toHaveBeenCalled();
expect(mockBannerManager.showBannerWithAutoHide).not.toHaveBeenCalled();
});
it('should not hide banner if it is not a performance banner', () => {

View file

@ -61,9 +61,9 @@ function shouldShowConnectingBanner(
function shouldShowPerformanceBanner(
performanceState: NetworkPerformanceState | null,
performanceSuppressed: boolean,
suppressPerformanceBanner: boolean,
): boolean {
return performanceState === 'slow' && !performanceSuppressed;
return performanceState === 'slow' && !suppressPerformanceBanner;
}
function shouldShowReconnectionBanner(
@ -84,8 +84,7 @@ class NetworkConnectivityManagerSingleton {
private appState: string | null = null;
private readonly intl = getIntlShape();
private currentPerformanceState: NetworkPerformanceState | null = null;
private performanceSuppressedUntilNormal = false;
private suppressSlowPerformanceBanner = false;
private getConnectionMessage(): string {
if (!this.websocketState || !this.netInfo) {
@ -137,7 +136,7 @@ class NetworkConnectivityManagerSingleton {
id: NETWORK_STATUS_BANNER_ID,
dismissible: true,
onDismiss: () => {
this.performanceSuppressedUntilNormal = true;
this.suppressSlowPerformanceBanner = true;
},
customComponent: React.createElement(ConnectionBanner, {
isConnected: false,
@ -186,19 +185,32 @@ class NetworkConnectivityManagerSingleton {
updatePerformanceState(
performanceState: NetworkPerformanceState,
) {
const wasSlowPerformance = this.currentPerformanceState === 'slow';
this.currentPerformanceState = performanceState;
if (this.performanceSuppressedUntilNormal && performanceState === 'normal') {
this.performanceSuppressedUntilNormal = false;
}
// Performance state changes require special handling to avoid showing reconnection banners.
// We can't always call updateBanner() like updateState() does, because it would re-evaluate
// all banner conditions (including reconnection) when only performance has changed.
this.updateBanner();
// Show slow performance banner (suppression check here prevents unnecessary updateBanner calls.
// suppressSlowPerformanceBanner also checks suppression for calls coming from updateState).
if (performanceState === 'slow' && !this.suppressSlowPerformanceBanner) {
this.updateBanner();
} else if (wasSlowPerformance && performanceState === 'normal') {
BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID);
}
}
reset() {
this.suppressSlowPerformanceBanner = false;
}
cleanup() {
BannerManager.cleanup();
this.previousWebsocketState = null;
this.isOnAppStart = true;
this.reset();
}
shutdown() {
@ -208,7 +220,6 @@ class NetworkConnectivityManagerSingleton {
this.netInfo = null;
this.appState = null;
this.currentPerformanceState = null;
this.performanceSuppressedUntilNormal = false;
}
private updateBanner() {
@ -258,7 +269,7 @@ class NetworkConnectivityManagerSingleton {
}
private handlePerformanceState(): boolean {
if (shouldShowPerformanceBanner(this.currentPerformanceState, this.performanceSuppressedUntilNormal)) {
if (shouldShowPerformanceBanner(this.currentPerformanceState, this.suppressSlowPerformanceBanner)) {
this.showPerformance(PERFORMANCE_BANNER_DURATION);
return true;
}

View file

@ -47,6 +47,7 @@ jest.mock('./network_connectivity_manager', () => ({
updateState: jest.fn(),
updatePerformanceState: jest.fn(),
shutdown: jest.fn(),
reset: jest.fn(),
}));
jest.mock('./websocket_manager', () => ({
observeWebsocketState: jest.fn(),
@ -282,6 +283,32 @@ describe('NetworkConnectivitySubscriptionManager', () => {
expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://test.com');
});
it('should call updateState when websocket state changes', async () => {
let websocketCallback: ((state: string) => void) | undefined;
mockWebsocketManager.observeWebsocketState.mockReturnValue({
subscribe: jest.fn((callback: (state: string) => void) => {
websocketCallback = callback;
return mockWebsocketSubscription;
}),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}];
startNetworkConnectivitySubscriptions();
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
expect(websocketCallback).toBeDefined();
websocketCallback!('connected');
expect(mockNetworkConnectivityManager.updateState).toHaveBeenCalledWith(
'connected',
{isInternetReachable: null},
'active',
);
});
it('should unsubscribe from previous websocket subscription when servers change', async () => {
const mockServers1: MockServer[] = [{url: 'https://server1.com', lastActiveAt: 1000}];
const mockServers2: MockServer[] = [{url: 'https://server2.com', lastActiveAt: 2000}];
@ -299,6 +326,115 @@ describe('NetworkConnectivitySubscriptionManager', () => {
});
});
describe('performance subscription handling', () => {
it('should subscribe to performance state changes', async () => {
const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}];
startNetworkConnectivitySubscriptions();
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
expect(mockNetworkPerformanceManager.observePerformanceState).toHaveBeenCalledWith('https://test.com');
});
it('should call updatePerformanceState when performance state changes', async () => {
let performanceCallback: ((state: string) => void) | undefined;
mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({
subscribe: jest.fn((callback: (state: string) => void) => {
performanceCallback = callback;
return mockPerformanceSubscription;
}),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}];
startNetworkConnectivitySubscriptions();
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
expect(performanceCallback).toBeDefined();
performanceCallback!('slow');
expect(mockNetworkConnectivityManager.updatePerformanceState).toHaveBeenCalledWith('slow');
});
});
describe('edge cases', () => {
it('should find most recent server when first server is more recent', async () => {
const mockServers: MockServer[] = [
{url: 'https://server1.com', lastActiveAt: 2000},
{url: 'https://server2.com', lastActiveAt: 1000},
];
startNetworkConnectivitySubscriptions();
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
expect(mockNetworkConnectivityManager.setServerConnectionStatus).toHaveBeenCalledWith(true, 'https://server1.com');
expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server1.com');
});
it('should reinitialize when app becomes active after subscriptions were cleared', () => {
const mockAppStateListener = {remove: jest.fn()};
const mockNetInfoUnsubscriber = jest.fn();
const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()};
mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any
mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber);
mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any
startNetworkConnectivitySubscriptions();
const appStateCallback = mockAppState.addEventListener.mock.calls[0][1];
mockNetInfo.addEventListener.mockClear();
mockSubscribeActiveServers.mockClear();
appStateCallback('background');
expect(mockNetInfoUnsubscriber).toHaveBeenCalled();
expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled();
mockNetInfo.addEventListener.mockClear();
mockSubscribeActiveServers.mockClear();
appStateCallback('active');
expect(mockNetInfo.addEventListener).toHaveBeenCalled();
expect(mockSubscribeActiveServers).toHaveBeenCalled();
});
it('should not reinitialize when transitioning to active if subscriptions exist', async () => {
startNetworkConnectivitySubscriptions();
const appStateCallback = mockAppState.addEventListener.mock.calls[0][1];
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
await serversCallback([{url: 'https://test.com', lastActiveAt: 1000}] as any); // eslint-disable-line @typescript-eslint/no-explicit-any
mockNetInfo.addEventListener.mockClear();
mockSubscribeActiveServers.mockClear();
appStateCallback('active');
expect(mockNetInfo.addEventListener).not.toHaveBeenCalled();
expect(mockSubscribeActiveServers).not.toHaveBeenCalled();
});
it('should handle netInfo with undefined isInternetReachable', () => {
startNetworkConnectivitySubscriptions();
const netInfoCallback = mockNetInfo.addEventListener.mock.calls[0][0];
const mockNetInfoState = {} as MockNetInfoState;
netInfoCallback(mockNetInfoState as any); // eslint-disable-line @typescript-eslint/no-explicit-any
expect(mockNetInfo.addEventListener).toHaveBeenCalledWith(expect.any(Function));
});
});
describe('shutdownNetworkConnectivitySubscriptions', () => {
it('should clean up all subscriptions including AppState listener', async () => {
const mockAppStateListener = {remove: jest.fn()};

View file

@ -80,6 +80,7 @@ class NetworkConnectivitySubscriptionManagerSingleton {
this.init();
}
} else if (previousAppState === 'active') {
NetworkConnectivityManager.reset();
this.stop();
}
};

View file

@ -139,7 +139,7 @@ class NetworkManagerSingleton {
timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForRequest,
timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForResource,
waitsForConnectivity: managedConfig?.useVPN === 'true',
collectMetrics: LocalConfig.CollectNetworkMetrics || LocalConfig.MonitorNetworkPerformance,
collectMetrics: true,
},
headers,
};

View file

@ -1,6 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {of as of$} from 'rxjs';
import {testExports} from './network_performance_manager';
import type {ClientResponseMetrics} from '@mattermost/react-native-network-client';
@ -18,10 +22,18 @@ jest.mock('react-native', () => ({
},
}));
jest.mock('@queries/app/global', () => {
const {of: mockOf} = require('rxjs');
return {
observeLowConnectivityMonitor: jest.fn(() => mockOf(true)),
};
});
const {
NetworkPerformanceManagerSingleton,
REQUEST_OUTCOME_WINDOW_SIZE,
MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION,
calculatePerformanceStateFromOutcomes,
} = testExports;
@ -43,13 +55,13 @@ const createMockMetrics = (latency: number, size: number, compressedSize: number
describe('Pure Functions', () => {
describe('calculatePerformanceStateFromOutcomes', () => {
describe('initial detection', () => {
it('should return normal when not enough requests for initial detection', () => {
it('should return current state when not enough requests for initial detection', () => {
const outcomes = [
{timestamp: Date.now(), isSlow: true, wasEarlyDetection: false},
{timestamp: Date.now(), isSlow: true, wasEarlyDetection: false},
];
const state = calculatePerformanceStateFromOutcomes(outcomes, true);
const state = calculatePerformanceStateFromOutcomes(outcomes, true, 'normal');
expect(state).toBe('normal');
});
@ -60,7 +72,7 @@ describe('Pure Functions', () => {
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, true);
const state = calculatePerformanceStateFromOutcomes(outcomes, true, 'normal');
expect(state).toBe('slow');
});
@ -71,21 +83,24 @@ describe('Pure Functions', () => {
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, true);
const state = calculatePerformanceStateFromOutcomes(outcomes, true, 'normal');
expect(state).toBe('normal');
});
});
describe('subsequent detection', () => {
it('should return slow even with fewer requests than subsequent threshold', () => {
it('should return current state with fewer requests than subsequent threshold', () => {
const outcomes = Array.from({length: 6}, (_, i) => ({
timestamp: Date.now(),
isSlow: i < 5, // 5 out of 6 = 83%
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
expect(state).toBe('slow');
const slowState = calculatePerformanceStateFromOutcomes(outcomes, false, 'slow');
expect(slowState).toBe('slow');
const normalState = calculatePerformanceStateFromOutcomes(outcomes, false, 'normal');
expect(normalState).toBe('normal');
});
it('should return normal when slow percentage is below threshold', () => {
@ -95,7 +110,7 @@ describe('Pure Functions', () => {
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
const state = calculatePerformanceStateFromOutcomes(outcomes, false, 'slow');
expect(state).toBe('normal');
});
@ -106,7 +121,7 @@ describe('Pure Functions', () => {
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
const state = calculatePerformanceStateFromOutcomes(outcomes, false, 'normal');
expect(state).toBe('slow');
});
@ -117,7 +132,7 @@ describe('Pure Functions', () => {
wasEarlyDetection: false,
}));
const state = calculatePerformanceStateFromOutcomes(outcomes, false);
const state = calculatePerformanceStateFromOutcomes(outcomes, false, 'normal');
expect(state).toBe('slow');
});
});
@ -127,11 +142,17 @@ describe('Pure Functions', () => {
describe('NetworkPerformanceManager', () => {
let performanceManager: InstanceType<typeof NetworkPerformanceManagerSingleton>;
const serverUrl = 'https://test-server.com';
const {observeLowConnectivityMonitor} = require('@queries/app/global');
beforeEach(() => {
observeLowConnectivityMonitor.mockReturnValue(of$(true));
performanceManager = new NetworkPerformanceManagerSingleton();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('request tracking lifecycle', () => {
it('should start and complete request tracking', () => {
performanceManager.observePerformanceState(serverUrl);
@ -154,7 +175,7 @@ describe('NetworkPerformanceManager', () => {
performanceManager.observePerformanceState(serverUrl);
const url = '/api/v4/posts';
const slowMetrics = createMockMetrics(3000, 1000, 500);
const slowMetrics = createMockMetrics(1000, 1000, 500);
const requestId = performanceManager.startRequestTracking(serverUrl, url);
performanceManager.completeRequestTracking(serverUrl, requestId, slowMetrics);
@ -179,13 +200,13 @@ describe('NetworkPerformanceManager', () => {
describe('performance state calculation', () => {
it('should return normal when slow percentage is below threshold', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
states.push(state);
});
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500);
const metrics = createMockMetrics(i < 3 ? 1000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
@ -195,13 +216,13 @@ describe('NetworkPerformanceManager', () => {
it('should return slow when slow percentage meets threshold', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
states.push(state);
});
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 7 ? 3000 : 500, 1000, 500);
const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
@ -211,13 +232,13 @@ describe('NetworkPerformanceManager', () => {
it('should use initial detection threshold for first slow detection', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
states.push(state);
});
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500); // 3 out of 4 = 75%
const metrics = createMockMetrics(i < 3 ? 1000 : 500, 1000, 500); // 3 out of 4 = 75%
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
@ -225,28 +246,28 @@ describe('NetworkPerformanceManager', () => {
expect(states).toEqual(['normal', 'slow']);
});
it('should not return to normal when switching from initial to subsequent detection', () => {
it('should require minimum requests for subsequent detection after initial phase', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
states.push(state);
});
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500);
const metrics = createMockMetrics(1000, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
for (let i = 0; i < 4; i++) {
for (let i = 0; i < MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-extra-${i}`);
const metrics = createMockMetrics(i < 3 ? 3000 : 500, 1000, 500); // 3 more slow out of 4
const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
subscription.unsubscribe();
expect(states.filter((s) => s === 'normal')).toHaveLength(1);
expect(states).toEqual(['normal', 'slow']);
expect(states[0]).toBe('normal');
expect(states).toContain('slow');
});
});
@ -254,16 +275,16 @@ describe('NetworkPerformanceManager', () => {
it('should provide accurate request outcome statistics', () => {
performanceManager.observePerformanceState(serverUrl);
for (let i = 0; i < 20; i++) {
for (let i = 0; i < REQUEST_OUTCOME_WINDOW_SIZE; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 15 ? 3000 : 500, 1000, 500);
const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(20);
expect(stats.slowRequests).toBe(15);
expect(stats.slowPercentage).toBe(0.75);
expect(stats.totalRequests).toBe(REQUEST_OUTCOME_WINDOW_SIZE);
expect(stats.slowRequests).toBe(7);
expect(stats.slowPercentage).toBeCloseTo(7 / REQUEST_OUTCOME_WINDOW_SIZE);
expect(stats.earlyDetectionCount).toBe(0);
});
@ -296,7 +317,7 @@ describe('NetworkPerformanceManager', () => {
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl2, `/api/request-${i}`);
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl2, requestId, metrics);
}
@ -327,7 +348,7 @@ describe('NetworkPerformanceManager', () => {
performanceManager.removeServer(serverUrl);
const metrics = createMockMetrics(3000, 1000, 500);
const metrics = createMockMetrics(1000, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
@ -361,13 +382,13 @@ describe('NetworkPerformanceManager', () => {
describe('observable behavior', () => {
it('should emit state changes when performance changes', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
states.push(state);
});
for (let i = 0; i < 10; i++) {
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
const metrics = createMockMetrics(1000, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
@ -388,7 +409,7 @@ describe('NetworkPerformanceManager', () => {
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
@ -422,7 +443,7 @@ describe('NetworkPerformanceManager', () => {
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(3000, 1000, 500);
const metrics = createMockMetrics(1000, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
@ -444,10 +465,12 @@ describe('NetworkPerformanceManager', () => {
);
});
it('should not log when performance recovers from slow to normal', () => {
it('should log when performance recovers from slow to normal', () => {
performanceManager.observePerformanceState(serverUrl);
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(3000, 1000, 500);
const metrics = createMockMetrics(1000, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
@ -458,8 +481,21 @@ describe('NetworkPerformanceManager', () => {
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
expect(logDebug).not.toHaveBeenCalled();
expect(logDebug).toHaveBeenCalledWith(
`Network performance improved for ${serverUrl}: slow -> normal`,
expect.objectContaining({
totalRequests: expect.any(Number),
slowRequests: expect.any(Number),
slowPercentage: expect.any(String),
earlyDetectionCount: expect.any(Number),
lastOutcome: expect.objectContaining({
isSlow: expect.any(Boolean),
wasEarlyDetection: expect.any(Boolean),
}),
}),
);
});
});
describe('AppState monitoring', () => {
@ -493,13 +529,13 @@ describe('NetworkPerformanceManager', () => {
it('should reset performance state to normal when app becomes inactive', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => {
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
states.push(state);
});
for (let i = 0; i < 10; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500);
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
@ -533,4 +569,158 @@ describe('NetworkPerformanceManager', () => {
expect(mockRemove).toHaveBeenCalled();
});
});
describe('Low Connectivity Monitor Integration', () => {
it('should subscribe to observeLowConnectivityMonitor on construction', () => {
expect(observeLowConnectivityMonitor).toHaveBeenCalled();
});
it('should emit performance state changes when monitoring is enabled', () => {
const states: string[] = [];
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
states.push(state);
});
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(1000, 1000, 500);
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
}
subscription.unsubscribe();
expect(states).toEqual(['normal', 'slow']);
});
it('should not emit performance state changes when monitoring is disabled', () => {
observeLowConnectivityMonitor.mockReturnValue(of$(false));
const disabledManager = new NetworkPerformanceManagerSingleton();
const states: string[] = [];
const subscription = disabledManager.observePerformanceState(serverUrl).subscribe((state: string) => {
states.push(state);
});
for (let i = 0; i < 10; i++) {
const requestId = disabledManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
disabledManager.completeRequestTracking(serverUrl, requestId, metrics);
}
subscription.unsubscribe();
expect(states).toEqual([]);
disabledManager.destroy();
});
it('should still track requests and log when monitoring is disabled', () => {
const {logDebug} = require('@utils/log');
logDebug.mockClear();
observeLowConnectivityMonitor.mockReturnValue(of$(false));
const disabledManager = new NetworkPerformanceManagerSingleton();
disabledManager.observePerformanceState(serverUrl);
for (let i = 0; i < 10; i++) {
const requestId = disabledManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
disabledManager.completeRequestTracking(serverUrl, requestId, metrics);
}
const stats = disabledManager.getRequestOutcomeStats(serverUrl);
expect(stats.totalRequests).toBe(10);
expect(stats.slowRequests).toBe(8);
expect(logDebug).toHaveBeenCalledWith(
`Network performance degraded for ${serverUrl}: normal -> slow`,
expect.any(Object),
);
disabledManager.destroy();
});
it('should clean up monitor subscription on destroy', () => {
const mockUnsubscribe = jest.fn();
observeLowConnectivityMonitor.mockReturnValue({
subscribe: jest.fn(() => ({
unsubscribe: mockUnsubscribe,
})),
});
const manager = new NetworkPerformanceManagerSingleton();
manager.destroy();
expect(mockUnsubscribe).toHaveBeenCalled();
});
it('should handle monitor state changing from enabled to disabled', () => {
const {BehaviorSubject} = require('rxjs');
const monitorSubject = new BehaviorSubject(true);
observeLowConnectivityMonitor.mockReturnValue(monitorSubject.asObservable());
const dynamicManager = new NetworkPerformanceManagerSingleton();
const states: string[] = [];
const subscription = dynamicManager.observePerformanceState(serverUrl).subscribe((state: string) => {
states.push(state);
});
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(1000, 1000, 500);
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
}
expect(states).toEqual(['normal', 'slow']);
monitorSubject.next(false);
states.length = 0;
for (let i = 0; i < 5; i++) {
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-extra-${i}`);
const metrics = createMockMetrics(500, 1000, 500);
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
}
expect(states).toEqual([]);
subscription.unsubscribe();
dynamicManager.destroy();
});
it('should handle monitor state changing from disabled to enabled', () => {
const {BehaviorSubject} = require('rxjs');
const monitorSubject = new BehaviorSubject(false);
observeLowConnectivityMonitor.mockReturnValue(monitorSubject.asObservable());
const dynamicManager = new NetworkPerformanceManagerSingleton();
const states: string[] = [];
const subscription = dynamicManager.observePerformanceState(serverUrl).subscribe((state: string) => {
states.push(state);
});
for (let i = 0; i < 5; i++) {
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(i < 4 ? 1000 : 500, 1000, 500);
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
}
expect(states).toEqual([]);
monitorSubject.next(true);
for (let i = 5; i < 10; i++) {
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`);
const metrics = createMockMetrics(500, 1000, 500);
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
}
expect(states.length).toBeGreaterThan(0);
subscription.unsubscribe();
dynamicManager.destroy();
});
});
});

View file

@ -1,10 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppState, type AppStateStatus} from 'react-native';
import {BehaviorSubject} from 'rxjs';
import {distinctUntilChanged} from 'rxjs/operators';
import {AppState, type AppStateStatus, type NativeEventSubscription} from 'react-native';
import {BehaviorSubject, type Subscription} from 'rxjs';
import {distinctUntilChanged, filter} from 'rxjs/operators';
import {observeLowConnectivityMonitor} from '@queries/app/global';
import {logDebug} from '@utils/log';
import type {ClientResponseMetrics} from '@mattermost/react-native-network-client';
@ -24,21 +25,23 @@ interface RequestOutcome {
wasEarlyDetection: boolean;
}
const SLOW_REQUEST_THRESHOLD = 2000;
const SLOW_REQUEST_THRESHOLD = 800;
const EARLY_DETECTION_SLOW_THRESHOLD = 2000;
const SLOW_REQUEST_PERCENTAGE_THRESHOLD = 0.7;
const REQUEST_OUTCOME_WINDOW_SIZE = 20;
const MINIMUM_REQUESTS_FOR_INITIAL_DETECTION = 4;
const MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION = 10;
const calculatePerformanceStateFromOutcomes = (outcomes: RequestOutcome[], isInitialDetection: boolean): NetworkPerformanceState => {
const calculatePerformanceStateFromOutcomes = (outcomes: RequestOutcome[], isInitialDetection: boolean, currentState: NetworkPerformanceState): NetworkPerformanceState => {
const minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION;
if (isInitialDetection && outcomes.length < minimumRequests) {
return 'normal';
if (outcomes.length < minimumRequests) {
return currentState;
}
const slowRequestCount = outcomes.filter((outcome) => outcome.isSlow).length;
const slowPercentage = slowRequestCount / outcomes.length;
const recentOutcomes = outcomes.slice(-minimumRequests);
const slowRequestCount = recentOutcomes.filter((outcome) => outcome.isSlow).length;
const slowPercentage = slowRequestCount / recentOutcomes.length;
return slowPercentage >= SLOW_REQUEST_PERCENTAGE_THRESHOLD ? 'slow' : 'normal';
};
@ -47,12 +50,16 @@ class NetworkPerformanceManagerSingleton {
private performanceSubjects: Record<string, BehaviorSubject<NetworkPerformanceState>> = {};
private activeRequests: Record<string, Record<string, ActiveRequest>> = {};
private requestOutcomes: Record<string, RequestOutcome[]> = {};
private totalRequestCount: Record<string, number> = {};
private initialRequestTimestamp: Record<string, number> = {};
private isInitialDetection: Record<string, boolean> = {};
private appStateSubscription: any = null;
private appStateSubscription: NativeEventSubscription | null = null;
private lowConnectivityMonitorEnabled = true;
private monitorSubscription: Subscription | null = null;
constructor() {
this.setupAppStateMonitoring();
this.setupMonitorObserver();
}
/**
@ -73,7 +80,7 @@ class NetworkPerformanceManagerSingleton {
request.checkTimer = setTimeout(() => {
this.checkRequestLatency(serverUrl, requestId);
}, SLOW_REQUEST_THRESHOLD);
}, EARLY_DETECTION_SLOW_THRESHOLD);
this.activeRequests[serverUrl][requestId] = request;
return requestId;
@ -110,9 +117,11 @@ class NetworkPerformanceManagerSingleton {
/**
* Returns an observable that emits network performance state changes.
* Emits 'normal' or 'slow' based on current performance metrics.
* Only emits when low connectivity monitoring is enabled.
*/
public observePerformanceState = (serverUrl: string) => {
return this.getPerformanceSubject(serverUrl).asObservable().pipe(
filter(() => this.lowConnectivityMonitorEnabled),
distinctUntilChanged(),
);
};
@ -127,8 +136,15 @@ class NetworkPerformanceManagerSingleton {
/**
* Gets the current request outcome statistics for a server.
*/
public getRequestOutcomeStats = (serverUrl: string) => {
const outcomes = this.requestOutcomes[serverUrl] || [];
public getRequestOutcomeStats = (serverUrl: string, useRecentOnly = false) => {
let outcomes = this.requestOutcomes[serverUrl] || [];
if (useRecentOnly && outcomes.length > 0) {
const isInitialDetection = !this.isInitialDetection[serverUrl];
const minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION;
outcomes = outcomes.slice(-minimumRequests);
}
if (!outcomes.length) {
return {
totalRequests: 0,
@ -166,6 +182,7 @@ class NetworkPerformanceManagerSingleton {
delete this.performanceSubjects[serverUrl];
}
delete this.requestOutcomes[serverUrl];
delete this.totalRequestCount[serverUrl];
delete this.initialRequestTimestamp[serverUrl];
delete this.isInitialDetection[serverUrl];
};
@ -179,6 +196,11 @@ class NetworkPerformanceManagerSingleton {
this.appStateSubscription = null;
}
if (this.monitorSubscription) {
this.monitorSubscription.unsubscribe();
this.monitorSubscription = null;
}
Object.keys(this.activeRequests).forEach((serverUrl) => {
this.clearAllActiveRequests(serverUrl);
});
@ -189,6 +211,7 @@ class NetworkPerformanceManagerSingleton {
this.performanceSubjects = {};
this.requestOutcomes = {};
this.totalRequestCount = {};
this.initialRequestTimestamp = {};
this.isInitialDetection = {};
};
@ -202,7 +225,7 @@ class NetworkPerformanceManagerSingleton {
const currentTime = Date.now();
const elapsedTime = currentTime - activeRequest.startTime;
if (elapsedTime >= SLOW_REQUEST_THRESHOLD) {
if (elapsedTime >= EARLY_DETECTION_SLOW_THRESHOLD) {
this.recordRequestOutcome(serverUrl, {
timestamp: currentTime,
isSlow: true,
@ -244,11 +267,16 @@ class NetworkPerformanceManagerSingleton {
this.requestOutcomes[serverUrl] = [];
}
if (!this.totalRequestCount[serverUrl]) {
this.totalRequestCount[serverUrl] = 0;
}
if (!this.initialRequestTimestamp[serverUrl] && outcome.isSlow) {
this.initialRequestTimestamp[serverUrl] = outcome.timestamp;
}
this.requestOutcomes[serverUrl].push(outcome);
this.totalRequestCount[serverUrl]++;
if (this.requestOutcomes[serverUrl].length > REQUEST_OUTCOME_WINDOW_SIZE) {
this.requestOutcomes[serverUrl] = this.requestOutcomes[serverUrl].slice(-REQUEST_OUTCOME_WINDOW_SIZE);
@ -257,28 +285,34 @@ class NetworkPerformanceManagerSingleton {
const outcomes = this.requestOutcomes[serverUrl];
const currentPerformanceState = this.getCurrentPerformanceState(serverUrl);
const isInitialDetection = !this.isInitialDetection[serverUrl];
const newPerformanceState = calculatePerformanceStateFromOutcomes(outcomes, isInitialDetection);
const newPerformanceState = calculatePerformanceStateFromOutcomes(outcomes, isInitialDetection, currentPerformanceState);
if (currentPerformanceState !== newPerformanceState && newPerformanceState === 'slow') {
const stats = this.getRequestOutcomeStats(serverUrl);
const detectionDelayMs = outcome.timestamp - this.initialRequestTimestamp[serverUrl];
if (currentPerformanceState !== newPerformanceState) {
const stats = this.getRequestOutcomeStats(serverUrl, true);
const statusChange = newPerformanceState === 'slow' ? 'degraded' : 'improved';
logDebug(`Network performance degraded for ${serverUrl}: ${currentPerformanceState} -> ${newPerformanceState}`, {
const logData: Record<string, unknown> = {
totalRequests: stats.totalRequests,
slowRequests: stats.slowRequests,
slowPercentage: `${(stats.slowPercentage * 100).toFixed(1)}%`,
earlyDetectionCount: stats.earlyDetectionCount,
currentTimestamp: Date.now(),
detectionDelayMs,
detectionDelaySeconds: `${(detectionDelayMs / 1000).toFixed(1)}s`,
lastOutcome: {
isSlow: outcome.isSlow,
wasEarlyDetection: outcome.wasEarlyDetection,
},
});
};
this.isInitialDetection[serverUrl] = true;
delete this.initialRequestTimestamp[serverUrl];
if (newPerformanceState === 'slow') {
const detectionDelayMs = outcome.timestamp - this.initialRequestTimestamp[serverUrl];
logData.currentTimestamp = Date.now();
logData.detectionDelayMs = detectionDelayMs;
logData.detectionDelaySeconds = `${(detectionDelayMs / 1000).toFixed(1)}s`;
this.isInitialDetection[serverUrl] = true;
delete this.initialRequestTimestamp[serverUrl];
}
logDebug(`Network performance ${statusChange} for ${serverUrl}: ${currentPerformanceState} -> ${newPerformanceState}`, logData);
}
this.getPerformanceSubject(serverUrl).next(newPerformanceState);
@ -295,6 +329,12 @@ class NetworkPerformanceManagerSingleton {
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
};
private setupMonitorObserver = () => {
this.monitorSubscription = observeLowConnectivityMonitor().subscribe((enabled) => {
this.lowConnectivityMonitorEnabled = enabled;
});
};
private handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState !== 'active') {
this.cleanupOnAppStateChange();
@ -315,6 +355,7 @@ class NetworkPerformanceManagerSingleton {
export const testExports = {
NetworkPerformanceManagerSingleton,
SLOW_REQUEST_THRESHOLD,
EARLY_DETECTION_SLOW_THRESHOLD,
REQUEST_OUTCOME_WINDOW_SIZE,
MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION,

View file

@ -0,0 +1,169 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {of as of$} from 'rxjs';
import DatabaseManager from '@database/manager';
import {observeLowConnectivityMonitor} from './global';
import type {AppDatabase} from '@typings/database/database';
jest.mock('@database/manager', () => ({
getAppDatabaseAndOperator: jest.fn(),
}));
describe('observeLowConnectivityMonitor', () => {
const mockDatabase = {
get: jest.fn(),
};
const mockQuery = {
query: jest.fn(),
observe: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(DatabaseManager.getAppDatabaseAndOperator).mockReturnValue({
database: mockDatabase,
operator: {},
} as unknown as AppDatabase);
});
it('should return observable that defaults to true when no database record exists', (done) => {
mockDatabase.get.mockReturnValue({
query: jest.fn().mockReturnValue(mockQuery),
});
mockQuery.observe.mockReturnValue(of$([]));
const observable = observeLowConnectivityMonitor();
observable.subscribe((value) => {
expect(value).toBe(true);
done();
});
});
it('should return observable with database value when record exists', (done) => {
const mockRecord = {
observe: jest.fn().mockReturnValue(of$({value: false})),
};
mockDatabase.get.mockReturnValue({
query: jest.fn().mockReturnValue(mockQuery),
});
mockQuery.observe.mockReturnValue(of$([mockRecord]));
const observable = observeLowConnectivityMonitor();
observable.subscribe((value) => {
expect(value).toBe(false);
done();
});
});
it('should handle boolean value directly', (done) => {
const mockRecord = {
observe: jest.fn().mockReturnValue(of$(true)),
};
mockDatabase.get.mockReturnValue({
query: jest.fn().mockReturnValue(mockQuery),
});
mockQuery.observe.mockReturnValue(of$([mockRecord]));
const observable = observeLowConnectivityMonitor();
observable.subscribe((value) => {
expect(value).toBe(true);
done();
});
});
it('should handle false boolean value directly', (done) => {
const mockRecord = {
observe: jest.fn().mockReturnValue(of$(false)),
};
mockDatabase.get.mockReturnValue({
query: jest.fn().mockReturnValue(mockQuery),
});
mockQuery.observe.mockReturnValue(of$([mockRecord]));
const observable = observeLowConnectivityMonitor();
observable.subscribe((value) => {
expect(value).toBe(false);
done();
});
});
it('should default to true when record value is undefined', (done) => {
const mockRecord = {
observe: jest.fn().mockReturnValue(of$({value: undefined})),
};
mockDatabase.get.mockReturnValue({
query: jest.fn().mockReturnValue(mockQuery),
});
mockQuery.observe.mockReturnValue(of$([mockRecord]));
const observable = observeLowConnectivityMonitor();
observable.subscribe((value) => {
expect(value).toBe(true);
done();
});
});
it('should default to true when query returns null', (done) => {
jest.mocked(DatabaseManager.getAppDatabaseAndOperator).mockImplementation(() => {
throw new Error('Database not found');
});
const observable = observeLowConnectivityMonitor();
observable.subscribe((value) => {
expect(value).toBe(true);
done();
});
});
it('should handle enabled state (true)', (done) => {
const mockRecord = {
observe: jest.fn().mockReturnValue(of$({value: true})),
};
mockDatabase.get.mockReturnValue({
query: jest.fn().mockReturnValue(mockQuery),
});
mockQuery.observe.mockReturnValue(of$([mockRecord]));
const observable = observeLowConnectivityMonitor();
observable.subscribe((value) => {
expect(value).toBe(true);
done();
});
});
it('should handle disabled state (false)', (done) => {
const mockRecord = {
observe: jest.fn().mockReturnValue(of$({value: false})),
};
mockDatabase.get.mockReturnValue({
query: jest.fn().mockReturnValue(mockQuery),
});
mockQuery.observe.mockReturnValue(of$([mockRecord]));
const observable = observeLowConnectivityMonitor();
observable.subscribe((value) => {
expect(value).toBe(false);
done();
});
});
});

View file

@ -100,3 +100,19 @@ export const observeTutorialWatched = (tutorial: string) => {
switchMap((v) => of$(Boolean(v))),
);
};
export const observeLowConnectivityMonitor = () => {
const query = queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR);
if (!query) {
return of$(true);
}
return query.observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(true))),
switchMap((v) => {
if (typeof v === 'boolean') {
return of$(v);
}
return of$(v?.value ?? true);
}),
);
};

View file

@ -0,0 +1,464 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, screen, waitFor} from '@testing-library/react-native';
import React from 'react';
import {Alert} from 'react-native';
import {storeLowConnectivityMonitor} from '@actions/app/global';
import {Screens} from '@constants';
import {goToScreen} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import {deleteFileCache, getAllFilesInCachesDirectory} from '@utils/file';
import AdvancedSettings from './advanced';
import type {FileInfo} from 'expo-file-system';
jest.mock('@actions/app/global');
jest.mock('@utils/file');
jest.mock('@screens/navigation');
jest.mock('@hooks/android_back_handler', () => jest.fn());
jest.mock('@context/server', () => ({
useServerUrl: jest.fn(() => 'https://test.mattermost.com'),
}));
jest.mock('@hooks/utils', () => ({
usePreventDoubleTap: jest.fn((callback) => callback),
}));
const mockStoreLowConnectivityMonitor = storeLowConnectivityMonitor as jest.Mock;
const mockGetAllFilesInCachesDirectory = getAllFilesInCachesDirectory as jest.Mock;
const mockDeleteFileCache = deleteFileCache as jest.Mock;
const mockGoToScreen = goToScreen as jest.Mock;
describe('AdvancedSettings', () => {
const defaultProps = {
componentId: 'advanced-settings-screen' as const,
isDevMode: false,
lowConnectivityMonitorEnabled: false,
};
const mockFiles: FileInfo[] = [
{
uri: 'file:///cache/file1.jpg',
size: 1024 * 1024,
modificationTime: Date.now() / 1000,
isDirectory: false,
exists: true,
md5: 'abc123',
},
{
uri: 'file:///cache/file2.png',
size: 2048 * 1024,
modificationTime: Date.now() / 1000,
isDirectory: false,
exists: true,
md5: 'def456',
},
];
beforeEach(() => {
jest.clearAllMocks();
mockGetAllFilesInCachesDirectory.mockResolvedValue({
totalSize: 3072 * 1024,
files: mockFiles,
});
Alert.alert = jest.fn();
});
describe('rendering', () => {
it('should render advanced settings container', async () => {
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.screen')).toBeTruthy();
});
});
it('should render delete local files option', async () => {
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
});
});
it('should render low connectivity monitor toggle', async () => {
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option')).toBeTruthy();
});
});
it('should render component library option in dev mode', async () => {
renderWithIntlAndTheme(
<AdvancedSettings
{...defaultProps}
isDevMode={true}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.component_library.option')).toBeTruthy();
});
});
it('should not render component library option when not in dev mode', async () => {
renderWithIntlAndTheme(
<AdvancedSettings
{...defaultProps}
isDevMode={false}
/>,
);
await waitFor(() => {
expect(screen.queryByTestId('advanced_settings.component_library.option')).toBeNull();
});
});
it('should display file size for delete option', async () => {
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
expect(deleteOption).toBeTruthy();
});
});
it('should handle empty cache gracefully', async () => {
mockGetAllFilesInCachesDirectory.mockResolvedValue({
totalSize: 0,
files: [],
});
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.screen')).toBeTruthy();
});
});
});
describe('delete local files', () => {
it('should show confirmation alert when delete button is pressed with files', async () => {
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
});
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
fireEvent.press(deleteOption);
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
'Delete local files',
'\nThis will delete all files downloaded through the app for this server. Please confirm to proceed.\n',
[
{text: 'Cancel', style: 'cancel'},
{
text: 'Delete',
style: 'destructive',
onPress: expect.any(Function),
},
],
{cancelable: false},
);
});
});
it('should delete files when user confirms', async () => {
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
});
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
fireEvent.press(deleteOption);
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalled();
});
const alertCalls = (Alert.alert as jest.Mock).mock.calls;
const deleteCallback = alertCalls[0][2][1].onPress;
await deleteCallback();
await waitFor(() => {
expect(mockDeleteFileCache).toHaveBeenCalledWith('https://test.mattermost.com');
expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalledTimes(2);
});
});
it('should not show alert when no files exist', async () => {
mockGetAllFilesInCachesDirectory.mockResolvedValue({
totalSize: 0,
files: [],
});
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
});
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
fireEvent.press(deleteOption);
await waitFor(() => {
expect(Alert.alert).not.toHaveBeenCalled();
});
});
it('should handle delete error gracefully', async () => {
mockDeleteFileCache.mockResolvedValue(undefined);
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
});
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
fireEvent.press(deleteOption);
expect(Alert.alert).toHaveBeenCalled();
});
});
describe('component library navigation', () => {
it('should navigate to component library when option is pressed', async () => {
renderWithIntlAndTheme(
<AdvancedSettings
{...defaultProps}
isDevMode={true}
/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.component_library.option')).toBeTruthy();
});
const componentLibraryOption = screen.getByTestId('advanced_settings.component_library.option');
fireEvent.press(componentLibraryOption);
await waitFor(() => {
expect(mockGoToScreen).toHaveBeenCalledWith(
Screens.COMPONENT_LIBRARY,
'Component library',
);
});
});
it('should not navigate when component library is not rendered', async () => {
renderWithIntlAndTheme(
<AdvancedSettings
{...defaultProps}
isDevMode={false}
/>);
await waitFor(() => {
expect(screen.queryByTestId('advanced_settings.component_library.option')).toBeNull();
});
expect(mockGoToScreen).not.toHaveBeenCalled();
});
});
describe('low connectivity monitor toggle', () => {
it('should render toggle in off state when disabled', async () => {
renderWithIntlAndTheme(
<AdvancedSettings
{...defaultProps}
lowConnectivityMonitorEnabled={false}
/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button')).toBeTruthy();
});
});
it('should render toggle in on state when enabled', async () => {
renderWithIntlAndTheme(
<AdvancedSettings
{...defaultProps}
lowConnectivityMonitorEnabled={true}
/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button')).toBeTruthy();
});
});
it('should call storeLowConnectivityMonitor when toggled on', async () => {
mockStoreLowConnectivityMonitor.mockResolvedValue({});
renderWithIntlAndTheme(
<AdvancedSettings
{...defaultProps}
lowConnectivityMonitorEnabled={false}
/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button')).toBeTruthy();
});
const toggle = screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button');
fireEvent(toggle, 'valueChange', true);
await waitFor(() => {
expect(mockStoreLowConnectivityMonitor).toHaveBeenCalledWith(true);
});
});
it('should call storeLowConnectivityMonitor when toggled off', async () => {
mockStoreLowConnectivityMonitor.mockResolvedValue({});
renderWithIntlAndTheme(
<AdvancedSettings
{...defaultProps}
lowConnectivityMonitorEnabled={true}
/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button')).toBeTruthy();
});
const toggle = screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button');
fireEvent(toggle, 'valueChange', false);
await waitFor(() => {
expect(mockStoreLowConnectivityMonitor).toHaveBeenCalledWith(false);
});
});
it('should update local state when toggle is changed', async () => {
mockStoreLowConnectivityMonitor.mockResolvedValue({});
renderWithIntlAndTheme(
<AdvancedSettings
{...defaultProps}
lowConnectivityMonitorEnabled={false}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button')).toBeTruthy();
});
const toggle = screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.false.button');
fireEvent(toggle, 'valueChange', true);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option.toggled.true.button')).toBeTruthy();
});
});
});
describe('data fetching', () => {
it('should fetch cached files on mount', async () => {
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalledWith('https://test.mattermost.com');
});
});
it('should handle fetch error gracefully', async () => {
mockGetAllFilesInCachesDirectory.mockRejectedValue(new Error('Fetch failed'));
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalled();
});
});
it('should refetch files after deletion', async () => {
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
});
expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalledTimes(1);
const deleteOption = screen.getByTestId('advanced_settings.delete_data.option');
fireEvent.press(deleteOption);
const alertCalls = (Alert.alert as jest.Mock).mock.calls;
const deleteCallback = alertCalls[0][2][1].onPress;
await deleteCallback();
await waitFor(() => {
expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalledTimes(2);
});
});
});
describe('edge cases', () => {
it('should handle undefined totalSize gracefully', async () => {
mockGetAllFilesInCachesDirectory.mockResolvedValue({
totalSize: undefined,
files: [],
});
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.screen')).toBeTruthy();
});
});
it('should handle null files gracefully', async () => {
mockGetAllFilesInCachesDirectory.mockResolvedValue({
totalSize: 0,
files: null,
});
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.screen')).toBeTruthy();
});
});
it('should handle very large file sizes', async () => {
mockGetAllFilesInCachesDirectory.mockResolvedValue({
totalSize: 5 * 1024 * 1024 * 1024,
files: [],
});
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy();
});
});
it('should render experimental features section header', async () => {
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByText('Experimental Features')).toBeTruthy();
});
});
it('should display low connectivity monitor description', async () => {
renderWithIntlAndTheme(<AdvancedSettings {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByText('Display banners when network connectivity or performance issues are detected')).toBeTruthy();
});
});
});
});

View file

@ -2,9 +2,11 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {defineMessage, useIntl} from 'react-intl';
import {Alert, TouchableOpacity} from 'react-native';
import {storeLowConnectivityMonitor} from '@actions/app/global';
import SettingBlock from '@components/settings/block';
import SettingContainer from '@components/settings/container';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
@ -23,15 +25,18 @@ const EMPTY_FILES: FileInfo[] = [];
type AdvancedSettingsProps = {
componentId: AvailableScreens;
isDevMode: boolean;
lowConnectivityMonitorEnabled: boolean;
};
const AdvancedSettings = ({
componentId,
isDevMode,
lowConnectivityMonitorEnabled,
}: AdvancedSettingsProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const [dataSize, setDataSize] = useState<number | undefined>(0);
const [files, setFiles] = useState<FileInfo[]>(EMPTY_FILES);
const [isLowConnectivityMonitorEnabled, setIsLowConnectivityMonitorEnabled] = useState(lowConnectivityMonitorEnabled);
const getAllCachedFiles = useCallback(async () => {
const {totalSize = 0, files: cachedFiles} = await getAllFilesInCachesDirectory(serverUrl);
@ -76,9 +81,14 @@ const AdvancedSettings = ({
goToScreen(screen, title);
}, [intl]);
const onToggleLowConnectivityMonitor = useCallback(async (value: boolean) => {
setIsLowConnectivityMonitorEnabled(value);
await storeLowConnectivityMonitor(value);
}, []);
useEffect(() => {
getAllCachedFiles();
}, []);
}, [getAllCachedFiles]);
const close = useCallback(() => {
popTopScreen(componentId);
@ -114,9 +124,30 @@ const AdvancedSettings = ({
testID='advanced_settings.component_library.option'
type='none'
/>
<SettingSeparator/>
{/* <SettingSeparator/> */}
</TouchableOpacity>
)}
<SettingBlock
headerText={defineMessage({
id: 'settings.advanced.experimental_features',
defaultMessage: 'Experimental Features',
})}
>
<SettingSeparator/>
<SettingOption
action={onToggleLowConnectivityMonitor}
label={intl.formatMessage({id: 'settings.advanced.low_connectivity_monitor', defaultMessage: 'Low Connectivity Monitor'})}
description={intl.formatMessage({
id: 'settings.advanced.low_connectivity_monitor.description',
defaultMessage: 'Display banners when network connectivity or performance issues are detected',
})}
selected={isLowConnectivityMonitorEnabled}
testID='advanced_settings.low_connectivity_monitor.option'
type='toggle'
/>
<SettingSeparator/>
</SettingBlock>
</SettingContainer>
);
};

View file

@ -3,6 +3,7 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeLowConnectivityMonitor} from '@queries/app/global';
import {observeConfigBooleanValue} from '@queries/servers/system';
import AdvancedSettings from './advanced';
@ -12,6 +13,7 @@ import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
isDevMode: observeConfigBooleanValue(database, 'EnableDeveloper', false),
lowConnectivityMonitorEnabled: observeLowConnectivityMonitor(),
};
});

View file

@ -32,3 +32,5 @@
"CollectNetworkMetrics": false,
"MonitorNetworkPerformance": true
}

View file

@ -1263,6 +1263,9 @@
"settings.advanced.delete": "Delete",
"settings.advanced.delete_data": "Delete local files",
"settings.advanced.delete_message.confirmation": "\nThis will delete all files downloaded through the app for this server. Please confirm to proceed.\n",
"settings.advanced.experimental_features": "Experimental Features",
"settings.advanced.low_connectivity_monitor": "Low Connectivity Monitor",
"settings.advanced.low_connectivity_monitor.description": "Display banners when network connectivity or performance issues are detected",
"settings.display": "Display",
"settings.notice_mobile_link": "mobile apps",
"settings.notice_platform_link": "server",

View file

@ -522,50 +522,113 @@ updateState(websocketState, netInfo, appState) {
## Configuration
### Config Flags
### User Preference
#### `assets/base/config.json`
```json
{
"CollectNetworkMetrics": false,
"MonitorNetworkPerformance": true
}
**Low Connectivity Monitor** (User Setting):
- Accessible in: Settings → Advanced Settings → Experimental Features
- Controls real-time performance monitoring and banner display
- Enables early detection and network performance tracking
- **Independent** from `CollectNetworkMetrics`
- Stored in app-level database via `GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR`
- Default: **Enabled** (true)
- Observable via `observeLowConnectivityMonitor()` from `@queries/app/global`
#### Implementation Details
**Actions** (`app/actions/app/global.ts`):
```typescript
export const storeLowConnectivityMonitor = async (enabled: boolean) => {
return storeGlobal(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR, enabled, false);
};
```
**`CollectNetworkMetrics`** (Existing):
- Controls telemetry/analytics collection
- Used by `PerformanceMetricsManager`
- Tracks request groups, parallel requests, etc.
**`MonitorNetworkPerformance`** (New):
- Controls real-time performance monitoring
- Used by `NetworkPerformanceManager`
- Enables early detection and banner display
- **Independent** from `CollectNetworkMetrics`
**Queries** (`app/queries/app/global.ts`):
```typescript
export const observeLowConnectivityMonitor = () => {
const query = queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR);
if (!query) {
return of$(true); // Default to enabled
}
return query.observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(true))),
switchMap((v) => {
if (typeof v === 'boolean') {
return of$(v);
}
return of$(v?.value ?? true);
}),
);
};
```
### Integration in ClientTracking
```typescript
// Network metrics collection (existing)
class ClientTracking {
private lowConnectivityMonitorEnabled = true;
constructor(apiClient: APIClientInterface) {
this.apiClient = apiClient;
// Subscribe to user preference changes
observeLowConnectivityMonitor().subscribe((enabled) => {
this.lowConnectivityMonitorEnabled = enabled;
});
}
startNetworkPerformanceTracking(url: string): string | undefined {
if (!this.lowConnectivityMonitorEnabled) {
return undefined;
}
return NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url);
}
completeNetworkPerformanceTracking(
requestId: string | undefined,
url: string,
metrics: ClientResponseMetrics | undefined
) {
if (!this.lowConnectivityMonitorEnabled || !requestId || !metrics) {
return;
}
NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, requestId, metrics);
}
cancelNetworkPerformanceTracking(requestId: string | undefined) {
if (!this.lowConnectivityMonitorEnabled || !requestId) {
return;
}
NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, requestId);
}
}
```
**Network metrics collection** (existing - for telemetry):
```typescript
if (groupLabel && CollectNetworkMetrics) {
this.incrementRequestCount(groupLabel);
this.trackRequest(groupLabel, url, response.metrics);
this.decrementRequestCount(groupLabel);
}
// Performance monitoring (new)
if (MonitorNetworkPerformance) {
const requestId = NetworkPerformanceManager.startRequestTracking(baseUrl, url);
try {
response = await request(url, options);
NetworkPerformanceManager.completeRequestTracking(baseUrl, requestId, response.metrics);
} catch (error) {
NetworkPerformanceManager.cancelRequestTracking(baseUrl, requestId);
}
}
```
### Network Manager Configuration
**Metrics Collection** (`app/managers/network_manager.ts`):
```typescript
const config = {
sessionConfiguration: {
timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForRequest,
timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForResource,
waitsForConnectivity: managedConfig?.useVPN === 'true',
collectMetrics: true, // Always enabled to support Low Connectivity Monitor
},
headers,
};
```
**Note**: `collectMetrics` is now always set to `true` to ensure network metrics are available for the Low Connectivity Monitor. The actual usage of these metrics is controlled dynamically by the user's Low Connectivity Monitor preference in `ClientTracking`.
---
## Lifecycle Management