Fix: CBCentralManager State 2 (Unsupported) and No Bluetooth Permission Prompt on iOS 18

Fix: CBCentralManager State 2 (Unsupported) and No Bluetooth Permission Prompt on iOS 18

Written by

in

Quick answer: Your Info.plist has the Bluetooth usage string buried inside UTImportedTypeDeclarations instead of a real NSBluetoothAlwaysUsageDescription key. Without that key, iOS never shows the permission prompt and CBCentralManager reports state 2 (unsupported). Move the string to a top-level NSBluetoothAlwaysUsageDescription entry and the alert appears the first time you create a CBCentralManager.

Why this happens

On iOS 13 and later, an app cannot touch Bluetooth until the user grants the Bluetooth permission. iOS decides whether to show that prompt based on one thing: a top-level NSBluetoothAlwaysUsageDescription key in your app’s Info.plist. If the key is missing, CoreBluetooth still initializes, but the system treats the app as if Bluetooth hardware is unavailable — CBCentralManager.state reports .unsupported (raw value 2) and centralManagerDidUpdateState fires once with no prompt ever shown.

The trap in this case is that the usage string is in the plist — just in the wrong place. It was placed inside UTImportedTypeDeclarations, as the UTTypeDescription of an imported type whose UTTypeIdentifier happens to be the string NSBluetoothAlwaysUsageDescription. That is a completely unrelated plist section: UTImportedTypeDeclarations describes document types your app can open, and iOS never reads permission strings from it. Xcode generated the skeleton, but the key ended up nested where it has no effect.

A secondary issue in the sample code: BluetoothManager is created as a private var inside the ContentView struct. SwiftUI re-creates the struct constantly, so a fresh CBCentralManager can be initialized repeatedly. That won’t block the first permission prompt, but it makes state handling unreliable once you do fix the plist — use @StateObject instead.

How to fix it

1. Add a top-level NSBluetoothAlwaysUsageDescription key

Open your target’s Info tab (or the Info.plist file) and add a top-level dictionary entry:

  • Key: Privacy - Bluetooth Always Usage Description (raw key NSBluetoothAlwaysUsageDescription)
  • Type: String
  • Value: a sentence explaining why you need Bluetooth, e.g. “This app scans for nearby smart accessories so it can connect to them.”

If you edit the XML directly, the key must sit directly inside the root <dict>, not inside any nested structure:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>NSBluetoothAlwaysUsageDescription</key>
    <string>This app scans for nearby smart accessories so it can connect to them.</string>
</dict>
</plist>

You can safely delete the bogus UTImportedTypeDeclarations entry — it does nothing for Bluetooth and, as written, declares an imported type with an empty UTTypeConformsTo array, which is meaningless anyway.

Then clean and rebuild so the new plist is actually embedded in the app bundle (a plain Build sometimes reuses the old signed bundle):

Product → Clean Build Folder (⇧⌘K)

Delete the app from the phone and reinstall. iOS caches permission decisions per app install, and a stale install with no Bluetooth usage key can keep reporting .unsupported even after you fix the plist.

2. Hold the manager in @StateObject and react to state correctly

With the plist fixed, the prompt appears the first time a CBCentralManager (or CBPeripheralManager) is initialized. Make sure you create it exactly once and keep it alive:

import CoreBluetooth
import SwiftUI

final class BluetoothManager: NSObject, ObservableObject, CBCentralManagerDelegate {
    @Published var discoveredDevices: [CBPeripheral] = []
    @Published var bluetoothReady = false

    private var centralManager: CBCentralManager!

    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: .main)
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            bluetoothReady = true
            centralManager.scanForPeripherals(withServices: nil)
        case .unsupported:
            print("Bluetooth unsupported — check NSBluetoothAlwaysUsageDescription")
        default:
            print("Bluetooth not ready: \(central.state.rawValue)")
        }
    }

    func centralManager(_ central: CBCentralManager,
                        didDiscover peripheral: CBPeripheral,
                        advertisementData: [String: Any],
                        rssi RSSI: NSNumber) {
        if !discoveredDevices.contains(peripheral) {
            discoveredDevices.append(peripheral)
        }
    }
}

struct ContentView: View {
    @StateObject private var bluetoothManager = BluetoothManager()

    var body: some View {
        List {
            ForEach(bluetoothManager.discoveredDevices, id: \.identifier) { peripheral in
                Text(peripheral.name ?? "No name")
            }
        }
    }
}

Two details matter here:

  • @StateObject keeps one BluetoothManager instance for the view’s lifetime, so the permission prompt fires once and the delegate survives SwiftUI re-renders.
  • discoveredDevices needs @Published (and the class needs ObservableObject conformance, which it already had) for the List to refresh — the original code stored plain vars that SwiftUI never observes.

3. If the prompt still doesn’t appear

  • Check the built bundle, not the source. In the build products folder, open the app’s Info.plist and confirm NSBluetoothAlwaysUsageDescription is at the top level. If you use generated plists (GENERATE_INFOPLIST_FILE = YES), add the key under Target → Info → Custom iOS Target Properties or in Build Settings’ Info.plist values instead of a file Xcode ignores.
  • Reset permissions: Settings → General → Transfer or Reset iPhone → Reset → Reset Location & Privacy, then reinstall. Or simply delete the app and install again.
  • Verify on a real device. The simulator’s Bluetooth support is limited; state values can differ from hardware. Test the prompt on the physical phone.
  • Check for a deny rule: if the app was previously denied (Settings → your app → Bluetooth → Off), no prompt appears and state will be .poweredOff-adjacent behavior; re-enable it there for testing.

How to prevent it

  • Always add privacy usage keys through the Target → Info tab so Xcode puts them at the top level of the plist, never inside nested arrays.
  • Never hand-edit UTImportedTypeDeclarations for permissions — that section is only for document types.
  • Keep CoreBluetooth managers in an @StateObject/owned long-lived object, not a struct property.
  • After changing Info.plist on a device, delete the app and reinstall so cached permission state is cleared.
  • Test the first-launch flow on a real device, where the permission alert actually shows.

FAQ

Why does CBCentralManager report state 2 instead of crashing when the key is missing?

CoreBluetooth degrades gracefully: without the usage description the system treats the app as lacking Bluetooth capability, so the central manager reports .unsupported (raw value 2) rather than failing to initialize. It’s Apple’s way of signaling “this app may not use Bluetooth” without an exception.

Do I need NSBluetoothPeripheralUsageDescription too?

Only if you support iOS 12 or earlier. NSBluetoothPeripheralUsageDescription is the legacy pre-iOS-13 key; on iOS 13+ the always key (NSBluetoothAlwaysUsageDescription) covers both central and peripheral roles.

The prompt appeared once and never again — is that a bug?

No. iOS shows the Bluetooth permission alert only the first time the app initializes a CoreBluetooth object. After that, the decision lives in Settings under your app’s entry; flip Bluetooth off/on there to re-test, or delete and reinstall the app to see the prompt again.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.