diff --git a/app/_layout.tsx b/app/_layout.tsx index 339480a..f521e6e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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) { diff --git a/contexts/BLEContext.tsx b/contexts/BLEContext.tsx index 3b37901..0d6aeae 100644 --- a/contexts/BLEContext.tsx +++ b/contexts/BLEContext.tsx @@ -20,6 +20,7 @@ interface BLEContextType { setWiFi: (deviceId: string, ssid: string, password: string) => Promise; getCurrentWiFi: (deviceId: string) => Promise; rebootDevice: (deviceId: string) => Promise; + cleanupBLE: () => Promise; 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, }; diff --git a/services/__tests__/api.logout.test.ts b/services/__tests__/api.logout.test.ts new file mode 100644 index 0000000..cb9f761 --- /dev/null +++ b/services/__tests__/api.logout.test.ts @@ -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(); + }); + }); +}); diff --git a/services/api.ts b/services/api.ts index 71e82bb..b3a8c8e 100644 --- a/services/api.ts +++ b/services/api.ts @@ -11,6 +11,13 @@ export function setOnUnauthorizedCallback(callback: () => void) { onUnauthorizedCallback = callback; } +// Callback for BLE cleanup on logout +let onLogoutBLECleanupCallback: (() => Promise) | null = null; + +export function setOnLogoutBLECleanupCallback(callback: () => Promise) { + 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 { + // 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'); diff --git a/services/ble/BLEManager.ts b/services/ble/BLEManager.ts index a58af45..23dca66 100644 --- a/services/ble/BLEManager.ts +++ b/services/ble/BLEManager.ts @@ -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 { + 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'); + } } diff --git a/services/ble/MockBLEManager.ts b/services/ble/MockBLEManager.ts index 3fd29e1..c65db2b 100644 --- a/services/ble/MockBLEManager.ts +++ b/services/ble/MockBLEManager.ts @@ -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 { + 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'); + } } diff --git a/services/ble/__tests__/BLEManager.cleanup.test.ts b/services/ble/__tests__/BLEManager.cleanup.test.ts new file mode 100644 index 0000000..d0cdfbd --- /dev/null +++ b/services/ble/__tests__/BLEManager.cleanup.test.ts @@ -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'); + }); + }); +}); diff --git a/services/ble/index.ts b/services/ble/index.ts index 2cb5ce1..3f6488d 100644 --- a/services/ble/index.ts +++ b/services/ble/index.ts @@ -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 diff --git a/services/ble/types.ts b/services/ble/types.ts index 4a2b682..32b6696 100644 --- a/services/ble/types.ts +++ b/services/ble/types.ts @@ -57,4 +57,5 @@ export interface IBLEManager { setWiFi(deviceId: string, ssid: string, password: string): Promise; getCurrentWiFi(deviceId: string): Promise; rebootDevice(deviceId: string): Promise; + cleanup(): Promise; }