npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

munim-bluetooth

v0.8.1

Published

A comprehensive React Native Bluetooth library for BLE central/peripheral, Android Classic Bluetooth, LE L2CAP, Apple Multipeer Connectivity, and Expo support

Downloads

17,278

Readme

Introduction

munim-bluetooth is a comprehensive React Native Bluetooth library for BLE central/peripheral work, Android-only Classic Bluetooth APIs, LE L2CAP channels where the OS exposes them, and Apple Multipeer Connectivity for iOS/iPadOS peer messaging. It lets your React Native app advertise services, scan, connect, read, write, subscribe, exchange nearby peer messages, and check platform capabilities before using optional APIs.

Fully compatible with Expo! Works seamlessly with both Expo managed and bare workflows.

Built with React Native's Nitro modules architecture for high performance and reliability.

Note: Bluetooth is heavily platform-gated. This library exposes the features that iOS and Android make available to third-party apps, and it reports unsupported OS-level capabilities through getCapabilities() or explicit unsupported errors instead of silently pretending they work.

Table of contents

📚 Documentation

🚀 Features

Peripheral Mode

  • 🔵 BLE Peripheral Mode: Transform your React Native app into a BLE peripheral device
  • 📡 Service Advertising: Advertise custom GATT services with multiple characteristics
  • 🔄 Real-time Communication: Support for read, write, and notify operations
  • Platform-Aware BLE Advertising: Use service UUIDs and local names cross-platform, plus Android advertising payload data where the OS allows it
  • 🔧 Dynamic Updates: Update advertising data while advertising is active

Central Mode

  • 🔍 Device Scanning: Scan for BLE devices with filtering options
  • 🔗 Device Connection: Connect and disconnect from BLE devices
  • 📊 GATT Operations: Discover services, read/write characteristics
  • 🔔 Notifications: Subscribe to characteristic notifications/indications
  • 📶 RSSI Monitoring: Read signal strength for connected devices

Additional Features

  • 📱 Cross-platform: Works on both iOS and Android
  • 🧭 Capability Reporting: getCapabilities() reports platform and hardware support before you call optional APIs
  • 🕸️ Apple Multipeer Transport: iOS/iPadOS devices can discover, invite, and message nearby peers with Apple's Multipeer Connectivity
  • 🧵 LE L2CAP Channels: Stream payloads over LE Credit Based Channels on supported iOS and Android versions
  • 🔌 Android Classic Bluetooth: Android RFCOMM client/server messaging for SPP-style devices
  • 🎯 TypeScript Support: Full TypeScript definitions included
  • High Performance: Built with React Native's Nitro modules architecture
  • 🚀 Expo Compatible: Works seamlessly with Expo managed and bare workflows
  • 🔐 Permission Handling: Built-in permission request helpers

Platform Support Matrix

| Capability | iOS | Android | Notes | | --- | --- | --- | --- | | Peripheral advertising | ✅ | ✅ | iOS only allows CoreBluetooth-supported advertising keys such as local name and service UUIDs. Android splits primary advertising data and scan response data to stay within BLE size limits. | | Peripheral GATT services | ✅ | ✅ | Read and write requests are handled natively on both platforms. Characteristics can require encrypted access; authenticated-MITM permissions are Android-only because CoreBluetooth has no matching public option. | | Peripheral notify/indicate subscriptions | ✅ | ✅ | Subscribe/unsubscribe events are emitted when centrals change CCC state. | | Central scan | ✅ | ✅ | Service UUID, name, and manufacturer filters on both platforms (iOS applies name/manufacturer in-process); Android also takes address filters and ScanSettings. Android scan failures, including its 5-starts-per-30-seconds throttle, emit scanFailed. | | Central connect/disconnect | ✅ | ✅ | connect() times out after 15 seconds by default (timeoutMs configurable); autoConnect maps to Android background connect and iOS 17+ auto-reconnect. | | Central service discovery | ✅ | ✅ | Emits servicesDiscovered in addition to resolving the Promise. Native timeout rejects if callbacks do not arrive. | | Central characteristic read | ✅ | ✅ | Resolves with hex-encoded values. Native timeout rejects if callbacks do not arrive. | | Central characteristic write | ✅ | ✅ | Supports write and writeWithoutResponse. With-response writes have native timeout protection; write-without-response is flow controlled, and getMaximumWriteLength() reports the payload limit. | | Central descriptor read/write | ✅ | ✅ | Uses readDescriptor() and writeDescriptor() with hex-encoded values. Native timeout rejects if callbacks do not arrive. | | Central notify/indicate subscription | ✅ | ✅ | Values emit through characteristicValueChanged. | | RSSI read | ✅ | ✅ | Resolves with dBm. | | ATT MTU request | ➖ | ✅ | Android negotiates the requested MTU. iOS negotiates the MTU itself; requestMTU() resolves with the MTU in effect. | | Connection priority | ➖ | ✅ | Android requestConnectionPriority(); iOS resolves false. | | GATT cache refresh / Service Changed | ✅ | ✅ | Both emit servicesChanged (Android 12+). refreshGattCache() is Android-only; iOS resolves false. | | Enable Bluetooth prompt | ❌ | ✅ | Android requestEnable() shows the system dialog; iOS resolves with the current state. | | BLE PHY read/preference | ❌ | ✅ | Android 8+ supports readPhy() and setPreferredPhy() when hardware allows it. | | Pairing/bond state | ❌ | ✅ | Android supports bond state, lists bonded devices (getBondedDevices()), and starts/removes bonds. iOS handles pairing automatically and does not expose bond management through CoreBluetooth. | | Extended advertising | ❌ | ✅ | Android 8+ supports startExtendedAdvertising() on hardware with LE extended advertising. iOS does not expose BLE extended advertising. | | BLE L2CAP channel streams | ✅ | ✅ | iOS uses CoreBluetooth LE Credit Based Channels. Android requires Android 10+ for LE CoC sockets. Published and outbound channels require encryption by default. | | Classic Bluetooth RFCOMM | ❌ | ✅ | Android supports discovery, SPP-style RFCOMM client connections, server/listener sockets, disconnect, write, and receive events. iOS apps cannot use public Classic Bluetooth RFCOMM APIs. | | Apple Multipeer Connectivity | ✅ | ❌ | iOS/iPadOS devices can discover peers, approve incoming invitations explicitly, manually invite selected peers, and exchange encrypted messages. Android cannot join Apple's Multipeer sessions; use BLE/GATT for iOS-to-Android. |

Call getCapabilities() at runtime when you need optional behavior. Platform support can still vary by OS version, hardware, permissions, and app background state.

