Add BLE permissions handling with graceful fallback

- Create permissions helper module with comprehensive error handling
- Update BLEManager to use new permission system
- Add permission state tracking in BLEContext
- Improve add-sensor screen with permission error banner
- Add "Open Settings" button for permission issues
- Handle Android 12+ and older permission models
- Provide user-friendly error messages for all states

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-31 15:23:06 -08:00
parent d499d9d62a
commit 5d40da0409
5 changed files with 388 additions and 60 deletions

View File

@ -7,12 +7,12 @@ import {
TouchableOpacity, TouchableOpacity,
Alert, Alert,
ActivityIndicator, ActivityIndicator,
Linking,
} 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, useFocusEffect } from 'expo-router'; import { router, useLocalSearchParams, useFocusEffect } from 'expo-router';
import { useBLE } from '@/contexts/BLEContext'; import { useBLE } from '@/contexts/BLEContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { import {
AppColors, AppColors,
BorderRadius, BorderRadius,
@ -24,14 +24,16 @@ import {
export default function AddSensorScreen() { export default function AddSensorScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary } = useBeneficiary();
const { const {
foundDevices, foundDevices,
isScanning, isScanning,
connectedDevices, connectedDevices,
isBLEAvailable, isBLEAvailable,
error,
permissionError,
scanDevices, scanDevices,
stopScan, stopScan,
clearError,
} = useBLE(); } = useBLE();
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set()); const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
@ -80,12 +82,25 @@ export default function AddSensorScreen() {
const handleScan = async () => { const handleScan = async () => {
try { try {
// Clear any previous errors
clearError();
// Perform scan
await scanDevices(); await scanDevices();
} catch (error: any) { } catch (error: any) {
Alert.alert('Scan Failed', error.message || 'Failed to scan for sensors. Please try again.'); // Error is already set in BLE context, but show alert for critical issues
if (!permissionError) {
// Non-permission errors - show generic alert
Alert.alert('Scan Failed', error.message || 'Failed to scan for sensors. Please try again.');
}
// Permission errors are already shown with proper dialogs by checkBLEReadiness
} }
}; };
const handleOpenSettings = () => {
Linking.openSettings();
};
const handleAddSelected = () => { const handleAddSelected = () => {
if (selectedCount === 0) { if (selectedCount === 0) {
Alert.alert('No Sensors Selected', 'Please select at least one sensor to add.'); Alert.alert('No Sensors Selected', 'Please select at least one sensor to add.');
@ -143,6 +158,20 @@ export default function AddSensorScreen() {
</View> </View>
)} )}
{/* Permission Error Banner */}
{permissionError && error && (
<View style={styles.permissionError}>
<View style={styles.permissionErrorContent}>
<Ionicons name="warning" size={20} color={AppColors.error} />
<Text style={styles.permissionErrorText}>{error}</Text>
</View>
<TouchableOpacity style={styles.settingsButton} onPress={handleOpenSettings}>
<Text style={styles.settingsButtonText}>Open Settings</Text>
<Ionicons name="settings-outline" size={16} color={AppColors.error} />
</TouchableOpacity>
</View>
)}
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}> <ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
{/* Instructions */} {/* Instructions */}
<View style={styles.instructionsCard}> <View style={styles.instructionsCard}>
@ -157,7 +186,7 @@ export default function AddSensorScreen() {
<View style={styles.stepNumber}> <View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>2</Text> <Text style={styles.stepNumberText}>2</Text>
</View> </View>
<Text style={styles.stepText}>Tap "Scan for Sensors" to search for available devices</Text> <Text style={styles.stepText}>Tap &ldquo;Scan for Sensors&rdquo; to search for available devices</Text>
</View> </View>
<View style={styles.step}> <View style={styles.step}>
<View style={styles.stepNumber}> <View style={styles.stepNumber}>
@ -312,7 +341,7 @@ export default function AddSensorScreen() {
<Text style={styles.helpTitle}>Troubleshooting</Text> <Text style={styles.helpTitle}>Troubleshooting</Text>
</View> </View>
<Text style={styles.helpText}> <Text style={styles.helpText}>
Sensor not showing up? Make sure it's powered on and the LED is blinking{'\n'} Sensor not showing up? Make sure it&apos;s powered on and the LED is blinking{'\n'}
Weak signal? Move closer to the sensor{'\n'} Weak signal? Move closer to the sensor{'\n'}
Connection fails? Try restarting the sensor{'\n'} Connection fails? Try restarting the sensor{'\n'}
Still having issues? Contact support for assistance Still having issues? Contact support for assistance
@ -364,6 +393,42 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.medium, fontWeight: FontWeights.medium,
flex: 1, flex: 1,
}, },
permissionError: {
backgroundColor: AppColors.errorLight,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
borderBottomWidth: 1,
borderBottomColor: AppColors.error,
gap: Spacing.sm,
},
permissionErrorContent: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: Spacing.xs,
},
permissionErrorText: {
fontSize: FontSizes.sm,
color: AppColors.error,
fontWeight: FontWeights.medium,
flex: 1,
lineHeight: 20,
},
settingsButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.white,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.md,
borderRadius: BorderRadius.sm,
gap: Spacing.xs,
alignSelf: 'flex-start',
},
settingsButtonText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.error,
},
content: { content: {
flex: 1, flex: 1,
}, },

