From 5d40da0409b7823a5b9e83fc18417cbebac6c83f Mon Sep 17 00:00:00 2001 From: Sergei Date: Sat, 31 Jan 2026 15:23:06 -0800 Subject: [PATCH] Add BLE permissions handling with graceful fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/(tabs)/beneficiaries/[id]/add-sensor.tsx | 75 +++++- contexts/BLEContext.tsx | 55 +++- services/ble/BLEManager.ts | 66 ++--- services/ble/index.ts | 3 + services/ble/permissions.ts | 249 +++++++++++++++++++ 5 files changed, 388 insertions(+), 60 deletions(-) create mode 100644 services/ble/permissions.ts diff --git a/app/(tabs)/beneficiaries/[id]/add-sensor.tsx b/app/(tabs)/beneficiaries/[id]/add-sensor.tsx index 046ee0d..ecce087 100644 --- a/app/(tabs)/beneficiaries/[id]/add-sensor.tsx +++ b/app/(tabs)/beneficiaries/[id]/add-sensor.tsx @@ -7,12 +7,12 @@ import { TouchableOpacity, Alert, ActivityIndicator, + Linking, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router, useLocalSearchParams, useFocusEffect } from 'expo-router'; import { useBLE } from '@/contexts/BLEContext'; -import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { AppColors, BorderRadius, @@ -24,14 +24,16 @@ import { export default function AddSensorScreen() { const { id } = useLocalSearchParams<{ id: string }>(); - const { currentBeneficiary } = useBeneficiary(); const { foundDevices, isScanning, connectedDevices, isBLEAvailable, + error, + permissionError, scanDevices, stopScan, + clearError, } = useBLE(); const [selectedDevices, setSelectedDevices] = useState>(new Set()); @@ -80,12 +82,25 @@ export default function AddSensorScreen() { const handleScan = async () => { try { + // Clear any previous errors + clearError(); + + // Perform scan await scanDevices(); } 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 = () => { if (selectedCount === 0) { Alert.alert('No Sensors Selected', 'Please select at least one sensor to add.'); @@ -143,6 +158,20 @@ export default function AddSensorScreen() { )} + {/* Permission Error Banner */} + {permissionError && error && ( + + + + {error} + + + Open Settings + + + + )} + {/* Instructions */} @@ -157,7 +186,7 @@ export default function AddSensorScreen() { 2 - Tap "Scan for Sensors" to search for available devices + Tap “Scan for Sensors” to search for available devices @@ -312,7 +341,7 @@ export default function AddSensorScreen() { Troubleshooting - • Sensor not showing up? Make sure it's powered on and the LED is blinking{'\n'} + • Sensor not showing up? Make sure it's powered on and the LED is blinking{'\n'} • Weak signal? Move closer to the sensor{'\n'} • Connection fails? Try restarting the sensor{'\n'} • Still having issues? Contact support for assistance @@ -364,6 +393,42 @@ const styles = StyleSheet.create({ fontWeight: FontWeights.medium, 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: { flex: 1, }, diff --git a/contexts/BLEContext.tsx b/contexts/BLEContext.tsx index 5bc0b4b..655bbf7 100644 --- a/contexts/BLEContext.tsx +++ b/contexts/BLEContext.tsx @@ -1,8 +1,9 @@ // BLE Context - Global state for Bluetooth management 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 { BleManager } from 'react-native-ble-plx'; interface BLEContextType { // State @@ -11,6 +12,7 @@ interface BLEContextType { connectedDevices: Set; isBLEAvailable: boolean; error: string | null; + permissionError: boolean; // true if error is related to permissions // Actions scanDevices: () => Promise; @@ -23,6 +25,7 @@ interface BLEContextType { rebootDevice: (deviceId: string) => Promise; cleanupBLE: () => Promise; clearError: () => void; + checkPermissions: () => Promise; // Manual permission check with UI prompts } const BLEContext = createContext(undefined); @@ -32,17 +35,51 @@ export function BLEProvider({ children }: { children: ReactNode }) { const [isScanning, setIsScanning] = useState(false); const [connectedDevices, setConnectedDevices] = useState>(new Set()); const [error, setError] = useState(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 => { + 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 () => { try { setError(null); + setPermissionError(false); setIsScanning(true); const devices = await bleManager.scanDevices(); // Sort by RSSI (strongest first) const sorted = devices.sort((a, b) => b.rssi - a.rssi); setFoundDevices(sorted); } 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; } finally { setIsScanning(false); @@ -57,16 +94,21 @@ export function BLEProvider({ children }: { children: ReactNode }) { const connectDevice = useCallback(async (deviceId: string): Promise => { try { setError(null); + setPermissionError(false); const success = await bleManager.connectDevice(deviceId); if (success) { setConnectedDevices(prev => new Set(prev).add(deviceId)); } else { // 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; } 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; } }, []); @@ -139,6 +181,7 @@ export function BLEProvider({ children }: { children: ReactNode }) { const clearError = useCallback(() => { setError(null); + setPermissionError(false); }, []); const cleanupBLE = useCallback(async () => { @@ -157,7 +200,7 @@ export function BLEProvider({ children }: { children: ReactNode }) { setConnectedDevices(new Set()); setError(null); - } catch (err: any) { + } catch { // Don't throw - we want to allow logout to proceed even if BLE cleanup fails } }, [isScanning, stopScan]); @@ -177,6 +220,7 @@ export function BLEProvider({ children }: { children: ReactNode }) { connectedDevices, isBLEAvailable, error, + permissionError, scanDevices, stopScan, connectDevice, @@ -187,6 +231,7 @@ export function BLEProvider({ children }: { children: ReactNode }) { rebootDevice, cleanupBLE, clearError, + checkPermissions, }; return {children}; diff --git a/services/ble/BLEManager.ts b/services/ble/BLEManager.ts index ece968f..1a015b6 100644 --- a/services/ble/BLEManager.ts +++ b/services/ble/BLEManager.ts @@ -1,8 +1,9 @@ // Real BLE Manager для физических устройств 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 { requestBLEPermissions, checkBluetoothEnabled } from './permissions'; import base64 from 'react-native-base64'; export class RealBLEManager implements IBLEManager { @@ -22,52 +23,17 @@ export class RealBLEManager implements IBLEManager { // Don't initialize BleManager here - use lazy initialization } - // Check and request permissions - private async requestPermissions(): Promise { - 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 { - const state = await this.manager.state(); - return state === State.PoweredOn; - } - async scanDevices(): Promise { - const hasPermission = await this.requestPermissions(); - if (!hasPermission) { - throw new Error('Bluetooth permissions not granted'); + // Check permissions with graceful fallback + const permissionStatus = await requestBLEPermissions(); + if (!permissionStatus.granted) { + throw new Error(permissionStatus.error || 'Bluetooth permissions not granted'); } - const isEnabled = await this.isBluetoothEnabled(); - if (!isEnabled) { - throw new Error('Bluetooth is disabled. Please enable it in settings.'); + // Check Bluetooth state + const bluetoothStatus = await checkBluetoothEnabled(this.manager); + if (!bluetoothStatus.enabled) { + throw new Error(bluetoothStatus.error || 'Bluetooth is disabled. Please enable it in settings.'); } const foundDevices = new Map(); @@ -123,15 +89,15 @@ export class RealBLEManager implements IBLEManager { async connectDevice(deviceId: string): Promise { try { // Step 0: Check permissions (required for Android 12+) - const hasPermission = await this.requestPermissions(); - if (!hasPermission) { - throw new Error('Bluetooth permissions not granted'); + const permissionStatus = await requestBLEPermissions(); + if (!permissionStatus.granted) { + throw new Error(permissionStatus.error || 'Bluetooth permissions not granted'); } // Step 0.5: Check Bluetooth is enabled - const isEnabled = await this.isBluetoothEnabled(); - if (!isEnabled) { - throw new Error('Bluetooth is disabled. Please enable it in settings.'); + const bluetoothStatus = await checkBluetoothEnabled(this.manager); + if (!bluetoothStatus.enabled) { + throw new Error(bluetoothStatus.error || 'Bluetooth is disabled. Please enable it in settings.'); } // Check if already connected diff --git a/services/ble/index.ts b/services/ble/index.ts index 906cfae..6e973fa 100644 --- a/services/ble/index.ts +++ b/services/ble/index.ts @@ -40,3 +40,6 @@ export const bleManager: IBLEManager = { // Re-export types export * from './types'; + +// Re-export permission utilities +export * from './permissions'; diff --git a/services/ble/permissions.ts b/services/ble/permissions.ts new file mode 100644 index 0000000..c4e0d89 --- /dev/null +++ b/services/ble/permissions.ts @@ -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 { + 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 { + 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 { + // 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; +}