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 the module needs. This guide covers each step for iOS and Android, from two working hosts:

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

On Android, bare-bluetooth only works 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, so add NSBluetoothAlwaysUsageDescription with a sentence the user will see 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, that is, the first new Central() or new Server(). If the user declines, the manager's state becomes 'unauthorized' instead of 'poweredOn', so handle that state in your stateChange listener rather than waiting forever.

Android

Android 12 (API level 31) and later splits Bluetooth access into three runtime permissions: BLUETOOTH_SCAN to discover devices, BLUETOOTH_CONNECT to connect to them, and BLUETOOTH_ADVERTISE to be discoverable. 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" />

android:usesPermissionFlags="neverForLocation" 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 above together with ACCESS_FINE_LOCATION, since it doesn't set the flag, and the legacy BLUETOOTH and BLUETOOTH_ADMIN permissions. It also raises the minimum SDK to 31, so it 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
          }
        }
      ],

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 as a thrown Java exception. Both hosts ask first and only start the worklet once everything is granted, so the Bare code never runs without access.

In React Native, with 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, 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()
  }

run() is where the activity starts the Bare Kit worklet. On iOS there is nothing to request up front: the system prompts on first use, as described above.

bare-bluetooth is JavaScript, but it loads native addons: bare-bluetooth-apple on iOS and bare-bluetooth-android on Android. They have to be linked into the app, which bare-link does from the worklet's entry point. On Android, bare-bluetooth-android also carries Java helper classes, which bare-link writes out as a *.classes.jar next to the addon, and the app has to compile those in too.

bare-mobile-doctor links every worklet into the addon folders react-native-bare-kit already picks up, with a short script 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 run before every build, and 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

The worklet itself is packed with bare-pack into one bundle per app, with a --host for every platform and architecture you ship and --linked so it loads the addons linked above. 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-0, on Linux:

  • macOS uses CoreBluetooth, as on iOS. A packaged macOS app needs the same NSBluetoothAlwaysUsageDescription key in its Info.plist, plus the com.apple.security.device.bluetooth entitlement if it runs in the App Sandbox. A script 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 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