Flutter BLE Deep Dive: Platform Channels for Native Bluetooth Integration

Note: This deep dive focuses on the essential concepts and implementations, trimming excessive code while preserving educational value.

Introduction

Bluetooth Low Energy (BLE) has become an essential technology for connecting mobile apps to a vast ecosystem of smart devices, from fitness trackers and health monitors to IoT sensors and smart home equipment. While Flutter excels at cross-platform UI development, it doesn’t provide direct access to native BLE capabilities. This is where platform channels come in.

Flutter’s platform channels act as a bridge between your Dart code and the native platform, allowing you to leverage the full power of each platform’s BLE APIs: CoreBluetooth on iOS and the Android Bluetooth API on Android.

BLE Fundamentals

Key Concept: Understanding the BLE architecture is crucial before implementing it in Flutter. BLE follows a hierarchical structure of Services, Characteristics, and Descriptors.

What is BLE?

Bluetooth Low Energy (BLE) is a wireless communication technology designed for short-range, low-power applications. Unlike Classic Bluetooth, BLE:

  • Consumes significantly less power
  • Is optimized for sending small amounts of data periodically
  • Supports a “connectionless” broadcast mode (Advertising)
  • Uses a different protocol stack and API

BLE Terminology

  • Central: The device that initiates connections (usually your phone)
  • Peripheral: The device that accepts connections (e.g., fitness trackers, sensors)
  • GATT (Generic Attribute Profile): The foundation for BLE data organization
  • Service: A collection of related data and behaviors (e.g., “Battery Service”)
  • Characteristic: A specific piece of data within a service (e.g., “Battery Level”)
  • Descriptor: Metadata about a characteristic (e.g., units, permissions)
  • UUID: Unique identifiers for services and characteristics

The GATT Hierarchy

The following diagram illustrates the hierarchical structure of a typical BLE device:

BLE Peripheral Device

BLE Communication Flow

  1. Advertising: Peripherals broadcast their presence and basic information
  2. Scanning: Central devices discover peripherals by scanning for advertisements
  3. Connecting: Central establishes a connection with a peripheral
  4. Service Discovery: Central queries peripheral for available services
  5. Characteristic Operations: Reading, writing, or subscribing to notifications
  6. Disconnection: Either device terminates the connection

Common BLE Operations

The diagram below illustrates the typical flow of BLE operations between a central device (like a smartphone) and a peripheral device:

  • Read: Get the current value of a characteristic
  • Write: Send data to a characteristic (with or without response)
  • Notify/Indicate: Subscribe to updates when a characteristic’s value changes
  • RSSI: Measure signal strength to estimate distance

Characteristics Properties

Each characteristic has properties that define what operations are supported:

Property Description
Read Can be read by the central device
Write Can be written by the central device (with response)
Write Without Response Can be written without waiting for acknowledgment
Notify Can notify the central when its value changes (unacknowledged)
Indicate Like notify, but with acknowledgment
Broadcast Value can be broadcast in advertisements

BLE in Mobile Development

On mobile platforms, BLE implementations differ:

  • iOS (CoreBluetooth): Uses delegate-based patterns and operation queues
  • Android (Bluetooth API): Uses callback patterns and requires runtime permissions

Understanding these platform differences is crucial when implementing BLE in Flutter, as you’ll need to bridge both APIs through platform channels.

Understanding Platform Channels for BLE

Key Concept: Platform channels are the communication bridge that allows Flutter code to interact with native platform capabilities like Bluetooth.

Platform channels in Flutter provide a messaging system between Flutter (Dart) and platform-specific code (Swift/Objective-C for iOS, Kotlin/Java for Android).

Types of Platform Channels for BLE

  1. MethodChannel: For one-off operations (scanning, connecting, reading/writing)
  2. EventChannel: For continuous data streams (scan results, notifications)

Communication Flow

The diagram below illustrates how data flows between your Flutter code and BLE devices through platform channels:

Flutter BLE Communication Flow

This architecture allows your Flutter app to maintain a consistent API while leveraging the full capabilities of each platform’s native BLE implementation.

Flutter Side: Core Implementation

Key Concept: Create a clean abstraction layer in your Flutter code to hide the complexity of platform channels from your UI code.

Project Structure

A well-organized project structure separates concerns and creates clear abstractions:

