Bluetooth, Sensors & Auto Explained
ADVANCED › Emerging
**Bluetooth Low Energy (BLE)** is the low-power radio Android apps use to talk to nearby gadgets, heart-rate straps, thermometers, beacons, smart locks, without the battery cost of classic Bluetooth. Before the API makes sense, you need its data model, called **GATT** (the Generic Attribute Profile). Devices exchange data as attributes organized into a hierarchy: a **service** groups related **characteristics** (the actual values you read, write, or subscribe to), and a characteristic can have **descriptors** attached to it, all identified by UUIDs and carried over the Attribute Protocol.
BLE actually has two independent role pairs. **Central or peripheral** is about who initiates the radio connection: central scans and connects, peripheral advertises and accepts. **GATT client or server** is about who owns the data: client requests it, server serves it. Most commonly the peripheral is also the GATT server (a heart-rate strap serving its own readings), and the central is the client, but the pairs aren't the same thing.
Android 12 (API 31) split the old blanket BLUETOOTH or BLUETOOTH_ADMIN permissions into purpose-specific runtime permissions: BLUETOOTH_SCAN to discover devices, BLUETOOTH_CONNECT to talk to an already-known device, and BLUETOOTH_ADVERTISE to advertise as a peripheral.
Before this, scanning required ACCESS_FINE_LOCATION too, since a scan can be used to infer a user's location. If your app genuinely doesn't do that, you can add the neverForLocation flag to the scan permission and skip the location prompt entirely:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
The client-side connection flow follows a fixed shape. Get a BluetoothAdapter from BluetoothManager, scan with BluetoothLeScanner, then call device.connectGatt(), which returns a BluetoothGatt handle. From there everything is asynchronous through BluetoothGattCallback:
val gatt = device.connectGatt(context, false, object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
g.discoverServices()
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
// now g.getService(uuid) / characteristics are available
}
})
Until onServicesDiscovered fires, the peripheral's services and characteristics simply aren't known to your BluetoothGatt object yet.
Getting push updates from a characteristic is a two-step handshake, and it's a classic gotcha. Calling setCharacteristicNotification(characteristic, true) only configures the **local** Android BLE stack, it doesn't tell the peripheral anything yet:
gatt.setCharacteristicNotification(characteristic, true)
// nothing arrives yet
The peripheral only starts actually pushing notifications once you write the enable value to the characteristic's **CCCD** (Client Characteristic Configuration Descriptor, UUID 00002902-...):
val cccd = characteristic.getDescriptor(CCCD_UUID)
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(cccd)
Skip that write and setCharacteristicNotification alone will silently do nothing.
The sensor framework has its own lifecycle rule that's easy to get wrong. You get SensorManager via getSystemService(SENSOR_SERVICE), grab a sensor with getDefaultSensor(TYPE_X), and register a listener with a requested delay:
sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI)
Crucially, the framework does **not** stop delivering events just because your activity isn't visible, that's on you. Leave a listener registered past onPause() and the sensor (and your onSensorChanged callback) keeps running in the background, burning battery for no reason.
Sensors split into two kinds. **Hardware sensors** read directly from a physical chip: TYPE_ACCELEROMETER, TYPE_MAGNETIC_FIELD, TYPE_GYROSCOPE, TYPE_PRESSURE. **Composite (software) sensors** are computed by the OS from one or more hardware sensors: TYPE_GRAVITY, TYPE_LINEAR_ACCELERATION, TYPE_ROTATION_VECTOR, TYPE_STEP_COUNTER.
One more nuance worth knowing: the SENSOR_DELAY_* constant you pass to registerListener (FASTEST, GAME, UI, NORMAL) is only a **hint**, the system can deliver events faster or slower. And if a device's sensor axes don't match the app's default orientation, SensorManager.remapCoordinateSystem() reorients the rotation matrix so getOrientation() still returns correct values.
Two very different products get confused constantly in interviews. **Android Auto** is phone projection: your app runs on the phone, and a driving-safe UI is cast to a compatible in-car head unit, the phone does the work. **Android Automotive OS (AAOS)** is a full Android operating system embedded directly in the vehicle, apps are installed on the car itself, no phone required.
The distinction matters because it changes what you're actually building against, a projected screen versus a standalone install target, though the same app code can often target both.
To build a navigation or point-of-interest app that runs on both Android Auto and AAOS with one codebase, you use the **Android for Cars App Library** (androidx.car.app). The entry point is a CarAppService, declared in the manifest, which creates a Session that pushes a stack of Screen objects:
class MyCarAppService : CarAppService() {
override fun onCreateSession() = object : Session() {
override fun onCreateScreen(intent: Intent): Screen = MyScreen(carContext)
}
}
Each Screen returns a **template** (ListTemplate, NavigationTemplate, GridTemplate, and so on) rather than arbitrary custom views. That fixed template set is deliberate, it caps interaction complexity to meet driver-distraction guidelines, and it's what lets the exact same code run consistently on both Android Auto and AAOS.