📦 Installation

React Native CLI

npm install munim-bluetooth react-native-nitro-modules
# or
yarn add munim-bluetooth react-native-nitro-modules

Expo

npx expo install munim-bluetooth react-native-nitro-modules

Note: This library requires Expo SDK 50+ and works with both managed and bare workflows. To support Nitro modules, you need React Native version v0.78.0 or higher.

Important: This package requires a native development build in Expo. It does not work in Expo Go. After installing, run npx expo run:ios, npx expo run:android, or create a development build with EAS.

iOS Setup

For iOS, the library is automatically linked. However, you need to add the following to your Info.plist:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth for BLE communication</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to create a peripheral device</string>
<key>NSLocalNetworkUsageDescription</key>
<string>This app uses the local network to discover and communicate with nearby peer devices</string>
<key>NSBonjourServices</key>
<array>
  <string>_munim-mesh._tcp</string>
</array>

For Expo projects, add these permissions to your app.json:

{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSBluetoothAlwaysUsageDescription": "This app uses Bluetooth for BLE communication",
        "NSBluetoothPeripheralUsageDescription": "This app uses Bluetooth to create a peripheral device",
        "NSLocalNetworkUsageDescription": "This app uses the local network to discover and communicate with nearby peer devices",
        "NSBonjourServices": ["_munim-mesh._tcp"]
      }
    }
  }
}

With the included Expo config plugin, the default munim-mesh Multipeer service is declared automatically. For custom service types:

{
  "expo": {
    "plugins": [
      [
        "munim-bluetooth",
        {
          "multipeerServiceTypes": ["anonmesh", "munim-mesh"],
          "localNetworkUsageDescription": "This app discovers nearby private wallet peers."
        }
      ]
    ]
  }
}

Android Setup

The library does not merge optional Bluetooth permissions into your app. Declare only the capabilities your app uses.

For a central app that scans and connects, add:

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission
  android:name="android.permission.BLUETOOTH_SCAN"
  android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

An app that connects to a known device without scanning can omit BLUETOOTH_ADMIN, ACCESS_FINE_LOCATION, and BLUETOOTH_SCAN. Add BLUETOOTH_ADVERTISE only when using peripheral advertising or background sessions that advertise.

The example above asserts that scan results are never used to derive physical location. This avoids a location permission on Android 12+ and is required for scan-result delivery on devices that enforce that distinction. Android may filter some beacon formats under this mode. If your app derives location from Bluetooth scans or needs every beacon format, remove neverForLocation, remove android:maxSdkVersion="30" from ACCESS_FINE_LOCATION, and request location permission at runtime.

For Expo projects, select capabilities through the included config plugin. Central mode is the default:

{
  "expo": {
    "plugins": [
      [
        "munim-bluetooth",
        {
          "androidBluetoothPermissions": ["scan", "connect"]
        }
      ]
    ]
  }
}

Use ["connect"] for connection-only apps, or add "advertise" for peripheral/background advertising. Set androidBluetoothPermissions to false to manage every Android permission outside the plugin.

Device-to-Device Messaging

For phone-to-phone apps, treat advertising as discovery and GATT as the reliable message channel:

  • A peripheral advertises one or more service UUIDs, defines writable/readable/notifiable characteristics with setServices(), and listens for peripheralReadRequest, peripheralWriteRequest, peripheralSubscribed, and peripheralUnsubscribed.
  • A central scans for that service, connects, discovers services, reads or writes the characteristic, and subscribes for notifications through characteristicValueChanged.
  • Multiple nearby centrals can connect, write, and subscribe at the same time. Track peers by deviceId on the central side and centralId on the peripheral side.

Advertising payload caveat: Android can advertise manufacturer data, service data, TX power, appearance, and related fields subject to BLE payload size and hardware limits. iOS public CoreBluetooth peripheral advertising only exposes local name and service UUIDs, so arbitrary relay bytes should go in a GATT characteristic for iOS-to-iOS and iOS-to-Android communication. If you need a tiny discovery hint on iOS, encode it into your advertised service UUID choice or local name with the normal privacy and size tradeoffs.

Apple Multipeer Connectivity

For iOS-to-iOS or iPadOS-to-iOS communication, startMultipeerSession() exposes Apple's Multipeer Connectivity as a higher-level peer transport. It advertises and browses using a Bonjour service type and sends hex-encoded payloads to one peer or all connected peers. For security, discovery does not invite or accept unknown peers by default. The app must manually invite a selected discovered peer or accept/reject each incoming invitation.

import {
  acceptMultipeerInvitation,
  addEventListener,
  inviteMultipeerPeer,
  rejectMultipeerInvitation,
  sendMultipeerMessage,
  startMultipeerSession,
  stopMultipeerSession,
} from 'munim-bluetooth'

startMultipeerSession({
  serviceType: 'munim-mesh',
  displayName: 'Sheehan iPhone',
  discoveryInfo: [{ key: 'role', value: 'wallet-peer' }],
  encryptionPreference: 'required',
})

addEventListener('multipeerPeerFound', (peer) => {
  if (isExpectedPeer(peer)) {
    inviteMultipeerPeer(peer.id)
  }
})

addEventListener('multipeerInvitationReceived', (invitation) => {
  if (isExpectedPeerId(invitation.peerId)) {
    acceptMultipeerInvitation(invitation.invitationId)
  } else {
    rejectMultipeerInvitation(invitation.invitationId)
  }
})

addEventListener('multipeerPeerStateChanged', async (peer) => {
  if (peer.state === 'connected') {
    await sendMultipeerMessage('68656c6c6f', [peer.id], true)
  }
})

addEventListener('multipeerMessageReceived', ({ displayName, value }) => {
  console.log('message from', displayName, value)
})

// Later:
stopMultipeerSession()

Multipeer service types must be 1-15 lowercase letters/numbers/hyphens, and the matching Bonjour entry must be declared in iOS Info.plist as _<serviceType>._tcp (for example _munim-mesh._tcp). The Expo config plugin adds _munim-mesh._tcp by default and accepts a multipeerServiceTypes option for custom service types.

Incoming invitations use opaque, runtime-only IDs. At most 32 are retained; they expire after the configured invitation timeout (capped at 60 seconds) and are rejected automatically. Setting autoInvite or autoAcceptInvitations to true is an explicit broad trust decision and should only be used when discovery itself is authenticated.

To prevent unbounded native memory growth while JavaScript is not observing events, iOS retains at most 256 queued events and approximately 1 MiB. Discovery/RSSI/latest-value events are coalesced first, and high-rate data events are dropped before lifecycle events when pressure remains.

