From c872e2e2b17e003d969491a37af9304f9180d253 Mon Sep 17 00:00:00 2001 From: Rahim Rahman Date: Sun, 12 Oct 2025 23:21:11 -0600 Subject: [PATCH] feat(MM-65625): low connectivity feature toggle in advance settings (#9191) --- app/actions/app/global.test.ts | 15 + app/actions/app/global.ts | 4 + app/actions/remote/entry/common.test.ts | 8 +- app/client/rest/tracking.test.ts | 28 +- app/client/rest/tracking.ts | 9 +- app/components/option_item/index.tsx | 6 +- app/constants/database.ts | 3 +- app/init/launch.test.ts | 10 +- .../network_connectivity_manager.test.ts | 18 +- app/managers/network_connectivity_manager.ts | 33 +- ..._connectivity_subscription_manager.test.ts | 136 +++++ ...twork_connectivity_subscription_manager.ts | 1 + app/managers/network_manager.ts | 2 +- .../network_performance_manager.test.ts | 272 ++++++++-- app/managers/network_performance_manager.ts | 91 +++- app/queries/app/global.test.ts | 169 +++++++ app/queries/app/global.ts | 16 + .../settings/advanced/advanced.test.tsx | 464 ++++++++++++++++++ app/screens/settings/advanced/advanced.tsx | 37 +- app/screens/settings/advanced/index.ts | 2 + assets/base/config.json | 2 + assets/base/i18n/en.json | 3 + docs/network-connectivity-observation.md | 123 +++-- 23 files changed, 1294 insertions(+), 158 deletions(-) create mode 100644 app/queries/app/global.test.ts create mode 100644 app/screens/settings/advanced/advanced.test.tsx diff --git a/app/actions/app/global.test.ts b/app/actions/app/global.test.ts index 911c2e37a..07c4edf0e 100644 --- a/app/actions/app/global.test.ts +++ b/app/actions/app/global.test.ts @@ -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); + }); }); diff --git a/app/actions/app/global.ts b/app/actions/app/global.ts index 8f47d6466..26232f67b 100644 --- a/app/actions/app/global.ts +++ b/app/actions/app/global.ts @@ -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); +}; diff --git a/app/actions/remote/entry/common.test.ts b/app/actions/remote/entry/common.test.ts index 7d9361832..83f0c8108 100644 --- a/app/actions/remote/entry/common.test.ts +++ b/app/actions/remote/entry/common.test.ts @@ -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', () => { diff --git a/app/client/rest/tracking.test.ts b/app/client/rest/tracking.test.ts index ce10822a1..36a84cf66 100644 --- a/app/client/rest/tracking.test.ts +++ b/app/client/rest/tracking.test.ts @@ -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 */ diff --git a/app/client/rest/tracking.ts b/app/client/rest/tracking.ts index f5e6ff0a7..af61ddc95 100644 --- a/app/client/rest/tracking.ts +++ b/app/client/rest/tracking.ts @@ -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); diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index fcc447754..05c124dfe 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -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, diff --git a/app/constants/database.ts b/app/constants/database.ts index a487e6025..b9da06fad 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -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', }; diff --git a/app/init/launch.test.ts b/app/init/launch.test.ts index 2142f37de..154e431a5 100644 --- a/app/init/launch.test.ts +++ b/app/init/launch.test.ts @@ -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'); diff --git a/app/managers/network_connectivity_manager.test.ts b/app/managers/network_connectivity_manager.test.ts index 51cd2fd20..d5a86c6b0 100644 --- a/app/managers/network_connectivity_manager.test.ts +++ b/app/managers/network_connectivity_manager.test.ts @@ -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', () => { diff --git a/app/managers/network_connectivity_manager.ts b/app/managers/network_connectivity_manager.ts index d1bf3b2a6..2565216b0 100644 --- a/app/managers/network_connectivity_manager.ts +++ b/app/managers/network_connectivity_manager.ts @@ -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; } diff --git a/app/managers/network_connectivity_subscription_manager.test.ts b/app/managers/network_connectivity_subscription_manager.test.ts index 83f021b6d..534a94677 100644 --- a/app/managers/network_connectivity_subscription_manager.test.ts +++ b/app/managers/network_connectivity_subscription_manager.test.ts @@ -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()}; diff --git a/app/managers/network_connectivity_subscription_manager.ts b/app/managers/network_connectivity_subscription_manager.ts index 40cbc1794..f73f82276 100644 --- a/app/managers/network_connectivity_subscription_manager.ts +++ b/app/managers/network_connectivity_subscription_manager.ts @@ -80,6 +80,7 @@ class NetworkConnectivitySubscriptionManagerSingleton { this.init(); } } else if (previousAppState === 'active') { + NetworkConnectivityManager.reset(); this.stop(); } }; diff --git a/app/managers/network_manager.ts b/app/managers/network_manager.ts index 25d7addd9..4eb5988ec 100644 --- a/app/managers/network_manager.ts +++ b/app/managers/network_manager.ts @@ -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, }; diff --git a/app/managers/network_performance_manager.test.ts b/app/managers/network_performance_manager.test.ts index 1dd0a54bc..3205c4574 100644 --- a/app/managers/network_performance_manager.test.ts +++ b/app/managers/network_performance_manager.test.ts @@ -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; 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(); + }); + }); }); diff --git a/app/managers/network_performance_manager.ts b/app/managers/network_performance_manager.ts index 6f15f0c0a..e4c4b4819 100644 --- a/app/managers/network_performance_manager.ts +++ b/app/managers/network_performance_manager.ts @@ -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> = {}; private activeRequests: Record> = {}; private requestOutcomes: Record = {}; + private totalRequestCount: Record = {}; private initialRequestTimestamp: Record = {}; private isInitialDetection: Record = {}; - 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 = { 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, diff --git a/app/queries/app/global.test.ts b/app/queries/app/global.test.ts new file mode 100644 index 000000000..c32268624 --- /dev/null +++ b/app/queries/app/global.test.ts @@ -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(); + }); + }); +}); + diff --git a/app/queries/app/global.ts b/app/queries/app/global.ts index e1ff99bb8..62fe04de9 100644 --- a/app/queries/app/global.ts +++ b/app/queries/app/global.ts @@ -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); + }), + ); +}; diff --git a/app/screens/settings/advanced/advanced.test.tsx b/app/screens/settings/advanced/advanced.test.tsx new file mode 100644 index 000000000..17ab0c7a9 --- /dev/null +++ b/app/screens/settings/advanced/advanced.test.tsx @@ -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(); + + await waitFor(() => { + expect(screen.getByTestId('advanced_settings.screen')).toBeTruthy(); + }); + }); + + it('should render delete local files option', async () => { + renderWithIntlAndTheme(); + + await waitFor(() => { + expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy(); + }); + }); + + it('should render low connectivity monitor toggle', async () => { + renderWithIntlAndTheme(); + + await waitFor(() => { + expect(screen.getByTestId('advanced_settings.low_connectivity_monitor.option')).toBeTruthy(); + }); + }); + + it('should render component library option in dev mode', async () => { + renderWithIntlAndTheme( + , + ); + + 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( + , + ); + + await waitFor(() => { + expect(screen.queryByTestId('advanced_settings.component_library.option')).toBeNull(); + }); + }); + + it('should display file size for delete option', async () => { + renderWithIntlAndTheme(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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( + ); + + 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( + ); + + 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( + ); + + 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( + ); + + 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( + ); + + 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( + ); + + 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( + , + ); + + 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(); + + await waitFor(() => { + expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalledWith('https://test.mattermost.com'); + }); + }); + + it('should handle fetch error gracefully', async () => { + mockGetAllFilesInCachesDirectory.mockRejectedValue(new Error('Fetch failed')); + + renderWithIntlAndTheme(); + + await waitFor(() => { + expect(mockGetAllFilesInCachesDirectory).toHaveBeenCalled(); + }); + }); + + it('should refetch files after deletion', async () => { + renderWithIntlAndTheme(); + + 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(); + + await waitFor(() => { + expect(screen.getByTestId('advanced_settings.screen')).toBeTruthy(); + }); + }); + + it('should handle null files gracefully', async () => { + mockGetAllFilesInCachesDirectory.mockResolvedValue({ + totalSize: 0, + files: null, + }); + + renderWithIntlAndTheme(); + + 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(); + + await waitFor(() => { + expect(screen.getByTestId('advanced_settings.delete_data.option')).toBeTruthy(); + }); + }); + + it('should render experimental features section header', async () => { + renderWithIntlAndTheme(); + + await waitFor(() => { + expect(screen.getByText('Experimental Features')).toBeTruthy(); + }); + }); + + it('should display low connectivity monitor description', async () => { + renderWithIntlAndTheme(); + + await waitFor(() => { + expect(screen.getByText('Display banners when network connectivity or performance issues are detected')).toBeTruthy(); + }); + }); + }); +}); diff --git a/app/screens/settings/advanced/advanced.tsx b/app/screens/settings/advanced/advanced.tsx index 79a40324f..68a5aeb28 100644 --- a/app/screens/settings/advanced/advanced.tsx +++ b/app/screens/settings/advanced/advanced.tsx @@ -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(0); const [files, setFiles] = useState(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' /> - + {/* */} )} + + + + + + ); }; diff --git a/app/screens/settings/advanced/index.ts b/app/screens/settings/advanced/index.ts index 2769077cf..128fa8fa8 100644 --- a/app/screens/settings/advanced/index.ts +++ b/app/screens/settings/advanced/index.ts @@ -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(), }; }); diff --git a/assets/base/config.json b/assets/base/config.json index c6833a84b..074f6d507 100644 --- a/assets/base/config.json +++ b/assets/base/config.json @@ -32,3 +32,5 @@ "CollectNetworkMetrics": false, "MonitorNetworkPerformance": true } + + diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 2ebd51798..12eabefb1 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/docs/network-connectivity-observation.md b/docs/network-connectivity-observation.md index 20d727fd7..17c9e220b 100644 --- a/docs/network-connectivity-observation.md +++ b/docs/network-connectivity-observation.md @@ -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