lib/
  ble/
    ble_device.dart        # BLE device model
    ble_manager.dart       # Main BLE API (used by UI code)
    platform/
      method_channel_ble.dart  # Platform channel implementation (hidden from UI)

Defining

// ble_device.dart
class BleDevice {
  final String id;
  final String name;
  final int rssi;
  final Map<String, dynamic> manufacturerData;
  
  BleDevice({
    required this.id,
    required this.name,
    required this.rssi,
    this.manufacturerData = const {},
  });
  
  factory BleDevice.fromMap(Map<String, dynamic> map) {
    return BleDevice(
      id: map['id'],
      name: map['name'] ?? 'Unknown',
      rssi: map['rssi'] ?? 0,
      manufacturerData: map['manufacturerData'] ?? {},
    );
  }
}

Setting Up Platform Channels

Key Concept: Each BLE operation maps to a method call, while continuous data (like scan results) is handled through event streams.

// method_channel_ble.dart
import 'dart:async';
import 'package:flutter/services.dart';
import '../ble_device.dart';

class MethodChannelBle {
  // Method channel for one-off operations
  static const MethodChannel _methodChannel = 
      MethodChannel('com.example.flutter_ble/method');
  
  // Event channel for scan results
  static const EventChannel _scanResultsChannel = 
      EventChannel('com.example.flutter_ble/scan_results');
  
  // BLE methods
  Future<void> startScan() async {
    try {
      await _methodChannel.invokeMethod('startScan');
    } catch (e) {
      throw 'Failed to start scan: $e';
    }
  }
  
  Future<void> stopScan() async {
    try {
      await _methodChannel.invokeMethod('stopScan');
    } catch (e) {
      throw 'Failed to stop scan: $e';
    }
  }
  
  Future<bool> connect(String deviceId) async {
    try {
      return await _methodChannel.invokeMethod('connect', {'deviceId': deviceId});
    } catch (e) {
      throw 'Failed to connect: $e';
    }
  }
  
  // Get streams
  Stream<BleDevice> get scanResults => 
      _scanResultsChannel.receiveBroadcastStream()
          .map((dynamic data) => BleDevice.fromMap(Map<String, dynamic>.from(data)));
}

User-Friendly BLE Manager

// ble_manager.dart
import 'dart:async';
import 'ble_device.dart';
import 'platform/method_channel_ble.dart';

enum ConnectionState {
  disconnected,
  connecting,
  connected,
  disconnecting
}

class BleManager {
  static final BleManager _instance = BleManager._internal();
  factory BleManager() => _instance;
  
  final MethodChannelBle _methodChannelBle = MethodChannelBle();
  final Map<String, BleDevice> _discoveredDevices = {};
  final StreamController<List<BleDevice>> _devicesController = 
      StreamController.broadcast();
  
  BleManager._internal() {
    // Listen to scan results
    _methodChannelBle.scanResults.listen((device) {
      _discoveredDevices[device.id] = device;
      _devicesController.add(_discoveredDevices.values.toList());
    });
  }
  
  // Scanning methods
  Future<void> startScan() async {
    _discoveredDevices.clear();
    _devicesController.add([]);
    await _methodChannelBle.startScan();
  }
  
  Future<void> stopScan() async {
    await _methodChannelBle.stopScan();
  }
  
  // Connection methods
  Future<bool> connect(String deviceId) async {
    return await _methodChannelBle.connect(deviceId);
  }
  
  // Streams
  Stream<List<BleDevice>> get devices => _devicesController.stream;
  
  void dispose() {
    _devicesController.close();
  }
}

iOS Implementation (CoreBluetooth)

Key Concept: On iOS, the CoreBluetooth framework requires delegate patterns to handle BLE events. We convert these delegate callbacks into platform channel messages.

Instead of showing the entire implementation, here’s a focused example of handling scan results:

// In FlutterBlePlugin.swift

// Set up channels
public static func register(with registrar: FlutterPluginRegistrar) {
  // Set up method channel
  let channel = FlutterMethodChannel(name: "com.example.flutter_ble/method", binaryMessenger: registrar.messenger())
  
  // Set up event channels
  let scanResultsChannel = FlutterEventChannel(name: "com.example.flutter_ble/scan_results", 
                                              binaryMessenger: registrar.messenger())
  
  let scanResultsStreamHandler = ScanResultsStreamHandler()
  scanResultsChannel.setStreamHandler(scanResultsStreamHandler)
  
  // Create and register plugin instance
  let instance = FlutterBlePlugin(methodChannel: channel, 
                                 scanResultsStreamHandler: scanResultsStreamHandler)
  registrar.addMethodCallDelegate(instance, channel: channel)
}