View File

@ -1,8 +1,9 @@
// BLE Context - Global state for Bluetooth management // BLE Context - Global state for Bluetooth management
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { bleManager, WPDevice, WiFiNetwork, WiFiStatus, isBLEAvailable } from '@/services/ble'; import { bleManager, WPDevice, WiFiNetwork, WiFiStatus, isBLEAvailable, checkBLEReadiness } from '@/services/ble';
import { setOnLogoutBLECleanupCallback } from '@/services/api'; import { setOnLogoutBLECleanupCallback } from '@/services/api';
import { BleManager } from 'react-native-ble-plx';
interface BLEContextType { interface BLEContextType {
// State // State
@ -11,6 +12,7 @@ interface BLEContextType {
connectedDevices: Set<string>; connectedDevices: Set<string>;
isBLEAvailable: boolean; isBLEAvailable: boolean;
error: string | null; error: string | null;
permissionError: boolean; // true if error is related to permissions
// Actions // Actions
scanDevices: () => Promise<void>; scanDevices: () => Promise<void>;
@ -23,6 +25,7 @@ interface BLEContextType {
rebootDevice: (deviceId: string) => Promise<void>; rebootDevice: (deviceId: string) => Promise<void>;
cleanupBLE: () => Promise<void>; cleanupBLE: () => Promise<void>;
clearError: () => void; clearError: () => void;
checkPermissions: () => Promise<boolean>; // Manual permission check with UI prompts
} }
const BLEContext = createContext<BLEContextType | undefined>(undefined); const BLEContext = createContext<BLEContextType | undefined>(undefined);
@ -32,17 +35,51 @@ export function BLEProvider({ children }: { children: ReactNode }) {
const [isScanning, setIsScanning] = useState(false); const [isScanning, setIsScanning] = useState(false);
const [connectedDevices, setConnectedDevices] = useState<Set<string>>(new Set()); const [connectedDevices, setConnectedDevices] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [permissionError, setPermissionError] = useState(false);
// Lazy BleManager instance for permission checks
const [bleManagerInstance] = useState(() => new BleManager());
const isPermissionError = (errorMessage: string): boolean => {
const permissionKeywords = [
'permission',
'unauthorized',
'not granted',
'denied',
'bluetooth is disabled',
'enable it in settings',
];
const lowerMessage = errorMessage.toLowerCase();
return permissionKeywords.some(keyword => lowerMessage.includes(keyword));
};
const checkPermissions = useCallback(async (): Promise<boolean> => {
try {
setError(null);
setPermissionError(false);
const ready = await checkBLEReadiness(bleManagerInstance);
return ready;
} catch {
const errorMsg = 'Failed to check Bluetooth permissions';
setError(errorMsg);
setPermissionError(true);
return false;
}
}, [bleManagerInstance]);
const scanDevices = useCallback(async () => { const scanDevices = useCallback(async () => {
try { try {
setError(null); setError(null);
setPermissionError(false);
setIsScanning(true); setIsScanning(true);
const devices = await bleManager.scanDevices(); const devices = await bleManager.scanDevices();
// Sort by RSSI (strongest first) // Sort by RSSI (strongest first)
const sorted = devices.sort((a, b) => b.rssi - a.rssi); const sorted = devices.sort((a, b) => b.rssi - a.rssi);
setFoundDevices(sorted); setFoundDevices(sorted);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to scan for devices'); const errorMsg = err.message || 'Failed to scan for devices';
setError(errorMsg);
setPermissionError(isPermissionError(errorMsg));
throw err; throw err;
} finally { } finally {
setIsScanning(false); setIsScanning(false);
@ -57,16 +94,21 @@ export function BLEProvider({ children }: { children: ReactNode }) {
const connectDevice = useCallback(async (deviceId: string): Promise<boolean> => { const connectDevice = useCallback(async (deviceId: string): Promise<boolean> => {
try { try {
setError(null); setError(null);
setPermissionError(false);
const success = await bleManager.connectDevice(deviceId); const success = await bleManager.connectDevice(deviceId);
if (success) { if (success) {
setConnectedDevices(prev => new Set(prev).add(deviceId)); setConnectedDevices(prev => new Set(prev).add(deviceId));
} else { } else {
// Connection failed but no exception - set user-friendly error // Connection failed but no exception - set user-friendly error
setError('Could not connect to sensor. Please move closer and try again.'); const errorMsg = 'Could not connect to sensor. Please move closer and try again.';
setError(errorMsg);
setPermissionError(false);
} }
return success; return success;
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to connect to device'); const errorMsg = err.message || 'Failed to connect to device';
setError(errorMsg);
setPermissionError(isPermissionError(errorMsg));
return false; return false;
} }
}, []); }, []);
@ -139,6 +181,7 @@ export function BLEProvider({ children }: { children: ReactNode }) {
const clearError = useCallback(() => { const clearError = useCallback(() => {
setError(null); setError(null);
setPermissionError(false);
}, []); }, []);
const cleanupBLE = useCallback(async () => { const cleanupBLE = useCallback(async () => {
@ -157,7 +200,7 @@ export function BLEProvider({ children }: { children: ReactNode }) {
setConnectedDevices(new Set()); setConnectedDevices(new Set());
setError(null); setError(null);
} catch (err: any) { } catch {
// Don't throw - we want to allow logout to proceed even if BLE cleanup fails // Don't throw - we want to allow logout to proceed even if BLE cleanup fails
} }
}, [isScanning, stopScan]); }, [isScanning, stopScan]);
@ -177,6 +220,7 @@ export function BLEProvider({ children }: { children: ReactNode }) {
connectedDevices, connectedDevices,
isBLEAvailable, isBLEAvailable,
error, error,
permissionError,
scanDevices, scanDevices,
stopScan, stopScan,
connectDevice, connectDevice,
@ -187,6 +231,7 @@ export function BLEProvider({ children }: { children: ReactNode }) {
rebootDevice, rebootDevice,
cleanupBLE, cleanupBLE,
clearError, clearError,
checkPermissions,
}; };
return <BLEContext.Provider value={value}>{children}</BLEContext.Provider>; return <BLEContext.Provider value={value}>{children}</BLEContext.Provider>;

View File

@ -1,8 +1,9 @@
// Real BLE Manager для физических устройств // Real BLE Manager для физических устройств
import { BleManager, Device, State } from 'react-native-ble-plx'; import { BleManager, Device, State } from 'react-native-ble-plx';
import { PermissionsAndroid, Platform } from 'react-native'; import { Platform } from 'react-native';
import { IBLEManager, WPDevice, WiFiNetwork, WiFiStatus, BLE_CONFIG, BLE_COMMANDS } from './types'; import { IBLEManager, WPDevice, WiFiNetwork, WiFiStatus, BLE_CONFIG, BLE_COMMANDS } from './types';
import { requestBLEPermissions, checkBluetoothEnabled } from './permissions';
import base64 from 'react-native-base64'; import base64 from 'react-native-base64';
export class RealBLEManager implements IBLEManager { export class RealBLEManager implements IBLEManager {
@ -22,52 +23,17 @@ export class RealBLEManager implements IBLEManager {
// Don't initialize BleManager here - use lazy initialization // Don't initialize BleManager here - use lazy initialization
} }
// Check and request permissions
private async requestPermissions(): Promise<boolean> {
if (Platform.OS === 'ios') {
// iOS handles permissions automatically via Info.plist
return true;
}
if (Platform.OS === 'android') {
if (Platform.Version >= 31) {
// Android 12+
const granted = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN!,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT!,
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION!,
]);
return Object.values(granted).every(
status => status === PermissionsAndroid.RESULTS.GRANTED
);
} else {
// Android < 12
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION!
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
}
}
return false;
}
// Check if Bluetooth is enabled
private async isBluetoothEnabled(): Promise<boolean> {
const state = await this.manager.state();
return state === State.PoweredOn;
}
async scanDevices(): Promise<WPDevice[]> { async scanDevices(): Promise<WPDevice[]> {
const hasPermission = await this.requestPermissions(); // Check permissions with graceful fallback
if (!hasPermission) { const permissionStatus = await requestBLEPermissions();
throw new Error('Bluetooth permissions not granted'); if (!permissionStatus.granted) {
throw new Error(permissionStatus.error || 'Bluetooth permissions not granted');
} }
const isEnabled = await this.isBluetoothEnabled(); // Check Bluetooth state
if (!isEnabled) { const bluetoothStatus = await checkBluetoothEnabled(this.manager);
throw new Error('Bluetooth is disabled. Please enable it in settings.'); if (!bluetoothStatus.enabled) {
throw new Error(bluetoothStatus.error || 'Bluetooth is disabled. Please enable it in settings.');
} }
const foundDevices = new Map<string, WPDevice>(); const foundDevices = new Map<string, WPDevice>();
@ -123,15 +89,15 @@ export class RealBLEManager implements IBLEManager {
async connectDevice(deviceId: string): Promise<boolean> { async connectDevice(deviceId: string): Promise<boolean> {
try { try {
// Step 0: Check permissions (required for Android 12+) // Step 0: Check permissions (required for Android 12+)
const hasPermission = await this.requestPermissions(); const permissionStatus = await requestBLEPermissions();
if (!hasPermission) { if (!permissionStatus.granted) {
throw new Error('Bluetooth permissions not granted'); throw new Error(permissionStatus.error || 'Bluetooth permissions not granted');
} }
// Step 0.5: Check Bluetooth is enabled // Step 0.5: Check Bluetooth is enabled
const isEnabled = await this.isBluetoothEnabled(); const bluetoothStatus = await checkBluetoothEnabled(this.manager);
if (!isEnabled) { if (!bluetoothStatus.enabled) {
throw new Error('Bluetooth is disabled. Please enable it in settings.'); throw new Error(bluetoothStatus.error || 'Bluetooth is disabled. Please enable it in settings.');
} }
// Check if already connected // Check if already connected

View File

@ -40,3 +40,6 @@ export const bleManager: IBLEManager = {
// Re-export types // Re-export types
export * from './types'; export * from './types';
// Re-export permission utilities
export * from './permissions';

249
services/ble/permissions.ts Normal file
View File

@ -0,0 +1,249 @@
// BLE Permissions Helper
// Handles Bluetooth permission requests with graceful fallback
import { PermissionsAndroid, Platform, Linking, Alert } from 'react-native';
import { BleManager, State } from 'react-native-ble-plx';
export interface PermissionStatus {
granted: boolean;
canRequest: boolean; // false if user previously denied with "Don't ask again"
error?: string;
}
export interface BluetoothStatus {
enabled: boolean;
canEnable: boolean;
error?: string;
}
/**
* Check and request BLE permissions based on platform
* Returns permission status with graceful fallback info
*/
export async function requestBLEPermissions(): Promise<PermissionStatus> {
if (Platform.OS === 'ios') {
// iOS handles permissions automatically via Info.plist
// BLE permission dialog shows on first BLE operation
return { granted: true, canRequest: true };
}
if (Platform.OS === 'android') {
try {
if (Platform.Version >= 31) {
// Android 12+ requires BLUETOOTH_SCAN and BLUETOOTH_CONNECT
const results = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN!,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT!,
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION!,
]);
const allGranted = Object.values(results).every(
status => status === PermissionsAndroid.RESULTS.GRANTED
);
const anyNeverAskAgain = Object.values(results).some(
status => status === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN
);
if (allGranted) {
return { granted: true, canRequest: true };
}
if (anyNeverAskAgain) {
return {
granted: false,
canRequest: false,
error: 'Bluetooth permissions were previously denied. Please enable them in Settings.',
};
}
return {
granted: false,
canRequest: true,
error: 'Bluetooth permissions are required to scan for sensors.',
};
} else {
// Android < 12 requires ACCESS_FINE_LOCATION
const result = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION!
);
if (result === PermissionsAndroid.RESULTS.GRANTED) {
return { granted: true, canRequest: true };
}
if (result === PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN) {
return {
granted: false,
canRequest: false,
error: 'Location permission was previously denied. Please enable it in Settings.',
};
}
return {
granted: false,
canRequest: true,
error: 'Location permission is required to scan for Bluetooth devices.',
};
}
} catch (error: any) {
return {
granted: false,
canRequest: false,
error: `Failed to request permissions: ${error.message}`,
};
}
}
// Unknown platform
return {
granted: false,
canRequest: false,
error: 'Bluetooth permissions not supported on this platform',
};
}
/**
* Check Bluetooth state (enabled/disabled)
* Returns status with helpful error messages
*/
export async function checkBluetoothEnabled(manager: BleManager): Promise<BluetoothStatus> {
try {
const state = await manager.state();
switch (state) {
case State.PoweredOn:
return { enabled: true, canEnable: true };
case State.PoweredOff:
return {
enabled: false,
canEnable: true,
error: 'Bluetooth is turned off. Please enable it in your device settings.',
};
case State.Unauthorized:
return {
enabled: false,
canEnable: false,
error: 'Bluetooth access is not authorized. Please enable permissions in Settings.',
};
case State.Unsupported:
return {
enabled: false,
canEnable: false,
error: 'Bluetooth is not supported on this device.',
};
case State.Resetting:
return {
enabled: false,
canEnable: true,
error: 'Bluetooth is resetting. Please wait a moment and try again.',
};
case State.Unknown:
default:
return {
enabled: false,
canEnable: true,
error: 'Bluetooth state is unknown. Please check your device settings.',
};
}
} catch (error: any) {
return {
enabled: false,
canEnable: false,
error: `Failed to check Bluetooth state: ${error.message}`,
};
}
}
/**
* Show a user-friendly alert for permission/Bluetooth issues
* Offers to open Settings if needed
*/
export function showPermissionAlert(
permissionStatus: PermissionStatus,
bluetoothStatus: BluetoothStatus
): void {
// Bluetooth disabled (can be fixed easily)
if (!bluetoothStatus.enabled && bluetoothStatus.canEnable) {
Alert.alert(
'Bluetooth Required',
bluetoothStatus.error || 'Please enable Bluetooth to scan for sensors.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Open Settings',
onPress: () => {
if (Platform.OS === 'ios') {
Linking.openURL('App-Prefs:Bluetooth');
} else {
Linking.sendIntent('android.settings.BLUETOOTH_SETTINGS');
}
},
},
]
);
return;
}
// Permission denied with "Never ask again"
if (!permissionStatus.granted && !permissionStatus.canRequest) {
Alert.alert(
'Permissions Required',
permissionStatus.error || 'Bluetooth permissions are required to scan for sensors. Please enable them in Settings.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Open Settings',
onPress: () => {
Linking.openSettings();
},
},
]
);
return;
}
// Permission denied (can retry)
if (!permissionStatus.granted) {
Alert.alert(
'Permissions Required',
permissionStatus.error || 'Bluetooth permissions are required to scan for sensors.',
[{ text: 'OK' }]
);
return;
}
// Bluetooth not supported or other unrecoverable error
if (!bluetoothStatus.canEnable) {
Alert.alert(
'Bluetooth Unavailable',
bluetoothStatus.error || 'Bluetooth is not available on this device.',
[{ text: 'OK' }]
);
}
}
/**
* Comprehensive pre-scan check
* Returns true if ready to scan, false otherwise (with user alert shown)
*/
export async function checkBLEReadiness(manager: BleManager): Promise<boolean> {
// Step 1: Check permissions
const permissionStatus = await requestBLEPermissions();
// Step 2: Check Bluetooth state
const bluetoothStatus = await checkBluetoothEnabled(manager);
// Step 3: If not ready, show appropriate alert
if (!permissionStatus.granted || !bluetoothStatus.enabled) {
showPermissionAlert(permissionStatus, bluetoothStatus);
return false;
}
return true;
}