From 597e03d7d8b88c9a88f3e70f8f2f7021db897a80 Mon Sep 17 00:00:00 2001 From: Rahim Rahman Date: Sun, 12 Oct 2025 10:10:05 -0600 Subject: [PATCH] feat(MM-65145): Network connectivity/performance observer (#9173) * feat: implement floating banner system - Add FloatingBanner component with gesture support and keyboard awareness - Implement BannerManager singleton for banner lifecycle management - Create floating banner screen with SafeAreaProvider integration - Add comprehensive banner configuration types and positioning - Update Banner component to use modern gesture handling - Enhance BannerItem with improved typography and spacing (40px height) - Add ConnectionBanner improvements with better sizing - Remove ConnectionBanner from channel list (moved to floating system) - Update screens constants (remove FLOATING_BANNER - handled as overlay) - Add i18n support for limited network connection message The system provides: - Auto-hide functionality with customizable duration - Position-aware rendering (top/bottom with keyboard adjustment) - Tablet-specific offset handling - Swipe-to-dismiss with configurable thresholds - Custom component support alongside default banner items - Comprehensive test coverage with device-specific scenarios * docs: add floating banner system documentation and cleanup - Add comprehensive floating-banner.md with architecture diagrams - Remove incompatible connection_banner/index.ts file - Update device.ts hooks for better keyboard handling - Simplify screens/index.tsx floating banner registration - Update test/setup.ts to remove deprecated keyboard mocks - Clean up keyboard height logic and ESLint issues The documentation covers: - System architecture and component relationships - API reference and usage patterns - Performance considerations and best practices - Integration points and troubleshooting guide - Comprehensive testing strategy All tests now pass with the updated setup. * fix issue with translation file * some self cleanup. * renamed index.tsx => Banner.tsx * creaete meaningful tests for Banner component and all the hooks. * fix tests * cleanup based on initial review by AI * dismissible was set to true, changing to what was configured. * making title and message optional * feat(MM-65145): Network connectivity/performance observer * add MONITOR_NETWORK_PERFORMANCE * i18n stuff * remove unused props * Undo some AI changes that caught by another AI * network connectivity observation md * addressed some comments in PR * more fixes based on PR review. * added future enhancement * dismissOverlay will be awaited * delay dismissing overlay so we don't have to show a new one all the time * make the banner stackable * Fix issue with last banner dismissal delayed by 2s * update floating-banner test * put back an extra space * add a todo to use namespace * add comment on priority order for connectivity performance * use static const vs magic number * add a guard against adding performanceSubject when server has been removed * fix failing tests * clean-up based on review by @enahum * fix failing test * fix failiing tests * rename confusing var * update changes to types * fixed issue with swipe not working on android * performance and connectivity use the same id so that 2 banners won't appear * fix issue w/ android not registering touch events behind the overlay * fix failing test * hideBanner now needs id. * Connection status unknown added in en.json * update the doc * animate the banner moving up when bottom banner first appear. * removed unused functions and update tests * add useMemo and useCallback * update jsdoc to say dismissable is default true * fix failing test --- app/client/rest/tracking.test.ts | 143 ++++ app/client/rest/tracking.ts | 29 +- app/init/app.ts | 10 +- .../network_connectivity_manager.test.ts | 717 +++++++++++++++++ app/managers/network_connectivity_manager.ts | 296 +++++++ ..._connectivity_subscription_manager.test.ts | 337 ++++++++ ...twork_connectivity_subscription_manager.ts | 168 ++++ app/managers/network_manager.ts | 2 +- .../network_performance_manager.test.ts | 536 +++++++++++++ app/managers/network_performance_manager.ts | 325 ++++++++ app/managers/session_manager.ts | 6 + assets/base/config.json | 3 +- assets/base/i18n/en.json | 6 + docs/network-connectivity-observation.md | 740 ++++++++++++++++++ fastlane/.env.android.pr | 1 + fastlane/.env.ios.pr | 1 + fastlane/Fastfile | 5 + 17 files changed, 3320 insertions(+), 5 deletions(-) create mode 100644 app/managers/network_connectivity_manager.test.ts create mode 100644 app/managers/network_connectivity_manager.ts create mode 100644 app/managers/network_connectivity_subscription_manager.test.ts create mode 100644 app/managers/network_connectivity_subscription_manager.ts create mode 100644 app/managers/network_performance_manager.test.ts create mode 100644 app/managers/network_performance_manager.ts create mode 100644 docs/network-connectivity-observation.md diff --git a/app/client/rest/tracking.test.ts b/app/client/rest/tracking.test.ts index 280842307..ce10822a1 100644 --- a/app/client/rest/tracking.test.ts +++ b/app/client/rest/tracking.test.ts @@ -6,6 +6,8 @@ import {DeviceEventEmitter} from 'react-native'; import LocalConfig from '@assets/config.json'; import {Events} from '@constants'; +import NetworkPerformanceManager from '@managers/network_performance_manager'; +import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import test_helper from '@test/test_helper'; import * as ClientConstants from './constants'; @@ -62,7 +64,18 @@ jest.mock('@managers/performance_metrics_manager', () => ({ collectNetworkRequestData: jest.fn(), })); +jest.mock('@managers/network_performance_manager', () => ({ + __esModule: true, + default: { + startRequestTracking: jest.fn(() => 'mock-request-id-123'), + completeRequestTracking: jest.fn(), + cancelRequestTracking: jest.fn(), + }, +})); + describe('ClientTracking', () => { + const mockedNPM = jest.mocked(NetworkPerformanceManager); + const mockedPMM = jest.mocked(PerformanceMetricsManager); const apiClientMock = { baseUrl: 'https://example.com', get: jest.fn(), @@ -877,5 +890,135 @@ describe('ClientTracking', () => { expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined(); }); }); + + describe('Network Performance Tracking', () => { + const createMockMetrics = (overrides = {}) => ({ + latency: 500, + size: 1000, + compressedSize: 500, + startTime: Date.now(), + endTime: Date.now() + 500, + speedInMbps: 1, + networkType: 'Wi-Fi', + tlsCipherSuite: 'none', + tlsVersion: 'none', + isCached: false, + httpVersion: 'h2', + connectionTime: 0, + ...overrides, + }); + + const mockSuccessResponse = (metrics = createMockMetrics()) => ({ + ok: true, + data: {success: true}, + headers: {}, + metrics, + }); + + const requestOptions = { + method: 'GET', + 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 () => { + const mockMetrics = createMockMetrics(); + apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics)); + + await client.doFetchWithTracking('https://example.com/api', requestOptions); + + expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api'); + 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, + data: {success: true}, + headers: {}, + }); + + await client.doFetchWithTracking('https://example.com/api', requestOptions); + + expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api'); + expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled(); + }); + + it('should call tracking methods independently of CollectNetworkMetrics', async () => { + LocalConfig.CollectNetworkMetrics = false; + const mockMetrics = createMockMetrics(); + apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics)); + + await client.doFetchWithTracking('https://example.com/api', requestOptions); + + expect(mockedPMM.collectNetworkRequestData).not.toHaveBeenCalled(); + expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api'); + expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123', mockMetrics); + }); + + it('should call tracking methods when both flags are enabled', async () => { + LocalConfig.CollectNetworkMetrics = true; + const mockMetrics = createMockMetrics(); + apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics)); + + await client.doFetchWithTracking('https://example.com/api', requestOptions); + + expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api'); + expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123', mockMetrics); + }); + + it('should pass correct server URL to NetworkPerformanceManager', async () => { + const customBaseUrl = 'https://custom-server.com'; + const customApiClient = {...apiClientMock, baseUrl: customBaseUrl}; + const customClient = new ClientTracking(customApiClient as unknown as APIClientInterface); + const mockMetrics = createMockMetrics({latency: 300, size: 2000, compressedSize: 1000, speedInMbps: 2}); + + customApiClient.get.mockResolvedValue(mockSuccessResponse(mockMetrics)); + + await customClient.doFetchWithTracking('https://custom-server.com/api', requestOptions); + + expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith(customBaseUrl, 'https://custom-server.com/api'); + expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith(customBaseUrl, 'mock-request-id-123', mockMetrics); + }); + + it('should call cancelRequestTracking when request fails', async () => { + 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).toHaveBeenCalledWith('https://example.com', 'https://example.com/api'); + 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 1f307bca3..f5e6ff0a7 100644 --- a/app/client/rest/tracking.ts +++ b/app/client/rest/tracking.ts @@ -4,9 +4,10 @@ import {defineMessage} from 'react-intl'; import {DeviceEventEmitter, Platform} from 'react-native'; -import {CollectNetworkMetrics} from '@assets/config.json'; +import {CollectNetworkMetrics, MonitorNetworkPerformance} from '@assets/config.json'; import {Events} from '@constants'; import {setServerCredentials} from '@init/credentials'; +import NetworkPerformanceManager from '@managers/network_performance_manager'; import PerformanceMetricsManager from '@managers/performance_metrics_manager'; import {NetworkRequestMetrics} from '@managers/performance_metrics_manager/constant'; import {isErrorWithStatusCode} from '@utils/errors'; @@ -131,6 +132,27 @@ export default class ClientTracking { group.totalCompressedSize += metrics?.compressedSize ?? 0; } + 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) { + return; + } + NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, requestId, metrics); + } + + cancelNetworkPerformanceTracking(requestId: string | undefined) { + if (!MonitorNetworkPerformance || !requestId) { + return; + } + NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, requestId); + } + getAverageLatency(groupLabel: RequestGroupLabel): number { const groupData = this.requestGroups.get(groupLabel); if (!groupData) { @@ -383,10 +405,13 @@ export default class ClientTracking { this.incrementRequestCount(groupLabel); } + const performanceRequestId = this.startNetworkPerformanceTracking(url); + let response: ClientResponse; try { response = await request!(url, this.buildRequestOptions(options)); } catch (error) { + this.cancelNetworkPerformanceTracking(performanceRequestId); const response_error = error as ClientError; const status_code = isErrorWithStatusCode(error) ? error.status_code : undefined; throw new ClientError(this.apiClient.baseUrl, { @@ -409,6 +434,7 @@ export default class ClientTracking { if (groupLabel && CollectNetworkMetrics) { this.trackRequest(groupLabel, url, response.metrics); } + this.completeNetworkPerformanceTracking(performanceRequestId, url, response.metrics); const serverVersion = semverFromServerVersion( headers[ClientConstants.HEADER_X_VERSION_ID] || headers[ClientConstants.HEADER_X_VERSION_ID.toLowerCase()], ); @@ -435,7 +461,6 @@ export default class ClientTracking { server_error_id: response.data?.id as string, status_code: response.code, url, - headers, }); }; } diff --git a/app/init/app.ts b/app/init/app.ts index bb4362299..8610f5ced 100644 --- a/app/init/app.ts +++ b/app/init/app.ts @@ -3,11 +3,13 @@ import {CallsManager} from '@calls/calls_manager'; import DatabaseManager from '@database/manager'; -import {getAllServerCredentials} from '@init/credentials'; +import {getAllServerCredentials, getActiveServerUrl} from '@init/credentials'; import {initialLaunch} from '@init/launch'; import ManagedApp from '@init/managed_app'; import PushNotifications from '@init/push_notifications'; import GlobalEventHandler from '@managers/global_event_handler'; +import NetworkConnectivityManager from '@managers/network_connectivity_manager'; +import NetworkConnectivitySubscriptionManager from '@managers/network_connectivity_subscription_manager'; import NetworkManager from '@managers/network_manager'; import SessionManager from '@managers/session_manager'; import WebsocketManager from '@managers/websocket_manager'; @@ -49,6 +51,9 @@ export async function initialize() { ManagedApp.init(); SessionManager.init(); CallsManager.initialize(); + + const activeServerUrl = await getActiveServerUrl(); + NetworkConnectivityManager.init(activeServerUrl || null); } } @@ -67,5 +72,8 @@ export async function start() { await WebsocketManager.init(serverCredentials); + NetworkConnectivitySubscriptionManager.init(); + initialLaunch(); } + diff --git a/app/managers/network_connectivity_manager.test.ts b/app/managers/network_connectivity_manager.test.ts new file mode 100644 index 000000000..51cd2fd20 --- /dev/null +++ b/app/managers/network_connectivity_manager.test.ts @@ -0,0 +1,717 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {BannerManager} from './banner_manager'; +import NetworkConnectivityManager, {testExports} from './network_connectivity_manager'; + +const { + shouldShowDisconnectedBanner, + shouldShowConnectingBanner, + shouldShowPerformanceBanner, + shouldShowReconnectionBanner, +} = testExports; + +// Define constants locally for testing +const FLOATING_BANNER_OVERLAY_ID = 'floating-banner-overlay'; +const TIME_TO_OPEN = 1000; +const TIME_TO_CLOSE = 5000; + +const mockBannerManager = BannerManager as jest.Mocked; + +jest.mock('./banner_manager', () => ({ + BannerManager: { + showBanner: jest.fn(), + showBannerWithAutoHide: jest.fn(), + showBannerWithDelay: jest.fn(), + hideBanner: jest.fn(), + cleanup: jest.fn(), + getCurrentBannerId: jest.fn().mockReturnValue(null), + isBannerVisible: jest.fn().mockReturnValue(false), + }, +})); + +function setupConnectedState(manager: typeof NetworkConnectivityManager) { + manager.updateState('connected', {isInternetReachable: true}, 'active'); +} + +function setupReconnectionScenario(manager: typeof NetworkConnectivityManager) { + setupConnectedState(manager); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); +} + +describe('NetworkConnectivityManager', () => { + let manager: typeof NetworkConnectivityManager; + const mockSetTimeout = jest.spyOn(global, 'setTimeout').mockImplementation((cb: () => void) => { + cb(); + return 1 as unknown as NodeJS.Timeout; + }); + jest.spyOn(global, 'clearTimeout').mockImplementation(() => undefined); + + beforeEach(() => { + mockSetTimeout.mockClear(); + Object.values(mockBannerManager).forEach((mock) => { + if (typeof mock === 'function' && 'mockClear' in mock) { + mock.mockClear(); + } + }); + manager = NetworkConnectivityManager; + + manager.cleanup(); + manager.setServerConnectionStatus(false, null); + }); + + describe('singleton pattern', () => { + it('should return the same instance', () => { + const instance1 = NetworkConnectivityManager; + const instance2 = NetworkConnectivityManager; + expect(instance1).toBe(instance2); + }); + }); + + describe('initialization', () => { + it('should initialize with server URL', () => { + const serverUrl = 'https://test.server.com'; + manager.init(serverUrl); + + expect((manager as any).currentServerUrl).toBe(serverUrl); + }); + + it('should initialize with null server URL', () => { + manager.init(null); + + expect((manager as any).currentServerUrl).toBeNull(); + }); + + it('should reset state on initialization', () => { + manager.setServerConnectionStatus(true, 'https://old.server.com'); + setupReconnectionScenario(manager); + + manager.init('https://new.server.com'); + + expect((manager as any).currentServerUrl).toBe('https://new.server.com'); + expect((manager as any).isOnAppStart).toBe(true); + }); + }); + + describe('shutdown', () => { + it('should completely reset all state', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + setupReconnectionScenario(manager); + + manager.shutdown(); + + expect((manager as any).currentServerUrl).toBeNull(); + expect((manager as any).websocketState).toBeNull(); + 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).isOnAppStart).toBe(true); + }); + + it('should call cleanup during shutdown', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + manager.shutdown(); + + expect(mockBannerManager.cleanup).toHaveBeenCalled(); + }); + }); + + describe('server connection status', () => { + it('should hide banner when server is disconnected', () => { + manager.setServerConnectionStatus(false, null); + manager.updateState('not_connected', {isInternetReachable: false}, 'active'); + + expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); + }); + + it('should show banner when server is connected after first connection', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + setupReconnectionScenario(manager); + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBanner).toHaveBeenCalled(); + }); + + it('should hide banner when server URL is empty', () => { + manager.setServerConnectionStatus(true, ''); + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); + }); + + it('should hide banner when server URL is null', () => { + manager.setServerConnectionStatus(true, null); + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); + }); + }); + + describe('websocket state handling', () => { + beforeEach(() => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + }); + + it('should not show banner when connecting on first connection', () => { + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); + }); + + it('should not show banner when not connected on first connection', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + + expect(mockSetTimeout).not.toHaveBeenCalled(); + expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); + }); + + it('should show banner when connecting after first connection', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + setupReconnectionScenario(manager); + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBanner).toHaveBeenCalled(); + }); + + it('should show banner with auto-hide when transitioning to connected', () => { + manager.updateState('connected', {isInternetReachable: true}, 'active'); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + expect(mockBannerManager.showBanner).toHaveBeenCalled(); + + mockBannerManager.showBanner.mockClear(); + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); + }); + }); + + describe('app state handling', () => { + beforeEach(() => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + }); + + it('should hide banner when app goes to background', () => { + manager.updateState('connected', {isInternetReachable: true}, 'active'); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + expect(mockBannerManager.showBanner).toHaveBeenCalled(); + + mockBannerManager.hideBanner.mockClear(); + manager.updateState('not_connected', {isInternetReachable: true}, 'background'); + + expect(mockBannerManager.hideBanner).toHaveBeenCalled(); + }); + + it('should show banner when app becomes active and not connected after first connection', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBanner).toHaveBeenCalled(); + }); + }); + + describe('cleanup', () => { + it('should hide banner and cleanup on cleanup', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + + manager.cleanup(); + + expect(mockBannerManager.cleanup).toHaveBeenCalled(); + }); + }); + + describe('connection restoration behavior', () => { + it('should not show banner on initial connection', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + expect((manager as any).previousWebsocketState).toBeNull(); + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); + }); + + it('should show banner when transitioning from disconnected to connected after first connection', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + mockBannerManager.showBannerWithAutoHide.mockClear(); + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); + }); + + it('should not show banner when transitioning from connecting to connected on first connection', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + mockBannerManager.showBanner.mockClear(); + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBannerWithAutoHide).not.toHaveBeenCalled(); + }); + + it('should not show banner when staying connected', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + mockBannerManager.showBanner.mockClear(); + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.showBanner).not.toHaveBeenCalled(); + }); + + }); + + describe('constants', () => { + it('should have correct timeout values', () => { + expect(TIME_TO_OPEN).toBe(1000); + expect(TIME_TO_CLOSE).toBe(5000); + }); + + it('should have correct overlay ID', () => { + expect(FLOATING_BANNER_OVERLAY_ID).toBe('floating-banner-overlay'); + }); + }); + + describe('null state handling', () => { + it('should return status unknown when websocketState is null', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + (manager as any).websocketState = null; + (manager as any).netInfo = {isInternetReachable: true}; + + const message = (manager as any).getConnectionMessage(); + + expect(message).toBe('Connection status unknown'); + }); + + it('should return status unknown when netInfo is null', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + (manager as any).websocketState = 'connected'; + (manager as any).netInfo = null; + + const message = (manager as any).getConnectionMessage(); + + expect(message).toBe('Connection status unknown'); + }); + }); + + describe('pure functions', () => { + const {getConnectionMessageText, isReconnection} = testExports; + + describe('getConnectionMessageText', () => { + const mockFormatMessage = jest.fn((descriptor) => descriptor.defaultMessage); + + beforeEach(() => { + mockFormatMessage.mockClear(); + }); + + it('should return connected message when websocket is connected', () => { + const result = getConnectionMessageText('connected', true, mockFormatMessage); + expect(result).toBe('Connection restored'); + expect(mockFormatMessage).toHaveBeenCalledWith({ + id: 'connection_banner.connected', + defaultMessage: 'Connection restored', + }); + }); + + it('should return connecting message when websocket is connecting', () => { + const result = getConnectionMessageText('connecting', true, mockFormatMessage); + expect(result).toBe('Connecting...'); + expect(mockFormatMessage).toHaveBeenCalledWith({ + id: 'connection_banner.connecting', + defaultMessage: 'Connecting...', + }); + }); + + it('should return not reachable message when internet is reachable but not connected', () => { + const result = getConnectionMessageText('not_connected', true, mockFormatMessage); + expect(result).toBe('The server is not reachable'); + expect(mockFormatMessage).toHaveBeenCalledWith({ + id: 'connection_banner.not_reachable', + defaultMessage: 'The server is not reachable', + }); + }); + + it('should return not connected message when internet is not reachable', () => { + const result = getConnectionMessageText('not_connected', false, mockFormatMessage); + expect(result).toBe('Unable to connect to network'); + expect(mockFormatMessage).toHaveBeenCalledWith({ + id: 'connection_banner.not_connected', + defaultMessage: 'Unable to connect to network', + }); + }); + }); + + describe('isReconnection', () => { + it('should return true when previous state was not_connected and not first connection', () => { + const result = isReconnection('not_connected', false); + expect(result).toBe(true); + }); + + it('should return true when previous state was connecting and not first connection', () => { + const result = isReconnection('connecting', false); + expect(result).toBe(true); + }); + + it('should return false when previous state was connected', () => { + const result = isReconnection('connected', false); + expect(result).toBe(false); + }); + + it('should return false when it is first connection regardless of previous state', () => { + expect(isReconnection('not_connected', true)).toBe(false); + expect(isReconnection('connecting', true)).toBe(false); + expect(isReconnection('connected', true)).toBe(false); + }); + + it('should return true when previous state is null and not first connection', () => { + const result = isReconnection(null, false); + expect(result).toBe(true); + }); + }); + + describe('shouldShowDisconnectedBanner', () => { + it('should return true when websocket is not_connected and not first connection', () => { + const result = shouldShowDisconnectedBanner('not_connected', false); + expect(result).toBe(true); + }); + + it('should return false when websocket is not_connected but is first connection', () => { + const result = shouldShowDisconnectedBanner('not_connected', true); + expect(result).toBe(false); + }); + + it('should return false when websocket is connected', () => { + const result = shouldShowDisconnectedBanner('connected', false); + expect(result).toBe(false); + }); + + it('should return false when websocket is connecting', () => { + const result = shouldShowDisconnectedBanner('connecting', false); + expect(result).toBe(false); + }); + + it('should return false when websocket state is null', () => { + const result = shouldShowDisconnectedBanner(null, false); + expect(result).toBe(false); + }); + }); + + describe('shouldShowConnectingBanner', () => { + it('should return true when websocket is connecting and not first connection', () => { + const result = shouldShowConnectingBanner('connecting', false); + expect(result).toBe(true); + }); + + it('should return false when websocket is connecting but is first connection', () => { + const result = shouldShowConnectingBanner('connecting', true); + expect(result).toBe(false); + }); + + it('should return false when websocket is connected', () => { + const result = shouldShowConnectingBanner('connected', false); + expect(result).toBe(false); + }); + + it('should return false when websocket is not_connected', () => { + const result = shouldShowConnectingBanner('not_connected', false); + expect(result).toBe(false); + }); + + it('should return false when websocket state is null', () => { + const result = shouldShowConnectingBanner(null, false); + expect(result).toBe(false); + }); + }); + + describe('shouldShowPerformanceBanner', () => { + it('should return true when performance is slow and not suppressed', () => { + const result = shouldShowPerformanceBanner('slow', false); + expect(result).toBe(true); + }); + + it('should return false when performance is normal', () => { + const result = shouldShowPerformanceBanner('normal', false); + expect(result).toBe(false); + }); + + it('should return false when performance is suppressed', () => { + const result = shouldShowPerformanceBanner('slow', true); + expect(result).toBe(false); + }); + + it('should return false when performance state is null', () => { + const result = shouldShowPerformanceBanner(null, false); + expect(result).toBe(false); + }); + }); + + describe('shouldShowReconnectionBanner', () => { + it('should return true when all conditions are met for reconnection', () => { + const result = shouldShowReconnectionBanner('connected', 'not_connected', false); + expect(result).toBe(true); + }); + + it('should return true when previous state was connecting', () => { + const result = shouldShowReconnectionBanner('connected', 'connecting', false); + expect(result).toBe(true); + }); + + it('should return false when websocket is not connected', () => { + const result = shouldShowReconnectionBanner('not_connected', 'not_connected', false); + expect(result).toBe(false); + }); + + it('should return false when websocket is connecting', () => { + const result = shouldShowReconnectionBanner('connecting', 'not_connected', false); + expect(result).toBe(false); + }); + + it('should return false when it is first connection', () => { + const result = shouldShowReconnectionBanner('connected', 'not_connected', true); + expect(result).toBe(false); + }); + + it('should return true when reconnection conditions are met', () => { + const result = shouldShowReconnectionBanner('connected', 'not_connected', false); + expect(result).toBe(true); + }); + + it('should return false when previous state was connected', () => { + const result = shouldShowReconnectionBanner('connected', 'connected', false); + expect(result).toBe(false); + }); + + it('should return true when previous state is null and not first connection', () => { + const result = shouldShowReconnectionBanner('connected', null, false); + expect(result).toBe(true); + }); + + it('should return false when websocket state is null', () => { + const result = shouldShowReconnectionBanner(null, 'not_connected', false); + expect(result).toBe(false); + }); + }); + }); + + describe('performance state handling', () => { + beforeEach(() => { + mockSetTimeout.mockClear(); + Object.values(mockBannerManager).forEach((mock) => { + if (typeof mock === 'function' && 'mockClear' in mock) { + mock.mockClear(); + } + }); + manager = NetworkConnectivityManager; + manager.setServerConnectionStatus(true, 'https://test.server.com'); + + jest.spyOn(global, 'setTimeout').mockImplementation((cb: () => void, delay: number) => { + if (!delay) { + cb(); + } + return 1 as unknown as NodeJS.Timeout; + }); + }); + + it('should show performance banner when performance is slow', () => { + setupConnectedState(manager); + manager.updatePerformanceState('slow'); + + expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); + }); + + it('should suppress performance banner until normal when banner is manually dismissed', () => { + manager.updateState('connected', {isInternetReachable: true}, 'active'); + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + mockBannerManager.showBannerWithAutoHide.mockClear(); + manager.updatePerformanceState('slow'); + + expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); + const performanceBannerConfig = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0]; + expect((performanceBannerConfig.customComponent as any).props.message).toBe('Limited network connection'); + + const onDismiss = performanceBannerConfig.onDismiss; + onDismiss!(); + + mockBannerManager.showBannerWithAutoHide.mockClear(); + mockBannerManager.showBanner.mockClear(); + + manager.updatePerformanceState('slow'); + expect(mockBannerManager.showBannerWithAutoHide).not.toHaveBeenCalled(); + 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); + + manager.updatePerformanceState('normal'); + + expect((manager as any).performanceSuppressedUntilNormal).toBe(false); + }); + + it('should show reconnection banner when performance returns to normal', () => { + setupConnectedState(manager); + + manager.updatePerformanceState('slow'); + expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); + + mockBannerManager.hideBanner.mockClear(); + mockBannerManager.showBanner.mockClear(); + mockBannerManager.showBannerWithAutoHide.mockClear(); + + manager.updatePerformanceState('normal'); + const reconnectionBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0]; + expect((reconnectionBannerCall.customComponent as any).props.message).toBe('Connection restored'); + }); + + it('should not hide banner if it is not a performance banner', () => { + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + + mockBannerManager.hideBanner.mockClear(); + + manager.updatePerformanceState('normal'); + expect(mockBannerManager.hideBanner).not.toHaveBeenCalled(); + }); + + it('should maintain performance state when performance becomes slow', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + manager.updatePerformanceState('slow'); + expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled(); + expect((manager as any).currentPerformanceState).toBe('slow'); + }); + + it('should show banners on first disconnect/reconnect cycle after performance auto-hide', () => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + manager.updatePerformanceState('slow'); + const performanceBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0]; + expect((performanceBannerCall.customComponent as any).props.message).toBe('Limited network connection'); + + mockBannerManager.showBanner.mockClear(); + mockBannerManager.showBannerWithAutoHide.mockClear(); + mockBannerManager.hideBanner.mockClear(); + + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + mockBannerManager.showBanner.mockClear(); + mockBannerManager.showBannerWithAutoHide.mockClear(); + + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + + const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0]; + expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable'); + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + }); + + }); + + describe('banner priority order', () => { + beforeEach(() => { + manager.setServerConnectionStatus(true, 'https://test.server.com'); + setupConnectedState(manager); + }); + + it('should prioritize disconnected over performance', () => { + manager.updatePerformanceState('slow'); + mockBannerManager.showBanner.mockClear(); + mockBannerManager.showBannerWithAutoHide.mockClear(); + + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + + const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0]; + expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable'); + }); + + it('should prioritize disconnected over connecting', () => { + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + mockBannerManager.showBanner.mockClear(); + + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + + const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0]; + expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable'); + }); + + it('should prioritize performance over connecting', () => { + manager.updatePerformanceState('slow'); + mockBannerManager.showBannerWithAutoHide.mockClear(); + + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + + const performanceBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0]; + expect((performanceBannerCall.customComponent as any).props.message).toBe('Limited network connection'); + }); + + it('should prioritize connecting over reconnection', () => { + manager.updatePerformanceState('normal'); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + mockBannerManager.showBanner.mockClear(); + mockBannerManager.showBannerWithAutoHide.mockClear(); + + manager.updateState('connecting', {isInternetReachable: true}, 'active'); + + const connectingBannerCall = mockBannerManager.showBanner.mock.calls[0][0]; + expect((connectingBannerCall.customComponent as any).props.message).toBe('Connecting...'); + }); + + it('should show reconnection banner only when connected after disconnect', () => { + manager.updatePerformanceState('normal'); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + mockBannerManager.showBannerWithAutoHide.mockClear(); + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + const reconnectionBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0]; + expect((reconnectionBannerCall.customComponent as any).props.message).toBe('Connection restored'); + }); + + it('should hide banner when connected and no performance issues', () => { + manager.updatePerformanceState('normal'); + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + manager.updateState('connected', {isInternetReachable: true}, 'active'); + mockBannerManager.hideBanner.mockClear(); + + manager.updateState('connected', {isInternetReachable: true}, 'active'); + + expect(mockBannerManager.hideBanner).toHaveBeenCalled(); + }); + + it('should handle all conditions simultaneously and show highest priority', () => { + manager.updatePerformanceState('slow'); + mockBannerManager.showBanner.mockClear(); + mockBannerManager.showBannerWithAutoHide.mockClear(); + + manager.updateState('not_connected', {isInternetReachable: true}, 'active'); + + const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0]; + expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable'); + }); + }); +}); diff --git a/app/managers/network_connectivity_manager.ts b/app/managers/network_connectivity_manager.ts new file mode 100644 index 000000000..d1bf3b2a6 --- /dev/null +++ b/app/managers/network_connectivity_manager.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import ConnectionBanner from '@components/connection_banner/connection_banner'; +import {toMilliseconds} from '@utils/datetime'; +import {getIntlShape} from '@utils/general'; + +import {BannerManager} from './banner_manager'; + +import type {NetworkPerformanceState} from './network_performance_manager'; +import type {FloatingBannerConfig} from '@components/floating_banner/types'; + +const RECONNECTION_BANNER_DURATION = toMilliseconds({seconds: 3}); +const PERFORMANCE_BANNER_DURATION = toMilliseconds({seconds: 10}); +const NETWORK_STATUS_BANNER_ID = 'network-status'; + +function getConnectionMessageText( + websocketState: WebsocketConnectedState, + isInternetReachable: boolean | null, + formatMessage: (descriptor: {id: string; defaultMessage: string}) => string, +): string { + const isConnected = websocketState === 'connected'; + + if (isConnected) { + return formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'}); + } + + if (websocketState === 'connecting') { + return formatMessage({id: 'connection_banner.connecting', defaultMessage: 'Connecting...'}); + } + + if (isInternetReachable) { + return formatMessage({id: 'connection_banner.not_reachable', defaultMessage: 'The server is not reachable'}); + } + + return formatMessage({id: 'connection_banner.not_connected', defaultMessage: 'Unable to connect to network'}); +} + +function isReconnection( + previousWebsocketState: WebsocketConnectedState | null, + isOnAppStart: boolean, +): boolean { + return previousWebsocketState !== 'connected' && !isOnAppStart; +} + +function shouldShowDisconnectedBanner( + websocketState: WebsocketConnectedState | null, + isOnAppStart: boolean, +): boolean { + return websocketState === 'not_connected' && !isOnAppStart; +} + +function shouldShowConnectingBanner( + websocketState: WebsocketConnectedState | null, + isOnAppStart: boolean, +): boolean { + return websocketState === 'connecting' && !isOnAppStart; +} + +function shouldShowPerformanceBanner( + performanceState: NetworkPerformanceState | null, + performanceSuppressed: boolean, +): boolean { + return performanceState === 'slow' && !performanceSuppressed; +} + +function shouldShowReconnectionBanner( + websocketState: WebsocketConnectedState | null, + previousWebsocketState: WebsocketConnectedState | null, + isOnAppStart: boolean, +): boolean { + return websocketState === 'connected' && + isReconnection(previousWebsocketState, isOnAppStart); +} + +class NetworkConnectivityManagerSingleton { + private currentServerUrl: string | null = null; + private websocketState: WebsocketConnectedState | null = null; + private previousWebsocketState: WebsocketConnectedState | null = null; + private isOnAppStart = true; + private netInfo: {isInternetReachable: boolean | null} | null = null; + private appState: string | null = null; + private readonly intl = getIntlShape(); + private currentPerformanceState: NetworkPerformanceState | null = null; + + private performanceSuppressedUntilNormal = false; + + private getConnectionMessage(): string { + if (!this.websocketState || !this.netInfo) { + return this.intl.formatMessage({id: 'connection_banner.status_unknown', defaultMessage: 'Connection status unknown'}); + } + + return getConnectionMessageText(this.websocketState, this.netInfo.isInternetReachable, this.intl.formatMessage); + } + + private getPerformanceMessage(): string { + return this.intl.formatMessage({id: 'connection_banner.limited_network_connection', defaultMessage: 'Limited network connection'}); + } + + private showConnectivity(isConnected: boolean, durationMs?: number) { + const message = this.getConnectionMessage(); + const bannerConfig = this.createConnectivityBannerConfig(message, isConnected); + + if (durationMs) { + BannerManager.showBannerWithAutoHide(bannerConfig, durationMs); + } else { + BannerManager.showBanner(bannerConfig); + } + } + + private showPerformance(durationMs: number) { + const message = this.getPerformanceMessage(); + const bannerConfig = this.createPerformanceBannerConfig(message); + BannerManager.showBannerWithAutoHide(bannerConfig, durationMs); + } + + private createConnectivityBannerConfig(message: string, isConnected: boolean): FloatingBannerConfig { + return { + id: NETWORK_STATUS_BANNER_ID, + dismissible: true, + customComponent: React.createElement(ConnectionBanner, { + isConnected, + message, + dismissible: true, + onDismiss: () => { + // Placeholder to enable dismiss button - actual dismissal handled by BannerManager + }, + }), + position: 'bottom', + }; + } + + private createPerformanceBannerConfig(message: string): FloatingBannerConfig { + return { + id: NETWORK_STATUS_BANNER_ID, + dismissible: true, + onDismiss: () => { + this.performanceSuppressedUntilNormal = true; + }, + customComponent: React.createElement(ConnectionBanner, { + isConnected: false, + message, + dismissible: true, + onDismiss: () => { + // Placeholder to enable dismiss button - actual dismissal handled by BannerManager + }, + }), + position: 'bottom', + }; + } + + init(serverUrl: string | null = null) { + this.currentServerUrl = serverUrl; + this.cleanup(); + } + + setServerConnectionStatus(connected: boolean, serverUrl: string | null = null) { + this.currentServerUrl = serverUrl; + + if (!connected) { + BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID); + this.websocketState = null; + this.previousWebsocketState = null; + } + } + + updateState( + websocketState: WebsocketConnectedState, + netInfo: {isInternetReachable: boolean | null}, + appState: string, + ) { + this.previousWebsocketState = this.websocketState; + this.websocketState = websocketState; + this.netInfo = netInfo; + this.appState = appState; + + this.updateBanner(); + + if (websocketState === 'connected' && this.isOnAppStart) { + this.isOnAppStart = false; + } + } + + updatePerformanceState( + performanceState: NetworkPerformanceState, + ) { + this.currentPerformanceState = performanceState; + + if (this.performanceSuppressedUntilNormal && performanceState === 'normal') { + this.performanceSuppressedUntilNormal = false; + } + + this.updateBanner(); + } + + cleanup() { + BannerManager.cleanup(); + this.previousWebsocketState = null; + this.isOnAppStart = true; + } + + shutdown() { + this.cleanup(); + this.currentServerUrl = null; + this.websocketState = null; + this.netInfo = null; + this.appState = null; + this.currentPerformanceState = null; + this.performanceSuppressedUntilNormal = false; + } + + private updateBanner() { + if (!this.currentServerUrl || this.appState === 'background') { + BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID); + return; + } + + // Banner priority order (highest to lowest) + // 1. Disconnected - critical connection loss + if (this.handleDisconnectedState()) { + return; + } + + // 2. Performance - slow network warning + if (this.handlePerformanceState()) { + return; + } + + // 3. Connecting - attempting to reconnect + if (this.handleConnectingState()) { + return; + } + + // 4. Reconnection - successful reconnection (auto-hide) + if (this.handleReconnectionState()) { + return; + } + + BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID); + } + + private handleDisconnectedState(): boolean { + if (shouldShowDisconnectedBanner(this.websocketState, this.isOnAppStart)) { + this.showConnectivity(false); + return true; + } + return false; + } + + private handleConnectingState(): boolean { + if (shouldShowConnectingBanner(this.websocketState, this.isOnAppStart)) { + this.showConnectivity(false); + return true; + } + return false; + } + + private handlePerformanceState(): boolean { + if (shouldShowPerformanceBanner(this.currentPerformanceState, this.performanceSuppressedUntilNormal)) { + this.showPerformance(PERFORMANCE_BANNER_DURATION); + return true; + } + return false; + } + + private handleReconnectionState(): boolean { + if (shouldShowReconnectionBanner( + this.websocketState, + this.previousWebsocketState, + this.isOnAppStart, + )) { + this.showConnectivity(true, RECONNECTION_BANNER_DURATION); + return true; + } + return false; + } +} + +const NetworkConnectivityManager = new NetworkConnectivityManagerSingleton(); + +export default NetworkConnectivityManager; + +export const testExports = { + NetworkConnectivityManager: NetworkConnectivityManagerSingleton, + getConnectionMessageText, + isReconnection, + shouldShowDisconnectedBanner, + shouldShowConnectingBanner, + shouldShowPerformanceBanner, + shouldShowReconnectionBanner, + RECONNECTION_BANNER_DURATION, + PERFORMANCE_BANNER_DURATION, + NETWORK_STATUS_BANNER_ID, +}; diff --git a/app/managers/network_connectivity_subscription_manager.test.ts b/app/managers/network_connectivity_subscription_manager.test.ts new file mode 100644 index 000000000..83f021b6d --- /dev/null +++ b/app/managers/network_connectivity_subscription_manager.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetInfo from '@react-native-community/netinfo'; +import {AppState} from 'react-native'; +import {Subscription} from 'rxjs'; + +import {subscribeActiveServers} from '@database/subscription/servers'; + +import NetworkConnectivityManager from './network_connectivity_manager'; +import { + startNetworkConnectivitySubscriptions, + stopNetworkConnectivitySubscriptions, + shutdownNetworkConnectivitySubscriptions, +} from './network_connectivity_subscription_manager'; +import NetworkPerformanceManager from './network_performance_manager'; +import WebsocketManager from './websocket_manager'; + +type MockServer = { + url: string; + lastActiveAt: number; +}; + +type MockNetInfoState = { + isInternetReachable: boolean | null; +}; + +// Mock dependencies +jest.mock('@react-native-community/netinfo', () => ({ + addEventListener: jest.fn(), +})); +jest.mock('react-native', () => ({ + AppState: { + addEventListener: jest.fn(), + }, +})); +jest.mock('@database/subscription/servers', () => ({ + subscribeActiveServers: jest.fn(), +})); +jest.mock('@utils/general', () => ({ + getIntlShape: jest.fn(() => ({ + formatMessage: jest.fn((descriptor) => descriptor.defaultMessage), + })), +})); +jest.mock('./network_connectivity_manager', () => ({ + setServerConnectionStatus: jest.fn(), + updateState: jest.fn(), + updatePerformanceState: jest.fn(), + shutdown: jest.fn(), +})); +jest.mock('./websocket_manager', () => ({ + observeWebsocketState: jest.fn(), +})); +jest.mock('./network_performance_manager', () => ({ + observePerformanceState: jest.fn(), +})); + +const mockAppState = AppState as jest.Mocked; +const mockNetInfo = NetInfo as jest.Mocked; +const mockSubscribeActiveServers = subscribeActiveServers as jest.MockedFunction; +const mockNetworkConnectivityManager = NetworkConnectivityManager as jest.Mocked; +const mockNetworkPerformanceManager = NetworkPerformanceManager as jest.Mocked; +const mockWebsocketManager = WebsocketManager as jest.Mocked; + +describe('NetworkConnectivitySubscriptionManager', () => { + const mockWebsocketSubscription = { + unsubscribe: jest.fn(), + } as unknown as Subscription; + + const mockPerformanceSubscription = { + unsubscribe: jest.fn(), + } as unknown as Subscription; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAppState.addEventListener.mockReturnValue({ + remove: jest.fn(), + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + mockNetInfo.addEventListener.mockReturnValue(jest.fn()); + + mockWebsocketManager.observeWebsocketState.mockReturnValue({ + subscribe: jest.fn(() => mockWebsocketSubscription), + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({ + subscribe: jest.fn(() => mockPerformanceSubscription), + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + afterEach(() => { + shutdownNetworkConnectivitySubscriptions(); + }); + + describe('startNetworkConnectivitySubscriptions', () => { + it('should set up app state listener on first call', () => { + startNetworkConnectivitySubscriptions(); + + expect(mockAppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('should not recreate app state listener on subsequent calls', () => { + startNetworkConnectivitySubscriptions(); + mockAppState.addEventListener.mockClear(); + + startNetworkConnectivitySubscriptions(); + + expect(mockAppState.addEventListener).not.toHaveBeenCalled(); + }); + + it('should set up net info listener', () => { + startNetworkConnectivitySubscriptions(); + + expect(mockNetInfo.addEventListener).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should set up active servers subscription', () => { + startNetworkConnectivitySubscriptions(); + + expect(mockSubscribeActiveServers).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should stop subscriptions when going to background and restart when returning to active', () => { + 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]; + + appStateCallback('background'); + + expect(mockNetInfoUnsubscriber).toHaveBeenCalled(); + expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled(); + expect(mockAppStateListener.remove).not.toHaveBeenCalled(); + + mockNetInfo.addEventListener.mockClear(); + mockSubscribeActiveServers.mockClear(); + + appStateCallback('active'); + + expect(mockNetInfo.addEventListener).toHaveBeenCalled(); + expect(mockSubscribeActiveServers).toHaveBeenCalled(); + expect(mockAppStateListener.remove).not.toHaveBeenCalled(); + }); + + it('should not restart subscriptions when app state does not change', () => { + const mockNetInfoUnsubscriber = jest.fn(); + const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()}; + + 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('active'); + + expect(mockNetInfo.addEventListener).not.toHaveBeenCalled(); + expect(mockSubscribeActiveServers).not.toHaveBeenCalled(); + expect(mockNetInfoUnsubscriber).not.toHaveBeenCalled(); + expect(mockActiveServersUnsubscriber.unsubscribe).not.toHaveBeenCalled(); + }); + + it('should handle net info changes', () => { + startNetworkConnectivitySubscriptions(); + + const netInfoCallback = mockNetInfo.addEventListener.mock.calls[0][0]; + const mockNetInfoState: MockNetInfoState = {isInternetReachable: true}; + netInfoCallback(mockNetInfoState as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(mockNetInfo.addEventListener).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should handle active servers change with no servers', async () => { + startNetworkConnectivitySubscriptions(); + + const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; + await serversCallback([] as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(mockNetworkConnectivityManager.setServerConnectionStatus).toHaveBeenCalledWith(false, null); + expect(mockNetworkConnectivityManager.shutdown).toHaveBeenCalled(); + }); + + it('should handle active servers change with servers', async () => { + const mockServers: MockServer[] = [ + {url: 'https://server1.com', lastActiveAt: 1000}, + {url: 'https://server2.com', lastActiveAt: 2000}, + ]; + + 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://server2.com'); + expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server2.com'); + expect(mockNetworkPerformanceManager.observePerformanceState).toHaveBeenCalledWith('https://server2.com'); + }); + + }); + + describe('stopNetworkConnectivitySubscriptions', () => { + it('should clean up subscriptions but preserve AppState listener', async () => { + const mockAppStateListener = {remove: jest.fn()}; + const mockNetInfoUnsubscriber = jest.fn(); + const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()}; + const mockWebsocketSubscriptionForCleanup = {unsubscribe: jest.fn()}; + const mockPerformanceSubscriptionForCleanup = {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 + mockWebsocketManager.observeWebsocketState.mockReturnValue({ + subscribe: jest.fn(() => mockWebsocketSubscriptionForCleanup), + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({ + subscribe: jest.fn(() => mockPerformanceSubscriptionForCleanup), + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + startNetworkConnectivitySubscriptions(); + + // Simulate active servers to create websocket subscription + 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 + + stopNetworkConnectivitySubscriptions(); + + expect(mockAppStateListener.remove).not.toHaveBeenCalled(); + expect(mockNetInfoUnsubscriber).toHaveBeenCalled(); + expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled(); + expect(mockWebsocketSubscriptionForCleanup.unsubscribe).toHaveBeenCalled(); + expect(mockPerformanceSubscriptionForCleanup.unsubscribe).toHaveBeenCalled(); + }); + + it('should be safe to call multiple times', () => { + 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(); + stopNetworkConnectivitySubscriptions(); + + // Clear mock call counts + mockAppStateListener.remove.mockClear(); + mockNetInfoUnsubscriber.mockClear(); + mockActiveServersUnsubscriber.unsubscribe.mockClear(); + + // Call stop again - should not throw and should handle gracefully + expect(() => stopNetworkConnectivitySubscriptions()).not.toThrow(); + + // Verify cleanup methods are not called again (they should be no-ops) + expect(mockAppStateListener.remove).not.toHaveBeenCalled(); + expect(mockNetInfoUnsubscriber).not.toHaveBeenCalled(); + expect(mockActiveServersUnsubscriber.unsubscribe).not.toHaveBeenCalled(); + }); + }); + + describe('websocket subscription handling', () => { + it('should subscribe to websocket 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(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://test.com'); + }); + + 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}]; + + startNetworkConnectivitySubscriptions(); + + const serversCallback = mockSubscribeActiveServers.mock.calls[0][0]; + + await serversCallback(mockServers1 as any); // eslint-disable-line @typescript-eslint/no-explicit-any + expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server1.com'); + + await serversCallback(mockServers2 as any); // eslint-disable-line @typescript-eslint/no-explicit-any + expect(mockWebsocketSubscription.unsubscribe).toHaveBeenCalled(); + expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server2.com'); + }); + }); + + describe('shutdownNetworkConnectivitySubscriptions', () => { + it('should clean up all subscriptions including AppState listener', async () => { + const mockAppStateListener = {remove: jest.fn()}; + const mockNetInfoUnsubscriber = jest.fn(); + const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()}; + const mockWebsocketSubscriptionForCleanup = {unsubscribe: jest.fn()}; + const mockPerformanceSubscriptionForCleanup = {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 + + mockWebsocketManager.observeWebsocketState.mockReturnValue({ + subscribe: jest.fn(() => mockWebsocketSubscriptionForCleanup), + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({ + subscribe: jest.fn(() => mockPerformanceSubscriptionForCleanup), + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + startNetworkConnectivitySubscriptions(); + + // Simulate active servers to create websocket subscription + 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 + + shutdownNetworkConnectivitySubscriptions(); + + expect(mockAppStateListener.remove).toHaveBeenCalled(); + expect(mockNetInfoUnsubscriber).toHaveBeenCalled(); + expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled(); + expect(mockWebsocketSubscriptionForCleanup.unsubscribe).toHaveBeenCalled(); + expect(mockPerformanceSubscriptionForCleanup.unsubscribe).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/managers/network_connectivity_subscription_manager.ts b/app/managers/network_connectivity_subscription_manager.ts new file mode 100644 index 000000000..40cbc1794 --- /dev/null +++ b/app/managers/network_connectivity_subscription_manager.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetInfo, {type NetInfoSubscription} from '@react-native-community/netinfo'; +import {AppState, type AppStateStatus, type NativeEventSubscription} from 'react-native'; +import {Subscription} from 'rxjs'; + +import {subscribeActiveServers} from '@database/subscription/servers'; + +import NetworkConnectivityManager from './network_connectivity_manager'; +import NetworkPerformanceManager from './network_performance_manager'; +import WebsocketManager from './websocket_manager'; + +type Server = { + url: string; + lastActiveAt: number; +}; + +class NetworkConnectivitySubscriptionManagerSingleton { + private appState: AppStateStatus = 'active'; + private isInternetReachable: boolean | null = null; + private appSubscription: NativeEventSubscription | undefined; + private netInfoSubscription: NetInfoSubscription | undefined; + private activeServersUnsubscriber: {unsubscribe: () => void} | undefined; + private websocketSubscription: Subscription | undefined; + private performanceSubscription: Subscription | undefined; + + private findMostRecentServer = (servers: Server[]): Server | undefined => { + return servers?.length ? servers.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a)) : undefined; + }; + + private cleanupWebsocketSubscription = (): void => { + this.websocketSubscription?.unsubscribe(); + this.websocketSubscription = undefined; + }; + + private cleanupPerformanceSubscription = (): void => { + this.performanceSubscription?.unsubscribe(); + this.performanceSubscription = undefined; + }; + + private handleActiveServersChange = async (servers: Server[]): Promise => { + this.cleanupWebsocketSubscription(); + this.cleanupPerformanceSubscription(); + + const activeServer = this.findMostRecentServer(servers); + const serverUrl = activeServer?.url; + + if (!serverUrl) { + NetworkConnectivityManager.setServerConnectionStatus(false, null); + NetworkConnectivityManager.shutdown(); + return; + } + + NetworkConnectivityManager.setServerConnectionStatus(true, serverUrl); + + this.websocketSubscription = WebsocketManager.observeWebsocketState(serverUrl).subscribe((websocketState) => { + NetworkConnectivityManager.updateState( + websocketState, + {isInternetReachable: this.isInternetReachable}, + this.appState, + ); + }); + + this.performanceSubscription = NetworkPerformanceManager.observePerformanceState(serverUrl).subscribe((performanceState) => { + NetworkConnectivityManager.updatePerformanceState(performanceState); + }); + }; + + private handleAppStateChange = (newAppState: AppStateStatus): void => { + const previousAppState = this.appState; + this.appState = newAppState; + + if (previousAppState === newAppState) { + return; + } + + if (newAppState === 'active') { + if (!this.netInfoSubscription || !this.activeServersUnsubscriber) { + this.init(); + } + } else if (previousAppState === 'active') { + this.stop(); + } + }; + + private handleNetInfoChange = (netInfo: {isInternetReachable: boolean | null}): void => { + this.isInternetReachable = netInfo.isInternetReachable ?? null; + }; + + /** + * Initializes all network connectivity subscriptions including app state, network info, + * and active servers. This starts the global connectivity monitoring. + */ + public init = (): void => { + if (!this.appSubscription) { + this.appSubscription = AppState.addEventListener('change', this.handleAppStateChange); + } + + this.netInfoSubscription?.(); + this.netInfoSubscription = NetInfo.addEventListener(this.handleNetInfoChange); + + this.activeServersUnsubscriber?.unsubscribe?.(); + this.activeServersUnsubscriber = subscribeActiveServers(this.handleActiveServersChange); + }; + + private cleanupAllSubscriptions = (): void => { + this.websocketSubscription?.unsubscribe(); + this.performanceSubscription?.unsubscribe(); + this.activeServersUnsubscriber?.unsubscribe?.(); + this.netInfoSubscription?.(); + + this.websocketSubscription = undefined; + this.performanceSubscription = undefined; + this.activeServersUnsubscriber = undefined; + this.netInfoSubscription = undefined; + this.isInternetReachable = null; + }; + + /** + * Stops network connectivity subscriptions and cleans up resources. + * AppState listener persists to handle app lifecycle transitions. + */ + public stop = (): void => { + this.cleanupAllSubscriptions(); + }; + + /** + * Completely shuts down all subscriptions including the persistent AppState listener. + * This should only be called when the app is completely shutting down. + */ + public shutdown = (): void => { + this.websocketSubscription?.unsubscribe(); + this.performanceSubscription?.unsubscribe(); + this.activeServersUnsubscriber?.unsubscribe?.(); + this.netInfoSubscription?.(); + this.appSubscription?.remove(); + + this.appState = 'active'; + this.isInternetReachable = null; + this.appSubscription = undefined; + this.netInfoSubscription = undefined; + this.activeServersUnsubscriber = undefined; + this.websocketSubscription = undefined; + this.performanceSubscription = undefined; + }; +} + +const NetworkConnectivitySubscriptionManager = new NetworkConnectivitySubscriptionManagerSingleton(); + +export const startNetworkConnectivitySubscriptions = (): void => { + NetworkConnectivitySubscriptionManager.init(); +}; + +export const stopNetworkConnectivitySubscriptions = (): void => { + NetworkConnectivitySubscriptionManager.stop(); +}; + +export const shutdownNetworkConnectivitySubscriptions = (): void => { + NetworkConnectivitySubscriptionManager.shutdown(); +}; + +export default NetworkConnectivitySubscriptionManager; + +export const testExports = { + NetworkConnectivitySubscriptionManagerSingleton, +}; + diff --git a/app/managers/network_manager.ts b/app/managers/network_manager.ts index 6e8d1286a..25d7addd9 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, + collectMetrics: LocalConfig.CollectNetworkMetrics || LocalConfig.MonitorNetworkPerformance, }, headers, }; diff --git a/app/managers/network_performance_manager.test.ts b/app/managers/network_performance_manager.test.ts new file mode 100644 index 000000000..1dd0a54bc --- /dev/null +++ b/app/managers/network_performance_manager.test.ts @@ -0,0 +1,536 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {testExports} from './network_performance_manager'; + +import type {ClientResponseMetrics} from '@mattermost/react-native-network-client'; +import type {AppStateStatus} from 'react-native'; + +jest.mock('@utils/log', () => ({ + logDebug: jest.fn(), +})); + +jest.mock('react-native', () => ({ + AppState: { + addEventListener: jest.fn(() => ({ + remove: jest.fn(), + })), + }, +})); + +const { + NetworkPerformanceManagerSingleton, + REQUEST_OUTCOME_WINDOW_SIZE, + MINIMUM_REQUESTS_FOR_INITIAL_DETECTION, + calculatePerformanceStateFromOutcomes, +} = testExports; + +const createMockMetrics = (latency: number, size: number, compressedSize: number): ClientResponseMetrics => ({ + latency, + size, + compressedSize, + startTime: Date.now(), + endTime: Date.now() + latency, + networkType: 'wifi', + tlsCipherSuite: 'TLS_AES_256_GCM_SHA384', + tlsVersion: 'TLSv1.3', + httpVersion: 'HTTP/2', + isCached: false, + connectionTime: 0, + speedInMbps: 0, +}); + +describe('Pure Functions', () => { + describe('calculatePerformanceStateFromOutcomes', () => { + describe('initial detection', () => { + it('should return normal 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); + expect(state).toBe('normal'); + }); + + it('should return slow when initial detection threshold is met', () => { + const outcomes = Array.from({length: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 3, // 3 out of 4 = 75% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, true); + expect(state).toBe('slow'); + }); + + it('should return normal when initial detection threshold is met but percentage is low', () => { + const outcomes = Array.from({length: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 2, // 2 out of 4 = 50% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, true); + expect(state).toBe('normal'); + }); + }); + + describe('subsequent detection', () => { + it('should return slow even 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'); + }); + + it('should return normal when slow percentage is below threshold', () => { + const outcomes = Array.from({length: 10}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 3, // 3 out of 10 = 30% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, false); + expect(state).toBe('normal'); + }); + + it('should return slow when slow percentage meets threshold', () => { + const outcomes = Array.from({length: 10}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 7, // 7 out of 10 = 70% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, false); + expect(state).toBe('slow'); + }); + + it('should return slow when slow percentage exceeds threshold', () => { + const outcomes = Array.from({length: 10}, (_, i) => ({ + timestamp: Date.now(), + isSlow: i < 8, // 8 out of 10 = 80% + wasEarlyDetection: false, + })); + + const state = calculatePerformanceStateFromOutcomes(outcomes, false); + expect(state).toBe('slow'); + }); + }); + }); +}); + +describe('NetworkPerformanceManager', () => { + let performanceManager: InstanceType; + const serverUrl = 'https://test-server.com'; + + beforeEach(() => { + performanceManager = new NetworkPerformanceManagerSingleton(); + }); + + describe('request tracking lifecycle', () => { + it('should start and complete request tracking', () => { + performanceManager.observePerformanceState(serverUrl); + + const url = '/api/v4/users/me'; + const metrics = createMockMetrics(500, 1000, 500); + + const requestId = performanceManager.startRequestTracking(serverUrl, url); + expect(typeof requestId).toBe('string'); + expect(requestId).toContain('-'); + + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(1); + expect(stats.slowRequests).toBe(0); + }); + + it('should track slow requests correctly', () => { + performanceManager.observePerformanceState(serverUrl); + + const url = '/api/v4/posts'; + const slowMetrics = createMockMetrics(3000, 1000, 500); + + const requestId = performanceManager.startRequestTracking(serverUrl, url); + performanceManager.completeRequestTracking(serverUrl, requestId, slowMetrics); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(1); + expect(stats.slowRequests).toBe(1); + expect(stats.slowPercentage).toBe(1.0); + }); + + it('should cancel request tracking on failure', () => { + const url = '/api/v4/teams'; + + const requestId = performanceManager.startRequestTracking(serverUrl, url); + performanceManager.cancelRequestTracking(serverUrl, requestId); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(0); + }); + }); + + describe('performance state calculation', () => { + it('should return normal when slow percentage is below threshold', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + 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); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + subscription.unsubscribe(); + expect(states).toContain('normal'); + }); + + it('should return slow when slow percentage meets threshold', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + 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); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + subscription.unsubscribe(); + expect(states).toContain('slow'); + }); + + it('should use initial detection threshold for first slow detection', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + 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% + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + subscription.unsubscribe(); + expect(states).toEqual(['normal', 'slow']); + }); + + it('should not return to normal when switching from initial to subsequent detection', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + 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); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + for (let i = 0; i < 4; 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 + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + subscription.unsubscribe(); + + expect(states.filter((s) => s === 'normal')).toHaveLength(1); + expect(states).toEqual(['normal', 'slow']); + }); + }); + + describe('outcome statistics', () => { + it('should provide accurate request outcome statistics', () => { + performanceManager.observePerformanceState(serverUrl); + + for (let i = 0; i < 20; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(i < 15 ? 3000 : 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.earlyDetectionCount).toBe(0); + }); + + it('should limit outcome window size', () => { + performanceManager.observePerformanceState(serverUrl); + + for (let i = 0; i < REQUEST_OUTCOME_WINDOW_SIZE + 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(REQUEST_OUTCOME_WINDOW_SIZE); + }); + }); + + describe('server management', () => { + it('should handle multiple servers independently', () => { + const serverUrl2 = 'https://test-server-2.com'; + + performanceManager.observePerformanceState(serverUrl); + performanceManager.observePerformanceState(serverUrl2); + + for (let i = 0; i < 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + for (let i = 0; i < 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl2, `/api/request-${i}`); + const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl2, requestId, metrics); + } + + const state1 = performanceManager.getCurrentPerformanceState(serverUrl); + const state2 = performanceManager.getCurrentPerformanceState(serverUrl2); + + expect(state1).toBe('normal'); + expect(state2).toBe('slow'); + + performanceManager.removeServer(serverUrl2); + }); + + it('should clean up when server is removed', () => { + const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test'); + const metrics = createMockMetrics(500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + + performanceManager.removeServer(serverUrl); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(0); + }); + + it('should not recreate storage when request completes after server removal', () => { + performanceManager.observePerformanceState(serverUrl); + + const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test'); + + performanceManager.removeServer(serverUrl); + + const metrics = createMockMetrics(3000, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(0); + + const state = performanceManager.getCurrentPerformanceState(serverUrl); + expect(state).toBe('normal'); + }); + + it('should not recreate storage when early detection timer fires after server removal', () => { + jest.useFakeTimers(); + + performanceManager.observePerformanceState(serverUrl); + + performanceManager.startRequestTracking(serverUrl, '/api/test'); + + performanceManager.removeServer(serverUrl); + + jest.advanceTimersByTime(2000); + + const stats = performanceManager.getRequestOutcomeStats(serverUrl); + expect(stats.totalRequests).toBe(0); + + const state = performanceManager.getCurrentPerformanceState(serverUrl); + expect(state).toBe('normal'); + + jest.useRealTimers(); + }); + }); + + describe('observable behavior', () => { + it('should emit state changes when performance changes', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + 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); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + subscription.unsubscribe(); + expect(states).toEqual(['normal', 'slow']); + }); + }); + + describe('debug logging', () => { + const {logDebug} = require('@utils/log'); + + beforeEach(() => { + logDebug.mockClear(); + }); + + it('should log when performance state degrades from normal to slow', () => { + performanceManager.observePerformanceState(serverUrl); + + for (let i = 0; i < 10; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(i < 8 ? 3000 : 500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + expect(logDebug).toHaveBeenCalledWith( + `Network performance degraded for ${serverUrl}: normal -> slow`, + 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), + }), + }), + ); + }); + + it('should not log when performance state remains the same', () => { + for (let i = 0; i < 3; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`); + const metrics = createMockMetrics(500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + expect(logDebug).not.toHaveBeenCalled(); + }); + + it('should log with correct details when performance degrades', () => { + 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); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + expect(logDebug).toHaveBeenCalledWith( + `Network performance degraded for ${serverUrl}: normal -> slow`, + expect.objectContaining({ + totalRequests: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION, + slowRequests: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION, + slowPercentage: '100.0%', + earlyDetectionCount: 0, + currentTimestamp: expect.any(Number), + detectionDelayMs: expect.any(Number), + detectionDelaySeconds: expect.any(String), + lastOutcome: { + isSlow: true, + wasEarlyDetection: false, + }, + }), + ); + }); + + it('should not log when performance recovers from slow to normal', () => { + 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); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + logDebug.mockClear(); + for (let i = 0; i < 20; i++) { + const requestId = performanceManager.startRequestTracking(serverUrl, `/api/fast-${i}`); + const metrics = createMockMetrics(500, 1000, 500); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + expect(logDebug).not.toHaveBeenCalled(); + }); + }); + + describe('AppState monitoring', () => { + const {AppState} = require('react-native'); + let appStateHandler: (nextAppState: AppStateStatus) => void; + + beforeEach(() => { + AppState.addEventListener.mockImplementation((event: string, handler: (nextAppState: AppStateStatus) => void) => { + if (event === 'change') { + appStateHandler = handler; + } + return {remove: jest.fn()}; + }); + }); + + it('should setup AppState monitoring on construction', () => { + const manager = new NetworkPerformanceManagerSingleton(); + expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + manager.destroy(); + }); + + it('should clean up active requests when app goes to background', () => { + performanceManager.startRequestTracking(serverUrl, '/api/test'); + + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1); + + appStateHandler('background'); + + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(0); + }); + + it('should reset performance state to normal when app becomes inactive', () => { + const states: string[] = []; + const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state) => { + 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); + performanceManager.completeRequestTracking(serverUrl, requestId, metrics); + } + + expect(performanceManager.getCurrentPerformanceState(serverUrl)).toBe('slow'); + + appStateHandler('inactive'); + + expect(performanceManager.getCurrentPerformanceState(serverUrl)).toBe('normal'); + subscription.unsubscribe(); + }); + + it('should not clean up when app state is active', () => { + const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test'); + + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1); + + appStateHandler('active'); + + expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1); + + performanceManager.cancelRequestTracking(serverUrl, requestId); + }); + + it('should clean up AppState subscription on destroy', () => { + const mockRemove = jest.fn(); + AppState.addEventListener.mockReturnValue({remove: mockRemove}); + + const manager = new NetworkPerformanceManagerSingleton(); + manager.destroy(); + + expect(mockRemove).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/managers/network_performance_manager.ts b/app/managers/network_performance_manager.ts new file mode 100644 index 000000000..6f15f0c0a --- /dev/null +++ b/app/managers/network_performance_manager.ts @@ -0,0 +1,325 @@ +// 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 {logDebug} from '@utils/log'; + +import type {ClientResponseMetrics} from '@mattermost/react-native-network-client'; + +export type NetworkPerformanceState = 'normal' | 'slow'; + +interface ActiveRequest { + id: string; + url: string; + startTime: number; + checkTimer?: NodeJS.Timeout; +} + +interface RequestOutcome { + timestamp: number; + isSlow: boolean; + wasEarlyDetection: boolean; +} + +const SLOW_REQUEST_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 minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; + + if (isInitialDetection && outcomes.length < minimumRequests) { + return 'normal'; + } + + const slowRequestCount = outcomes.filter((outcome) => outcome.isSlow).length; + const slowPercentage = slowRequestCount / outcomes.length; + + return slowPercentage >= SLOW_REQUEST_PERCENTAGE_THRESHOLD ? 'slow' : 'normal'; +}; + +class NetworkPerformanceManagerSingleton { + private performanceSubjects: Record> = {}; + private activeRequests: Record> = {}; + private requestOutcomes: Record = {}; + private initialRequestTimestamp: Record = {}; + private isInitialDetection: Record = {}; + private appStateSubscription: any = null; + + constructor() { + this.setupAppStateMonitoring(); + } + + /** + * Starts tracking a request for early performance detection. + * Returns a unique request ID that should be used when the request completes. + */ + public startRequestTracking = (serverUrl: string, url: string): string => { + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + if (!this.activeRequests[serverUrl]) { + this.activeRequests[serverUrl] = {}; + } + + const request: ActiveRequest = { + id: requestId, + url, + startTime: Date.now(), + }; + + request.checkTimer = setTimeout(() => { + this.checkRequestLatency(serverUrl, requestId); + }, SLOW_REQUEST_THRESHOLD); + + this.activeRequests[serverUrl][requestId] = request; + return requestId; + }; + + /** + * Completes request tracking and adds metrics for performance reporting. + * Should be called when the request finishes with the ID from startRequestTracking. + */ + public completeRequestTracking = (serverUrl: string, requestId: string, metrics: ClientResponseMetrics) => { + const activeRequest = this.activeRequests[serverUrl]?.[requestId]; + const wasEarlyDetected = !activeRequest; // If not found, it was already early detected and removed + + this.clearActiveRequest(serverUrl, requestId); + + // Only record the outcome if it wasn't already recorded by early detection + if (!wasEarlyDetected) { + this.recordRequestOutcome(serverUrl, { + timestamp: Date.now(), + isSlow: metrics.latency >= SLOW_REQUEST_THRESHOLD, + wasEarlyDetection: false, + }); + } + }; + + /** + * Cancels request tracking when a request fails. + * Should be called when the request fails with the ID from startRequestTracking. + */ + public cancelRequestTracking = (serverUrl: string, requestId: string) => { + this.clearActiveRequest(serverUrl, requestId); + }; + + /** + * Returns an observable that emits network performance state changes. + * Emits 'normal' or 'slow' based on current performance metrics. + */ + public observePerformanceState = (serverUrl: string) => { + return this.getPerformanceSubject(serverUrl).asObservable().pipe( + distinctUntilChanged(), + ); + }; + + /** + * Gets the current network performance state for a server. + */ + public getCurrentPerformanceState = (serverUrl: string): NetworkPerformanceState => { + return this.getPerformanceSubject(serverUrl).getValue(); + }; + + /** + * Gets the current request outcome statistics for a server. + */ + public getRequestOutcomeStats = (serverUrl: string) => { + const outcomes = this.requestOutcomes[serverUrl] || []; + if (!outcomes.length) { + return { + totalRequests: 0, + slowRequests: 0, + slowPercentage: 0, + earlyDetectionCount: 0, + }; + } + + const slowRequests = outcomes.filter((outcome) => outcome.isSlow).length; + const earlyDetectionCount = outcomes.filter((outcome) => outcome.wasEarlyDetection).length; + + return { + totalRequests: outcomes.length, + slowRequests, + slowPercentage: slowRequests / outcomes.length, + earlyDetectionCount, + }; + }; + + /** + * Gets the count of active requests for a server (for testing purposes). + */ + public getActiveRequestCount = (serverUrl: string): number => { + return Object.keys(this.activeRequests[serverUrl] || {}).length; + }; + + /** + * Removes all data and subscriptions for a server. + */ + public removeServer = (serverUrl: string) => { + this.clearAllActiveRequests(serverUrl); + if (this.performanceSubjects[serverUrl]) { + this.performanceSubjects[serverUrl].complete(); + delete this.performanceSubjects[serverUrl]; + } + delete this.requestOutcomes[serverUrl]; + delete this.initialRequestTimestamp[serverUrl]; + delete this.isInitialDetection[serverUrl]; + }; + + /** + * Cleans up all resources when the manager is being destroyed. + */ + public destroy = () => { + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + this.appStateSubscription = null; + } + + Object.keys(this.activeRequests).forEach((serverUrl) => { + this.clearAllActiveRequests(serverUrl); + }); + + Object.keys(this.performanceSubjects).forEach((serverUrl) => { + this.performanceSubjects[serverUrl].complete(); + }); + + this.performanceSubjects = {}; + this.requestOutcomes = {}; + this.initialRequestTimestamp = {}; + this.isInitialDetection = {}; + }; + + private checkRequestLatency = (serverUrl: string, requestId: string) => { + const activeRequest = this.activeRequests[serverUrl]?.[requestId]; + if (!activeRequest) { + return; + } + + const currentTime = Date.now(); + const elapsedTime = currentTime - activeRequest.startTime; + + if (elapsedTime >= SLOW_REQUEST_THRESHOLD) { + this.recordRequestOutcome(serverUrl, { + timestamp: currentTime, + isSlow: true, + wasEarlyDetection: true, + }); + + this.clearActiveRequest(serverUrl, requestId); + } + }; + + private clearActiveRequest = (serverUrl: string, requestId: string) => { + const activeRequest = this.activeRequests[serverUrl]?.[requestId]; + if (activeRequest?.checkTimer) { + clearTimeout(activeRequest.checkTimer); + } + if (this.activeRequests[serverUrl]) { + delete this.activeRequests[serverUrl][requestId]; + } + }; + + private clearAllActiveRequests = (serverUrl: string) => { + const requests = this.activeRequests[serverUrl]; + if (requests) { + Object.values(requests).forEach((request) => { + if (request.checkTimer) { + clearTimeout(request.checkTimer); + } + }); + delete this.activeRequests[serverUrl]; + } + }; + + private recordRequestOutcome = (serverUrl: string, outcome: RequestOutcome) => { + if (!this.performanceSubjects[serverUrl]) { + return; + } + + if (!this.requestOutcomes[serverUrl]) { + this.requestOutcomes[serverUrl] = []; + } + + if (!this.initialRequestTimestamp[serverUrl] && outcome.isSlow) { + this.initialRequestTimestamp[serverUrl] = outcome.timestamp; + } + + this.requestOutcomes[serverUrl].push(outcome); + + if (this.requestOutcomes[serverUrl].length > REQUEST_OUTCOME_WINDOW_SIZE) { + this.requestOutcomes[serverUrl] = this.requestOutcomes[serverUrl].slice(-REQUEST_OUTCOME_WINDOW_SIZE); + } + + const outcomes = this.requestOutcomes[serverUrl]; + const currentPerformanceState = this.getCurrentPerformanceState(serverUrl); + const isInitialDetection = !this.isInitialDetection[serverUrl]; + const newPerformanceState = calculatePerformanceStateFromOutcomes(outcomes, isInitialDetection); + + if (currentPerformanceState !== newPerformanceState && newPerformanceState === 'slow') { + const stats = this.getRequestOutcomeStats(serverUrl); + const detectionDelayMs = outcome.timestamp - this.initialRequestTimestamp[serverUrl]; + + logDebug(`Network performance degraded for ${serverUrl}: ${currentPerformanceState} -> ${newPerformanceState}`, { + 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]; + } + + this.getPerformanceSubject(serverUrl).next(newPerformanceState); + }; + + private getPerformanceSubject = (serverUrl: string) => { + if (!this.performanceSubjects[serverUrl]) { + this.performanceSubjects[serverUrl] = new BehaviorSubject('normal'); + } + return this.performanceSubjects[serverUrl]; + }; + + private setupAppStateMonitoring = () => { + this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange); + }; + + private handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState !== 'active') { + this.cleanupOnAppStateChange(); + } + }; + + private cleanupOnAppStateChange = () => { + Object.keys(this.activeRequests).forEach((serverUrl) => { + this.clearAllActiveRequests(serverUrl); + }); + + Object.keys(this.performanceSubjects).forEach((serverUrl) => { + this.performanceSubjects[serverUrl].next('normal'); + }); + }; +} + +export const testExports = { + NetworkPerformanceManagerSingleton, + SLOW_REQUEST_THRESHOLD, + REQUEST_OUTCOME_WINDOW_SIZE, + MINIMUM_REQUESTS_FOR_INITIAL_DETECTION, + MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION, + calculatePerformanceStateFromOutcomes, +}; + +const NetworkPerformanceManager = new NetworkPerformanceManagerSingleton(); +export default NetworkPerformanceManager; diff --git a/app/managers/session_manager.ts b/app/managers/session_manager.ts index 49ac6a9ba..d76aad642 100644 --- a/app/managers/session_manager.ts +++ b/app/managers/session_manager.ts @@ -13,7 +13,10 @@ import {resetMomentLocale} from '@i18n'; import {getAllServerCredentials, removeServerCredentials} from '@init/credentials'; import {relaunchApp} from '@init/launch'; import PushNotifications from '@init/push_notifications'; +import NetworkConnectivityManager from '@managers/network_connectivity_manager'; +import NetworkConnectivitySubscriptionManager from '@managers/network_connectivity_subscription_manager'; import NetworkManager from '@managers/network_manager'; +import NetworkPerformanceManager from '@managers/network_performance_manager'; import SecurityManager from '@managers/security_manager'; import WebsocketManager from '@managers/websocket_manager'; import {getAllServers, getServerDisplayName} from '@queries/app/servers'; @@ -117,7 +120,10 @@ export class SessionManagerSingleton { PushNotifications.removeServerNotifications(serverUrl); SecurityManager.removeServer(serverUrl); + NetworkConnectivityManager.setServerConnectionStatus(false, serverUrl); NetworkManager.invalidateClient(serverUrl); + NetworkConnectivitySubscriptionManager.stop(); + NetworkPerformanceManager.removeServer(serverUrl); WebsocketManager.invalidateClient(serverUrl); if (removeServer) { diff --git a/assets/base/config.json b/assets/base/config.json index 2ae360213..c6833a84b 100644 --- a/assets/base/config.json +++ b/assets/base/config.json @@ -29,5 +29,6 @@ "ExperimentalNormalizeMarkdownLinks": false, "CustomRequestHeaders": {}, - "CollectNetworkMetrics": false + "CollectNetworkMetrics": false, + "MonitorNetworkPerformance": true } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 1afd9af54..5d7fcef8c 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -270,6 +270,12 @@ "combined_system_message.removed_from_team.one_you": "You were **removed from the team**.", "combined_system_message.removed_from_team.two": "{firstUser} and {secondUser} were **removed from the team**.", "combined_system_message.you": "You", + "connection_banner.connected": "Connection restored", + "connection_banner.connecting": "Connecting...", + "connection_banner.limited_network_connection": "Limited network connection", + "connection_banner.not_connected": "Unable to connect to network", + "connection_banner.not_reachable": "The server is not reachable", + "connection_banner.status_unknown": "Connection status unknown", "create_direct_message.title": "Create Direct Message", "create_post.deactivated": "You are viewing an archived channel with a deactivated user.", "create_post.thread_reply": "Reply to this thread...", diff --git a/docs/network-connectivity-observation.md b/docs/network-connectivity-observation.md new file mode 100644 index 000000000..20d727fd7 --- /dev/null +++ b/docs/network-connectivity-observation.md @@ -0,0 +1,740 @@ +# Network Connectivity Observation System + +## Table of Contents +- [Overview](#overview) +- [Architecture](#architecture) +- [System Components](#system-components) +- [Data Flow](#data-flow) +- [Technical Specifications](#technical-specifications) +- [Banner Display Logic](#banner-display-logic) +- [Performance Detection Algorithm](#performance-detection-algorithm) +- [Configuration](#configuration) + +## Overview + +The Network Connectivity Observation System is a comprehensive monitoring solution that tracks network performance and connectivity status in real-time, providing contextual feedback to users through an intelligent banner system. + +### Key Features +- **Real-time Performance Monitoring**: Tracks request latency to detect network degradation +- **Early Detection System**: Identifies slow requests before completion (2s threshold) +- **Smart Banner Management**: Context-aware banner display with anti-spam mechanisms +- **Multi-Server Support**: Independent tracking per server URL +- **Lifecycle Management**: Handles app state transitions (background/foreground) + +--- + +## Architecture + +### System Diagram + +```mermaid +graph TB + subgraph NCSM["NetworkConnectivitySubscriptionManager
Manages 5 Subscriptions"] + direction LR + SUB1[1. AppState] + SUB2[2. NetInfo] + SUB3[3. Active Servers] + SUB4[4. WebSocket State] + SUB5[5. Performance State] + end + + NPM["NetworkPerformanceManager
• Request Tracking
• Performance Detection
• Observable Streams
• Sliding Window 20 requests"] + + NCM["NetworkConnectivityManager
• updateState()
• updatePerformanceState()
• Banner Priority Logic
• Suppression Control"] + + BM[BannerManager
• Show/Hide Banner
• Auto-Hide
• Cleanup] + + CB[ConnectionBanner
FloatingBanner] + + NCSM -->|updates| NCM + NCM -->|controls| BM + BM -->|renders| CB + + style NCSM fill:#e1f5ff + style NCM fill:#fff4e1 + style NPM fill:#f0e1ff + style BM fill:#d4edda +``` + +--- + +## System Components + +### 1. NetworkPerformanceManager + +**Purpose**: Monitors request performance and detects network degradation + +**Key Responsibilities**: +- Track active requests with unique IDs +- Detect slow requests (≥2000ms) early via timers +- Maintain sliding window of request outcomes (20 requests) +- Calculate performance state based on slow request percentage +- Emit performance state changes via RxJS observables + +**Public API**: +```typescript +class NetworkPerformanceManager { + // Start tracking a request, returns unique ID + startRequestTracking(serverUrl: string, url: string): string + + // Complete tracking with metrics + completeRequestTracking(serverUrl: string, requestId: string, metrics: ClientResponseMetrics): void + + // Cancel tracking on failure + cancelRequestTracking(serverUrl: string, requestId: string): void + + // Observe performance state changes + observePerformanceState(serverUrl: string): Observable + + // Get current state + getCurrentPerformanceState(serverUrl: string): NetworkPerformanceState + + // Cleanup + removeServer(serverUrl: string): void +} +``` + +### 2. NetworkConnectivityManager + +**Purpose**: Orchestrates banner display based on connectivity and performance states + +**Key Responsibilities**: +- Maintain current WebSocket, network, and performance state +- Implement banner display priority logic +- Handle suppression mechanisms +- Manage first connection vs reconnection scenarios + +**Public API**: +```typescript +class NetworkConnectivityManager { + // Initialize with server URL + init(serverUrl: string | null): void + + // Update connection status + setServerConnectionStatus(connected: boolean, serverUrl: string | null): void + + // Update WebSocket/network state + updateState( + websocketState: WebsocketConnectedState, + netInfo: {isInternetReachable: boolean | null}, + appState: string + ): void + + // Update performance state + updatePerformanceState(performanceState: NetworkPerformanceState): void + + // Cleanup + cleanup(): void + shutdown(): void +} +``` + +### 3. NetworkConnectivitySubscriptionManager + +**Purpose**: Coordinates all network-related subscriptions and server lifecycle + +**Key Responsibilities**: +- Subscribe to app state changes (active/background) +- Subscribe to network info changes +- Subscribe to active server changes +- Subscribe to WebSocket state per server +- Subscribe to performance state per server + +**Public API**: +```typescript +class NetworkConnectivitySubscriptionManager { + // Initialize all subscriptions + init(): void + + // Stop subscriptions (preserves AppState listener) + stop(): void + + // Complete shutdown + shutdown(): void +} +``` + +### 4. ClientTracking (Enhanced) + +**Purpose**: Integrate performance tracking into network requests + +**Integration Points**: +```typescript +class ClientTracking { + doFetchWithTracking = async (url: string, options: ClientOptions) => { + // 1. Start performance tracking + const performanceRequestId = this.startNetworkPerformanceTracking(url); + + try { + // 2. Execute request + response = await request(url, this.buildRequestOptions(options)); + + // 3. Complete tracking with metrics + this.completeNetworkPerformanceTracking(performanceRequestId, url, response.metrics); + } catch (error) { + // 4. Cancel tracking on error + this.cancelNetworkPerformanceTracking(performanceRequestId); + throw error; + } + } +} +``` + +--- + +## Data Flow + +### 1. Request Performance Tracking Flow + +```mermaid +flowchart TD + Start[HTTP Request Starts] --> CT[ClientTracking.doFetchWithTracking] + CT -->|startNetworkPerformanceTracking| NPM[NetworkPerformanceManager] + + NPM -->|1. Generate unique requestId
2. Set 2s timer
3. Store in activeRequests| Decision{Request Outcome} + + Decision -->|Completes < 2s| Complete[Record Outcome
wasEarly: false] + Decision -->|Timer Triggers @ 2s| Early[Early Detection
Record Outcome
wasEarly: true] + Decision -->|Fails| Cancel[Cancel Tracking
No outcome recorded] + + Complete --> Update[Update Performance State] + Early --> Update + + Update --> Steps[1. Add to outcomes window max 20
2. Calculate slow %
3. Determine state
4. Emit if changed] + + style NPM fill:#f0e1ff + style Update fill:#e1f5ff + style Complete fill:#d4edda + style Early fill:#fff3cd + style Cancel fill:#f8d7da +``` + +### 2. Subscription and State Flow + +```mermaid +sequenceDiagram + participant Init as app/init/app.ts + participant NCSM as NetworkConnectivitySubscriptionManager + participant NCM as NetworkConnectivityManager + participant WSM as WebsocketManager + participant NPM as NetworkPerformanceManager + participant DB as Active Servers DB + + Init->>NCM: 1. initialize()
init(activeServerUrl) + Init->>NCSM: 2. start()
init() + + NCSM->>NCSM: AppState.addEventListener() + NCSM->>NCSM: NetInfo.addEventListener() + NCSM->>DB: subscribeActiveServers() + + DB->>NCSM: Active Server Changed
findMostRecentServer() → serverUrl + + NCSM->>NCM: setServerConnectionStatus(true, serverUrl) + NCSM->>WSM: observeWebsocketState(serverUrl) + NCSM->>NPM: observePerformanceState(serverUrl) + + loop State Updates + WSM-->>NCSM: Emit WebSocket State + NCSM->>NCM: updateState(websocketState, netInfo, appState) + NCM->>NCM: updateBanner()
Priority Order:
1. Disconnected
2. Performance
3. Connecting
4. Reconnection
5. Connected + + NPM-->>NCSM: Emit Performance State + NCSM->>NCM: updatePerformanceState(performanceState) + NCM->>NCM: updateBanner() + end +``` + +--- + +## Technical Specifications + +### Performance Detection Algorithm + +#### Constants +```typescript +const SLOW_REQUEST_THRESHOLD = 2000; // 2 seconds +const SLOW_REQUEST_PERCENTAGE_THRESHOLD = 0.7; // 70% +const REQUEST_OUTCOME_WINDOW_SIZE = 20; // Last 20 requests +const MINIMUM_REQUESTS_FOR_INITIAL_DETECTION = 4; // First detection +const MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION = 10; // Later detections +``` + +#### State Calculation Logic +```typescript +function calculatePerformanceStateFromOutcomes( + outcomes: RequestOutcome[], + isInitialDetection: boolean +): NetworkPerformanceState { + const minimumRequests = isInitialDetection + ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION // 4 requests + : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; // 10 requests + + // Not enough data yet for initial detection + if (isInitialDetection && outcomes.length < minimumRequests) { + return 'normal'; + } + + const slowRequestCount = outcomes.filter(o => o.isSlow).length; + const slowPercentage = slowRequestCount / outcomes.length; + + // Slow if ≥70% of requests are slow + return slowPercentage >= SLOW_REQUEST_PERCENTAGE_THRESHOLD + ? 'slow' + : 'normal'; +} +``` + +#### Early Detection System +```typescript +// When request tracking starts +startRequestTracking(serverUrl: string, url: string): string { + const requestId = generateUniqueId(); + + // Set timer for early detection + const checkTimer = setTimeout(() => { + // If still active after 2s, mark as slow + if (activeRequests[requestId]) { + recordRequestOutcome({ + timestamp: Date.now(), + isSlow: true, + wasEarlyDetection: true // Flag for early detection + }); + clearActiveRequest(requestId); + } + }, SLOW_REQUEST_THRESHOLD); // 2000ms + + activeRequests[requestId] = { id, url, startTime, checkTimer }; + return requestId; +} + +// When request completes +completeRequestTracking(serverUrl: string, requestId: string, metrics: ClientResponseMetrics) { + const wasEarlyDetected = !activeRequests[requestId]; // Already removed by timer + + clearActiveRequest(requestId); + + // Only record if not already recorded by early detection + if (!wasEarlyDetected) { + recordRequestOutcome({ + timestamp: Date.now(), + isSlow: metrics.latency >= SLOW_REQUEST_THRESHOLD, + wasEarlyDetection: false + }); + } +} +``` + +### Sliding Window Management +```typescript +recordRequestOutcome(serverUrl: string, outcome: RequestOutcome) { + if (!requestOutcomes[serverUrl]) { + requestOutcomes[serverUrl] = []; + } + + requestOutcomes[serverUrl].push(outcome); + + // Keep only last 20 requests + if (requestOutcomes[serverUrl].length > REQUEST_OUTCOME_WINDOW_SIZE) { + requestOutcomes[serverUrl] = requestOutcomes[serverUrl].slice(-REQUEST_OUTCOME_WINDOW_SIZE); + } + + // Recalculate and emit new state + const newState = calculatePerformanceStateFromOutcomes( + requestOutcomes[serverUrl], + isInitialDetection[serverUrl] + ); + + performanceSubject.next(newState); +} +``` + +--- + +## Banner Display Logic + +### Priority Order (First Match Wins) + +```typescript +private updateBanner() { + // 0. Hide banner if no server or app in background + if (!currentServerUrl || appState === 'background') { + BannerManager.hideBanner(); + return; + } + + // 1. HIGHEST PRIORITY: Disconnected state + if (shouldShowDisconnectedBanner(websocketState, isOnAppStart)) { + showConnectivity(false); // Persistent banner + return; + } + + // 2. Performance degradation + if (shouldShowPerformanceBanner(performanceState, performanceSuppressed)) { + showPerformance(10000); // Auto-hide after 10s + return; + } + + // 3. Connecting state + if (shouldShowConnectingBanner(websocketState, isOnAppStart)) { + showConnectivity(false); // Persistent banner + return; + } + + // 4. Reconnection success + if (shouldShowReconnectionBanner(websocketState, previousState, isOnAppStart)) { + showConnectivity(true, 3000); // Auto-hide after 3s + return; + } + + // 5. LOWEST PRIORITY: Connected (hide banner) + BannerManager.hideBanner(); +} +``` + +### Banner Decision Functions + +#### Disconnected Banner +```typescript +shouldShowDisconnectedBanner( + websocketState: WebsocketConnectedState | null, + isOnAppStart: boolean +): boolean { + return websocketState === 'not_connected' && !isOnAppStart; +} +``` +- **Shows**: When disconnected after initial connection +- **Hides**: On first connection (app start) +- **Duration**: Persistent until state changes +- **Messages** (internationalized): + - "The server is not reachable" (`connection_banner.not_reachable`) - when internet is reachable but server is not + - "Unable to connect to network" (`connection_banner.not_connected`) - when internet is not reachable + - "Connection status unknown" (`connection_banner.status_unknown`) - when websocketState or netInfo is null + +#### Performance Banner +```typescript +shouldShowPerformanceBanner( + performanceState: NetworkPerformanceState | null, + performanceSuppressed: boolean +): boolean { + return performanceState === 'slow' && !performanceSuppressed; +} +``` +- **Shows**: When performance degrades to 'slow' +- **Suppression**: User can dismiss; suppressed until performance returns to 'normal' +- **Duration**: Auto-hide after 10 seconds +- **Message**: "Limited network connection" (`connection_banner.limited_network_connection`) + +#### Connecting Banner +```typescript +shouldShowConnectingBanner( + websocketState: WebsocketConnectedState | null, + isOnAppStart: boolean +): boolean { + return websocketState === 'connecting' && !isOnAppStart; +} +``` +- **Shows**: When reconnecting after initial connection +- **Hides**: During first connection +- **Duration**: Persistent until state changes +- **Message**: "Connecting..." (`connection_banner.connecting`) + +#### Reconnection Banner +```typescript +shouldShowReconnectionBanner( + websocketState: WebsocketConnectedState | null, + previousWebsocketState: WebsocketConnectedState | null, + isOnAppStart: boolean +): boolean { + return websocketState === 'connected' && + previousWebsocketState !== 'connected' && + !isOnAppStart; +} +``` +- **Shows**: When connection restored after being disconnected/connecting +- **Hides**: On first connection +- **Duration**: Auto-hide after 3 seconds +- **Message**: "Connection restored" (`connection_banner.connected`) + +### Message Internationalization + +All banner messages are internationalized using `formatMessage()` for proper localization: + +```typescript +private getConnectionMessage(): string { + // Guard against uninitialized state + if (!this.websocketState || !this.netInfo) { + return this.intl.formatMessage({ + id: 'connection_banner.status_unknown', + defaultMessage: 'Connection status unknown' + }); + } + + return getConnectionMessageText( + this.websocketState, + this.netInfo.isInternetReachable, + this.intl.formatMessage + ); +} +``` + +**Message Keys**: +- `connection_banner.status_unknown` - When websocketState or netInfo is null +- `connection_banner.connected` - Connection restored +- `connection_banner.connecting` - Connecting... +- `connection_banner.not_reachable` - Server not reachable (but internet is) +- `connection_banner.not_connected` - Unable to connect to network +- `connection_banner.limited_network_connection` - Performance degraded + +### Suppression Mechanisms + +#### Performance Suppression +```typescript +// User dismisses performance banner +onDismiss: () => { + this.performanceSuppressedUntilNormal = true; +} + +// Reset when performance returns to normal +updatePerformanceState(performanceState: NetworkPerformanceState) { + if (this.performanceSuppressedUntilNormal && performanceState === 'normal') { + this.performanceSuppressedUntilNormal = false; + } +} +``` + +#### First Connection Logic +```typescript +// isOnAppStart flag prevents banners on initial connection +updateState(websocketState, netInfo, appState) { + this.previousWebsocketState = this.websocketState; + this.websocketState = websocketState; + + this.updateBanner(); + + // Clear flag only after first successful connection + if (websocketState === 'connected' && this.isOnAppStart) { + this.isOnAppStart = false; + } +} +``` + +--- + +## Configuration + +### Config Flags + +#### `assets/base/config.json` +```json +{ + "CollectNetworkMetrics": false, + "MonitorNetworkPerformance": true +} +``` + +**`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` + +### Integration in ClientTracking + +```typescript +// Network metrics collection (existing) +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); + } +} +``` + +--- + +## Lifecycle Management + +### App State Transitions + +```typescript +// AppState: active → background +handleAppStateChange(nextAppState: AppStateStatus) { + if (nextAppState !== 'active') { + // 1. Clear all active request timers + clearAllActiveRequests(); + + // 2. Reset performance state to normal + performanceSubjects.forEach(subject => subject.next('normal')); + + // 3. Hide all banners + BannerManager.hideBanner(); + + // 4. Stop subscriptions (except AppState listener) + NetworkConnectivitySubscriptionManager.stop(); + } +} + +// AppState: background → active +handleAppStateChange(nextAppState: AppStateStatus) { + if (nextAppState === 'active') { + // Restart all subscriptions + NetworkConnectivitySubscriptionManager.init(); + } +} +``` + +### Server Lifecycle + +```typescript +// Server added/activated +handleActiveServersChange(servers: Server[]) { + const activeServer = findMostRecentServer(servers); + + if (activeServer) { + // 1. Set connection status + NetworkConnectivityManager.setServerConnectionStatus(true, activeServer.url); + + // 2. Subscribe to WebSocket state + websocketSubscription = WebsocketManager + .observeWebsocketState(activeServer.url) + .subscribe(state => { + NetworkConnectivityManager.updateState(state, netInfo, appState); + }); + + // 3. Subscribe to performance state + performanceSubscription = NetworkPerformanceManager + .observePerformanceState(activeServer.url) + .subscribe(state => { + NetworkConnectivityManager.updatePerformanceState(state); + }); + } +} + +// Server removed/logout +terminateSession(serverUrl: string) { + // 1. Clear connection status + NetworkConnectivityManager.setServerConnectionStatus(false, serverUrl); + + // 2. Remove performance tracking + NetworkPerformanceManager.removeServer(serverUrl); + + // 3. Stop subscriptions + NetworkConnectivitySubscriptionManager.stop(); +} +``` + +--- + +## Performance Characteristics + +### Memory Usage +- **Request Outcomes**: Max 20 per server (sliding window) +- **Active Requests**: Cleared on completion or 2s timeout +- **Timers**: One per active request, auto-cleared + +### Detection Speed +- **Early Detection**: 2 seconds (timer-based) +- **Initial Detection**: 4-8 slow requests (4 minimum) +- **Subsequent Detection**: 10-20 requests (10 minimum with 70% threshold) + +### Banner Timing +- **Disconnected**: Immediate, persistent +- **Connecting**: Immediate, persistent +- **Performance**: After threshold met, auto-hide 10s +- **Reconnected**: Immediate, auto-hide 3s + +--- + +## Error Handling + +### Request Failure +```typescript +try { + response = await request(url, options); + completeRequestTracking(requestId, response.metrics); +} catch (error) { + // Cancel tracking - no outcome recorded + cancelRequestTracking(requestId); + throw error; +} +``` + +### Missing Metrics +```typescript +completeRequestTracking(requestId, metrics) { + if (!metrics) { + // Silently skip - no outcome recorded + clearActiveRequest(requestId); + return; + } + // ... record outcome +} +``` + +### Subscription Cleanup +```typescript +// Safe to call multiple times +stop() { + websocketSubscription?.unsubscribe(); + performanceSubscription?.unsubscribe(); + netInfoSubscription?.(); + activeServersUnsubscriber?.unsubscribe(); + + // Clear references + websocketSubscription = undefined; + performanceSubscription = undefined; + // ... +} +``` + +--- + +## Testing Strategy + +### Unit Tests +- **NetworkPerformanceManager**: Request tracking, state calculation, sliding window +- **NetworkConnectivityManager**: Banner decision functions, state transitions +- **NetworkConnectivitySubscriptionManager**: Subscription lifecycle, server changes + +### Test Coverage +- Early detection (timer triggers) +- Request completion before/after threshold +- State transitions (normal ↔ slow) +- Banner priority order +- Suppression mechanisms +- App lifecycle (background/foreground) +- Server lifecycle (add/remove) +- Error scenarios (missing metrics, failed requests) + +--- + +## Future Enhancements + +### Potential Improvements +1. **Adaptive Thresholds**: Adjust SLOW_REQUEST_THRESHOLD based on historical performance +2. **Network Type Awareness**: Different thresholds for WiFi vs Cellular +3. **Exponential Backoff**: For repeated performance banners +4. **Metrics Reporting**: Send detection events to analytics +5. **User Preferences**: Allow users to configure sensitivity + +### Known Limitations +1. **Single Server Focus**: Only monitors most recently active server +2. **Fixed Thresholds**: 2s threshold may not suit all use cases +3. **No Geographical Context**: Doesn't account for server location +4. **Binary States**: Only 'normal' vs 'slow' (no 'excellent' or 'poor') diff --git a/fastlane/.env.android.pr b/fastlane/.env.android.pr index 9eb87d8b3..579ee91c1 100644 --- a/fastlane/.env.android.pr +++ b/fastlane/.env.android.pr @@ -9,5 +9,6 @@ BUILD_FOR_RELEASE=true BUILD_PR=true COLLECT_NETWORK_METRICS=true MAIN_APP_IDENTIFIER=com.mattermost.rnbeta +MONITOR_NETWORK_PERFORMANCE=true REPLACE_ASSETS=false SHOW_ONBOARDING=true \ No newline at end of file diff --git a/fastlane/.env.ios.pr b/fastlane/.env.ios.pr index 29c813ed7..f34353fcd 100644 --- a/fastlane/.env.ios.pr +++ b/fastlane/.env.ios.pr @@ -20,6 +20,7 @@ MATCH_READONLY=true MATCH_SHALLOW_CLONE=true MATCH_SKIP_DOCS=true MATCH_TYPE=adhoc +MONITOR_NETWORK_PERFORMANCE=true NOTIFICATION_SERVICE_IDENTIFIER=com.mattermost.rnbeta.NotificationService PILOT_SKIP_WAITING_FOR_BUILD_PROCESSING=true REPLACE_ASSETS=false diff --git a/fastlane/Fastfile b/fastlane/Fastfile index f3008b103..8f30af36f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -186,6 +186,11 @@ lane :configure do json['CollectNetworkMetrics'] = true end + # Monitor network performance + if ENV['MONITOR_NETWORK_PERFORMANCE'] == 'true' + json['MonitorNetworkPerformance'] = true + end + # Configure the auth schemes auth_scheme = ENV['APP_AUTH_SCHEME'].to_s.empty? ? 'mmauth' : ENV['APP_AUTH_SCHEME'] auth_scheme_dev = ENV['APP_AUTH_SCHEME_BETA'].to_s.empty? ? 'mmauthbeta' : ENV['APP_AUTH_SCHEME_BETA']