my_bluetooth 2.0.0 copy "my_bluetooth: ^2.0.0" to clipboard
my_bluetooth: ^2.0.0 copied to clipboard

PlatformAndroid

Android Classic Bluetooth discovery, RFCOMM connections, and byte or file transfer for Flutter printer and accessory apps.

pub package GitHub Buy Me A Coffee Ko-fi

my_bluetooth #

my_bluetooth connects Flutter applications to Android Classic Bluetooth printers and accessories over RFCOMM/SPP. It can discover nearby devices, read bonded devices, connect by Bluetooth address, transfer bytes or files, and expose connection and response streams.

Platform support #

Platform Support Notes
Android Classic Bluetooth, RFCOMM/SPP UUID 0x1101
iOS Not supported; this package has no iOS implementation
Other platforms No native implementation

Version 2.x is Android-only. The old iOS class was a non-functional method-channel stub; this package does not register, ship, or expose Bluetooth functionality on iOS.

Requirements #

  • Flutter 3.47.1 or newer
  • Dart 3.13.1 or newer
  • Android API 24 or newer
  • Android compile SDK 36
  • Java 17

Install #

dependencies:
  my_bluetooth: ^2.0.0

Then run:

flutter pub get

Import the package:

import 'package:my_bluetooth/my_bluetooth.dart';

New in 2.0.0 #

Version 2.0.0 is a major upgrade from 1.x because it corrects platform support, models, lifecycle, and completion semantics. Its capability, permission, Scan Session V2, typed-error, and diagnostic APIs are additive alongside the retained legacy startScan, stopScan, connect, sendCmd, and sendFile call sites; review the migration guide before upgrading.

  • inspect adapter, permission, and protocol capabilities without opening UI;
  • check permissions, show an application rationale, and then request only the permissions needed for one operation;
  • own a Scan Session V2 with RSSI, Android device class, last-seen time, combined filters, timeout, stale-result removal, and a result limit;
  • handle stable typed error categories from the new additive APIs;
  • opt in to redacted operation diagnostics; and
  • safely serialize command and file writes against the exact RFCOMM connection on which each write started.

Android permissions #

The plugin manifest declares the correct permissions for both Android 12+ and older Android versions:

  • BLUETOOTH_SCAN and BLUETOOTH_CONNECT on Android 12+
  • legacy BLUETOOTH and BLUETOOTH_ADMIN through Android 11
  • ACCESS_FINE_LOCATION through Android 11, where discovery requires it

Legacy operations continue to request runtime permissions when needed. In 2.0.0, applications can use an explicit permission flow so they can explain why Nearby Devices access is needed before Android opens a dialog:

final capabilities = await bluetooth.getCapabilities();
if (!capabilities.supported || !capabilities.adapterAvailable) {
  return;
}

var permissions = await bluetooth.checkPermissions(); // No system dialog.
if (permissions.forOperation(BluetoothOperation.scan) ==
    BluetoothPermissionStatus.denied) {
  final acceptedRationale = await showNearbyDevicesRationale();
  if (!acceptedRationale) return;
  permissions = await bluetooth.requestPermissions(BluetoothOperation.scan);
}

final scanStatus = permissions.forOperation(BluetoothOperation.scan);
if (scanStatus == BluetoothPermissionStatus.granted ||
    scanStatus == BluetoothPermissionStatus.notRequired) {
  // It is now appropriate to start discovery.
}

The plugin marks BLUETOOTH_SCAN as neverForLocation on Android 12+. RSSI is exposed as radio metadata for filtering and troubleshooting, not as a distance or location estimate. Android notes that this assertion can filter some BLE beacons from scan results. If your application derives physical location from scan data, review the merged manifest, location permissions, user consent, and store data-safety disclosures; see Security notes and the Android Bluetooth permission guidance.

Basic flow #

1. Create the shared client and listen to state #