Background and Terminated Behavior

startBackgroundSession() starts a best-effort BLE session for apps that need nearby communication after the user leaves the app.

| State | iOS | Android | | --- | --- | --- | | App in background or suspended | Supported when UIBackgroundModes includes bluetooth-central and/or bluetooth-peripheral. The Expo config plugin adds both by default. | Supported through a foreground service with connectedDevice service type and a user-visible notification. | | App terminated by the system | Best-effort CoreBluetooth state restoration is enabled when background modes are present. The package uses restoration identifiers and emits backgroundSessionRestored when CoreBluetooth restores central/peripheral state. On iOS 26 and later, Apple's Bluetooth relaunch rules require AccessorySetupKit eligibility for background relaunch, so arbitrary phone-to-phone BLE mesh apps should not depend on terminated-state relaunch. | The foreground service persists its session config and uses START_STICKY; if the process is recreated, it restores scan, advertising, and a native GATT characteristic store from the services configured with setServices(). | | User force-quits / force-stops the app | Not supported by iOS for ongoing app-owned BLE work. The user has explicitly stopped the app. | Not supported after Android force stop. The OS prevents the app from running again until the user opens it or another allowed user/system action starts it. |

The Android background service is declared with foregroundServiceType="connectedDevice" only and starts with that type, which Android 14+ gates on the BLE runtime permissions the session already requires. The library does not declare FOREGROUND_SERVICE_LOCATION or a location service type. If your app needs location while backgrounded for its own reasons, declare that yourself (the location permission, FOREGROUND_SERVICE_LOCATION, and your own foreground service); do not widen this library's service type, because Android 14+ throws a SecurityException at startForeground when a declared type's permission is missing.

Background sessions are for keeping discovery and small GATT messages alive. They do not make JavaScript execute indefinitely. If the process is alive, normal JS events such as peripheralWriteRequest and characteristicValueChanged continue. After a system restart, iOS may relaunch the app only when Apple's current CoreBluetooth restoration rules allow it; Android restores native scan/advertise/GATT state in the foreground service, and app-specific business logic should reconcile state when the app opens again.

import {
  requestBluetoothPermission,
  setServices,
  startBackgroundSession,
  stopBackgroundSession,
} from 'munim-bluetooth'

const hasBackgroundPermissions = await requestBluetoothPermission([
  'scan',
  'connect',
  'advertise',
])
if (!hasBackgroundPermissions) {
  throw new Error('Background Bluetooth permission was not granted')
}

setServices([
  {
    uuid: SERVICE_UUID,
    characteristics: [
      {
        uuid: CHARACTERISTIC_UUID,
        properties: ['read', 'write', 'writeWithoutResponse', 'notify'],
        permissions: ['readEncrypted', 'writeEncrypted'],
        value: '70696e67',
      },
    ],
  },
])

startBackgroundSession({
  serviceUUIDs: [SERVICE_UUID],
  localName: 'MunimPeer',
  scanMode: 'lowPower',
  androidNotificationTitle: 'Nearby mode',
  androidNotificationText: 'Keeping Bluetooth available nearby',
})

// Later:
stopBackgroundSession()

⚡ Quick Start

Basic Usage - Peripheral Mode

import {
  requestBluetoothPermission,
  startAdvertising,
  stopAdvertising,
  setServices,
} from 'munim-bluetooth'

const canAdvertise = await requestBluetoothPermission(['advertise'])
if (!canAdvertise) {
  throw new Error('Bluetooth advertising permission was not granted')
}

// Start advertising with basic options
startAdvertising({
  serviceUUIDs: ['180D', '180F'],
  localName: 'My Device',
})

// Set GATT services
setServices([
  {
    uuid: '180D',
    characteristics: [
      {
        uuid: '2A37',
        properties: ['read', 'write', 'writeWithoutResponse', 'notify'],
        value: '48656c6c6f20576f726c64',
      },
    ],
  },
])

// Stop advertising
stopAdvertising()

Basic Usage - Central Mode

import {
  addDeviceFoundListener,
  isBluetoothEnabled,
  requestBluetoothPermission,
  startScan,
  stopScan,
  connect,
  discoverServices,
  readCharacteristic,
  subscribeToCharacteristic,
} from 'munim-bluetooth'

const hasPermission = await requestBluetoothPermission(['scan', 'connect'])
if (!hasPermission) {
  throw new Error('Bluetooth permission was not granted')
}

const enabled = await isBluetoothEnabled()
if (!enabled) {
  throw new Error('Bluetooth is turned off')
}

const removeDeviceFoundListener = addDeviceFoundListener((device) => {
  console.log('Found device:', device.id, device.name)
})

startScan({
  serviceUUIDs: ['180D'],
  allowDuplicates: false,
  scanMode: 'balanced',
})

// Later, after choosing a discovered device ID:
await connect('device-id-here')
const services = await discoverServices('device-id-here')
const value = await readCharacteristic('device-id-here', '180D', '2A37')
subscribeToCharacteristic('device-id-here', '180D', '2A37')

// Cleanup when finished scanning
stopScan()
removeDeviceFoundListener()

Peripheral Write and Subscribe Events

import {
  addEventListener,
  setServices,
  startAdvertising,
  updateCharacteristicValue,
} from 'munim-bluetooth'

const SERVICE_UUID = '71f271d0-8f4c-4c4d-8a2d-6f3a9497b41d'
const CHARACTERISTIC_UUID = '4ad4a6d2-3f4a-477c-9832-5e0d8f7654d8'

setServices([
  {
    uuid: SERVICE_UUID,
    characteristics: [
      {
        uuid: CHARACTERISTIC_UUID,
        properties: ['read', 'write', 'writeWithoutResponse', 'notify'],
        value: '70696e67',
      },
    ],
  },
])

addEventListener('peripheralWriteRequest', ({ centralId, value }) => {
  console.log('Peer wrote', centralId, value)
  updateCharacteristicValue(SERVICE_UUID, CHARACTERISTIC_UUID, value, true)
})

addEventListener('peripheralSubscribed', ({ centralId }) => {
  console.log('Peer subscribed', centralId)
  updateCharacteristicValue(SERVICE_UUID, CHARACTERISTIC_UUID, '706f6e67', true)
})

startAdvertising({
  serviceUUIDs: [SERVICE_UUID],
  localName: 'MunimPeer',
})

Advanced Usage with Android Advertising Data Types

import {
  startAdvertising,
  updateAdvertisingData,
  getAdvertisingData,
  type AdvertisingDataTypes,
} from 'munim-bluetooth'

