Add BLE cleanup on user logout
Implement comprehensive BLE cleanup functionality that properly disconnects all devices and releases resources when user logs out. Changes: - Add cleanup() method to BLEManager and MockBLEManager - Update IBLEManager interface to include cleanup - Add cleanupBLE() to BLEContext to disconnect all devices - Implement callback mechanism in api.ts for BLE cleanup on logout - Wire up BLE cleanup in app layout to trigger on logout - Add unit tests for BLE cleanup functionality This ensures no BLE connections remain active after logout, preventing resource leaks and potential connection issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2d7a5336b4
commit
2b2bd88726
@ -10,8 +10,9 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { ToastProvider } from '@/components/ui/Toast';
|
||||
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
|
||||
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
|
||||
import { BLEProvider } from '@/contexts/BLEContext';
|
||||
import { BLEProvider, useBLE } from '@/contexts/BLEContext';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { setOnLogoutBLECleanupCallback } from '@/services/api';
|
||||
|
||||
// Stripe publishable key (test mode) - must match backend STRIPE_PUBLISHABLE_KEY
|
||||
const STRIPE_PUBLISHABLE_KEY = 'pk_test_51P3kdqP0gvUw6M9C7ixPQHqbPcvga4G5kAYx1h6QXQAt1psbrC2rrmOojW0fTeQzaxD1Q9RKS3zZ23MCvjjZpWLi00eCFWRHMk';
|
||||
@ -25,12 +26,18 @@ let splashHidden = false;
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
const { isAuthenticated, isInitializing } = useAuth();
|
||||
const { cleanupBLE } = useBLE();
|
||||
const segments = useSegments();
|
||||
const navigationState = useRootNavigationState();
|
||||
|
||||
// Track if initial redirect was done
|
||||
const hasInitialRedirect = useRef(false);
|
||||
|
||||
// Set up BLE cleanup callback for logout
|
||||
useEffect(() => {
|
||||
setOnLogoutBLECleanupCallback(cleanupBLE);
|
||||
}, [cleanupBLE]);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for navigation to be ready
|
||||
if (!navigationState?.key) {
|
||||
|
||||
@ -20,6 +20,7 @@ interface BLEContextType {
|
||||
setWiFi: (deviceId: string, ssid: string, password: string) => Promise<boolean>;
|
||||
getCurrentWiFi: (deviceId: string) => Promise<WiFiStatus | null>;
|
||||
rebootDevice: (deviceId: string) => Promise<void>;
|
||||
cleanupBLE: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
@ -145,6 +146,30 @@ export function BLEProvider({ children }: { children: ReactNode }) {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const cleanupBLE = useCallback(async () => {
|
||||
try {
|
||||
console.log('[BLEContext] Cleanup called - cleaning up all BLE connections');
|
||||
|
||||
// Stop any ongoing scan
|
||||
if (isScanning) {
|
||||
stopScan();
|
||||
}
|
||||
|
||||
// Cleanup via bleManager (disconnects all devices)
|
||||
await bleManager.cleanup();
|
||||
|
||||
// Clear context state
|
||||
setFoundDevices([]);
|
||||
setConnectedDevices(new Set());
|
||||
setError(null);
|
||||
|
||||
console.log('[BLEContext] Cleanup complete');
|
||||
} catch (err: any) {
|
||||
console.error('[BLEContext] Cleanup error:', err);
|
||||
// Don't throw - we want to allow logout to proceed even if BLE cleanup fails
|
||||
}
|
||||
}, [isScanning, stopScan]);
|
||||
|
||||
const value: BLEContextType = {
|
||||
foundDevices,
|
||||
isScanning,
|
||||
@ -159,6 +184,7 @@ export function BLEProvider({ children }: { children: ReactNode }) {
|
||||
setWiFi,
|
||||
getCurrentWiFi,
|
||||
rebootDevice,
|
||||
cleanupBLE,
|
||||
clearError,
|
||||
};
|
||||
|
||||
|
||||
112
services/__tests__/api.logout.test.ts
Normal file
112
services/__tests__/api.logout.test.ts
Normal file
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* API Logout Tests
|
||||
* Tests for logout functionality including BLE cleanup
|
||||
*/
|
||||
|
||||
import { api, setOnLogoutBLECleanupCallback } from '../api';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('expo-secure-store');
|
||||
jest.mock('@react-native-async-storage/async-storage');
|
||||
|
||||
describe('API logout with BLE cleanup', () => {
|
||||
let bleCleanupCallback: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create mock BLE cleanup callback
|
||||
bleCleanupCallback = jest.fn().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('logout without BLE cleanup callback', () => {
|
||||
it('should clear all auth tokens and data', async () => {
|
||||
await api.logout();
|
||||
|
||||
// Verify SecureStore items are deleted
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('accessToken');
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('userId');
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('userEmail');
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('onboardingCompleted');
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('legacyAccessToken');
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('privileges');
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('maxRole');
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('userAvatar');
|
||||
|
||||
// Verify AsyncStorage items are removed
|
||||
expect(AsyncStorage.removeItem).toHaveBeenCalledWith('wellnuo_local_beneficiaries');
|
||||
});
|
||||
|
||||
it('should complete logout even if no BLE callback is set', async () => {
|
||||
// Should not throw
|
||||
await expect(api.logout()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout with BLE cleanup callback', () => {
|
||||
beforeEach(() => {
|
||||
// Set the BLE cleanup callback
|
||||
setOnLogoutBLECleanupCallback(bleCleanupCallback);
|
||||
});
|
||||
|
||||
it('should call BLE cleanup callback before clearing data', async () => {
|
||||
await api.logout();
|
||||
|
||||
// Verify BLE cleanup was called
|
||||
expect(bleCleanupCallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify auth data was still cleared
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('accessToken');
|
||||
});
|
||||
|
||||
it('should continue logout even if BLE cleanup fails', async () => {
|
||||
// Make BLE cleanup fail
|
||||
bleCleanupCallback.mockRejectedValue(new Error('BLE cleanup failed'));
|
||||
|
||||
// Logout should still complete
|
||||
await expect(api.logout()).resolves.not.toThrow();
|
||||
|
||||
// Verify BLE cleanup was attempted
|
||||
expect(bleCleanupCallback).toHaveBeenCalled();
|
||||
|
||||
// Verify auth data was still cleared despite BLE failure
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('accessToken');
|
||||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('userId');
|
||||
});
|
||||
|
||||
it('should log error but not throw if BLE cleanup fails', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
bleCleanupCallback.mockRejectedValue(new Error('BLE error'));
|
||||
|
||||
await api.logout();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[API] BLE cleanup failed during logout:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BLE cleanup callback registration', () => {
|
||||
it('should allow setting BLE cleanup callback', () => {
|
||||
const callback = jest.fn();
|
||||
expect(() => setOnLogoutBLECleanupCallback(callback)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should allow replacing BLE cleanup callback', () => {
|
||||
const callback1 = jest.fn().mockResolvedValue(undefined);
|
||||
const callback2 = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
setOnLogoutBLECleanupCallback(callback1);
|
||||
setOnLogoutBLECleanupCallback(callback2);
|
||||
|
||||
// Only callback2 should be called
|
||||
expect(callback1).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -11,6 +11,13 @@ export function setOnUnauthorizedCallback(callback: () => void) {
|
||||
onUnauthorizedCallback = callback;
|
||||
}
|
||||
|
||||
// Callback for BLE cleanup on logout
|
||||
let onLogoutBLECleanupCallback: (() => Promise<void>) | null = null;
|
||||
|
||||
export function setOnLogoutBLECleanupCallback(callback: () => Promise<void>) {
|
||||
onLogoutBLECleanupCallback = callback;
|
||||
}
|
||||
|
||||
const API_BASE_URL = 'https://eluxnetworks.net/function/well-api/api';
|
||||
const CLIENT_ID = 'MA_001';
|
||||
|
||||
@ -194,6 +201,16 @@ class ApiService {
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
// Call BLE cleanup callback if set
|
||||
if (onLogoutBLECleanupCallback) {
|
||||
try {
|
||||
await onLogoutBLECleanupCallback();
|
||||
} catch (error) {
|
||||
console.error('[API] BLE cleanup failed during logout:', error);
|
||||
// Continue with logout even if BLE cleanup fails
|
||||
}
|
||||
}
|
||||
|
||||
// Clear WellNuo API auth data
|
||||
await SecureStore.deleteItemAsync('accessToken');
|
||||
await SecureStore.deleteItemAsync('userId');
|
||||
|
||||
@ -526,4 +526,33 @@ export class RealBLEManager implements IBLEManager {
|
||||
// Remove from connected devices
|
||||
this.connectedDevices.delete(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all BLE connections and state
|
||||
* Should be called on app logout to properly release resources
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
console.log('[BLE] Cleanup: disconnecting all devices');
|
||||
|
||||
// Stop any ongoing scan
|
||||
if (this.scanning) {
|
||||
this.stopScan();
|
||||
}
|
||||
|
||||
// Disconnect all connected devices
|
||||
const deviceIds = Array.from(this.connectedDevices.keys());
|
||||
for (const deviceId of deviceIds) {
|
||||
try {
|
||||
await this.disconnectDevice(deviceId);
|
||||
} catch (error) {
|
||||
console.warn('[BLE] Cleanup: error disconnecting device', deviceId, error);
|
||||
// Continue cleanup even if one device fails
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the map
|
||||
this.connectedDevices.clear();
|
||||
|
||||
console.log('[BLE] Cleanup: complete');
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,4 +109,21 @@ export class MockBLEManager implements IBLEManager {
|
||||
await delay(500);
|
||||
this.connectedDevices.delete(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all BLE connections and state
|
||||
* Should be called on app logout to properly release resources
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
console.log('[MockBLE] Cleanup: disconnecting all devices');
|
||||
|
||||
// Disconnect all connected devices
|
||||
const deviceIds = Array.from(this.connectedDevices);
|
||||
for (const deviceId of deviceIds) {
|
||||
await this.disconnectDevice(deviceId);
|
||||
}
|
||||
|
||||
this.connectedDevices.clear();
|
||||
console.log('[MockBLE] Cleanup: complete');
|
||||
}
|
||||
}
|
||||
|
||||
127
services/ble/__tests__/BLEManager.cleanup.test.ts
Normal file
127
services/ble/__tests__/BLEManager.cleanup.test.ts
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* BLE Manager Cleanup Tests
|
||||
* Tests for BLE cleanup functionality on logout
|
||||
*/
|
||||
|
||||
import { RealBLEManager } from '../BLEManager';
|
||||
import { MockBLEManager } from '../MockBLEManager';
|
||||
|
||||
describe('BLEManager cleanup', () => {
|
||||
describe('RealBLEManager', () => {
|
||||
let manager: RealBLEManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new RealBLEManager();
|
||||
});
|
||||
|
||||
it('should have cleanup method', () => {
|
||||
expect(manager.cleanup).toBeDefined();
|
||||
expect(typeof manager.cleanup).toBe('function');
|
||||
});
|
||||
|
||||
it('should not throw when cleaning up with no connections', async () => {
|
||||
await expect(manager.cleanup()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should stop scanning when cleanup is called during scan', async () => {
|
||||
// Mock the scanning state
|
||||
(manager as any).scanning = true;
|
||||
const stopScanSpy = jest.spyOn(manager, 'stopScan');
|
||||
|
||||
await manager.cleanup();
|
||||
|
||||
expect(stopScanSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear connected devices map after cleanup', async () => {
|
||||
// Simulate connected devices
|
||||
const mockDevice = { id: 'device-1' } as any;
|
||||
(manager as any).connectedDevices.set('device-1', mockDevice);
|
||||
|
||||
await manager.cleanup();
|
||||
|
||||
expect((manager as any).connectedDevices.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should attempt to disconnect all connected devices', async () => {
|
||||
// Mock connected devices
|
||||
const device1 = { id: 'device-1', cancelConnection: jest.fn().mockResolvedValue(undefined) } as any;
|
||||
const device2 = { id: 'device-2', cancelConnection: jest.fn().mockResolvedValue(undefined) } as any;
|
||||
|
||||
(manager as any).connectedDevices.set('device-1', device1);
|
||||
(manager as any).connectedDevices.set('device-2', device2);
|
||||
|
||||
await manager.cleanup();
|
||||
|
||||
expect(device1.cancelConnection).toHaveBeenCalled();
|
||||
expect(device2.cancelConnection).toHaveBeenCalled();
|
||||
expect((manager as any).connectedDevices.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should continue cleanup even if one device disconnection fails', async () => {
|
||||
const device1 = {
|
||||
id: 'device-1',
|
||||
cancelConnection: jest.fn().mockRejectedValue(new Error('Connection lost'))
|
||||
} as any;
|
||||
const device2 = {
|
||||
id: 'device-2',
|
||||
cancelConnection: jest.fn().mockResolvedValue(undefined)
|
||||
} as any;
|
||||
|
||||
(manager as any).connectedDevices.set('device-1', device1);
|
||||
(manager as any).connectedDevices.set('device-2', device2);
|
||||
|
||||
// Should not throw even if one device fails
|
||||
await expect(manager.cleanup()).resolves.not.toThrow();
|
||||
|
||||
// Both should have been attempted
|
||||
expect(device1.cancelConnection).toHaveBeenCalled();
|
||||
expect(device2.cancelConnection).toHaveBeenCalled();
|
||||
|
||||
// Map should still be cleared
|
||||
expect((manager as any).connectedDevices.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MockBLEManager', () => {
|
||||
let manager: MockBLEManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new MockBLEManager();
|
||||
});
|
||||
|
||||
it('should have cleanup method', () => {
|
||||
expect(manager.cleanup).toBeDefined();
|
||||
expect(typeof manager.cleanup).toBe('function');
|
||||
});
|
||||
|
||||
it('should not throw when cleaning up with no connections', async () => {
|
||||
await expect(manager.cleanup()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should clear connected devices set after cleanup', async () => {
|
||||
// Simulate connected devices
|
||||
await manager.connectDevice('mock-743');
|
||||
await manager.connectDevice('mock-769');
|
||||
|
||||
expect((manager as any).connectedDevices.size).toBe(2);
|
||||
|
||||
await manager.cleanup();
|
||||
|
||||
expect((manager as any).connectedDevices.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should call disconnectDevice for each connected device', async () => {
|
||||
await manager.connectDevice('mock-743');
|
||||
await manager.connectDevice('mock-769');
|
||||
|
||||
const disconnectSpy = jest.spyOn(manager, 'disconnectDevice');
|
||||
|
||||
await manager.cleanup();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalledTimes(2);
|
||||
expect(disconnectSpy).toHaveBeenCalledWith('mock-743');
|
||||
expect(disconnectSpy).toHaveBeenCalledWith('mock-769');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -36,6 +36,7 @@ export const bleManager: IBLEManager = {
|
||||
setWiFi: (deviceId: string, ssid: string, password: string) => getBLEManager().setWiFi(deviceId, ssid, password),
|
||||
getCurrentWiFi: (deviceId: string) => getBLEManager().getCurrentWiFi(deviceId),
|
||||
rebootDevice: (deviceId: string) => getBLEManager().rebootDevice(deviceId),
|
||||
cleanup: () => getBLEManager().cleanup(),
|
||||
};
|
||||
|
||||
// Re-export types
|
||||
|
||||
@ -57,4 +57,5 @@ export interface IBLEManager {
|
||||
setWiFi(deviceId: string, ssid: string, password: string): Promise<boolean>;
|
||||
getCurrentWiFi(deviceId: string): Promise<WiFiStatus | null>;
|
||||
rebootDevice(deviceId: string): Promise<void>;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user