From a30769387fa8ea6f4566b784747befec0a2ef7a8 Mon Sep 17 00:00:00 2001 From: Sergei Date: Thu, 29 Jan 2026 12:08:37 -0800 Subject: [PATCH] Add BLE scanning cleanup on screen blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement proper cleanup of BLE scanning operations when users navigate away from the add-sensor screen to prevent resource waste and potential issues. Changes: - Add useFocusEffect hook to stop BLE scan when screen loses focus - Remove unused imports (Device, WPDevice, connectDevice, etc.) - Add comprehensive tests for BLE cleanup behavior - Add tests for screen unmount/blur scenarios 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/(tabs)/beneficiaries/[id]/add-sensor.tsx | 28 ++- .../__tests__/add-sensor.cleanup.test.tsx | 185 ++++++++++++++++++ .../ble/__tests__/BLEContext.cleanup.test.tsx | 182 +++++++++++++++++ 3 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 app/(tabs)/beneficiaries/__tests__/add-sensor.cleanup.test.tsx create mode 100644 services/ble/__tests__/BLEContext.cleanup.test.tsx diff --git a/app/(tabs)/beneficiaries/[id]/add-sensor.tsx b/app/(tabs)/beneficiaries/[id]/add-sensor.tsx index 4ab9f3a..368393b 100644 --- a/app/(tabs)/beneficiaries/[id]/add-sensor.tsx +++ b/app/(tabs)/beneficiaries/[id]/add-sensor.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { View, Text, @@ -10,11 +10,9 @@ import { } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { router, useLocalSearchParams } from 'expo-router'; -import * as Device from 'expo-device'; +import { router, useLocalSearchParams, useFocusEffect } from 'expo-router'; import { useBLE } from '@/contexts/BLEContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext'; -import type { WPDevice } from '@/services/ble'; import { AppColors, BorderRadius, @@ -34,21 +32,33 @@ export default function AddSensorScreen() { isBLEAvailable, scanDevices, stopScan, - connectDevice, } = useBLE(); const [selectedDevices, setSelectedDevices] = useState>(new Set()); - const [isConnecting, setIsConnecting] = useState(false); - - const beneficiaryName = currentBeneficiary?.name || 'this person'; // Select all devices by default when scan completes - React.useEffect(() => { + useEffect(() => { if (foundDevices.length > 0 && !isScanning) { setSelectedDevices(new Set(foundDevices.map(d => d.id))); } }, [foundDevices, isScanning]); + // Cleanup: Stop scan when screen loses focus or component unmounts + useFocusEffect( + useCallback(() => { + console.log('[AddSensor] Screen focused'); + + // Cleanup function - called when screen loses focus + return () => { + console.log('[AddSensor] Screen blur - cleaning up BLE scan'); + if (isScanning) { + console.log('[AddSensor] Stopping active scan'); + stopScan(); + } + }; + }, [isScanning, stopScan]) + ); + const toggleDeviceSelection = (deviceId: string) => { setSelectedDevices(prev => { const next = new Set(prev); diff --git a/app/(tabs)/beneficiaries/__tests__/add-sensor.cleanup.test.tsx b/app/(tabs)/beneficiaries/__tests__/add-sensor.cleanup.test.tsx new file mode 100644 index 0000000..4837433 --- /dev/null +++ b/app/(tabs)/beneficiaries/__tests__/add-sensor.cleanup.test.tsx @@ -0,0 +1,185 @@ +/** + * Add Sensor Screen - BLE Cleanup Tests + * Tests that BLE scanning stops when user navigates away from the screen + */ + +import React from 'react'; +import { render, waitFor } from '@testing-library/react-native'; +import { useLocalSearchParams, useFocusEffect } from 'expo-router'; +import AddSensorScreen from '../[id]/add-sensor'; +import { useBLE } from '@/contexts/BLEContext'; +import { useBeneficiary } from '@/contexts/BeneficiaryContext'; + +// Mock dependencies +jest.mock('expo-router', () => ({ + useLocalSearchParams: jest.fn(), + router: { + push: jest.fn(), + back: jest.fn(), + }, + useFocusEffect: jest.fn(), +})); + +jest.mock('@/contexts/BLEContext', () => ({ + useBLE: jest.fn(), +})); + +jest.mock('@/contexts/BeneficiaryContext', () => ({ + useBeneficiary: jest.fn(), +})); + +jest.mock('expo-device', () => ({ + isDevice: true, +})); + +describe('AddSensorScreen - BLE cleanup', () => { + const mockStopScan = jest.fn(); + const mockScanDevices = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock route params + (useLocalSearchParams as jest.Mock).mockReturnValue({ id: '1' }); + + // Mock beneficiary context + (useBeneficiary as jest.Mock).mockReturnValue({ + currentBeneficiary: { + id: 1, + name: 'Maria', + }, + }); + + // Mock BLE context + (useBLE as jest.Mock).mockReturnValue({ + foundDevices: [], + isScanning: false, + connectedDevices: new Set(), + isBLEAvailable: true, + error: null, + scanDevices: mockScanDevices, + stopScan: mockStopScan, + connectDevice: jest.fn(), + disconnectDevice: jest.fn(), + getWiFiList: jest.fn(), + setWiFi: jest.fn(), + getCurrentWiFi: jest.fn(), + rebootDevice: jest.fn(), + cleanupBLE: jest.fn(), + clearError: jest.fn(), + }); + }); + + it('should register cleanup handler with useFocusEffect', () => { + render(); + + expect(useFocusEffect).toHaveBeenCalled(); + }); + + it('should stop scan when cleanup handler is called while scanning', () => { + // Mock scanning state + (useBLE as jest.Mock).mockReturnValue({ + foundDevices: [], + isScanning: true, // Scan in progress + connectedDevices: new Set(), + isBLEAvailable: true, + error: null, + scanDevices: mockScanDevices, + stopScan: mockStopScan, + connectDevice: jest.fn(), + disconnectDevice: jest.fn(), + getWiFiList: jest.fn(), + setWiFi: jest.fn(), + getCurrentWiFi: jest.fn(), + rebootDevice: jest.fn(), + cleanupBLE: jest.fn(), + clearError: jest.fn(), + }); + + render(); + + // Get the cleanup function from useFocusEffect + const focusEffectCall = (useFocusEffect as jest.Mock).mock.calls[0][0]; + const cleanupFn = focusEffectCall(); // Call the effect to get cleanup function + + // Simulate screen blur by calling cleanup + if (cleanupFn) { + cleanupFn(); + } + + expect(mockStopScan).toHaveBeenCalled(); + }); + + it('should not stop scan when cleanup handler is called and not scanning', () => { + // Mock idle state + (useBLE as jest.Mock).mockReturnValue({ + foundDevices: [], + isScanning: false, // Not scanning + connectedDevices: new Set(), + isBLEAvailable: true, + error: null, + scanDevices: mockScanDevices, + stopScan: mockStopScan, + connectDevice: jest.fn(), + disconnectDevice: jest.fn(), + getWiFiList: jest.fn(), + setWiFi: jest.fn(), + getCurrentWiFi: jest.fn(), + rebootDevice: jest.fn(), + cleanupBLE: jest.fn(), + clearError: jest.fn(), + }); + + render(); + + // Get the cleanup function from useFocusEffect + const focusEffectCall = (useFocusEffect as jest.Mock).mock.calls[0][0]; + const cleanupFn = focusEffectCall(); + + // Simulate screen blur by calling cleanup + if (cleanupFn) { + cleanupFn(); + } + + // stopScan should not be called when not scanning + expect(mockStopScan).not.toHaveBeenCalled(); + }); + + it('should cleanup when component unmounts during active scan', () => { + (useBLE as jest.Mock).mockReturnValue({ + foundDevices: [ + { id: 'device-1', name: 'WP_497_81a14c', mac: '81A14C', rssi: -55, wellId: 497 }, + ], + isScanning: true, + connectedDevices: new Set(), + isBLEAvailable: true, + error: null, + scanDevices: mockScanDevices, + stopScan: mockStopScan, + connectDevice: jest.fn(), + disconnectDevice: jest.fn(), + getWiFiList: jest.fn(), + setWiFi: jest.fn(), + getCurrentWiFi: jest.fn(), + rebootDevice: jest.fn(), + cleanupBLE: jest.fn(), + clearError: jest.fn(), + }); + + const { unmount } = render(); + + // Get the cleanup function + const focusEffectCall = (useFocusEffect as jest.Mock).mock.calls[0][0]; + const cleanupFn = focusEffectCall(); + + // Unmount component + unmount(); + + // Cleanup should have been called during unmount + if (cleanupFn) { + cleanupFn(); + } + + expect(mockStopScan).toHaveBeenCalled(); + }); +}); diff --git a/services/ble/__tests__/BLEContext.cleanup.test.tsx b/services/ble/__tests__/BLEContext.cleanup.test.tsx new file mode 100644 index 0000000..dbd6d87 --- /dev/null +++ b/services/ble/__tests__/BLEContext.cleanup.test.tsx @@ -0,0 +1,182 @@ +/** + * BLE Context Cleanup Tests + * Tests for BLE scanning cleanup when screens lose focus + */ + +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { BLEProvider, useBLE } from '@/contexts/BLEContext'; +import * as bleModule from '@/services/ble'; + +// Mock the BLE service +jest.mock('@/services/ble', () => ({ + bleManager: { + scanDevices: jest.fn(), + stopScan: jest.fn(), + connectDevice: jest.fn(), + disconnectDevice: jest.fn(), + getWiFiList: jest.fn(), + setWiFi: jest.fn(), + getCurrentWiFi: jest.fn(), + rebootDevice: jest.fn(), + cleanup: jest.fn(), + }, + isBLEAvailable: true, +})); + +describe('BLEContext cleanup behavior', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should stop scan when cleanupBLE is called while scanning', async () => { + const mockScanDevices = jest.fn().mockResolvedValue([ + { id: 'device-1', name: 'WP_497_81a14c', mac: '81A14C', rssi: -55, wellId: 497 }, + ]); + const mockStopScan = jest.fn(); + + (bleModule.bleManager.scanDevices as jest.Mock) = mockScanDevices; + (bleModule.bleManager.stopScan as jest.Mock) = mockStopScan; + + const { result } = renderHook(() => useBLE(), { wrapper }); + + // Start scanning + act(() => { + result.current.scanDevices(); + }); + + expect(result.current.isScanning).toBe(true); + + // Cleanup while scanning + await act(async () => { + await result.current.cleanupBLE(); + }); + + expect(mockStopScan).toHaveBeenCalled(); + expect(result.current.isScanning).toBe(false); + }); + + it('should clear found devices when cleanupBLE is called', async () => { + const mockScanDevices = jest.fn().mockResolvedValue([ + { id: 'device-1', name: 'WP_497_81a14c', mac: '81A14C', rssi: -55, wellId: 497 }, + { id: 'device-2', name: 'WP_498_82b25d', mac: '82B25D', rssi: -60, wellId: 498 }, + ]); + + (bleModule.bleManager.scanDevices as jest.Mock) = mockScanDevices; + + const { result } = renderHook(() => useBLE(), { wrapper }); + + // Scan and find devices + await act(async () => { + await result.current.scanDevices(); + }); + + expect(result.current.foundDevices).toHaveLength(2); + + // Cleanup + await act(async () => { + await result.current.cleanupBLE(); + }); + + expect(result.current.foundDevices).toHaveLength(0); + }); + + it('should clear connected devices when cleanupBLE is called', async () => { + const mockConnectDevice = jest.fn().mockResolvedValue(true); + const mockCleanup = jest.fn().mockResolvedValue(undefined); + + (bleModule.bleManager.connectDevice as jest.Mock) = mockConnectDevice; + (bleModule.bleManager.cleanup as jest.Mock) = mockCleanup; + + const { result } = renderHook(() => useBLE(), { wrapper }); + + // Connect to a device + await act(async () => { + await result.current.connectDevice('device-1'); + }); + + expect(result.current.connectedDevices.size).toBe(1); + + // Cleanup + await act(async () => { + await result.current.cleanupBLE(); + }); + + expect(mockCleanup).toHaveBeenCalled(); + expect(result.current.connectedDevices.size).toBe(0); + }); + + it('should clear errors when cleanupBLE is called', async () => { + const mockScanDevices = jest.fn().mockRejectedValue(new Error('Scan failed')); + + (bleModule.bleManager.scanDevices as jest.Mock) = mockScanDevices; + + const { result } = renderHook(() => useBLE(), { wrapper }); + + // Trigger an error + await act(async () => { + try { + await result.current.scanDevices(); + } catch { + // Expected + } + }); + + expect(result.current.error).toBeTruthy(); + + // Cleanup should clear errors + await act(async () => { + await result.current.cleanupBLE(); + }); + + expect(result.current.error).toBeNull(); + }); + + it('should not throw if cleanup fails', async () => { + const mockCleanup = jest.fn().mockRejectedValue(new Error('Cleanup failed')); + + (bleModule.bleManager.cleanup as jest.Mock) = mockCleanup; + + const { result } = renderHook(() => useBLE(), { wrapper }); + + // Cleanup should not throw even if bleManager.cleanup fails + await expect( + act(async () => { + await result.current.cleanupBLE(); + }) + ).resolves.not.toThrow(); + }); + + it('should stop scan before calling bleManager.cleanup', async () => { + const callOrder: string[] = []; + + const mockStopScan = jest.fn(() => { + callOrder.push('stopScan'); + }); + const mockCleanup = jest.fn().mockResolvedValue(undefined).mockImplementation(() => { + callOrder.push('cleanup'); + }); + + (bleModule.bleManager.stopScan as jest.Mock) = mockStopScan; + (bleModule.bleManager.cleanup as jest.Mock) = mockCleanup; + + const { result } = renderHook(() => useBLE(), { wrapper }); + + // Start scanning + act(() => { + (result.current as any).setIsScanning?.(true); + }); + + // Trigger cleanup + await act(async () => { + await result.current.cleanupBLE(); + }); + + // stopScan should be called before cleanup + expect(callOrder).toEqual(['stopScan', 'cleanup']); + }); +});