// Android advertising data configuration. iOS peripheral advertising only
// broadcasts service UUIDs/local name through public CoreBluetooth APIs.
const advertisingData: AdvertisingDataTypes = {
  // 0x01 - Flags (LE General Discoverable Mode, BR/EDR Not Supported)
  flags: 0x06,

  // 0x02-0x07 - Service UUIDs
  completeServiceUUIDs16: ['180D', '180F'],
  incompleteServiceUUIDs128: ['0000180D-0000-1000-8000-00805F9B34FB'],

  // 0x08-0x09 - Local Name
  completeLocalName: 'My Smart Device',
  shortenedLocalName: 'SmartDev',

  // 0x0A - Tx Power Level
  txPowerLevel: -12,

  // 0x14-0x15 - Service Solicitation
  serviceSolicitationUUIDs16: ['180D'],
  serviceSolicitationUUIDs128: ['0000180D-0000-1000-8000-00805F9B34FB'],

  // 0x16, 0x20, 0x21 - Service Data
  serviceData16: [
    { uuid: '180D', data: '0102030405' },
    { uuid: '180F', data: '060708090A' },
  ],
  serviceData32: [
    { uuid: '0000180D-0000-1000-8000-00805F9B34FB', data: '0B0C0D0E0F' },
  ],

  // 0x19 - Appearance (partial support)
  appearance: 0x03c0, // Generic Watch

  // 0x1F - Service Solicitation (32-bit)
  serviceSolicitationUUIDs32: ['0000180D'],

  // 0xFF - Manufacturer Specific Data
  manufacturerData: '4C000215FDA50693A4E24FB1AFCFC6EB0764782500010001C5',
}

// Start advertising with Android payload data and cross-platform service UUIDs.
startAdvertising({
  serviceUUIDs: ['180D', '180F'],
  advertisingData: advertisingData,
})

// Update advertising data dynamically
updateAdvertisingData({
  flags: 0x04,
  completeLocalName: 'Updated Device Name',
  txPowerLevel: -8,
})

// Get current advertising data
const currentData = await getAdvertisingData()
console.log('Current advertising data:', currentData)

🔧 API Reference

Peripheral Functions

startAdvertising(options)

Starts BLE advertising with the specified options.

Parameters:

  • options (object):
    • serviceUUIDs (string[]): Array of service UUIDs to advertise
    • localName? (string): Device name (legacy support)
    • manufacturerData? (string): Manufacturer data in hex format (legacy Android advertising support)
    • advertisingData? (AdvertisingDataTypes): Platform-aware advertising data. Android can advertise payload fields; iOS advertises local name and service UUIDs.

BLE legacy advertisements have a small payload. When advertising 128-bit service UUIDs on iOS, keep localName short or omit it so the service UUID stays discoverable by Android service-filtered scans. For maximum interoperability, scan without a native filter and validate the discovered service UUID or local name in application code.

updateAdvertisingData(advertisingData)

Updates the advertising data while advertising is active.

Parameters:

  • advertisingData (AdvertisingDataTypes): New advertising data

getAdvertisingData()

Returns a Promise that resolves to the current advertising data.

Returns: Promise

stopAdvertising()

Stops BLE advertising.

setServices(services)

Sets GATT services and characteristics.

Parameters:

  • services (array): Array of service objects. Each characteristic has properties and may set permissions to one read choice and/or one write choice:
    • Plaintext: read, write
    • Link encryption required: readEncrypted, writeEncrypted
    • Authenticated MITM protection: readEncryptedMitm, writeEncryptedMitm (Android only; iOS rejects these because CoreBluetooth has no equivalent public permission)

When permissions is omitted, existing behavior is preserved: read/write properties receive plaintext permissions. When it is supplied, permissions must match the characteristic properties exactly; conflicting, unknown, or mismatched choices are rejected instead of falling back to plaintext. Android background GATT restoration preserves the configured permission choices.

updateCharacteristicValue(serviceUUID, characteristicUUID, value, notify)

Updates a local peripheral characteristic value. When notify is true, the new hex-encoded value is pushed to subscribed centrals using notify/indicate where the characteristic supports it.

Central Functions

isBluetoothEnabled()

Checks if Bluetooth is enabled on the device. On iOS the answer waits (up to 10 seconds) for CoreBluetooth to report its first real state instead of returning false while the state is still unknown.

Returns: Promise

requestEnable()

Asks the user to turn Bluetooth on. Android shows the system ACTION_REQUEST_ENABLE dialog and resolves true if the user accepts (true immediately if Bluetooth is already on). It needs a foreground Activity and, on Android 12+, the connect permission. iOS apps cannot switch Bluetooth on, so iOS resolves with whether it is currently on.

Returns: Promise

requestBluetoothPermission(permissions?)

Requests only the selected Android capabilities, or the Bluetooth permission on iOS. Supported capabilities are scan, connect, and advertise. The default is ['scan', 'connect']; advertising is never requested implicitly.

On iOS, importing the package no longer shows the Bluetooth permission prompt: CoreBluetooth managers are created on first use. requestBluetoothPermission() shows the prompt when the user has not decided yet and resolves once they answer (up to 60 seconds); it resolves false immediately when access was denied or restricted. Any other central or peripheral call (isBluetoothEnabled(), startScan(), startAdvertising(), ...) also creates the manager and can show the prompt, so call requestBluetoothPermission() first to control when that happens. Until permission is granted, synchronous calls such as startScan() throw a "permission has not been granted yet" error.

await requestBluetoothPermission(['connect'])
await requestBluetoothPermission(['advertise'])

Returns: Promise

getCapabilities()

Returns the platform/device Bluetooth feature set.

Returns: Promise

startBackgroundSession(options)

Starts a best-effort background BLE session. Android starts a foreground service that restores scan, advertising, and configured GATT services after normal process recreation. iOS keeps CoreBluetooth managers configured with state restoration identifiers when Bluetooth background modes are present; terminated-state relaunch is still subject to Apple's CoreBluetooth relaunch rules, including the iOS 26 AccessorySetupKit restriction.

Parameters:

  • serviceUUIDs (string[]): Service UUIDs to advertise and scan for.
  • localName? (string): Local name to advertise where supported.
  • allowDuplicates? (boolean): Whether scan callbacks may repeat the same device.
  • scanMode? ('lowPower' | 'balanced' | 'lowLatency'): Android scan mode preference.
  • androidNotificationChannelId?, androidNotificationChannelName?, androidNotificationTitle?, androidNotificationText?: Android foreground service notification options.

