Bluetooth, Sensors & Auto Explained

ADVANCED › Emerging

Bluetooth Low Energy is the low power radio Android apps use to talk to nearby gadgets: heart rate straps, thermometers, smart locks, and beacons, all without the battery cost of classic Bluetooth. Before any of the API calls make sense, you need its data model, called GATT, the Generic Attribute Profile. A peripheral organizes the data it exposes into a hierarchy. A service groups a set of related characteristics, and a characteristic is the actual value you read, write, or subscribe to. A characteristic can carry one or more descriptors that describe or configure it further. Every service, characteristic, and descriptor is identified by a UUID, and the whole exchange is carried over the Attribute Protocol, ATT, which GATT is built on top of.

BLE actually layers two independent pairs of roles on top of each other, and interviewers love to test whether you can tell them apart. Central or peripheral describes who initiates the radio connection: the central device scans and connects, the peripheral advertises itself and accepts the connection. GATT client or server describes who owns the data: the client requests data, the server stores it and replies. Most of the time a peripheral is also the GATT server, a heart rate strap serving its own readings, and the phone acting as central is also the client, but the two role pairs describe different things and don't have to line up that way.

Android 12, API level 31, tore up the old blanket BLUETOOTH and BLUETOOTH_ADMIN permissions and replaced them with purpose specific runtime permissions. BLUETOOTH_SCAN covers discovering nearby devices, BLUETOOTH_CONNECT covers talking to a device you already know about, and BLUETOOTH_ADVERTISE covers broadcasting your own presence as a peripheral. All three are runtime permissions, so on API 31 and above your app has to request them explicitly and handle the user denying the prompt, exactly like camera or microphone.

Before API 31, scanning for BLE devices also required ACCESS_FINE_LOCATION, because a scan can be used to infer where a user physically is, nearby beacons and Wi-Fi access points work the same way. If your app genuinely never uses BLE scan results to derive location, you can add the neverForLocation flag to the scan permission declaration in the manifest and skip the location prompt entirely, which matters for both user trust and app store review.

<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />

Once you've got the runtime permission, connecting follows a fixed shape. You get a BluetoothAdapter from the system BluetoothManager, scan with BluetoothLeScanner, and when you find the device you want, call device.connectGatt(), which hands back a BluetoothGatt object immediately, before any real connection work has happened. From there, everything happens asynchronously through a BluetoothGattCallback: the connection state change fires first, and only after you explicitly call discoverServices() inside that callback, and onServicesDiscovered fires in turn, does the peripheral's service table actually become populated on your BluetoothGatt handle.

Discovering services is only half the runtime permission story. Once you're connected and want to actually read or write a characteristic, Android 12 and higher gates those calls behind BLUETOOTH_CONNECT, a separate permission from the one that let you scan in the first place. It's a common interview trap to assume BLUETOOTH_SCAN covers everything BLE related; in practice scanning and connecting are gated by two different permissions, and a well behaved app checks for BLUETOOTH_CONNECT right before touching the GATT connection, not just once back at scan time.

if (ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT)
        == PackageManager.PERMISSION_GRANTED) {
    gatt.readCharacteristic(characteristic)
    gatt.writeCharacteristic(characteristic)
}

Subscribing to push updates from a characteristic is a two-step handshake, and it's a classic gotcha that trips people up in interviews. Calling setCharacteristicNotification(characteristic, true) only configures your local Android BLE stack to accept notifications, it doesn't tell the peripheral anything at all. The peripheral only starts actually sending updates once you separately write the enable value to the characteristic's CCCD, the Client Characteristic Configuration Descriptor, identified by the well known UUID ending in 2902. Skip that descriptor write and setCharacteristicNotification alone will silently do nothing, no error, no callback, just silence.

gatt.setCharacteristicNotification(characteristic, true)

val cccdUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
val descriptor = characteristic.getDescriptor(cccdUuid)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)

The sensor framework has a lifecycle rule of its own, and it's an easy one to get wrong under interview pressure. You get SensorManager from getSystemService(SENSOR_SERVICE), grab a sensor with getDefaultSensor(TYPE_X), and register a listener with a requested delay in onResume(). Crucially, the framework does not stop delivering events just because your activity or fragment isn't visible, that behavior is entirely on you to implement. Leave a listener registered past onPause() and the sensor keeps producing events, and your onSensorChanged callback keeps firing in the background, burning battery for no benefit to a screen nobody is looking at.

override fun onResume() {
    super.onResume()
    sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI)
}

override fun onPause() {
    super.onPause()
    sensorManager.unregisterListener(this)
}