Bluetooth is a process-wide Android resource. Multiple MyBluetooth() objects share one native channel, so callbacks are not lost when a widget creates a second object.

final bluetooth = MyBluetooth();

final adapterSubscription = bluetooth.adapterState.listen((state) {
  print('Adapter: $state');
});

final connectionSubscription = bluetooth.connectionState.listen((event) {
  print('Connection: ${event.connectionState}');
  print('Message: ${event.message}');
});

Check the target before showing Bluetooth controls:

if (!MyBluetooth.isSupported) {
  // Show an Android-only message or a different transport.
}

2. Ask Android to turn on Bluetooth #

Android displays its own confirmation dialog. The future completes after the adapter is on, the user rejects the request, or the timeout expires.

await bluetooth.turnOn(
  timeout: const Duration(seconds: 15),
);

3. Read paired devices #

final bonded = await bluetooth.bondedDevices;
for (final device in bonded) {
  print('${device.platformName}: ${device.remoteId}');
}

Permission errors are surfaced as PlatformException; they are no longer silently converted into an empty list.

4. Discover nearby Classic Bluetooth devices #

Scan Session V2 owns its streams, result snapshot, timeout, and stop lifecycle:

final session = await bluetooth.startScanSession(
  options: const BluetoothScanOptions(
    filter: BluetoothScanFilter(
      namePrefixes: ['Office'],
      deviceTypes: [MPDeviceTypeEnum.classic],
      minimumRssi: -75,
      includeUnnamed: false,
    ),
    timeout: Duration(seconds: 20),
    staleAfter: Duration(seconds: 5),
    maxResults: 20,
  ),
);

final resultsSubscription = session.results.listen((results) {
  for (final result in results) {
    print('${result.device.platformName}: ${result.device.remoteId}');
    print('RSSI: ${result.rssi} dBm');
    print('Android class: ${result.deviceClass}');
    print('Last seen (UTC): ${result.lastSeen}');
  }
});

await session.done; // Or call: await session.stop();
await resultsSubscription.cancel();

BluetoothScanFilter combines non-empty categories with AND. Values inside a category are alternatives. It supports remote IDs, exact names, prefixes, contained text, bond states, device types, minimum RSSI, case sensitivity, and unnamed-device handling. timeout ends the session with timedOut, and starts after Android discovery is running. staleAfter removes devices that have not been observed recently, and maxResults bounds each emitted snapshot. Listen to session.state, inspect session.currentState, session.lastResults, and session.failure, or await session.done for completion. done completes normally for stop, natural completion, timeout, and supersession; it completes with the typed failure only in failed state.

The legacy API remains unchanged. Listen before starting discovery so the application receives the first result:

final scanSubscription = bluetooth.scanResults.listen((devices) {
  for (final device in devices) {
    print('${device.platformName}: ${device.remoteId}');
  }
});

await bluetooth.startScan(
  withNames: const ['Office Printer'],
  withRemoteIds: const ['AA:BB:CC:DD:EE:FF'],
  withKeywords: const ['Printer'],
  removeIfGone: const Duration(seconds: 5),
);

Filters are combined with AND. A device must satisfy every non-empty filter. Results are de-duplicated by Bluetooth address. removeIfGone removes devices that have not been observed within the chosen duration. Concurrent start/stop requests are serialized, so stopping while Android is showing a permission dialog cannot allow an older scan to resume later.

Stop discovery when the page closes or before connecting:

await bluetooth.stopScan();

5. Connect #

Use the remoteId returned by discovery or bondedDevices:

final connected = await bluetooth.connect(
  remoteId: 'AA:BB:CC:DD:EE:FF',
  timeout: const Duration(seconds: 15),
);

The future now completes only after the RFCOMM socket and its input/output streams are ready. It no longer reports success merely because a background thread started.

6. Send bytes, text, or a file #

await bluetooth.sendCmd(
  const [0x1B, 0x2A, 0x44, 0x53],
  size: 34,
);