// Handle method calls
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
  switch call.method {
  case "startScan":
    startScan(result: result)
  case "stopScan":
    stopScan(result: result)
  case "connect":
    guard let args = call.arguments as? [String: Any],
          let deviceId = args["deviceId"] as? String else {
      result(FlutterError(code: "INVALID_ARGUMENT", message: "Invalid device ID", details: nil))
      return
    }
    connect(deviceId: deviceId, result: result)
  default:
    result(FlutterMethodNotImplemented)
  }
}

// CoreBluetooth delegate method for scanning
public func centralManager(_ central: CBCentralManager, 
                           didDiscover peripheral: CBPeripheral, 
                           advertisementData: [String : Any], 
                           rssi RSSI: NSNumber) {
  let deviceId = peripheral.identifier.uuidString
  peripherals[deviceId] = peripheral
  
  let device: [String: Any] = [
    "id": deviceId,
    "name": peripheral.name ?? "Unknown",
    "rssi": RSSI.intValue,
    "manufacturerData": advertisementData[CBAdvertisementDataManufacturerDataKey] as? [String: Any] ?? [:]
  ]
  
  scanResultsStreamHandler?.sendScanResult(device)
}

Android Implementation

Key Concept: The Android Bluetooth API requires runtime permissions and uses callback patterns for asynchronous operations. Android 12+ (API 31+) introduced new bluetooth-specific permissions.

Here’s a focused example of the Android implementation for scanning:

// In FlutterBlePlugin.kt

override fun onMethodCall(call: MethodCall, result: Result) {
  when (call.method) {
    "startScan" -> startScan(result)
    "stopScan" -> stopScan(result)
    "connect" -> {
      val deviceId = call.argument<String>("deviceId")
      if (deviceId != null) {
        connect(deviceId, result)
      } else {
        result.error("INVALID_ARGUMENT", "Device ID is required", null)
      }
    }
    else -> result.notImplemented()
  }
}

private fun startScan(result: Result) {
  if (bluetoothAdapter == null || !bluetoothAdapter!!.isEnabled) {
    result.error("BLUETOOTH_DISABLED", "Bluetooth is not enabled", null)
    return
  }
  
  // Check for required permissions
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
    if (ActivityCompat.checkSelfPermission(context, 
        Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
      result.error("PERMISSION_DENIED", "BLUETOOTH_SCAN permission not granted", null)
      return
    }
  }
  
  devices.clear()
  
  // Start scanning
  bluetoothAdapter?.bluetoothLeScanner?.startScan(scanCallback)
  result.success(null)
}

private val scanCallback = object : ScanCallback() {
  override fun onScanResult(callbackType: Int, result: ScanResult) {
    val device = result.device
    val deviceId = device.address
    
    // Store the device for later use
    devices[deviceId] = device
    
    val deviceMap = HashMap<String, Any>()
    deviceMap["id"] = deviceId
    deviceMap["name"] = device.name ?: "Unknown"
    deviceMap["rssi"] = result.rssi
    
    scanResultsHandler?.sendScanResult(deviceMap)
  }
}

Practical Example: A Simple BLE Scanner

Key Concept: A BLE scanner is an excellent first project for learning BLE integration, as it covers scanning, result handling, and UI integration.

Let’s implement a basic BLE scanner UI that demonstrates the concepts we’ve covered:

class BleScanner extends StatefulWidget {
  @override
  _BleScannerState createState() => _BleScannerState();
}

class _BleScannerState extends State<BleScanner> {
  final BleManager _bleManager = BleManager();
  bool _isScanning = false;
  
  void _startScan() async {
    setState(() {
      _isScanning = true;
    });
    
    try {
      await _bleManager.startScan();
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Failed to start scan: $e')),
      );
    }
    