stopBackgroundSession()

Stops the active background BLE session and clears persisted Android service restore state.

startMultipeerSession(options)

Starts Apple Multipeer Connectivity discovery and messaging on iOS/iPadOS.

Parameters:

  • serviceType (string): Bonjour service type, 1-15 lowercase letters/numbers/hyphens.
  • displayName? (string): Name shown to nearby peers.
  • discoveryInfo? ({ key: string; value: string }[]): Small discovery metadata.
  • autoInvite? (boolean): Automatically invite every discovered peer. Defaults to false; prefer inviteMultipeerPeer(peerId) after app-level approval.
  • autoAcceptInvitations? (boolean): Automatically accept every incoming invitation. Defaults to false; prefer the invitation event and explicit response APIs.
  • inviteTimeout? (number): Invitation timeout in seconds. Defaults to 30.
  • encryptionPreference? ('none' | 'optional' | 'required'): Defaults to required.

stopMultipeerSession()

Stops the local Multipeer advertiser, browser, and session.

inviteMultipeerPeer(peerId)

Invites a discovered Multipeer peer when autoInvite is disabled or you want manual control.

acceptMultipeerInvitation(invitationId), rejectMultipeerInvitation(invitationId)

Responds once to a pending multipeerInvitationReceived event. IDs are opaque, bounded, and expire automatically; unknown, reused, or expired IDs are rejected.

getMultipeerPeers()

Returns discovered and connected Multipeer peers for the active runtime session.

sendMultipeerMessage(value, peerIds?, reliable?)

Sends a hex-encoded payload to connected Multipeer peers. Omit peerIds to broadcast to every connected peer. reliable defaults to true.

startScan(options?)

Starts scanning for BLE devices.

Parameters:

  • options? (object):
    • serviceUUIDs? (string[]): Filter by service UUIDs
    • allowDuplicates? (boolean): Allow duplicate scan results
    • scanMode? ('lowPower' | 'balanced' | 'lowLatency'): Scan mode
    • rssiThreshold? (number): Drop results weaker than this RSSI (dBm), filtered in-process
    • namePrefix? (string): Only report names starting with this prefix, filtered in-process
    • deviceName? (string): Only report this exact advertised/local name. Android ScanFilter.setDeviceName; iOS filters in-process.
    • deviceAddress? (string): Android only. Only report this MAC address (ScanFilter.setDeviceAddress). Ignored on iOS, which has no MAC addresses.
    • manufacturerId? (number): Only report devices advertising manufacturer data for this company identifier. Android ScanFilter.setManufacturerData; iOS filters in-process.
    • manufacturerData? (hex string): Prefix the manufacturer payload (after the company identifier) must match. Requires manufacturerId.
    • manufacturerDataMask? (hex string): Same length as manufacturerData; 1 bits must match, 0 bits are ignored.
    • Android ScanSettings (ignored on iOS):
      • reportDelayMs? (number): Batch results and deliver them every N ms. Needs offloaded batch scanning; unsupported hardware reports scanFailed.
      • callbackType? ('allMatches' | 'firstMatch' | 'matchLost'): firstMatch/matchLost need at least one filter and hardware filter support. matchLost results arrive as a deviceLost event.
      • matchMode? ('aggressive' | 'sticky')
      • legacy? (boolean, Android 8+): Defaults to true. Set false to also receive extended advertisements.
      • phy? ('le1m' | 'leCoded' | 'allSupported', Android 8+): Only applies when legacy is false.

Android filters are combined the way the platform does it: service UUIDs are alternatives (one ScanFilter each), and the name/address/manufacturer criteria apply to every one of them.

Android allows an app only 5 scan starts per 30 seconds and silently ignores further starts. The library counts its own starts; a 6th start inside the window is not attempted and emits scanFailed with errorCode: 6 and retryAfterMs instead of leaving the app waiting for results that never come.

On Android, an unfiltered scan can be more reliable for iOS peripherals whose 128-bit service UUID was moved out of the primary legacy advertisement because of payload limits. Filter the emitted devices in application code when needed.

stopScan()

Stops scanning for BLE devices.

connect(deviceId, options?)

Connects to a BLE device.

Parameters:

  • deviceId (string): The unique identifier of the device
  • options? (object):
    • timeoutMs? (number): Cancel the attempt and reject after this many milliseconds. Defaults to 15000, or no timeout when autoConnect is true. 0 waits indefinitely.
    • autoConnect? (boolean): Android passes autoConnect = true to connectGatt, a background connection that completes whenever the device is next in range (slower, but it does not give up). iOS 17+ sets CBConnectPeripheralOptionEnableAutoReconnect, so the system reconnects after a link loss: deviceDisconnected then carries isReconnecting: true, connectionStateChanged reports connecting, and deviceConnected fires again when the link is back. Ignored on older iOS.

Returns: Promise. Rejects when the timeout elapses first.

disconnect(deviceId)

Disconnects from a BLE device.

Parameters:

  • deviceId (string): The unique identifier of the device

discoverServices(deviceId)

Discovers GATT services for a connected device.

Parameters:

  • deviceId (string): The unique identifier of the connected device

Returns: Promise<GATTService[]>. The promise rejects if native service discovery does not complete within 15 seconds.

readCharacteristic(deviceId, serviceUUID, characteristicUUID)

Reads a characteristic value from a connected device.

Parameters:

  • deviceId (string): The unique identifier of the connected device
  • serviceUUID (string): The UUID of the service
  • characteristicUUID (string): The UUID of the characteristic

Returns: Promise. The promise rejects if the native read callback does not arrive within 15 seconds.

readDescriptor(deviceId, serviceUUID, characteristicUUID, descriptorUUID)

Reads a descriptor value from a connected device.

Returns: Promise. The promise rejects if descriptor discovery/read callbacks do not arrive within 15 seconds.

writeCharacteristic(deviceId, serviceUUID, characteristicUUID, value, writeType?)

Writes a value to a characteristic on a connected device.

Parameters:

  • deviceId (string): The unique identifier of the connected device
  • serviceUUID (string): The UUID of the service
  • characteristicUUID (string): The UUID of the characteristic
  • value (string): The value to write (hex string)
  • writeType? ('write' | 'writeWithoutResponse'): Write type

Returns: Promise. With-response writes reject if the native write callback does not arrive within 15 seconds.