await bluetooth.sendText(value: 'Hello printer');

await bluetooth.sendFile(pathImage: '/absolute/path/image.rgb');

sendCmd validates that every value is within 0...255 and that size is not smaller than the byte list. When size is larger, Android pads the remaining bytes with zeroes. sendFile streams 4 KiB chunks and completes after transfer, instead of loading the entire file into memory.

Native writes use one serial queue, so a command cannot be inserted between file chunks. Each queued operation is bound to the exact connection generation captured when it arrives. If that connection is lost or replaced, the stale operation returns false and cannot write to or disconnect the replacement device. Await writes in application order when your accessory protocol also requires a specific command sequence.

7. Receive protocol frames #

The current printer protocol emits fixed 34-byte response frames:

final responseSubscription = bluetooth.commandResponses.listen((response) {
  print(response.data?.formatListAsHex);
});

8. Clean up application subscriptions #

await bluetooth.stopScan();
await bluetooth.disconnect();
await scanSubscription.cancel();
await adapterSubscription.cancel();
await connectionSubscription.cancel();
await responseSubscription.cancel();

Error handling #

The new capability, permission, and Scan Session V2 APIs use MyBluetoothException.kind for stable categories and retain the native method-channel code in nativeCode when available:

try {
  await bluetooth.startScanSession();
} on MyBluetoothException catch (error) {
  switch (error.kind) {
    case BluetoothErrorKind.permissionDenied:
      // Explain how to grant Nearby Devices access.
      break;
    case BluetoothErrorKind.adapterOff:
      // Ask the user to enable Bluetooth.
      break;
    default:
      print('${error.kind}: ${error.nativeCode}');
  }
}

For backward compatibility, retained legacy APIs keep their exception behavior. For example, a native startScan, connect, or transfer failure can still be a PlatformException, while Dart-side timeouts and user rejection keep their existing MyBluetoothException behavior:

try {
  await bluetooth.connect(remoteId: device.remoteId);
} on MyBluetoothException catch (error) {
  // Dart-side timeout or user rejection.
  print(error);
} on PlatformException catch (error) {
  // Android permission, adapter, validation, or socket error.
  print('${error.code}: ${error.message}');
} on UnsupportedError catch (error) {
  // Non-Android target.
  print(error);
}

FlutterBluePlusException remains as a deprecated type alias so existing source can migrate to MyBluetoothException gradually.

Opt-in diagnostics #

Structured diagnostics are disabled by default. Enable them only for an explicit troubleshooting session:

MyBluetooth.diagnosticsEnabled = true;
final diagnostics = bluetooth.diagnosticEvents.listen((event) {
  print('${event.operation} ${event.stage} ${event.elapsed}');
});

// Run the operation being investigated.

await diagnostics.cancel();
MyBluetooth.diagnosticsEnabled = false;

Events contain an operation ID/name, lifecycle stage, UTC timestamp, elapsed time, typed error category, and an allow-listed native error code. They do not contain arguments, Bluetooth addresses, device names, file paths, RSSI, exception messages, or transferred bytes. The broadcast stream does not retain or replay events.

More documentation #

Contributing #

Bug reports and pull requests are welcome at GitHub. Many thanks to Thao Doan and Duc Nguyen for their work on the package.

5
likes
160
points
9
downloads
screenshot

Documentation

API reference

Publisher

verified publisherwongcoupon.com

Weekly Downloads

Android Classic Bluetooth discovery, RFCOMM connections, and byte or file transfer for Flutter printer and accessory apps.

Repository (GitHub)
View/report issues

Topics

#bluetooth #bluetooth-classic #rfcomm #printer #flutter-plugin

Funding

Consider supporting this project:

buymeacoffee.com
ko-fi.com
github.com

License

MIT (license)

Dependencies

flutter

More

Packages that depend on my_bluetooth

Packages that implement my_bluetooth