BLEService: Powering Bluetooth Communication
The BLEService class is the core engine that powers Bluetooth communication in the Vortex app. It runs in the background as a Service and is responsible for:
Sending commands to the connected BLE device.
Handling communication through the proper characteristic.
Interacting with Android’s Bluetooth stack in a safe and consistent way.
Managing reliability, retries, and timing to avoid disconnects and command drops.
Why This Class Is So Important
Up until now, we’ve focused on the user interface - displaying buttons, sliders, and connection status. But none of that would matter without a reliable and well-tested Bluetooth backend, and that’s exactly what BLEService.java provides.
This service is where actual commands are sent to the LED controller (such as brightness changes or animation updates), and where timing, payload formatting, and device communication all come together.
BLEService - The Core of Your Bluetooth Engine
The BLEService.java file powers all Bluetooth communication in the Vortex App. It stays running in the background and:
Manages connections and disconnections
Sends communication to the LED controller
Keeps the app connected to the LED controller
Keeps the app connected even when it’s not in the foreground
Communicates status back to the UI via LocalBroadcastManager
1. Creating BLEService Class
We begin by creating a new class that extends Service and includes the necessary onCreate() method.
1.1 Class Declaration and onCreate()
public class BLEService extends Service {
// Debug tag
private static final String TAG = "BLEService";
// Bluetooth adapter
private BluetoothAdapter bluetoothAdapter;
@Override
public void onCreate() {
super.onCreate();
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
Log.d(TAG, "BLEService created");
}
}
1.2. Explanation
This class extends Service, allowing it to run in the background without UI.
onCreate() is called when the service is first created.
BluetoothAdapter.getDefaultAdapter() gets the system’s default Bluetooth controller.
The TAG is used for logging messages in Logcat.
2. Running BLEService as a Foreground Service
Android requires that long-running background tasks like Bluetooth communication be run as a foreground service - meaning they must display a notification and get higher system priority.
This ensures that the service isn’t killed unexpectedly while your app is in use.
To do this, we’ll need two helper methods:
createNotificationChanell() - required for Android 8.0+
startForegroundServiceWithCompability() - starts the service in foreground mode with a notification.
2.1. Creating the Notification Channel
Before we start the foreground service, we must register a notification channel (on Android 8.0 and above):
// Required for Android 8.0 (API 26) and above
private static final String CHANNEL_ID = "BLEServiceChannel"; // implement as class variable
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Create a system-level channel with ID and name
NotificationChannel serviceChannel = new NotificationChannel(
CHANNEL_ID,
"Vortex Service Channel",
NotificationManager.IMPORTANCE_DEFAULT
);
// Register it with the system
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.createNotificationChannel(serviceChannel);
}
}
}
2.2. Explanation
CHANNEL_ID is a constant used to uniquely identify the notification channel.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O checks whether the device is running Android 8.0 or higher, where notification channels are required.
NotificationManager.createNotificationChannel(…) registeres the channel with Android’s system UI.
3. Building the Notification
Now we’ll build a basic notification that will be shown as long as the service is running.
// Creates the persistent notification shown by the service
private Notification createNotification(String contentText) {
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("BLE Service")
.setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) // Default Bluetooth icon
.setPriority(NotificationCompat.PRIORITY_LOW)
.build();
}
3.1 Explanation
This uses NotificationCompact.Builder to build a cross-compatible notification.
It shows a Bluetooth icon and a simple message like “BLE Service is running…”.
The CHANNEL_ID ensures it’s registered under the correct system channel.
This notification will be used when the service starts in the next step.
4. Starting the Foreground Service
Now that we can create a notification, let’s start the service in foreground mode using that notification.
// Starts the service as a foreground service with a notification
private void startForegroundServiceWithCompatibility() {
Notification notification = createNotification("BLE Service is running...");
// Android 12+ requires a foreground service type
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION);
} else {
startForeground(1, notification);
}
}
4.1 Explanation
startForeground(…) tells Android that this service is important and should not be killed.
For Android 12.0+ (VERSION_CODES.S), we must also specify the type of foreground service (FOREGROUND_SERVICE_TYPE_LOCATION) to comply with new security rules.
This is mandatory for using Bluetooth scanning or connections in the background.
5. Update onCreate()
Once the above helper methods are defined, we can safely initialize the service in onCreate()
@Override
public void onCreate() {
super.onCreate();
// implement as class variable
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
createNotificationChannel();
startForegroundServiceWithCompatibility();
Log.d(TAG, "BLEService created");
}
5. Receiving Commands with onStartCommand()
The onStartCommand() method allows your app to send instructions to the BLEService while it’s running.
This is how any activity can tell the service:
“Connect to this device”
“Send a command”
“Disconnect now”
“Forget this device completely”
These actions are passed in via an Intent using a key named “management_BLEService”.
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && intent.hasExtra("management_BLEService")) {
String management = intent.getStringExtra("management_BLEService");
if ("DISCONNECT".equals(management) && intent.hasExtra("device_address")) {
String deviceAddress = intent.getStringExtra("device_address");
disconnectFromDevice(deviceAddress);
} else if ("CONNECT".equals(management) && intent.hasExtra("device_address")) {
String deviceAddress = intent.getStringExtra("device_address");
Log.d(TAG, "Connecting to device: " + deviceAddress);
ConnectedDeviceManager.getInstance().setDeviceAddress(deviceAddress);
connectToDevice(deviceAddress);
} else if ("DRIVER_COMMAND".equals(management) && intent.hasExtra("command")) {
int command = intent.getIntExtra("command", -1);
sendCommand(command);
} else if ("FORGET".equals(management) && intent.hasExtra("device_address")) {
String deviceAddress = intent.getStringExtra("device_address");
forgetDevice(deviceAddress);
}
}
return START_STICKY;
}
6. Connecting to a Device - connectToDevice()
This method is called when the app tells the service to connect to a specific BLE device. It receives a MAC address and:
Checks if Bluetooth permissions are granted
Disconnects any previously connected device
Connects to the new device using BluetoothGatt
Sets up timeouts and status notifications
6.1. connectToDevice(deviceAddress)
// Called when management_BLEService = "CONNECT"
@SuppressLint("MissingPermission")
private void connectToDevice(String deviceAddress) {
Log.d(TAG, "Trying to connect to device: " + deviceAddress);
// 1. Check permissions first
if (!hasBluetoothPermission()) {
Log.e(TAG, "Permission not granted");
return;
}
// 2. Get the device object
BluetoothDevice device = bluetoothAdapter.getRemoteDevice(deviceAddress);
if (device == null) {
Log.e(TAG, "Device not found");
return;
}
// 3. If a different device was already connected, disconnect it
if (bluetoothGatt != null) {
if (!bluetoothGatt.getDevice().getAddress().equals(deviceAddress)) {
Log.d(TAG, "Disconnecting from previous device");
bluetoothGatt.disconnect();
bluetoothGatt.close();
bluetoothGatt = null;
ConnectedDeviceManager.getInstance().setDeviceStatus(deviceAddress, false);
isDeviceConnected(false);
ConnectedDeviceManager.getInstance().setDeviceAddress(null);
} else {
Log.d(TAG, "Already connected to this device");
notifyConnectedUI(deviceAddress);
return;
}
}
// 4. Start a new connection
bluetoothGatt = device.connectGatt(this, false, gattCallback);
// 5. Notify UI immediately while discovering services
updateNotification("Connecting to " + device.getName());
sendStatusUpdate("Connecting to " + device.getName());
// 6. Set a timeout in case connection fails silently
handler.postDelayed(() -> {
if (bluetoothGatt != null && animationCharacteristic == null) {
Log.e(TAG, "Connection timed out");
handleConnectionTimeout(deviceAddress);
}
}, 5000);
}
5.2 Explanation
Permissions are always checked first - if they are missing, we log and exit early.
If there’s already an open BluetoothGatt session for a different device, it’s disconnected to avoid bugs or duplicate sessions.
We start the actual connection using connectGatt(…), which is Android’s API for BLE.
We immediately update the notification and status, so the UI feels responsible.
If the connection hangs (e.g., the device is offline), we trigger a 5-second timeout and show a disconnect message.
The helper methods, like hasBluetoothPermissions(), updateNotification(), and sendStatusUpdate(), were already introduced earlier or will be explained as needed.
7. Handling Connection Timeouts - handleConnectionTimeouts()
Sometimes, the connection fails silently - for example, if the device is out of range or unresponsive. We don’t want to leave the app hanging in a “connecting…” state.
This helper method gets called if the BLE service doesn’t respond within 5 seconds of calling connectGatt(…).
7.1. handleConnectionTimeout()
// Called when connection discovery takes too long
private void handleConnectionTimeout(String deviceAddress) {
// Save device as disconnected
PreferencesManager.getInstance(this).saveDevice(
deviceAddress,
PreferencesManager.getInstance(this).getDeviceName(deviceAddress),
PreferencesManager.getInstance(this).getDeviceCharFlag(deviceAddress),
"Disconnected"
);
Log.e(TAG, "Connection timed out");
sendStatusUpdate("Connect to a device");
updateNotification("Connect to a device");
Connected = false;
ConnectedDeviceManager.getInstance().setDeviceStatus(deviceAddress, false);
isDeviceConnected(false);
ConnectedDeviceManager.getInstance().setDeviceAddress(null);
ConnectedDeviceManager.getInstance().setDeviceName(null);
// Clean up connection object
bluetoothGatt.disconnect();
bluetoothGatt.close();
bluetoothGatt = null;
}
7.2. Explanation
The method is triggered after 5 seconds if no characteristic has been found.
We mark the device as disconnected in PreferenceManager and ConnectedDeviceManager.
sendStatusUpdate() and updateNotification() immediately tell the user that the connection failed.
Finally, we safely disconnect and clean up the bluetoothGatt object to free resources.
This ensures the service never stays stuck in a partially connected state.
8. Disconnecting from a Device - disconnectFromDevice()
This method is used to gracefully disconnect from a currently connected BLE device. It ensures all state and memory is cleaned up and the UI gets updated properly.
The app uses it when:
The user manually disconnects
The connection is no longer valid
The device is being switched
8.1. disconnectFromDevice(deviceAddress)
@SuppressLint("MissingPermission")
private void disconnectFromDevice(String deviceAddress) {
// Permission check
if (!hasBluetoothPermission()) {
Log.e(TAG, "Permission not granted");
return;
}
Log.d(TAG, "Trying to disconnect from device: " + deviceAddress);
// If no GATT connection exists, mark device as disconnected anyway
if (bluetoothGatt == null) {
Log.e(TAG, "No active connection found");
Connected = false;
ConnectedDeviceManager.getInstance().setDeviceStatus(deviceAddress, false);
PreferencesManager.getInstance(this).saveDevice(
deviceAddress,
PreferencesManager.getInstance(this).getDeviceName(deviceAddress),
PreferencesManager.getInstance(this).getDeviceCharFlag(deviceAddress),
"Disconnected"
);
isDeviceConnected(false);
return;
}
// If we are connected to this specific device
if (bluetoothGatt.getDevice().getAddress().equals(deviceAddress)) {
Log.d(TAG, "Disconnecting from device: " + deviceAddress);
String deviceName = PreferencesManager.getInstance(this).getDeviceName(deviceAddress);
if (deviceName == null) {
deviceName = bluetoothGatt.getDevice().getName();
}
Connected = false;
ConnectedDeviceManager.getInstance().setDeviceStatus(deviceAddress, false);
PreferencesManager.getInstance(this).saveDevice(
deviceAddress,
deviceName,
PreferencesManager.getInstance(this).getDeviceCharFlag(deviceAddress),
"Disconnected"
);
bluetoothGatt.disconnect();
bluetoothGatt.close();
bluetoothGatt = null;
isDeviceConnected(false);
ConnectedDeviceManager.getInstance().setDeviceAddress(null);
ConnectedDeviceManager.getInstance().setDeviceName(null);
sendStatusUpdate("Connect to a device");
updateNotification("Connect to a device");
} else {
Log.e(TAG, "No active connection found for address " + deviceAddress);
}
}
8.2. Explanation
If we’re not connected (bluetoothGatt == null), we still update the saved device’s status.
IF the connected device matches the provided address, we:
Disconnect the connection
Clean up memory (bluetoothGatt == null)
Save the device as “Disconnected” in Preferences
Broadcast the new connection status to the app
All updates are visible immediately to the user via sendStatusUpdate() and updateNotofication().
This method ensures that even if the user switches screens or backgrounded the app, Bluetooth resources are released safely and predictably.
9. Forgetting a Device - forgetDevice()
Sometimes, the user wants to remove a saved BLE device from their app - like when switching to a new LED strip or cleaning old data. The forgetDevice() method handles this functionality.
This is especially useful when:
You no longer trust the device.
You want to clear connection history.
You’re switching to a new controller.
9.1. forgetDevice(deviceAddress)
@SuppressLint("MissingPermission")
private void forgetDevice(String deviceAddress) {
// Permission check
if (!hasBluetoothPermission()) {
Log.e(TAG, "Permission not granted");
return;
}
Log.d(TAG, "Trying to forget device: " + deviceAddress);
// If no connection exists, mark device as disconnected
if (bluetoothGatt == null) {
Log.e(TAG, "No active connection found");
Connected = false;
isDeviceConnected(false);
ConnectedDeviceManager.getInstance().setDeviceStatus(deviceAddress, false);
return;
}
// If we are connected to the device being forgotten
if (bluetoothGatt.getDevice().getAddress().equals(deviceAddress)) {
Log.d(TAG, "Forgetting device: " + deviceAddress);
Connected = false;
isDeviceConnected(false);
ConnectedDeviceManager.getInstance().setDeviceStatus(deviceAddress, false);
ConnectedDeviceManager.getInstance().setDeviceAddress(null);
bluetoothGatt.disconnect();
bluetoothGatt.close();
bluetoothGatt = null;
Log.d(TAG, "Device Forgotten");
sendStatusUpdate("Connect to a device");
updateNotification("Connect to a device");
} else {
Log.e(TAG, "No active connection found for address " + deviceAddress);
}
}
9.2 Explanation
This method behaves similarly to disconnectFromDevice(), but it’s focuses on completely cleaning the device state:
If we aren’t connected (bluetoothGatt == null), we just update the stored status.
If we are connected to the target:
We disconnect from it.
Remove all references to it.
Clear its memory in ConnectedDeviceManager.
Broadcast a message to update the UI and notification.
Important note: This does not delete it from SharedPreferences, but it clears runtime connections.
Later in the guide (when we build the device list), we’ll show how to fully remove if from stored memory as well.
10. Connecting to a Device()
This method is the heart of your BLE workflow. It’s triggered when the user selects a device to connect to. It attempts to connect, prepares for communication, handles already-connected states, and also includes a timeout in case something goes wrong.
10.1. connectToDevice(deviceAddress)
@SuppressLint("MissingPermission")
private void connectToDevice(String deviceAddress) {
Log.d(TAG, "Trying to connect to device: " + deviceAddress);
// Permission check
if (!hasBluetoothPermission()) {
Log.e(TAG, "Permission not granted");
return;
}
// Retrieve BluetoothDevice instance
BluetoothDevice device = bluetoothAdapter.getRemoteDevice(deviceAddress);
if (device == null) {
Log.e(TAG, "Device not found");
return;
}
// Handle existing connection (same or different device)
if (bluetoothGatt != null) {
if (bluetoothGatt.getDevice().getAddress().equals(deviceAddress)) {
Log.d(TAG, "Already connected to device: " + deviceAddress);
Connected = true;
ConnectedDeviceManager.getInstance().setDeviceName(device.getName());
ConnectedDeviceManager.getInstance().setDeviceAddress(deviceAddress);
ConnectedDeviceManager.getInstance().setDeviceStatus(deviceAddress, true);
PreferencesManager.getInstance(this).saveDevice(
deviceAddress,
PreferencesManager.getInstance(this).getDeviceName(deviceAddress),
PreferencesManager.getInstance(this).getDeviceCharFlag(deviceAddress),
"Connected"
);
updateNotification("Connected to " + PreferencesManager.getInstance(this).getDeviceName(deviceAddress));
sendStatusUpdate("Connected to " + PreferencesManager.getInstance(this).getDeviceName(deviceAddress));
isDeviceConnected(true);
return;
} else {
Log.d(TAG, "Disconnecting from previous device");
Connected = false;
bluetoothGatt.disconnect();
bluetoothGatt.close();
bluetoothGatt = null;
ConnectedDeviceManager.getInstance().setDeviceStatus(deviceAddress, false);
isDeviceConnected(false);
ConnectedDeviceManager.getInstance().setDeviceAddress(null);
}
}
// Begin new connection
Log.d(TAG, "Connecting to device: " + device.getName() + " (" + deviceAddress + ")");
bluetoothGatt = device.connectGatt(this, false, gattCallback);
if (bluetoothGatt == null) {
Log.e(TAG, "Failed to connect to device");
return;
}
ConnectedDeviceManager.getInstance().setDeviceName(device.getName());
updateNotification("Connecting to " + PreferencesManager.getInstance(this).getDeviceName(deviceAddress));
sendStatusUpdate("Connecting to " + PreferencesManager.getInstance(this).getDeviceName(deviceAddress));
// Timeout fallback if device doesn't respond
handler.postDelayed(() -> {
if (bluetoothGatt != null && animationCharacteristic == null) {
PreferencesManager.getInstance(this).saveDevice(
deviceAddress,
PreferencesManager.getInstance(this).getDeviceName(deviceAddress),
PreferencesManager.getInstance(this).getDeviceCharFlag(deviceAddress),
"Disconnected"
);
Log.e(TAG, "Connection timed out");
sendStatusUpdate("Connect to a device");
updateNotification("Connect to a device");
Connected = false;
ConnectedDeviceManager.getInstance().setDeviceStatus(deviceAddress, false);
isDeviceConnected(false);
ConnectedDeviceManager.getInstance().setDeviceAddress(null);
ConnectedDeviceManager.getInstance().setDeviceName(null);
bluetoothGatt.disconnect();
bluetoothGatt.close();
}
}, 5000);
}
10.2. Explanation
Permission check: Ensures Bluetooth permissions are granted.
Get device: Uses the adapter to create a BluetoothDevice object from the address.
Handle reconnections:
If already connected to the same device, skip reconnection.
If connected to a different one, disconnect and reset the GATT.
Begin connection: uses connectGatt(…) with callback.
Update metadata:
Sets device name and address in ConnectedDeviceManager.
Sends UI updates and notification.
Timeout protection: If no characteristic is discovered after 5 seconds, mark the connection as failed.
11. Discovering Services
After connecting to a device, we need to discover its available services and characteristics. This is where we find the characteristic responsible for controlling the LEDs.
11.1. BluetoothGattCallback - onServicesDiscovered()
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (status == BluetoothGatt.GATT_SUCCESS) {
BluetoothGattService service = gatt.getService(SERVICE_UUID);
if (service != null) {
Log.d(TAG, "Service found with UUID: " + service.getUuid());
// Look for our target characteristic inside this service
for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) {
if (characteristic.getUuid().equals(LEDS_UUID)) {
animationCharacteristic = characteristic;
Log.d("ANIMATION", "Writable characteristic found: " + animationCharacteristic.getUuid());
break;
}
}
// Error message if characteristic wasn't found
if (animationCharacteristic == null) {
Log.e(TAG, "No writable characteristic found with UUID: " + LEDS_UUID);
}
} else {
Log.e(TAG, "Service not found with UUID: " + SERVICE_UUID);
}
} else {
Log.e(TAG, "Service discovery failed with status: " + status);
}
}
11.2 Explanation
Triggered after connection: Android calls this method when all services on the BLE device have been discovered.
SERVICE_UUID: We look for the specific service we declared earlier in SERVICE_UUID. This is the group where the LED controller lives.
Loop through characteristics: Inside the service, we iterate through its characteristics to find the one matching LEDS_UUID.
Save the writeable characteristic: Once found, we store it in animationCharacteristic so it can be used to send LED commands later.
Fallback logging: If we can’t find the correct service or characteristic, we log the error to help diagnose issues.
12. Writing Commands to the Device
Once we’ve discovered the writable characteristic, we can send commands to control the LEDs. This method is called by Android after a write operation is attempted.
12.1 onCharacteristicWrite()
@Override
public void onCharacteristicWrite(
BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic,
int status
) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.d(TAG, "Command sent successfully");
} else {
Log.e(TAG, "Failed to send command with status: " + status);
}
}
12.2 Explanation
This callback monitors whether the write operation (from sendCommand()) succeeded or failed:
GATT_SUCCESS: This constant means the data was successfully written to the device.
Failure Case: If the command wasn’t delivered, we log the failure with the BLE status code.
While this doesn’t directly trigger any UI change, it’s extremely useful for debugging. If your app isn’t working as expected, start by checking whether onCharacteristicWrite() is being called successfully.
13. Sending Commands
This command is called every time we want to sen a brightness or animation command to the BLE device. It handles:
Rate limiting to avoid overloading the device.
Writing a single byte value to the device’s characteristic.
13.1 sendCommand(command)
private void sendCommand(int command) {
long currentTime = System.currentTimeMillis();
// Rate limiting: allow COMMAND 12 more frequently than others
if ((command == 12 && (currentTime - lastCommand12Time) >= COMMAND_12_DELAY) ||
(command != 12 && (currentTime - lastCommandTime) >= COMMAND_DELAY)) {
if (command == 12) {
lastCommand12Time = currentTime;
} else {
lastCommandTime = currentTime;
}
// Safety: Check for Bluetooth permission
if (!hasBluetoothPermission()) {
Log.e(TAG, "Permission not granted");
return;
}
// If the writable characteristic is available, send the command
if (animationCharacteristic != null) {
animationCharacteristic.setValue(new byte[] { (byte) command });
boolean success = bluetoothGatt.writeCharacteristic(animationCharacteristic);
if (success) {
Log.d(TAG, "Command sent: " + command);
} else {
Log.e(TAG, "Failed to send command: " + command);
}
} else {
Log.e(TAG, "Writable characteristic is null. Cannot send command.");
}
} else {
Log.d(TAG, "Command: " + command + " not sent. Delay not reached.");
}
}
13.2 Explanation
Rate Limiting:
It checks if enough time has passed since the last command.
command == 12 is allowed more frequently (used for indicating the next command is a Brightness value).
This prevents flooding the BLE device with too many requests.
Permission Check:
hasBluetoothPermission() ensures the app has permission before accessing BLE operations.
Sending the Command:
The command is cast to a single byte and written to the device via animationCharacteristic.
Success or failure is logged for debugging.
Characteristic Check:
If animationCharacteristic is null, we log a failure. This can happen if onServicesDiscovered() hasn’t found the correct characteristic yet.