    // Auto-stop scan after 10 seconds
    Future.delayed(Duration(seconds: 10), () {
      if (mounted && _isScanning) {
        _stopScan();
      }
    });
  }
  
  void _stopScan() async {
    try {
      await _bleManager.stopScan();
    } finally {
      setState(() {
        _isScanning = false;
      });
    }
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('BLE Scanner'),
      ),
      body: StreamBuilder<List<BleDevice>>(
        stream: _bleManager.devices,
        initialData: [],
        builder: (context, snapshot) {
          final devices = snapshot.data ?? [];
          
          return ListView.builder(
            itemCount: devices.length,
            itemBuilder: (context, index) {
              final device = devices[index];
              return ListTile(
                title: Text(device.name),
                subtitle: Text('RSSI: ${device.rssi} | ID: ${device.id.substring(0, 8)}...'),
                trailing: Icon(Icons.bluetooth),
                onTap: () => _connectToDevice(device),
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _isScanning ? _stopScan : _startScan,
        tooltip: _isScanning ? 'Stop Scan' : 'Start Scan',
        child: Icon(_isScanning ? Icons.stop : Icons.search),
      ),
    );
  }
  
  // This would be implemented to handle device connection
  void _connectToDevice(BleDevice device) async {
    try {
      final connected = await _bleManager.connect(device.id);
      if (connected) {
        // Navigate to device details page
      }
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Connection failed: $e')),
      );
    }
  }
}

Working with Service and Characteristic Data

Once connected to a device, you might want to display and interact with its services and characteristics. Here’s a simple service explorer UI:

class DeviceDetailsScreen extends StatefulWidget {
  final BleDevice device;
  
  DeviceDetailsScreen({required this.device});
  
  @override
  _DeviceDetailsScreenState createState() => _DeviceDetailsScreenState();
}

class _DeviceDetailsScreenState extends State<DeviceDetailsScreen> {
  final BleManager _bleManager = BleManager();
  List<BluetoothService> _services = [];
  bool _isLoading = true;
  
  @override
  void initState() {
    super.initState();
    _discoverServices();
  }
  
  Future<void> _discoverServices() async {
    try {
      setState(() {
        _isLoading = true;
      });
      
      final services = await _bleManager.discoverServices();
      
      setState(() {
        _services = services;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
      });
      
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Error discovering services: $e')),
      );
    }
  }
  
  @override
  void dispose() {
    _bleManager.disconnect();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.device.name),
      ),
      body: _isLoading
          ? Center(child: CircularProgressIndicator())
          : ListView.builder(
              itemCount: _services.length,
              itemBuilder: (context, index) {
                final service = _services[index];
                
                return ExpansionTile(
                  title: Text('Service: ${service.uuid.toString().substring(0, 8)}...'),
                  children: service.characteristics.map((characteristic) {
                    return ListTile(
                      title: Text('Characteristic: ${characteristic.uuid.toString().substring(0, 8)}...'),
                      subtitle: Text('Properties: ${characteristic.properties.join(", ")}'),
                      trailing: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          if (characteristic.properties.contains('read'))
                            IconButton(
                              icon: Icon(Icons.refresh),
                              onPressed: () => _readCharacteristic(characteristic),
                            ),
                          if (characteristic.properties.contains('notify'))
                            IconButton(
                              icon: Icon(Icons.notifications),
                              onPressed: () => _toggleNotification(characteristic),
                            ),
                        ],
                      ),
                    );
                  }).toList(),
                );
              },
            ),
    );
  }
  
  // Reading a characteristic value
  Future<void> _readCharacteristic(BleCharacteristic characteristic) async {
    try {
      final value = await _bleManager.readCharacteristic(
        characteristic.serviceUuid,
        characteristic.uuid,
      );
      
      // Display the value (this will depend on the data format)
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Value: ${value.toString()}')),
      );
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Error reading characteristic: $e')),
      );
    }
  }
  
  // Toggle notifications for a characteristic
  Future<void> _toggleNotification(BleCharacteristic characteristic) async {
    try {
      // This would need to be expanded to track current notification state
      await _bleManager.setNotification(
        characteristic.serviceUuid,
        characteristic.uuid,
        true, // enable notifications
      );
      
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Notifications enabled')),
      );
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Error enabling notifications: $e')),
      );
    }
  }
}

Advanced BLE Concepts

Key Concept: In real-world applications, you’ll need to handle more complex aspects of BLE like MTU negotiation, reconnection strategies, and parsing specialized data formats.

MTU Negotiation

MTU (Maximum Transmission Unit) defines how much data can be transferred in a single BLE packet. By default, BLE uses 23 bytes, but it can be negotiated up to 512 bytes (platform-dependent).

