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:
parent
5887c3ab46
commit
597e03d7d8
17 changed files with 3320 additions and 5 deletions
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
717
app/managers/network_connectivity_manager.test.ts
Normal file
717
app/managers/network_connectivity_manager.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
296
app/managers/network_connectivity_manager.ts
Normal file
296
app/managers/network_connectivity_manager.ts
Normal 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,
|
||||
};
|
||||
337
app/managers/network_connectivity_subscription_manager.test.ts
Normal file
337
app/managers/network_connectivity_subscription_manager.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
168
app/managers/network_connectivity_subscription_manager.ts
Normal file
168
app/managers/network_connectivity_subscription_manager.ts
Normal 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,
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
536
app/managers/network_performance_manager.test.ts
Normal file
536
app/managers/network_performance_manager.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
325
app/managers/network_performance_manager.ts
Normal file
325
app/managers/network_performance_manager.ts
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@
|
|||
|
||||
"ExperimentalNormalizeMarkdownLinks": false,
|
||||
"CustomRequestHeaders": {},
|
||||
"CollectNetworkMetrics": false
|
||||
"CollectNetworkMetrics": false,
|
||||
"MonitorNetworkPerformance": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
740
docs/network-connectivity-observation.md
Normal file
740
docs/network-connectivity-observation.md
Normal 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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
Loading…
Reference in a new issue