l

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.

Implementing L2CAP Connection-Oriented Channels (CoC) over BLE in Flutter

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:

  1. Create an L2CAP CoC server on iOS and Android → obtain the assigned PSM.
  2. Send the PSM to Flutter via a MethodChannel.
  3. Write the PSM (2-byte little-endian) to a peripheral using flutter_blue_plus.
  4. 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)

  1. Flutter → startL2capServer() (MethodChannel).
  2. Native creates L2CAP server → gets assigned PSM (e.g., 257, 193, 4101…).
  3. Native → l2capChannelPublished with that exact PSM.
  4. Flutter encodes PSM (2-byte little-endian) → writes to GATT characteristic.
  5. Peripheral reads the 2 bytes → calls createL2capChannel(psm) (Android) or openL2CAPChannel (iOS).
  6. 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.

Read more