Future<int> requestMtu(String deviceId, int mtu) async {
  try {
    return await _methodChannel.invokeMethod('requestMtu', {
      'deviceId': deviceId,
      'mtu': mtu
    });
  } catch (e) {
    throw 'Failed to request MTU: $e';
  }
}

Handling GATT Data Types

BLE characteristics often use compact data formats that need parsing. For example, parsing a heart rate measurement:

List<int> parseHeartRate(List<int> data) {
  final results = <int>[];
  
  // Check format flag (first bit of first byte)
  final isFormat16Bit = data[0] & 0x01 == 0x01;
  
  if (isFormat16Bit && data.length >= 3) {
    // If format is 16-bit, heart rate is at index 1-2
    final heartRate = (data[2] << 8) + data[1];
    results.add(heartRate);
  } else if (data.length >= 2) {
    // If format is 8-bit, heart rate is at index 1
    results.add(data[1]);
  }
  
  // Parse additional data if available
  // ...
  
  return results;
}

Auto-Reconnection Strategy

In real-world applications, BLE connections can drop unexpectedly. Implementing a reconnection strategy is crucial:

class BleReconnectionManager {
  final BleManager _bleManager;
  final Duration _reconnectInterval;
  final int _maxRetries;
  
  String? _deviceId;
  Timer? _reconnectTimer;
  int _retryCount = 0;
  bool _isReconnecting = false;
  
  BleReconnectionManager(
    this._bleManager, {
    Duration reconnectInterval = const Duration(seconds: 3),
    int maxRetries = 5,
  })  : _reconnectInterval = reconnectInterval,
        _maxRetries = maxRetries {
    _setupConnectionListener();
  }
  
  void _setupConnectionListener() {
    _bleManager.connectionStateStream.listen((state) {
      if (state == ConnectionState.disconnected && _deviceId != null) {
        _startReconnection();
      }
    });
  }
  
  Future<void> _startReconnection() async {
    if (_isReconnecting || _deviceId == null) return;
    
    _isReconnecting = true;
    _retryCount = 0;
    
    _reconnectTimer = Timer.periodic(_reconnectInterval, (timer) async {
      if (_retryCount >= _maxRetries) {
        timer.cancel();
        _isReconnecting = false;
        return;
      }
      
      _retryCount++;
      
      try {
        final connected = await _bleManager.connect(_deviceId!);
        if (connected) {
          timer.cancel();
          _isReconnecting = false;
        }
      } catch (e) {
        // Failed to reconnect, will try again
      }
    });
  }
  
  void registerDevice(String deviceId) {
    _deviceId = deviceId;
  }
  
  void cancelReconnection() {
    _reconnectTimer?.cancel();
    _isReconnecting = false;
  }
  
  void dispose() {
    _reconnectTimer?.cancel();
  }
}

Best Practices

Key Concept: BLE operations are inherently unreliable due to their wireless nature. Always implement robust error handling, timeout mechanisms, and reconnection strategies.

1. Error Handling

Future<bool> connectWithRetry(String deviceId, {int maxRetries = 3}) async {
  for (int i = 0; i < maxRetries; i++) {
    try {
      final result = await _methodChannelBle.connect(deviceId);
      return result;
    } catch (e) {
      if (i == maxRetries - 1) rethrow;
      await Future.delayed(Duration(seconds: 1));
    }
  }
  return false;
}

2. Battery Optimization

  • Limit scan duration (auto-stop after 5-10 seconds)
  • Disconnect from devices when not in use
  • Use notifications instead of polling
  • Implement proper connection teardown

3. Platform Channel vs. Dedicated Plugin

Use Platform Channels When:

  • Simple, app-specific BLE requirements
  • Learning and prototyping
  • Full control is needed

Create a Dedicated Plugin When:

  • Reuse across multiple projects
  • Team collaboration
  • Open-source contribution
  • Complex BLE functionality

Troubleshooting Common BLE Issues

1. Device Not Found When Scanning

Possible causes:

  • Bluetooth is turned off
  • Device is out of range
  • Device is not advertising
  • Missing location permissions (Android)
  • Missing bluetooth permissions (iOS, Android 12+)

Solutions:

  • Check Bluetooth state
  • Ensure device is in advertising mode
  • Verify all required permissions
  • Add service UUID filters to scan for specific devices

2. Connection Failures

Possible causes:

  • Device moved out of range
  • Device battery is low
  • Too many concurrent connections
  • Device internal error