Write-without-response is flow controlled, so a burst of writes is not silently dropped:

  • iOS queues each value and hands it to CoreBluetooth only while canSendWriteWithoutResponse is true, draining the rest from peripheralIsReady(toSendWriteWithoutResponse:). The promise resolves once CoreBluetooth has accepted the value. Values longer than getMaximumWriteLength(deviceId, 'withoutResponse') are rejected; the queue holds at most 1024 pending values per device.
  • Android sends one write at a time through the per-device GATT queue and waits for the stack's onCharacteristicWrite before the next. If the stack still reports busy (ERROR_GATT_WRITE_REQUEST_BUSY on Android 13+, or false from writeCharacteristic before that), the write is retried with a short backoff (up to 20 attempts) before the promise rejects.

writeDescriptor(deviceId, serviceUUID, characteristicUUID, descriptorUUID, value)

Writes a descriptor value to a connected device.

Returns: Promise. The promise rejects if descriptor discovery/write callbacks do not arrive within 15 seconds.

subscribeToCharacteristic(deviceId, serviceUUID, characteristicUUID)

Subscribes to notifications/indications from a characteristic.

Parameters:

  • deviceId (string): The unique identifier of the connected device
  • serviceUUID (string): The UUID of the service
  • characteristicUUID (string): The UUID of the characteristic

unsubscribeFromCharacteristic(deviceId, serviceUUID, characteristicUUID)

Unsubscribes from notifications/indications from a characteristic.

Parameters:

  • deviceId (string): The unique identifier of the connected device
  • serviceUUID (string): The UUID of the service
  • characteristicUUID (string): The UUID of the characteristic

Events

Use addEventListener(eventName, callback) for BLE status and data events.

| Event | Payload | | --- | --- | | deviceFound | Discovered BLE device payload: { id, name?, localName?, rssi?, serviceUUIDs?, serviceData?, manufacturerData?, txPowerLevel?, isConnectable?, advertisingData? }. | | onDeviceFound, scanResult | Legacy aliases for deviceFound. | | scanFailed | { errorCode, message, retryAfterMs? } on Android scan callback failure, or when a start would exceed Android's 5-starts-per-30-seconds limit (errorCode: 6). | | deviceLost | Android callbackType: 'matchLost': { id, rssi? } when a matching device stops advertising. | | advertisingStarted | Empty payload when advertising starts. | | advertisingStartFailed | Android: { errorCode, message }; iOS: { error }. | | classicDeviceFound | Android Classic discovery result: { id, name, bondState, rssi?, bluetoothClass?, serviceUUIDs? }. | | classicScanFailed, classicScanFinished | Android Classic discovery status events. | | classicConnected, classicDisconnected | Android Classic RFCOMM connection status: { deviceId }. | | classicConnectionReceived | Android Classic RFCOMM inbound connection: { deviceId }. | | classicServerStarted, classicServerStopped | Android Classic RFCOMM listener status. | | classicDataReceived | Android Classic RFCOMM data: { deviceId, value }. | | adapterStateChanged | { state, authorization } when the Bluetooth adapter changes state. state is poweredOn, poweredOff, resetting, unauthorized, unsupported, or unknown; Android also reports turningOn/turningOff. Android's authorization is allowedAlways when BLUETOOTH_CONNECT is granted, otherwise unknown. | | deviceConnected | { deviceId } | | deviceDisconnected | { deviceId } | | servicesDiscovered | { deviceId, services } | | servicesChanged | { deviceId, invalidatedServices? } when the remote GATT database changes (iOS didModifyServices, Android 12+ onServiceChanged). Cached characteristics are dropped and the next discoverServices() re-reads the database; iOS lists the invalidated service UUIDs and fails queued write-without-response values that targeted them. | | characteristicValueChanged | { deviceId, serviceUUID, characteristicUUID, value } | | l2capChannelPublished, l2capChannelUnpublished | Local LE L2CAP channel lifecycle status. | | l2capChannelOpened, l2capChannelClosed | LE L2CAP stream lifecycle status. | | l2capChannelPublishFailed, l2capChannelOpenFailed | LE L2CAP failure status. | | l2capDataReceived | LE L2CAP stream data: { channelId, psm, deviceId, value }. | | rssiUpdated | { deviceId, rssi } | | peripheralReadRequest | { centralId, serviceUUID, characteristicUUID, value } | | peripheralWriteRequest | { centralId, serviceUUID, characteristicUUID, value } | | peripheralSubscribed | { centralId, serviceUUID, characteristicUUID } | | peripheralUnsubscribed | { centralId, serviceUUID, characteristicUUID } | | backgroundSessionStarted | { platform, serviceUUIDs?, localName? } | | backgroundSessionStopped | { platform } | | backgroundSessionRestored | { platform, role?, isScanning?, isAdvertising?, serviceUUIDs?, deviceIds? } | | backgroundSessionStartFailed | { platform, error } | | multipeerStarted, multipeerStopped, multipeerStartFailed | Apple Multipeer lifecycle status. | | multipeerPeerFound, multipeerPeerLost, multipeerPeerStateChanged | Apple Multipeer peer discovery and connection state. | | multipeerInvitationReceived | Pending Apple Multipeer invitation: { invitationId, peerId, displayName, expiresAt }. Explicitly accept or reject it before expiry. | | multipeerMessageReceived | Apple Multipeer data: { peerId, displayName, value }. |

getConnectedDevices()

Gets list of currently connected devices.

Returns: Promise<string[]>

readRSSI(deviceId)

Reads RSSI (signal strength) for a connected device.

Parameters:

  • deviceId (string): The unique identifier of the connected device

Returns: Promise

requestMTU(deviceId, mtu)

Requests an ATT MTU on Android and resolves with the negotiated value. CoreBluetooth negotiates the MTU itself, so iOS ignores mtu and resolves with the MTU in effect (maximumWriteValueLength(.withoutResponse) + 3).

Returns: Promise

getMaximumWriteLength(deviceId, type)

Returns the largest value, in bytes, that one characteristic write can carry on this connection. Use it to chunk write-without-response payloads.

  • type: 'withResponse' | 'withoutResponse'
  • iOS: CoreBluetooth's maximumWriteValueLength(for:).
  • Android: 'withoutResponse' is the last negotiated MTU minus 3 (23 − 3 = 20 until an MTU exchange has been reported, so call requestMTU() first); 'withResponse' is 512, the maximum attribute length that a long write can carry.

Returns: Promise

requestConnectionPriority(deviceId, priority)

Requests a connection interval profile: 'high' (short interval, more throughput and lower latency), 'balanced' (the default), or 'lowPower'. Android calls BluetoothGatt.requestConnectionPriority() and resolves with whether the request was accepted; the controller still negotiates the final parameters. iOS resolves false and changes nothing, because CoreBluetooth manages connection parameters itself.

