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
This commit is contained in:
Rahim Rahman 2025-10-12 10:10:05 -06:00 committed by GitHub
parent 5887c3ab46
commit 597e03d7d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 3320 additions and 5 deletions

View file

@ -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 */

View file

@ -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,
});
};
}

View file

@ -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();
}

View file

@ -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<typeof BannerManager>;
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');
});
});
});

View file

@ -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,
};

View file

@ -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<typeof AppState>;
const mockNetInfo = NetInfo as jest.Mocked<typeof NetInfo>;
const mockSubscribeActiveServers = subscribeActiveServers as jest.MockedFunction<typeof subscribeActiveServers>;
const mockNetworkConnectivityManager = NetworkConnectivityManager as jest.Mocked<typeof NetworkConnectivityManager>;
const mockNetworkPerformanceManager = NetworkPerformanceManager as jest.Mocked<typeof NetworkPerformanceManager>;
const mockWebsocketManager = WebsocketManager as jest.Mocked<typeof WebsocketManager>;
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();
});
});
});

View file

@ -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<void> => {
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,
};

View file

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

View file

@ -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<typeof NetworkPerformanceManagerSingleton>;
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();
});
});
});

View file

@ -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<string, BehaviorSubject<NetworkPerformanceState>> = {};
private activeRequests: Record<string, Record<string, ActiveRequest>> = {};
private requestOutcomes: Record<string, RequestOutcome[]> = {};
private initialRequestTimestamp: Record<string, number> = {};
private isInitialDetection: Record<string, boolean> = {};
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<NetworkPerformanceState>('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;

View file

@ -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) {

View file

@ -29,5 +29,6 @@
"ExperimentalNormalizeMarkdownLinks": false,
"CustomRequestHeaders": {},
"CollectNetworkMetrics": false
"CollectNetworkMetrics": false,
"MonitorNetworkPerformance": true
}

View file

@ -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...",

View file

@ -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<br/>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<br/>• Request Tracking<br/>• Performance Detection<br/>• Observable Streams<br/>• Sliding Window 20 requests"]
NCM["NetworkConnectivityManager<br/>• updateState()<br/>• updatePerformanceState()<br/>• Banner Priority Logic<br/>• Suppression Control"]
BM[BannerManager<br/>• Show/Hide Banner<br/>• Auto-Hide<br/>• Cleanup]
CB[ConnectionBanner<br/>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<NetworkPerformanceState>
// 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<br/>2. Set 2s timer<br/>3. Store in activeRequests| Decision{Request Outcome}
Decision -->|Completes < 2s| Complete[Record Outcome<br/>wasEarly: false]
Decision -->|Timer Triggers @ 2s| Early[Early Detection<br/>Record Outcome<br/>wasEarly: true]
Decision -->|Fails| Cancel[Cancel Tracking<br/>No outcome recorded]
Complete --> Update[Update Performance State]
Early --> Update
Update --> Steps[1. Add to outcomes window max 20<br/>2. Calculate slow %<br/>3. Determine state<br/>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()<br/>init(activeServerUrl)
Init->>NCSM: 2. start()<br/>init()
NCSM->>NCSM: AppState.addEventListener()
NCSM->>NCSM: NetInfo.addEventListener()
NCSM->>DB: subscribeActiveServers()
DB->>NCSM: Active Server Changed<br/>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()<br/>Priority Order:<br/>1. Disconnected<br/>2. Performance<br/>3. Connecting<br/>4. Reconnection<br/>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')

View file

@ -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

View file

@ -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

View file

@ -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']