Bare Docs

Set up Bluetooth in a mobile app

Declare and request the iOS and Android Bluetooth permissions, link bare-bluetooth's native addons, and start the worklet only after access is granted.

bare-bluetooth runs inside your app's Bare worklet, but the operating system grants Bluetooth access to the app, not to the worklet. So the host app has to declare what it uses, ask the user at runtime, and ship the native code that the module needs. This guide covers each step for iOS and Android, using two working hosts:

On desktop there is less to do. See Desktop at the end.

For how the pieces fit together, read Bluetooth in Bare first.

On Android, bare-bluetooth works only inside an app that hosts Bare, such as a Bare Kit worklet. It calls the Android Bluetooth API through the app's Java VM, which a plain bare process doesn't have.

Declare the permissions

iOS

iOS terminates an app that touches Bluetooth without a usage description in its Info.plist. Add NSBluetoothAlwaysUsageDescription with a sentence that the user sees in the permission prompt. In an Expo app, it goes under ios.infoPlist in app.json:

app.json
      "infoPlist": {
        "NSCameraUsageDescription": "This app uses the camera to capture video content",
        "NSMicrophoneUsageDescription": "This app uses the microphone to capture audio content",
        "NSBluetoothAlwaysUsageDescription": "This app uses Bluetooth to discover nearby devices and exchange messages"
      }

iOS shows the prompt the first time the app creates a Bluetooth manager: the first new Central() or new Server(). If the user declines, the manager's state becomes 'unauthorized' instead of 'poweredOn'. Handle that BluetoothState in your stateChange listener rather than waiting forever.

Android

Android 12 (API level 31) and later splits Bluetooth access into three runtime permissions:

A native app declares them in AndroidManifest.xml:

AndroidManifest.xml
  <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
  <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
  <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />

The android:usesPermissionFlags="neverForLocation" attribute states that the app never derives the user's location from scan results, which lets it scan without a location permission. Without that flag, Android also requires ACCESS_FINE_LOCATION for scanning.

An Expo app lists the permissions in app.json instead. bare-mobile-doctor lists:

  • The three permissions listed earlier.
  • ACCESS_FINE_LOCATION, because it doesn't set the flag.
  • The legacy BLUETOOTH and BLUETOOTH_ADMIN permissions.

It also raises the minimum SDK to 31 with minSdkVersion, so the app never runs on a release without the new permissions:

app.json
      "permissions": [
        "android.permission.ACCESS_FINE_LOCATION",
        "android.permission.BLUETOOTH",
        "android.permission.BLUETOOTH_ADMIN",
        "android.permission.BLUETOOTH_ADVERTISE",
        "android.permission.BLUETOOTH_CONNECT",
        "android.permission.BLUETOOTH_SCAN"
      ]
app.json
      [
        "expo-build-properties",
        {
          "android": {
            "minSdkVersion": 31
          },
          "ios": {
            "deploymentTarget": "16.4",
            "enableSceneSupport": true
          }
        }
      ],

The ios options in the same entry set the app's iOS deployment target and scene support. They aren't Bluetooth settings.

Request access before starting the worklet

On Android, declaring a permission isn't enough. The user has to grant it at runtime, and a Bluetooth call made without it fails. The Android binding surfaces that failure as a thrown Java exception. Both hosts ask first and start the worklet only once everything is granted, so the Bare code never runs without access.

In React Native, use PermissionsAndroid:

app/views/bluetooth/BluetoothTests.tsx
async function requestAndroidBluetoothPermissions() {
  const permissions = [
    PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
    PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
    PermissionsAndroid.PERMISSIONS.BLUETOOTH_ADVERTISE,
    PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION
  ].filter(Boolean)

  const results = await PermissionsAndroid.requestMultiple(permissions)
  const granted = PermissionsAndroid.RESULTS.GRANTED

  return permissions.every((permission) => results[permission] === granted)
}
app/views/bluetooth/BluetoothTests.tsx
      if (Platform.OS === 'android') {
        const granted = await requestAndroidBluetoothPermissions()

        if (!granted) {
          const message = 'Android Bluetooth permissions were not granted'
          setError(message)
          addLog(message)
          return
        }
      }

      const worklet = new Worklet()
      workletRef.current = worklet
      worklet.start('bluetooth.bundle', source, [name, String(logsEnabled)])

In a native Kotlin activity, make the same check with checkSelfPermission() and requestPermissions():

MainActivity.kt
  private val permissions = arrayOf(
    Manifest.permission.BLUETOOTH_SCAN,
    Manifest.permission.BLUETOOTH_CONNECT,
    Manifest.permission.BLUETOOTH_ADVERTISE
  )