Returns: Promise

setPreferredPhy(deviceId, txPhy, rxPhy, phyOption?)

Sets preferred BLE PHY on Android 8+ when hardware supports it. iOS rejects with an unsupported error.

Returns: Promise

readPhy(deviceId)

Reads the current BLE PHY on Android 8+ when hardware supports it. iOS rejects with an unsupported error.

Returns: Promise

refreshGattCache(deviceId)

Clears the OS GATT attribute cache for a connected device so the next discoverServices() re-reads the remote database. Useful after a peripheral firmware update when the stack keeps serving stale handles.

  • Android: calls the hidden BluetoothGatt.refresh() through the GATT queue and resolves with its result (false when the platform refuses it).
  • iOS: resolves false. CoreBluetooth has no public cache API and applies Service Changed indications itself (listen for servicesChanged).

Returns: Promise

getBondState(deviceId)

Returns Android bond state. iOS resolves to unsupported.

Returns: Promise

createBond(deviceId)

Starts Android pairing/bonding. iOS rejects with an unsupported error.

Returns: Promise

getBondedDevices()

Lists devices bonded (paired) with the phone: { id, name?, type }[], where type is 'classic' | 'le' | 'dual' | 'unknown' and id is the MAC address usable with connect()/connectClassic(). Android only; needs the connect permission on Android 12+. iOS keeps pairings private and resolves [].

Returns: Promise<BondedDevice[]>

removeBond(deviceId)

Removes an Android bond when the OS exposes that operation. iOS rejects with an unsupported error.

Returns: Promise

startExtendedAdvertising(options)

Starts an Android BLE extended advertising set on Android 8+ hardware that supports LE extended advertising. iOS rejects with an unsupported error.

Returns: Promise

stopExtendedAdvertising(advertisingId)

Stops an Android BLE extended advertising set.

publishL2CAPChannel(), openL2CAPChannel(), sendL2CAPData()

Opens BLE L2CAP channel streams. iOS uses CoreBluetooth LE Credit Based Channels. Android requires Android 10+. Publishing and outbound opening require encryption by default; pass false explicitly only for a deliberately insecure channel. Native servers admit at most 16 inbound channels globally and 4 per peer, closing excess channels immediately.

startClassicScan(), connectClassic(), startClassicServer(), writeClassic()

Android Classic Bluetooth RFCOMM discovery, client connection, server listener, write, disconnect, and receive events. iOS rejects with explicit unsupported errors because public iOS APIs do not expose arbitrary Classic RFCOMM.

classicDeviceFound includes Android classification metadata when the remote device reports it:

addEventListener('classicDeviceFound', (device) => {
  console.log(device.bluetoothClass?.deviceClass)
  console.log(device.bluetoothClass?.majorDeviceClass)
  console.log(device.bluetoothClass?.serviceClasses)
  console.log(device.serviceUUIDs)
})

deviceClass and majorDeviceClass are raw Android Bluetooth class values. serviceClasses contains matching BluetoothClass.Service bit flags. serviceUUIDs contains Android's cached SDP UUIDs and may be absent for unpaired or newly discovered devices. These values are classification hints; devices do not always advertise accurate class or service information.

Types

AdvertisingDataTypes

Platform-aware interface for BLE advertising data types. Android can advertise these payload fields when hardware and payload size allow it. iOS can scan many of these fields from other peripherals, but iOS peripheral advertising is limited to local name and service UUIDs.

interface AdvertisingDataTypes {
  // 0x01 - Flags
  flags?: number

  // 0x02-0x07 - Service UUIDs
  incompleteServiceUUIDs16?: string[]
  completeServiceUUIDs16?: string[]
  incompleteServiceUUIDs32?: string[]
  completeServiceUUIDs32?: string[]
  incompleteServiceUUIDs128?: string[]
  completeServiceUUIDs128?: string[]

  // 0x08-0x09 - Local Name
  shortenedLocalName?: string
  completeLocalName?: string

  // 0x0A - Tx Power Level
  txPowerLevel?: number

  // 0x14-0x15 - Service Solicitation
  serviceSolicitationUUIDs16?: string[]
  serviceSolicitationUUIDs128?: string[]

  // 0x16, 0x20, 0x21 - Service Data
  serviceData16?: Array<{
    uuid: string
    data: string
  }>
  serviceData32?: Array<{
    uuid: string
    data: string
  }>
  serviceData128?: Array<{
    uuid: string
    data: string
  }>

  // 0x19 - Appearance
  appearance?: number

  // 0x1F - Service Solicitation (32-bit)
  serviceSolicitationUUIDs32?: string[]

  // 0xFF - Manufacturer Specific Data
  manufacturerData?: string
}

Supported BLE Advertising Data Types

| Hex | Type Name | Description | Advertise Support | Example | | ---------------- | ----------------------------- | ------------------------------- | ----------------- | ------------------------------------------------- | | 0x01 | Flags | Basic device capabilities | Android | flags: 0x06 | | 0x02-0x07 | Service UUIDs | Service UUIDs offered | iOS + Android | completeServiceUUIDs16: ['180D'] | | 0x08-0x09 | Local Name | Device name | iOS + Android | completeLocalName: 'My Device' | | 0x0A | Tx Power Level | Transmit power in dBm | Android | txPowerLevel: -12 | | 0x14-0x15 | Service Solicitation | Services being sought | Android | serviceSolicitationUUIDs16: ['180D'] | | 0x16, 0x20, 0x21 | Service Data | Data associated with services | Android | serviceData16: [{uuid: '180D', data: '010203'}] | | 0x19 | Appearance | Appearance category | Android | appearance: 0x03C0 | | 0x1F | Service Solicitation (32-bit) | 32-bit services being solicited | Android | serviceSolicitationUUIDs32: ['0000180D'] | | 0xFF | Manufacturer Specific Data | Vendor-defined data | Android | manufacturerData: '4748494A4B4C4D4E' |

Note: This library focuses on reliable phone-to-phone BLE behavior exposed by public iOS and Android APIs. Bluetooth Mesh, LE Audio broadcast/isoc streams, and arbitrary iOS advertising payloads are not exposed by the mobile OS APIs this package can use.

📖 Usage Examples

Health Device Example

import { startAdvertising, setServices } from 'munim-bluetooth'

