Implementing L2CAP Connection-Oriented Channels (CoC) over BLE in Flutter
Implement secure BLE L2CAP CoC streaming with Flutter. Learn dynamic PSM, CoC setup on iOS/Android, and high-speed data over BLE.
Bluetooth Low Energy (BLE) is perfect for low-power, packet-based data, but when you need higher throughput – e.g. streaming sensor data, file transfers, or real-time bidirectional communication – L2CAP Connection-Oriented Channels (CoC) give you a reliable, stream-oriented pipe. CoC uses LE Credit-based Flow Control (Bluetooth 4.1+) and works on top of an existing BLE connection.
This guide shows how to:
- Create an L2CAP CoC server on iOS and Android → obtain the assigned PSM.
- Send the PSM to Flutter via a MethodChannel.
- Write the PSM (2-byte little-endian) to a peripheral using flutter_blue_plus.
- Let the peripheral open the CoC with that exact PSM.
We follow the Bluetooth Core Specification v5.3, §3.4 (L2CAP) and focus on cross-platform compatibility, security, and robust error handling.
Process Overview
| Step | What Happens |
|---|---|
| 1 | Native code creates an L2CAP server → OS assigns a dynamic PSM (odd value 0x0081–0xFFFF). |
| 2 | The PSM is sent back to Flutter via MethodChannel. |
| 3 | Flutter encodes the PSM as 2-byte little-endian and writes it to a GATT characteristic. |
| 4 | The peripheral reads the value and calls createL2capChannel(PSM) (Android) or openL2CAPChannel (iOS) to establish the CoC. |
Understanding MethodChannels
Flutter runs Dart; Bluetooth lives in native code. MethodChannel is the asynchronous bridge:
dart
await platform.invokeMethod('startL2capServer');
Native side replies:
swift
channel.invokeMethod("l2capChannelPublished", arguments: psm)
iOS Implementation (Swift)
iOS (≥11) uses CBPeripheralManager. The PSM is assigned automatically when publishL2CAPChannel succeeds.
swift
import Flutter
import CoreBluetooth
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate, CBPeripheralManagerDelegate {
private var peripheralManager: CBPeripheralManager?
private var channel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller = window?.rootViewController as! FlutterViewController
channel = FlutterMethodChannel(name: "bluetooth_l2cap",
binaryMessenger: controller.binaryMessenger)
// Give the Bluetooth stack a moment to initialise
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// MARK: - Flutter method handler
override func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
if call.method == "startL2capServer" {
startL2capServer(result: result)
} else {
result(FlutterMethodNotImplemented)
}
}
private func startL2capServer(result: @escaping FlutterResult) {
guard let manager = peripheralManager, manager.state == .poweredOn else {
result(FlutterError(code: "BLUETOOTH_OFF",
message: "Bluetooth is off or unavailable",
details: nil))
return
}
// Secure channel – requires pairing
manager.publishL2CAPChannel(withEncryption: true)
result(nil) // success, PSM will come via delegate
}
// MARK: - CBPeripheralManagerDelegate
func peripheralManager(_ peripheral: CBPeripheralManager,
didPublishL2CAPChannel PSM: CBL2CAPPSM,
error: Error?) {
if let error = error {
print("L2CAP publish error: \(error.localizedDescription)")
return
}
print("iOS assigned PSM: \(PSM)")
channel?.invokeMethod("l2capChannelPublished", arguments: PSM)
}
}
Key points
- withEncryption: true → secure CoC (recommended for production).
- No retry logic – the PSM is whatever iOS gives you.
- Test on real devices (simulators have no Bluetooth).
Android Implementation (Kotlin)
Android (API 29+) uses BluetoothAdapter.listenUsingL2capChannel(). The PSM is returned immediately on the created BluetoothServerSocket.
kotlin
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothServerSocket
import android.os.Build
import androidx.annotation.RequiresApi
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private lateinit var channel: MethodChannel
private var serverSocket: BluetoothServerSocket? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "bluetooth_l2cap")
channel.setMethodCallHandler { call, result ->
when (call.method) {
"startL2capServer" -> startL2capServer(result)
else -> result.notImplemented()
}
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun startL2capServer(result: MethodChannel.Result) {
val adapter = getSystemService(BluetoothManager::class.java).adapter
if (!adapter.isEnabled) {
result.error("BLUETOOTH_OFF", "Bluetooth is disabled", null)
return
}
// Close any previous socket
serverSocket?.close()
try {
// Secure version – use listenUsingL2capChannel() in production
serverSocket = adapter.listenUsingL2capChannel()
val psm = serverSocket!!.psm
println("Android assigned PSM: $psm")
channel.invokeMethod("l2capChannelPublished", psm)
// Start accept loop in background
Thread { acceptConnections() }.start()
result.success(psm)
} catch (e: Exception) {
result.error("SOCKET_ERROR", "Failed to create L2CAP socket: ${e.message}", null)
}
}
private fun acceptConnections() {
while (true) {
try {
val client = serverSocket?.accept()
// TODO: handle client.read() / client.write()
} catch (e: Exception) {
e.printStackTrace()
break
}
}
}
}
Key points
- No PSM-forcing loop – we accept whatever Android assigns.
- Use listenUsingL2capChannel() for encrypted channels (requires pairing).
- The PSM is immediately available via serverSocket!!.psm.
Flutter Integration (flutter_blue_plus)
dart
import 'dart:typed_data';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
class BluetoothViewModel extends ChangeNotifier {
static const platform = MethodChannel('bluetooth_l2cap');
int psm = -1;
BluetoothCharacteristic? writeChar;
BluetoothViewModel() {
platform.setMethodCallHandler(_handleMethod);
}
Future<void> startL2capServer() async {
try {
await platform.invokeMethod('startL2capServer');
debugPrint('L2CAP server request sent');
} on PlatformException catch (e) {
debugPrint('Platform error: ${e.message}');
rethrow;
}
}
Future<void> _handleMethod(MethodCall call) async {
if (call.method == 'l2capChannelPublished') {
psm = call.arguments as int;
debugPrint('Received PSM: $psm');
await _sendPsmToPeripheral();
notifyListeners();
}
}
Future<void> _sendPsmToPeripheral() async {
if (psm <= 0 || writeChar == null) {
debugPrint('Invalid state for PSM write');
return;
}
final bytes = Uint8List(2)
..buffer.asByteData().setUint16(0, psm, Endian.little);
try {
await writeChar!.write(bytes, withoutResponse: false);
debugPrint('PSM $psm written to peripheral');
} catch (e) {
debugPrint('Write failed: $e');
}
}
// Call after discovering services
void setWriteCharacteristic(BluetoothCharacteristic c) {
writeChar = c;
}
}
Usage
dart
final vm = BluetoothViewModel();
await vm.startL2capServer();
// After BLE connection & service discovery:
final char = /* find writable characteristic */;
vm.setWriteCharacteristic(char);
BLE Characteristic Setup (Peripheral Side)
- UUID example: 0000FF01-0000-1000-8000-00805F9B34FB
- Properties: write (or writeWithoutResponse if you prefer).
- Discovery:
dart
await device.discoverServices();
final char = device.services
.expand((s) => s.characteristics)
.firstWhere((c) => c.uuid.toString() == '0000ff01-...');
vm.setWriteCharacteristic(char);
Key Considerations
| Concern | Recommendation |
|---|---|
| PSM Assignment | Never force a value. iOS/Android assign a dynamic odd PSM (≥129). Use the value you receive. |
| Security | withEncryption: true (iOS) + listenUsingL2capChannel() (Android). Pair/bond devices first. |
| Permissions | Android: BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE (API 31+). iOS: NSBluetoothAlwaysUsageDescription. |
| MTU | Negotiate ≥23 bytes (default). For large streams, request higher MTU before CoC. |
| Testing | Two physical devices. Use nRF Connect to verify PSM and CoC connection. |
End-to-End Flow (No Hacks)
- Flutter → startL2capServer() (MethodChannel).
- Native creates L2CAP server → gets assigned PSM (e.g., 257, 193, 4101…).
- Native → l2capChannelPublished with that exact PSM.
- Flutter encodes PSM (2-byte little-endian) → writes to GATT characteristic.
- Peripheral reads the 2 bytes → calls createL2capChannel(psm) (Android) or openL2CAPChannel (iOS).
- CoC stream is now open for high-throughput data.
Troubleshooting
| Symptom | Fix |
|---|---|
| PSM never arrives | Check MethodChannel name, ensure handle(_:result:) is overridden on iOS. |
| Write fails | Verify characteristic write property, connection state, MTU. |
| Peripheral cannot connect | Confirm little-endian 2-byte PSM, Bluetooth ≥4.1, pairing completed. |
| Background loss | iOS: use UIBackgroundModes → bluetooth-central. Android: foreground service. |
Conclusion
Getting a PSM is not a hack – it’s the official mechanism defined by the Bluetooth SIG. By reading the PSM the OS gives you, sending it over a GATT write, and letting the peer open the channel, you get a robust, secure, high-throughput CoC that works across iOS and Android.
For deeper reference:
- Bluetooth Core Spec v5.3 – L2CAP (Section 3.4)
- flutter_blue_plus GitHub issues on CoC
- Apple Core Bluetooth – CBPeripheralManager.publishL2CAPChannel
- Android Docs – BluetoothAdapter.listenUsingL2capChannel
Test on real hardware, handle disconnections gracefully, and you’ll have a production-ready streaming channel over BLE.