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:
Sergei 2026-01-29 10:57:43 -08:00
parent 2d7a5336b4
commit 2b2bd88726
9 changed files with 338 additions and 1 deletions

View File

@ -10,8 +10,9 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToastProvider } from '@/components/ui/Toast'; import { ToastProvider } from '@/components/ui/Toast';
import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { AuthProvider, useAuth } from '@/contexts/AuthContext';
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext'; import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
import { BLEProvider } from '@/contexts/BLEContext'; import { BLEProvider, useBLE } from '@/contexts/BLEContext';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
import { setOnLogoutBLECleanupCallback } from '@/services/api';
// Stripe publishable key (test mode) - must match backend STRIPE_PUBLISHABLE_KEY // Stripe publishable key (test mode) - must match backend STRIPE_PUBLISHABLE_KEY
const STRIPE_PUBLISHABLE_KEY = 'pk_test_51P3kdqP0gvUw6M9C7ixPQHqbPcvga4G5kAYx1h6QXQAt1psbrC2rrmOojW0fTeQzaxD1Q9RKS3zZ23MCvjjZpWLi00eCFWRHMk'; const STRIPE_PUBLISHABLE_KEY = 'pk_test_51P3kdqP0gvUw6M9C7ixPQHqbPcvga4G5kAYx1h6QXQAt1psbrC2rrmOojW0fTeQzaxD1Q9RKS3zZ23MCvjjZpWLi00eCFWRHMk';
@ -25,12 +26,18 @@ let splashHidden = false;
function RootLayoutNav() { function RootLayoutNav() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const { isAuthenticated, isInitializing } = useAuth(); const { isAuthenticated, isInitializing } = useAuth();
const { cleanupBLE } = useBLE();
const segments = useSegments(); const segments = useSegments();
const navigationState = useRootNavigationState(); const navigationState = useRootNavigationState();
// Track if initial redirect was done // Track if initial redirect was done
const hasInitialRedirect = useRef(false); const hasInitialRedirect = useRef(false);
// Set up BLE cleanup callback for logout
useEffect(() => {
setOnLogoutBLECleanupCallback(cleanupBLE);
}, [cleanupBLE]);
useEffect(() => { useEffect(() => {
// Wait for navigation to be ready // Wait for navigation to be ready
if (!navigationState?.key) { if (!navigationState?.key) {

View File

@ -20,6 +20,7 @@ interface BLEContextType {
setWiFi: (deviceId: string, ssid: string, password: string) => Promise<boolean>; setWiFi: (deviceId: string, ssid: string, password: string) => Promise<boolean>;
getCurrentWiFi: (deviceId: string) => Promise<WiFiStatus | null>; getCurrentWiFi: (deviceId: string) => Promise<WiFiStatus | null>;
rebootDevice: (deviceId: string) => Promise<void>; rebootDevice: (deviceId: string) => Promise<void>;
cleanupBLE: () => Promise<void>;
clearError: () => void; clearError: () => void;
} }
@ -145,6 +146,30 @@ export function BLEProvider({ children }: { children: ReactNode }) {
setError(null); 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 = { const value: BLEContextType = {
foundDevices, foundDevices,
isScanning, isScanning,
@ -159,6 +184,7 @@ export function BLEProvider({ children }: { children: ReactNode }) {
setWiFi, setWiFi,
getCurrentWiFi, getCurrentWiFi,
rebootDevice, rebootDevice,
cleanupBLE,
clearError, clearError,
}; };

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

View File

@ -11,6 +11,13 @@ export function setOnUnauthorizedCallback(callback: () => void) {
onUnauthorizedCallback = callback; 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 API_BASE_URL = 'https://eluxnetworks.net/function/well-api/api';
const CLIENT_ID = 'MA_001'; const CLIENT_ID = 'MA_001';
@ -194,6 +201,16 @@ class ApiService {
} }
async logout(): Promise<void> { 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 // Clear WellNuo API auth data
await SecureStore.deleteItemAsync('accessToken'); await SecureStore.deleteItemAsync('accessToken');
await SecureStore.deleteItemAsync('userId'); await SecureStore.deleteItemAsync('userId');

View File

@ -526,4 +526,33 @@ export class RealBLEManager implements IBLEManager {
// Remove from connected devices // Remove from connected devices
this.connectedDevices.delete(deviceId); 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');
}
} }

View File

@ -109,4 +109,21 @@ export class MockBLEManager implements IBLEManager {
await delay(500); await delay(500);
this.connectedDevices.delete(deviceId); 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');
}
} }

View 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');
});
});
});

View File

@ -36,6 +36,7 @@ export const bleManager: IBLEManager = {
setWiFi: (deviceId: string, ssid: string, password: string) => getBLEManager().setWiFi(deviceId, ssid, password), setWiFi: (deviceId: string, ssid: string, password: string) => getBLEManager().setWiFi(deviceId, ssid, password),
getCurrentWiFi: (deviceId: string) => getBLEManager().getCurrentWiFi(deviceId), getCurrentWiFi: (deviceId: string) => getBLEManager().getCurrentWiFi(deviceId),
rebootDevice: (deviceId: string) => getBLEManager().rebootDevice(deviceId), rebootDevice: (deviceId: string) => getBLEManager().rebootDevice(deviceId),
cleanup: () => getBLEManager().cleanup(),
}; };
// Re-export types // Re-export types

View File

@ -57,4 +57,5 @@ export interface IBLEManager {
setWiFi(deviceId: string, ssid: string, password: string): Promise<boolean>; setWiFi(deviceId: string, ssid: string, password: string): Promise<boolean>;
getCurrentWiFi(deviceId: string): Promise<WiFiStatus | null>; getCurrentWiFi(deviceId: string): Promise<WiFiStatus | null>;
rebootDevice(deviceId: string): Promise<void>; rebootDevice(deviceId: string): Promise<void>;
cleanup(): Promise<void>;
} }