// Health device advertising. The payload fields inside advertisingData are
// advertised on Android; iOS uses serviceUUIDs/localName for advertising.
startAdvertising({
  serviceUUIDs: ['180D', '180F'], // Heart Rate, Battery Service
  advertisingData: {
    flags: 0x06, // LE General Discoverable Mode, BR/EDR Not Supported
    completeLocalName: 'Health Monitor',
    appearance: 0x03c0, // Generic Watch
    txPowerLevel: -8,
    manufacturerData: '0102030405', // Custom health data
    serviceData16: [
      { uuid: '180D', data: '6400' }, // Heart rate: 100 bpm
      { uuid: '180F', data: '64' }, // Battery: 100%
    ],
  },
})

// Set up GATT services
setServices([
  {
    uuid: '180D', // Heart Rate Service
    characteristics: [
      {
        uuid: '2A37', // Heart Rate Measurement
        properties: ['read', 'notify'],
        value: '6400', // 100 bpm
      },
    ],
  },
  {
    uuid: '180F', // Battery Service
    characteristics: [
      {
        uuid: '2A19', // Battery Level
        properties: ['read', 'notify'],
        value: '64', // 100%
      },
    ],
  },
])

Smart Home Device Example

import { startAdvertising, updateAdvertisingData } from 'munim-bluetooth'

// Smart home device. The payload fields inside advertisingData are advertised
// on Android; for iOS peers, put live state in GATT characteristics.
startAdvertising({
  serviceUUIDs: ['1812', '180F'], // HID, Battery Service
  advertisingData: {
    flags: 0x04, // LE General Discoverable Mode
    completeLocalName: 'Smart Light Bulb',
    appearance: 0x03c1, // Generic Light Fixture
    manufacturerData: '0102030405', // Custom light data
    serviceData16: [
      { uuid: '1812', data: '01' }, // HID: Keyboard
      { uuid: '180F', data: '64' }, // Battery: 100%
    ],
  },
})

// Update advertising data when light state changes
updateAdvertisingData({
  manufacturerData: '0102030406', // Updated light data
  serviceData16: [
    { uuid: '1812', data: '02' }, // HID: Mouse
    { uuid: '180F', data: '50' }, // Battery: 80%
  ],
})

Basic Peripheral Setup

import React, { useEffect } from 'react'
import { Text } from 'react-native'
import {
  startAdvertising,
  stopAdvertising,
  setServices,
} from 'munim-bluetooth'

const MyPeripheral = () => {
  useEffect(() => {
    // Configure services
    setServices([
      {
        uuid: '1800', // Generic Access Service
        characteristics: [
          {
            uuid: '2a00', // Device Name
            properties: ['read'],
            value: 'MyDevice',
          },
          {
            uuid: '2a01', // Appearance
            properties: ['read'],
            value: '0x03C0', // Generic Computer
          },
        ],
      },
      {
        uuid: '1801', // Generic Attribute Service
        characteristics: [
          {
            uuid: '2a05', // Service Changed
            properties: ['indicate'],
          },
        ],
      },
    ])

    // Start advertising
    startAdvertising({
      serviceUUIDs: ['1800', '1801'],
      localName: 'MyReactNativePeripheral',
    })

    // Cleanup on unmount
    return () => {
      stopAdvertising()
    }
  }, [])

  return <Text>Peripheral is running...</Text>
}

Device Scanner Example

import React, { useState, useEffect } from 'react'
import { Text, View } from 'react-native'
import {
  addDeviceFoundListener,
  addEventListener,
  disconnect,
  isBluetoothEnabled,
  requestBluetoothPermission,
  startScan,
  stopScan,
  connect,
  discoverServices,
  readCharacteristic,
  subscribeToCharacteristic,
} from 'munim-bluetooth'

const DeviceScanner = () => {
  const [devices, setDevices] = useState([])
  const [connectedDevice, setConnectedDevice] = useState(null)

  useEffect(() => {
    let removeDeviceFoundListener = () => {}
    let removeCharacteristicListener = () => {}

    const start = async () => {
      const hasPermission = await requestBluetoothPermission()
      if (!hasPermission) {
        return
      }

      const enabled = await isBluetoothEnabled()
      if (!enabled) {
        return
      }

      removeDeviceFoundListener = addDeviceFoundListener((device) => {
        setDevices((currentDevices) => {
          const alreadyExists = currentDevices.some(
            (currentDevice) => currentDevice.id === device.id
          )

          if (alreadyExists) {
            return currentDevices
          }

          return [...currentDevices, device]
        })
      })

      removeCharacteristicListener = addEventListener(
        'characteristicValueChanged',
        (event) => {
          console.log('Characteristic changed:', event)
        }
      )

      startScan({
        serviceUUIDs: ['180D'], // Filter by Heart Rate service
        allowDuplicates: false,
        scanMode: 'balanced',
      })
    }

    void start()

    // Cleanup
    return () => {
      stopScan()
      removeDeviceFoundListener()
      removeCharacteristicListener()

      if (connectedDevice) {
        disconnect(connectedDevice)
      }
    }
  }, [connectedDevice])

  const handleConnect = async (deviceId) => {
    await connect(deviceId)
    setConnectedDevice(deviceId)

    // Discover services
    const services = await discoverServices(deviceId)
    console.log('Services:', services)

    // Read a characteristic
    const value = await readCharacteristic(deviceId, '180D', '2A37')
    console.log('Heart Rate:', value)

    // Subscribe to notifications
    subscribeToCharacteristic(deviceId, '180D', '2A37')
  }

  return (
    <View>
      <Text>Found {devices.length} devices</Text>
      {/* Render device list */}
    </View>
  )
}

🔍 Troubleshooting

Common Issues

  1. Permission Denied: Ensure you have the necessary Bluetooth permissions in your app
  2. Advertising Not Starting: Check that Bluetooth is enabled on the device
  3. Services Not Visible: Verify that your service UUIDs are properly formatted
  4. Scanning Not Working: On Android 6.0+, ensure location permissions are granted
  5. Connection Fails: Verify the device is in range and advertising

Expo-Specific Issues

  1. Development Build Required: This library requires a development build in Expo. Use npx expo run:ios, npx expo run:android, or an EAS development build. Expo Go is not supported.
  2. Permissions Not Working: Make sure you've added the permissions to your app.json as shown in the setup section
  3. Build Errors: Ensure you're using Expo SDK 50+ and have the latest Expo CLI
  4. Nitro Modules: Make sure you have react-native-nitro-modules installed and configured

Debug Mode

Enable debug logging by setting the following environment variable:

export REACT_NATIVE_BLUETOOTH_DEBUG=1

👏 Contributing

We welcome contributions! Please see our Contributing Guide for details on how to submit pull requests, report issues, and contribute to the project.

📄 License

This project is licensed under the Apache License 2.0 - see the LICENSE file for details.