Sensors split into two families. Hardware sensors read directly off a physical chip: TYPE_ACCELEROMETER, TYPE_MAGNETIC_FIELD, TYPE_GYROSCOPE, and TYPE_PRESSURE are all raw readings from real silicon. Composite, or software, sensors are computed by the operating system by fusing one or more hardware sensors together: TYPE_GRAVITY, TYPE_LINEAR_ACCELERATION, TYPE_ROTATION_VECTOR, and TYPE_STEP_COUNTER don't correspond to a single physical part, they're derived values the OS produces in software.

val accel = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)       // hardware
val linearAccel = sensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION) // composite

The delay constant you pass to registerListener(), things like SENSOR_DELAY_GAME or SENSOR_DELAY_UI, is often misread as a guarantee. It isn't. It's only a hint to the system about how often you'd like events; the actual delivery rate can come in faster or slower depending on the device and what else is going on. SENSOR_DELAY_FASTEST requests events as fast as possible, SENSOR_DELAY_GAME targets roughly 20,000 microseconds, SENSOR_DELAY_UI roughly 60,000, and SENSOR_DELAY_NORMAL, the default, roughly 200,000. Since Android 3.0 you can also pass an explicit microsecond value instead of a named constant. The cost side matters too: FASTEST and GAME request higher rates, and that directly costs more battery and CPU, while UI and NORMAL are the cheaper, coarser options.

sensorManager.registerListener(
    listener,
    sensor,
    SensorManager.SENSOR_DELAY_GAME // hint only, roughly 20ms target
)

Sensor axes are defined relative to the device's natural, default orientation. If your app locks to landscape, or the device is mounted on some other axis entirely, like in a car dock, the raw rotation matrix from getRotationMatrix() no longer lines up with what the user considers up, forward, and sideways. SensorManager.remapCoordinateSystem() takes that rotation matrix and reorients it onto a new pair of axes you specify, so that a subsequent call to getOrientation() returns azimuth, pitch, and roll relative to the orientation you actually care about, not the device's factory default.

val rotationMatrix = FloatArray(9)
SensorManager.getRotationMatrix(rotationMatrix, null, gravity, geomagnetic)

val remapped = FloatArray(9)
SensorManager.remapCoordinateSystem(
    rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, remapped
)

val orientation = FloatArray(3)
SensorManager.getOrientation(remapped, orientation)

Most sensors are continuous, they keep streaming events until you unregister the listener. A trigger sensor is different: TYPE_SIGNIFICANT_MOTION is armed with requestTriggerSensor() and a TriggerEventListener, fires exactly one event the moment it detects significant motion, and then automatically disarms itself. There's no ongoing stream to unregister; if you want another trigger later you have to explicitly call requestTriggerSensor() again to re-arm it. It's a useful pattern for waking up logic only when the device actually starts moving, without a continuous listener burning battery the whole time it's still.

val sigMotion = sensorManager.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION)

val triggerListener = object : TriggerEventListener() {
    override fun onTrigger(event: TriggerEvent) {
        // fires once, then automatically unregisters itself
    }
}

sensorManager.requestTriggerSensor(triggerListener, sigMotion)

Two products get confused constantly in interviews because they sound similar but work completely differently. Android Auto is phone projection: your app keeps running on the phone, and a driving safe version of its UI is cast over to a compatible in car head unit, the phone is doing all the actual work. Android Automotive OS, AAOS, is a full Android operating system embedded directly in the vehicle itself, apps are installed on the car, and there's no phone required at all for it to run. The distinction matters because it changes what you're actually building and testing against: a projected screen driven by a phone, versus a standalone install target that is the car.

Building a navigation or point of interest app that runs on both Android Auto and AAOS from one codebase means stepping outside the usual Activity and Fragment stack entirely. The Android for Cars App Library, androidx.car.app, is built around three classes instead: a CarAppService, declared in the manifest, is the entry point; it creates a Session, which in turn pushes and manages a stack of Screen objects. Each Screen doesn't return a layout or a Compose function, it returns a template object describing what to show.

class MyCarAppService : CarAppService() {
    override fun onCreateSession() = object : Session() {
        override fun onCreateScreen(intent: Intent): Screen = MyScreen(carContext)
    }
}

class MyScreen(carContext: CarContext) : Screen(carContext) {
    override fun onGetTemplate(): Template =
        MessageTemplate.Builder("Hello, driver!").build()
}

Notice that onGetTemplate() returns a template object, MessageTemplate, ListTemplate, NavigationTemplate, GridTemplate, and so on, never a custom view hierarchy the way a regular Activity would. That restriction is deliberate, not a technical limitation of car displays. Google defines a fixed catalog of templates specifically to cap interaction complexity and enforce driver distraction guidelines: limited list lengths, limited text length, no arbitrary custom drawing while the vehicle can be in motion. It's also what makes one codebase portable across wildly different head units, since every host renders the same template contract instead of your raw UI code.

Back to Bluetooth, Sensors & Auto