Solutions:

  • Implement connection timeouts
  • Add automatic reconnection logic
  • Verify device is still advertising before connecting
  • Check for platform-specific connection limits

3. Characteristic Read/Write Failures

Possible causes:

  • Wrong characteristic UUID
  • Insufficient permissions
  • Characteristic requires authentication
  • Characteristic has specific properties

Solutions:

  • Verify characteristic UUIDs and properties
  • Check for read/write permissions
  • Handle platform-specific encryption requirements
  • Implement proper error handling for each operation

Flutter BLE Implementation Cheat Sheet

Platform Channel Setup

// Method channel for one-off operations
static const MethodChannel _methodChannel = MethodChannel('com.example.flutter_ble/method');

// Event channel for continuous data
static const EventChannel _scanResultsChannel = EventChannel('com.example.flutter_ble/scan_results');

Common BLE Operations

Scanning

// Start scanning
Future<void> startScan() async {
  await _methodChannel.invokeMethod('startScan');
}

// Stop scanning
Future<void> stopScan() async {
  await _methodChannel.invokeMethod('stopScan');
}

// Get scan results stream
Stream<BleDevice> get scanResults => 
  _scanResultsChannel.receiveBroadcastStream()
    .map((data) => BleDevice.fromMap(Map<String, dynamic>.from(data)));

Connection

// Connect to device
Future<bool> connect(String deviceId) async {
  return await _methodChannel.invokeMethod('connect', {'deviceId': deviceId});
}

// Disconnect from device
Future<void> disconnect(String deviceId) async {
  await _methodChannel.invokeMethod('disconnect', {'deviceId': deviceId});
}

GATT Operations

// Discover services
Future<List<Map<String, dynamic>>> discoverServices(String deviceId) async {
  return await _methodChannel.invokeMethod('discoverServices', {'deviceId': deviceId});
}

// Read characteristic
Future<List<int>> readCharacteristic(String deviceId, String serviceUuid, String characteristicUuid) async {
  final result = await _methodChannel.invokeMethod('readCharacteristic', {
    'deviceId': deviceId,
    'serviceUuid': serviceUuid,
    'characteristicUuid': characteristicUuid,
  });
  return List<int>.from(result);
}

// Write characteristic
Future<void> writeCharacteristic(
  String deviceId, 
  String serviceUuid, 
  String characteristicUuid, 
  List<int> value, 
  bool withResponse
) async {
  await _methodChannel.invokeMethod('writeCharacteristic', {
    'deviceId': deviceId,
    'serviceUuid': serviceUuid,
    'characteristicUuid': characteristicUuid,
    'value': value,
    'withResponse': withResponse,
  });
}

// Set up notifications
Future<void> setNotification(
  String deviceId, 
  String serviceUuid, 
  String characteristicUuid, 
  bool enable
) async {
  await _methodChannel.invokeMethod('setNotification', {
    'deviceId': deviceId,
    'serviceUuid': serviceUuid,
    'characteristicUuid': characteristicUuid,
    'enable': enable,
  });
}

Required Permissions

iOS (Info.plist)

<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to connect to BLE devices</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to connect to BLE devices</string>

Android (AndroidManifest.xml)

<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

<!-- For Android 11 and below -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />

Common Error Handling

try {
  await bleOperation();
} catch (e) {
  if (e.toString().contains('BLUETOOTH_DISABLED')) {
    // Prompt user to enable Bluetooth
  } else if (e.toString().contains('PERMISSION_DENIED')) {
    // Request necessary permissions
  } else if (e.toString().contains('DEVICE_NOT_FOUND')) {
    // Device is no longer available
  } else {
    // Generic error handling
  }
}

Common BLE UUID

Service UUID Description
Generic Access 1800 Device name, appearance
Generic Attribute 1801 Service changed
Device Information 180A Manufacturer, model, etc.
Battery Service 180F Battery level
Heart Rate 180D Heart rate measurements
Health Thermometer 1809 Temperature measurements

From concept to deployment, Gurzu provides an end-to-end mobile development solution. Our application development prioritizes intuitive interfaces, seamless user experiences, and robust functionality. 

Stuck on Flutter/Mobile App Development? Schedule a free consulting call with our Flutter expert!

You might also want to read “Mastering Flutter App Architecture with Bloc”

References and Resources

Official Documentation

Helpful Community Resources

Testing Tools