Skip to content

Commit a3bb04c

Browse files
committed
feat(webaocket): Add account activity
1 parent e90aa86 commit a3bb04c

19 files changed

Lines changed: 1594 additions & 12 deletions

app/core/Engine/Engine.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ import {
229229
import { ErrorReportingService } from '@metamask/error-reporting-service';
230230
import { captureException } from '@sentry/react-native';
231231
import { WebSocketServiceInit } from './controllers/snaps/websocket-service-init';
232+
import {
233+
WebSocketServiceInit as BackendWebSocketServiceInit,
234+
AccountActivityServiceInit
235+
} from './controllers/backend-platform';
232236

233237
import { seedlessOnboardingControllerInit } from './controllers/seedless-onboarding-controller';
234238
import { perpsControllerInit } from './controllers/perps-controller';
@@ -1275,6 +1279,8 @@ export class Engine {
12751279
///: END:ONLY_INCLUDE_IF
12761280
SeedlessOnboardingController: seedlessOnboardingControllerInit,
12771281
PerpsController: perpsControllerInit,
1282+
BackendWebSocketService: BackendWebSocketServiceInit,
1283+
AccountActivityService: AccountActivityServiceInit,
12781284
},
12791285
persistedState: initialState as EngineState,
12801286
existingControllersByName,
@@ -1291,6 +1297,8 @@ export class Engine {
12911297
const seedlessOnboardingController =
12921298
controllersByName.SeedlessOnboardingController;
12931299
const perpsController = controllersByName.PerpsController;
1300+
const backendWebSocketService = controllersByName.BackendWebSocketService;
1301+
const accountActivityService = controllersByName.AccountActivityService;
12941302
// Backwards compatibility for existing references
12951303
this.accountsController = accountsController;
12961304
this.gasFeeController = gasFeeController;
@@ -1525,6 +1533,7 @@ export class Engine {
15251533
'PreferencesController:stateChange',
15261534
'NetworkController:stateChange',
15271535
'KeyringController:accountRemoved',
1536+
'AccountActivityService:balanceUpdated',
15281537
],
15291538
}),
15301539
// TODO: This is long, can we decrease it?
@@ -1647,6 +1656,8 @@ export class Engine {
16471656
DeFiPositionsController: controllersByName.DeFiPositionsController,
16481657
SeedlessOnboardingController: seedlessOnboardingController,
16491658
PerpsController: perpsController,
1659+
BackendWebSocketService: backendWebSocketService,
1660+
AccountActivityService: accountActivityService,
16501661
};
16511662

16521663
const childControllers = Object.assign({}, this.context);

app/core/Engine/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export const STATELESS_NON_CONTROLLER_NAMES = [
1717
'NftDetectionController',
1818
'TokenDetectionController',
1919
'WebSocketService',
20+
'BackendWebSocketService',
21+
'AccountActivityService',
2022
] as const;
2123

