I've successfully created an onboarding flow that presents a bluetooth low energy permission dialogue when the user taps a continue button in a particular screen during onboarding. However, it's only working for Android. The app targets iOS 13 or newer. In iOS 13+, when the onboarding is launched the bluetooth permission dialogue appears automatically. It doesn't wait for the user to tap on the continue button, in the second onboarding screen, to activate it manually like it does in Android.
After launching the app for the first time: I'm asked if I want to allow my app to find and connect to devices on my local network, to which I agree. When the first screen of onboarding appears I'm asked if I want to allow bluetooth. I get the following log output:
LOG Manager initialized, checking Bluetooth state...
LOG Current Bluetooth state: Unknown
ERROR Error enabling Bluetooth: [BleError: Bluetooth state change failed]
I tap on ok to allow bluetooth, then tap continue to get to the bluetooth permission screen. When I tap on continue, the alert dialogue appears from the handleEnableBluetooth method in BluetoothScreen (see code snippet 2 below). I get the following output:
LOG Requesting Bluetooth permission...
LOG Bluetooth permission: unavailable
LOG Location permission: unavailable
LOG Location Always permission: unavailable
ERROR Bluetooth or Location permissions not granted
LOG Bluetooth permission: false
In my device settings bluetooth is enabled system wide, and my app's bluetooth toggle is also on.
My info.plist is:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ".0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TychoCare</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>co.uk.tycho.provisioner</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+provisioner</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>14</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>13.3</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>$(PRODUCT_NAME) requires bluetooth access to configure hubs and watches.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>$(PRODUCT_NAME) requires bluetooth access to configure hubs and watches.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>$(PRODUCT_NAME) requires your location to find nearby Bluetooth devices</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>$(PRODUCT_NAME) requires your location to find nearby Bluetooth devices</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>$(PRODUCT_NAME) requires your location</string>
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) needs access to your camera to scan Tycho hub QR code to authorize you and take profile pictures.</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) needs access to your microphone.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-peripheral</string>
</array>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarStyle</key>
<string></string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
My onboarding bluetooth screen with the continue button:
import React from "react";
import { View, Text, Alert } from "react-native";
import { useBluetoothConnection } from "../../Context/BLEContext";
import Icon from "react-native-vector-icons/Ionicons";
import AppButton from "../../Components/ButtonComponent";
import Pagination from "../../Components/PaginationComponent";
import ButtonStyle from "../../Styles/ButtonStyle";
import Colors from "../../Styles/ColorsStyle";
import Styles from "../../Styles/GeneralStyle";
const BluetoothScreen = ({ navigation }) => {
const { requestPermissions } = useBluetoothConnection();
const handleEnableBluetooth = async () => {
console.log("Requesting Bluetooth permission...");
const granted = await requestPermissions();
console.log("Bluetooth permission: ", granted);
if (granted) {
navigation.navigate("Camera");
} else {
Alert.alert("Permission required", "Bluetooth permissions are required to proceed. Enable bluetooth in your device settings. Also, make sure location permissions are enabled for the app.");
}
};
return (
<View style={Styles.container3}>
<View style={Styles.content}>
<Icon name="bluetooth" size={100} color={Colors.darkerGray} />
<Text style={Styles.text}>You must enable bluetooth so you can configure Tycho hubs.</Text>
<Text style={Styles.text}>Enabling bluetooth ensures you can connect to Tycho hubs to configure them.</Text>
</View>
<Pagination index={1} total={3} />
<AppButton
color={Colors.primary}
style={[ButtonStyle.bigButton, Styles.button]}
textStyle={ButtonStyle.bigButtonText}
title="Continue"
onPress={handleEnableBluetooth}
/>
</View>
);
};
export default BluetoothScreen;
My requestPermissions() function in my BLE context file:
import React, { createContext, useContext, useCallback, useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import { request, PERMISSIONS, RESULTS } from "react-native-permissions";
import { Buffer } from "buffer";
import { useActivityIndicator } from "./ActivityIndicatorContext.js";
import { useTheme } from "./ThemeContext.js";
import Colors from "../Styles/ColorsStyle.js";
import bleManagerSingleton from "../Singletons/BleManagerSingleton.js";
import {
UART_SERVICE_UUID,
UART_RX_CHARACTERISTIC_UUID,
UART_TX_CHARACTERISTIC_UUID,
EOT_MARKER
} from "../Constants/BLEConstants.js";
const BluetoothConnectionContext = createContext(null);
export const BluetoothConnectionProvider = ({ children }) => {
const [scanHubComplete, setScanHubComplete] = useState(false);
const [scanWatchComplete, setScanWatchComplete] = useState(false);
const [scanBleDeviceComplete, setScanBleDeviceComplete] = useState(false);
const [scanning, setScanning] = useState(false);
const [connectedDevice, setConnectedDevice] = useState(null);
const manager = bleManagerSingleton.getInstance();
const operationQueue = useRef([]);
const isOperationInProgress = useRef(false);
const { showLoader, hideLoader } = useActivityIndicator();
const { theme } = useTheme();
const processQueue = useCallback(async (loaderMessage) => {
if (operationQueue.current.length === 0 || isOperationInProgress.current) {
return;
}
isOperationInProgress.current = true;
const operation = operationQueue.current.shift();
try {
showLoader(Colors.primary, theme.text, loaderMessage || "Loading...");
await operation();
} catch (error) {
console.error("Error processing BLE operation:", error);
} finally {
isOperationInProgress.current = false;
hideLoader();
processQueue(loaderMessage); // Process the next operation in the queue
}
}, [showLoader, hideLoader, theme.text]);
const enqueueOperation = useCallback((operation, loaderMessage) => {
operationQueue.current.push(operation);
processQueue(loaderMessage);
}, [processQueue]);
const requestPermissions = useCallback(async () => {
if (Platform.OS === "android") {
try {
if (Platform.Version >= 31) {
const scanGranted = await request(PERMISSIONS.ANDROID.BLUETOOTH_SCAN);
const connectGranted = await request(PERMISSIONS.ANDROID.BLUETOOTH_CONNECT);
const locationGranted = await request(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
if (
scanGranted !== RESULTS.GRANTED ||
connectGranted !== RESULTS.GRANTED ||
locationGranted !== RESULTS.GRANTED
) {
console.error("Bluetooth permissions not granted");
return false;
}
} else {
const locationGranted = await request(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
if (locationGranted !== RESULTS.GRANTED) {
console.error("Location permission not granted");
return false;
}
}
return true; // Permissions granted
} catch (err) {
console.warn(err);
return false;
}
} else if (Platform.OS === "ios") {
try {
const bluetoothPermission = await request(PERMISSIONS.IOS.BLUETOOTH_PERIPHERAL);
const locationPermission = await request(PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
const locationAlwaysPermission = await request(PERMISSIONS.IOS.LOCATION_ALWAYS);
console.log("Bluetooth permission:", bluetoothPermission);
console.log("Location permission:", locationPermission);
console.log("Location Always permission:", locationAlwaysPermission);
if (
bluetoothPermission !== RESULTS.GRANTED ||
locationPermission !== RESULTS.GRANTED ||
locationAlwaysPermission !== RESULTS.GRANTED
) {
console.error("Bluetooth or Location permissions not granted");
return false;
}
return true; // Permissions granted
} catch (err) {
console.warn(err);
return false;
}
}
// Default return true for platforms other than Android and iOS
return true;
}, []);
const checkBluetoothState = useCallback(async () => {
return enqueueOperation(async () => {
const state = await manager.state();
console.log("Current Bluetooth state:", state);
if (state !== "PoweredOn") {
try {
await manager.enable();
} catch (error) {
console.error("Error enabling Bluetooth:", error);
}
}
}, "Checking Bluetooth state...");
}, [manager, enqueueOperation]);
useEffect(() => {
if (manager) {
console.log("Manager initialized, checking Bluetooth state...");
checkBluetoothState();
} else {
console.error("BleManager is not available");
}
}, [manager]);
const startDeviceScan = useCallback((deviceType) => {
return new Promise((resolve, reject) => {
enqueueOperation(async () => {
if (Platform.OS === "android" && Platform.Version >= 31) {
const permissionsGranted = await requestPermissions();
if (!permissionsGranted) {
reject(new Error("Permissions not granted"));
return;
}
}
console.log("Starting device scan...");
try {
setScanning(true);
let bestDevice = null;
manager.startDeviceScan(null, null, (error, scannedDevice) => {
if (error) {
console.error("Error during scan:", error);
if (deviceType === "Tycho-Hub") {
setScanHubComplete(true);
} else if (deviceType === "Tycho-Watch") {
setScanWatchComplete(true);
} else {
setScanBleDeviceComplete(true);
}
setScanning(false);
reject(error);
return;
}
if (scannedDevice.name && scannedDevice.name.includes(deviceType)) {
// Check if this device has the highest RSSI
if (!bestDevice || scannedDevice.rssi > bestDevice.rssi) {
bestDevice = scannedDevice;
}
}
});
// Stop scanning after a certain period or condition
setTimeout(() => {
if (bestDevice) {
if (deviceType === "Tycho-Hub") {
setScanHubComplete(true);
} else if (deviceType === "Tycho-Watch") {
setScanWatchComplete(true);
} else {
setScanBleDeviceComplete(true);
}
console.log("Best device found:", bestDevice);
resolve(bestDevice);
} else {
reject(new Error("No device found"));
}
stopDeviceScan();
setScanning(false);
}, 7500); // Adjust the timeout duration as needed
} catch (e) {
console.error("Exception during startDeviceScan:", e);
reject(e);
}
}, "Scanning...");
});
}, [stopDeviceScan, enqueueOperation, requestPermissions]);
const stopDeviceScan = useCallback(() => {
return enqueueOperation(async () => {
manager.stopDeviceScan();
}, "Stopping device scan...");
}, [enqueueOperation]);
const connectToDevice = useCallback(async (device) => {
return enqueueOperation(async () => {
try {
//await checkBluetoothState(); // Ensure Bluetooth is powered on
console.log("Device in connectToDevice:", device);
const newlyConnectedDevice = await manager.connectToDevice(device.id);
console.log(`Connected to device: ${newlyConnectedDevice.localName}`);
setConnectedDevice(newlyConnectedDevice);
} catch (error) {
console.error("Error connecting to device:", error);
throw new Error(error);
}
}, "Connecting to device...");
}, [enqueueOperation]);
const disconnectFromDevice = useCallback(async (device) => {
return enqueueOperation(async () => {
try {
await manager.cancelDeviceConnection(device.id);
} catch (error) {
console.error("Error disconnecting from device:", error);
throw new Error(error.message || "Unknown error", error.code);
}
}, "Disconnecting from device...");
}, [enqueueOperation]);
const characteristicsForService = useCallback(async (device) => {
if (!device) {
throw new Error("Device is not connected to the app");
}
const service = await device.discoverAllServicesAndCharacteristics();
const characteristics = await service.characteristicsForService(UART_SERVICE_UUID);
return characteristics;
}, []);
const obtainUartServiceAndCharacteristics = useCallback(async (device) => {
const characteristics = await characteristicsForService(device);
const rxCharacteristic = characteristics.find(c => c.uuid === UART_RX_CHARACTERISTIC_UUID);
const txCharacteristic = characteristics.find(c => c.uuid === UART_TX_CHARACTERISTIC_UUID);
if (!rxCharacteristic) {
throw new Error("RX characteristic not found");
}
if (!txCharacteristic) {
throw new Error("TX characteristic not found");
}
return { rxCharacteristic, txCharacteristic };
}, [characteristicsForService]);
const receiveFromHub = useCallback((device) => {
console.log("Receiving from hub...");
return new Promise((resolve, reject) => {
enqueueOperation(() => {
console.log("Device inside receiveFromHub is: ", device);
console.log(`Device is ${device.isConnected() ? "connected" : "not connected"}`);
if (device && !device.isConnected()) {
reconnect(device); // Call reconnect directly without enqueuing
}
console.log("Device in receiveFromHub: ", device);
let fullData = "";
let timeoutHandle;
try {
const subscription = manager.monitorCharacteristicForDevice(
device.id,
UART_SERVICE_UUID,
UART_TX_CHARACTERISTIC_UUID,
(error, characteristic) => {
if (error) {
console.error("Notification error:", error);
cleanup();
reject(error);
return;
}
const data = characteristic.value;
const decodedData = Buffer.from(data, "base64").toString("ascii");
console.log("Received notification data:", decodedData);
if (decodedData.includes(EOT_MARKER)) {
console.log("Full data received:", fullData);
cleanup();
resolve(fullData);
} else {
fullData += decodedData;
}
}
);
// Set a timeout to reject the promise if data is not received in time
const TIMEOUT_DURATION = 5000; // 5 seconds
timeoutHandle = setTimeout(() => {
console.error("Timeout reached without receiving full data");
cleanup();
reject(new Error("Timeout reached"));
}, TIMEOUT_DURATION);
// Cleanup function to remove subscription and clear timeout
const cleanup = () => {
if (subscription) {
subscription.remove();
}
clearTimeout(timeoutHandle);
};
} catch (error) {
console.error("Error subscribing to notifications:", error);
reject(error);
}
}, "Receiving from hub...");
});
}, [manager, enqueueOperation, reconnect]);
const sendToHub = useCallback(async (device, dataToSend) => {
console.log("Sending to hub: ", dataToSend);
return enqueueOperation(async () => {
console.log("Device inside sendToHub is: ", device);
console.log(`Device is ${device.isConnected() ? "connected" : "not connected"}`);
if (device && !device.isConnected()) {
reconnect(device);
}
console.log("Device in sendToHub: ", device);
try {
const { rxCharacteristic } = await obtainUartServiceAndCharacteristics(device);
await rxCharacteristic.writeWithoutResponse(dataToSend);
console.log("Data sent to hub:", dataToSend);
} catch (error) {
console.error("Error in sendToHub:", error);
throw new Error(error);
}
}, "Sending to hub...");
}, [obtainUartServiceAndCharacteristics, enqueueOperation]);
const requestConnectionPriority = useCallback(async (priority) => {
return enqueueOperation(async () => {
try {
const device = await manager.devices([device.id]);
if (device && await device.isConnected()) {
await device.requestConnectionPriority(priority);
console.log(`Requested connection priority: ${priority} for device: ${device.id}`);
} else {
console.error("Device is not connected");
}
} catch (error) {
console.error("Error requesting connection priority:", error);
throw new Error(error.message || "Unknown error", error.code);
}
}, "Requesting connection priority...");
}, [manager, enqueueOperation]);
const reconnect = useCallback(async (device) => {
try {
await disconnectFromDevice(device);
await connectToDevice(device);
} catch (error) {
console.error("Reconnection failed:", error);
setTimeout(() => reconnect(device), 5000); // Retry after 5 seconds
}
}, [disconnectFromDevice, connectToDevice]);
useEffect(() => {
const subscription = manager.onDeviceDisconnected((error, device) => {
if (error) {
console.error("Device disconnected:", error);
reconnect(device);
}
});
return () => subscription.remove();
}, [manager, reconnect]);
return (
<BluetoothConnectionContext.Provider
value={{
startDeviceScan,
scanning,
stopDeviceScan,
connectToDevice,
disconnectFromDevice,
characteristicsForService,
receiveFromHub,
sendToHub,
requestConnectionPriority,
scanHubComplete,
setScanHubComplete,
scanWatchComplete,
setScanWatchComplete,
scanBleDeviceComplete,
setScanBleDeviceComplete,
connectedDevice,
setConnectedDevice,
requestPermissions
}}
>
{children}
</BluetoothConnectionContext.Provider>
);
};
export const useBluetoothConnection = () => {
const context = useContext(BluetoothConnectionContext);
if (!context) {
throw new Error("useBluetoothConnection must be used within a BluetoothConnectionProvider");
}
return context;
};