MainActivity.kt
    val missing = permissions.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }

    if (missing.isEmpty()) run() else requestPermissions(missing.toTypedArray(), 0)
  }

  override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
  ) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)

    if (grantResults.isEmpty() || grantResults.any { it != PackageManager.PERMISSION_GRANTED }) {
      Log.e("bare-bluetooth-test", "Bluetooth permissions denied, not running the tests")
      finish()
      return
    }

    run()
  }

The activity's run() method starts the Bare Kit worklet. On iOS there is nothing to request up front: the system prompts on first use, as described in the iOS section.

bare-bluetooth is JavaScript, but it loads native addons: bare-bluetooth-apple on iOS and bare-bluetooth-android on Android. You have to link them into the app. bare-link does this from the worklet's entry point.

On Android, bare-bluetooth-android also carries Java helper classes. bare-link writes them out as a *.classes.jar file next to the addon, and the app has to compile them in too.

bare-mobile-doctor links every worklet into the addon folders that react-native-bare-kit already picks up, with a short script that you run as npm run link:

scripts/link.mjs
// Links the native addons loaded by the worklets into react-native-bare-kit's
// addon directories, which its native projects already pick up.
import link from 'bare-link'

const worklets = [
  'bare/bluetooth.js',
  'bare/checksum.js',
  'bare/hypercore/hypercore.mjs',
  'bare/hyperdb/hyperdb.mjs',
  'bare/ipc.js',
  'bare/os.js',
  'bare/rocksdb/index.mjs',
  'bare/udx.js',
  'bare/videocapture.js',
  'bare/videoconverter.js'
]

const out = {
  ios: 'node_modules/react-native-bare-kit/ios/addons',
  android: 'node_modules/react-native-bare-kit/android/src/main/addons'
}

const preset = process.argv[2]

if (preset in out === false) {
  throw new Error(`Usage: node scripts/link.mjs <${Object.keys(out).join('|')}>`)
}

for await (const _ of link(worklets, { preset, out: out[preset] })) {
  // Nothing to do; the generator does the linking.
}

The native test app does the same from Gradle:

  • A link task runs bare-link.
  • A pack task bundles the worklet.
  • Both tasks run before every build.
  • The dependencies pull in the addons' *.classes.jar files.
app/build.gradle
tasks.register("link", Exec) {
  workingDir repo

  commandLine([
    "node_modules/.bin/bare-link",
    "--host", "android-arm64",
    "--out", "test/test-app/app/src/main/addons",
    "test/test-app/test.js"
  ])
}

tasks.register("pack", Exec) {
  workingDir repo

  commandLine([
    "node_modules/.bin/bare-pack",
    "--host", "android-arm64",
    "--linked",
    "--defer", "bare-cov",
    "--out", "test/test-app/app/src/main/assets/test.bundle",
    "test/test-app/test.js"
  ])
}

preBuild.dependsOn link, pack

dependencies {
  api fileTree(dir: "libs", include: ["bare-kit/classes.jar"])
  api fileTree(dir: "src/main/addons", include: ["*.classes.jar"])
}

Run the link step again whenever you reinstall dependencies or change an addon dependency.

Bundle the worklet

bare-pack packs the worklet into one bundle per app. Pass a --host for every platform and architecture you ship, and --linked so the bundle loads the addons that you linked in the previous section. bare-mobile-doctor builds its Bluetooth bundle with:

package.json
    "gen:bluetooth": "npx bare-pack --out app/views/bluetooth/bluetooth.bundle.js bare/bluetooth.js --host ios-arm64 --host ios-arm64-simulator --host ios-x64-simulator --host android-arm --host android-arm64 --host android-ia32 --host android-x64 --linked",

Bundle a Bare app covers bare-pack in more depth.

Desktop

The same bare-bluetooth code runs with plain bare on macOS and, from 0.6.3, on Linux:

  • macOS uses CoreBluetooth, as on iOS. A packaged macOS app needs the same NSBluetoothAlwaysUsageDescription key in its Info.plist. If it runs in the App Sandbox, it also needs the com.apple.security.device.bluetooth entitlement. A script that you run with plain bare takes its permission from the terminal app, which needs Bluetooth access under System Settings, Privacy & Security.
  • Linux goes through the BlueZ daemon, so bluetoothd has to be running and the adapter powered on, for example with bluetoothctl power on. Linux has no system pairing prompt for the module to fall back on. If a peer asks to pair, your app gets a pairRequest event with a PairRequest to accept or reject.
  • Windows isn't supported. require('bare-bluetooth') fails there.

Where to go next

Last updated on

Was this helpful?

On this page