2224
export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { AppState } from 'react-native';
2+
import { AppStateWebSocketManager } from './AppStateWebSocketManager';
3+
import { MobileBackendWebSocketService } from './websocket-service-init';
4+
5+
// Mock react-native AppState
6+
jest.mock('react-native', () => ({
7+
AppState: {
8+
currentState: 'active',
9+
addEventListener: jest.fn(),
10+
},
11+
}));
12+
13+
// Mock Logger
14+
jest.mock('../../../../util/Logger', () => ({
15+
error: jest.fn(),
16+
}));
17+
18+
// Mock WebSocket service
19+
const mockWebSocketService = {
20+
connect: jest.fn(),
21+
disconnect: jest.fn(),
22+
sendMessage: jest.fn(),
23+
getSessionId: jest.fn(),
24+
} as unknown as MobileBackendWebSocketService;
25+
26+
describe('AppStateWebSocketManager', () => {
27+
let manager: AppStateWebSocketManager;
28+
let mockAddEventListener: jest.MockedFunction<typeof AppState.addEventListener>;
29+
30+
beforeEach(() => {
31+
jest.clearAllMocks();
32+
mockAddEventListener = AppState.addEventListener as jest.MockedFunction<typeof AppState.addEventListener>;
33+
34+
// Mock session ID
35+
(mockWebSocketService.getSessionId as jest.Mock).mockReturnValue('test-session-123');
36+
37+
// Create a new manager instance for each test
38+
manager = new AppStateWebSocketManager();
39+
manager.setWebSocketService(mockWebSocketService);
40+
});
41+
42+
afterEach(() => {
43+
manager.cleanup();
44+
});
45+
46+
describe('initialization', () => {
47+
it('should start app state listener when WebSocket service is set', () => {
48+
expect(mockAddEventListener).toHaveBeenCalledWith('change', expect.any(Function));
49+
});
50+
51+
it('should get current app state on initialization', () => {
52+
const status = manager.getConnectionStatus();
53+
expect(status.appState).toBe('active');
54+
});
55+
});
56+
57+
describe('connect method', () => {
58+
it('should connect immediately when app is active', async () => {
59+
await manager.connect();
60+
61+
expect(mockWebSocketService.connect).toHaveBeenCalled();
62+
const status = manager.getConnectionStatus();
63+
expect(status.isConnected).toBe(true);
64+
expect(status.shouldAutoConnect).toBe(true);
65+
expect(status.hasActiveSession).toBe(true);
66+
expect(status.sessionId).toBe('test-session-123');
67+
});
68+
69+
it('should defer connection when app is not active', async () => {
70+
// Mock app being in background
71+
(AppState as any).currentState = 'background';
72+
manager = new AppStateWebSocketManager();
73+
manager.setWebSocketService(mockWebSocketService);
74+
75+
await manager.connect();
76+
77+
expect(mockWebSocketService.connect).not.toHaveBeenCalled();
78+
const status = manager.getConnectionStatus();
79+
expect(status.isConnected).toBe(false);
80+
expect(status.shouldAutoConnect).toBe(true);
81+
expect(status.hasActiveSession).toBe(false);
82+
});
83+
});
84+
85+
describe('app state changes with session preservation', () => {
86+
it('should preserve session when app goes to background', async () => {
87+
// First connect
88+
await manager.connect();
89+
expect(mockWebSocketService.connect).toHaveBeenCalled();
90+
91+
// Simulate app going to background
92+
const changeHandler = mockAddEventListener.mock.calls[0][1];
93+
await changeHandler('background');
94+
95+
// Should call disconnect(false) to preserve session
96+
expect(mockWebSocketService.disconnect).toHaveBeenCalledWith(false);
97+
98+
const status = manager.getConnectionStatus();
99+
expect(status.isConnected).toBe(false);
100+
expect(status.shouldAutoConnect).toBe(true);
101+
expect(status.hasActiveSession).toBe(true); // Session preserved
102+
expect(status.sessionId).toBe('test-session-123'); // Session ID preserved
103+
});
104+
105+
it('should resume session when app returns to foreground', async () => {
106+
// Setup initial connection
107+
await manager.connect();
108+
jest.clearAllMocks();
109+
110+
// Go to background (preserve session)
111+
const changeHandler = mockAddEventListener.mock.calls[0][1];
112+
await changeHandler('background');
113+
114+
// Verify session preserved after background
115+
const statusAfterBackground = manager.getConnectionStatus();
116+
expect(statusAfterBackground.isConnected).toBe(false);
117+
expect(statusAfterBackground.hasActiveSession).toBe(true);
118+
expect(statusAfterBackground.sessionId).toBe('test-session-123');
119+
120+
// Return to foreground (should resume session)
121+
await changeHandler('active');
122+
123+
expect(mockWebSocketService.connect).toHaveBeenCalled();
124+
const statusAfterForeground = manager.getConnectionStatus();
125+
expect(statusAfterForeground.isConnected).toBe(true);
126+
expect(statusAfterForeground.hasActiveSession).toBe(true);
127+
expect(statusAfterForeground.sessionId).toBe('test-session-123');
128+
});
129+
130+
it('should not reconnect if it was never connected before', async () => {
131+
const changeHandler = mockAddEventListener.mock.calls[0][1];
132+
133+
// Go to background without ever connecting
134+
await changeHandler('background');
135+
136+
// Return to foreground
137+
await changeHandler('active');
138+
139+
expect(mockWebSocketService.connect).not.toHaveBeenCalled();
140+
const status = manager.getConnectionStatus();
141+
expect(status.shouldAutoConnect).toBe(false);
142+
expect(status.hasActiveSession).toBe(false);
143+
});
144+
145+
it('should ignore inactive state changes', async () => {
146+
const changeHandler = mockAddEventListener.mock.calls[0][1];
147+
148+
await changeHandler('inactive');
149+
150+
expect(mockWebSocketService.disconnect).not.toHaveBeenCalled();
151+
expect(mockWebSocketService.connect).not.toHaveBeenCalled();
152+
});
153+
154+
it('should handle reconnection failure gracefully while preserving session intent', async () => {
155+
// Setup initial connection
156+
await manager.connect();
157+
158+
// Mock connection failure for reconnection attempt
159+
mockWebSocketService.connect.mockRejectedValueOnce(new Error('Connection failed'));
160+
161+
const changeHandler = mockAddEventListener.mock.calls[0][1];
162+
163+
// Go to background
164+
await changeHandler('background');
165+
166+
// Return to foreground (should attempt reconnect but fail)
167+
await changeHandler('active');
168+
169+
expect(mockWebSocketService.connect).toHaveBeenCalled();
170+
const status = manager.getConnectionStatus();
171+
expect(status.isConnected).toBe(false);
172+
expect(status.shouldAutoConnect).toBe(true);
173+
expect(status.hasActiveSession).toBe(true); // Should still preserve session for future attempts
174+
});
175+
});
176+
177+
describe('manual disconnect method', () => {
178+
it('should clear session when manually disconnecting', async () => {
179+
await manager.connect();
180+
181+
await manager.disconnect();
182+
183+
// Should call disconnect(true) to clear session
184+
expect(mockWebSocketService.disconnect).toHaveBeenCalledWith(true);
185+
186+
const status = manager.getConnectionStatus();
187+
expect(status.isConnected).toBe(false);
188+
expect(status.shouldAutoConnect).toBe(false);
189+
expect(status.hasActiveSession).toBe(false); // Session cleared
190+
});
191+
});
192+
193+
describe('sendMessage method', () => {
194+
it('should send message when connected and in foreground', async () => {
195+
await manager.connect();
196+
197+
const message = { event: 'test', data: {} };
198+
await manager.sendMessage(message);
199+
200+
expect(mockWebSocketService.sendMessage).toHaveBeenCalledWith(message);
201+
});
202+
203+
it('should throw error when app is not in foreground', async () => {
204+
// Mock app being in background
205+
(AppState as any).currentState = 'background';
206+
manager = new AppStateWebSocketManager();
207+
manager.setWebSocketService(mockWebSocketService);
208+
209+
const message = { event: 'test', data: {} };
210+
211+
await expect(manager.sendMessage(message)).rejects.toThrow(
212+
'Cannot send message when app is not in foreground'
213+
);
214+
});
215+
216+
it('should throw error when not connected', async () => {
217+
const message = { event: 'test', data: {} };
218+
219+
await expect(manager.sendMessage(message)).rejects.toThrow(
220+
'WebSocket not connected'
221+
);
222+
});
223+
});
224+
225+
describe('cleanup', () => {
226+
it('should remove app state listener on cleanup', () => {
227+
const mockRemove = jest.fn();
228+
mockAddEventListener.mockReturnValue({ remove: mockRemove } as any);
229+
230+
manager = new AppStateWebSocketManager();
231+
manager.setWebSocketService(mockWebSocketService);
232+
manager.cleanup();
233+
234+
expect(mockRemove).toHaveBeenCalled();
235+
});
236+
});
237+
238+
describe('getConnectionStatus', () => {
239+
it('should return complete connection status including session info', async () => {
240+
await manager.connect();
241+
242+
const status = manager.getConnectionStatus();
243+
expect(status.isConnected).toBe(true);
244+
expect(status.appState).toBe('active');
245+
expect(status.shouldAutoConnect).toBe(true);
246+
expect(status.hasActiveSession).toBe(true);
247+
expect(status.sessionId).toBe('test-session-123');
248+
});
249+
250+
it('should handle missing getSessionId method gracefully', async () => {
251+
// Mock service without getSessionId method
252+
const serviceWithoutSessionId = {
253+
connect: jest.fn(),
254+
disconnect: jest.fn(),
255+
sendMessage: jest.fn(),
256+
} as unknown as MobileBackendWebSocketService;
257+
258+
manager = new AppStateWebSocketManager();
259+
manager.setWebSocketService(serviceWithoutSessionId);
260+
261+
const status = manager.getConnectionStatus();
262+
expect(status.sessionId).toBe(null);
263+
});
264+
});
265+
});

0 commit comments

Comments
 (0)