Add BLE scanning cleanup on screen blur

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 <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-29 12:08:37 -08:00
parent deddd3d5bc
commit a30769387f
3 changed files with 386 additions and 9 deletions

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { import {
View, View,
Text, Text,
@ -10,11 +10,9 @@ import {
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams, useFocusEffect } from 'expo-router';
import * as Device from 'expo-device';
import { useBLE } from '@/contexts/BLEContext'; import { useBLE } from '@/contexts/BLEContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import type { WPDevice } from '@/services/ble';
import { import {
AppColors, AppColors,
BorderRadius, BorderRadius,
@ -34,21 +32,33 @@ export default function AddSensorScreen() {
isBLEAvailable, isBLEAvailable,
scanDevices, scanDevices,
stopScan, stopScan,
connectDevice,
} = useBLE(); } = useBLE();
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set()); const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
const [isConnecting, setIsConnecting] = useState(false);
const beneficiaryName = currentBeneficiary?.name || 'this person';
// Select all devices by default when scan completes // Select all devices by default when scan completes
React.useEffect(() => { useEffect(() => {
if (foundDevices.length > 0 && !isScanning) { if (foundDevices.length > 0 && !isScanning) {
setSelectedDevices(new Set(foundDevices.map(d => d.id))); setSelectedDevices(new Set(foundDevices.map(d => d.id)));
} }
}, [foundDevices, isScanning]); }, [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) => { const toggleDeviceSelection = (deviceId: string) => {
setSelectedDevices(prev => { setSelectedDevices(prev => {
const next = new Set(prev); const next = new Set(prev);

View File

@ -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(<AddSensorScreen />);
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(<AddSensorScreen />);
// 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(<AddSensorScreen />);
// 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(<AddSensorScreen />);
// 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();
});
});

View File

@ -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 }) => (
<BLEProvider>{children}</BLEProvider>
);
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']);
});
});