Good Day. My name is David, and I’m a learning Android Developer who creates new projects to learn new concepts. This post aims to share my learning experience and point out interesting points in my learning curve.
I decided to learn about Bluetooth recently, so the app is my shot at it. I created the app using the MVVM pattern provided by Android Jetpack because all apps need structure. I also used LiveData because of the need to render changes to the UI indirectly without having to worry about lifecycle events. I learned the major Bluetooth concepts from the Android Developers website.
The primary and only feature of the app right now is the one-to-one chat. I plan on adding more later, but I think it has served its’ purpose of helping me learn.
The critical object in anything Bluetooth related is the BluetoothAdapter, which is gotten through this snippet:
val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter()
This object may be null, and if it is, it means the device doesn’t support Bluetooth. You can use this to alert the user if they’re missing out on any key features of your app.
From there, you’ll want to check if Bluetooth is turned on, and if not, request for it to be turned on:
val BT_REQUEST_CODE = 100
if(bluetoothAdapter?.isEnabled == false) val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, BT_REQUEST_CODE)
>
From here, you’ll want to do one of two things: discover or be discovered. Discovery is necessary for unpaired devices. Bluetooth devices are defined by the BluetoothDevice class. You can query your paired devices using:
val pairedDevices : Set = bluetoothAdapter?.bondedDevices
Paired devices aren’t necessarily connected, though, so the discovery process is still important.
To discover devices, you need to create a BroadcastReceiver and an IntentFilter with the BluetoothDevice.ACTION_FOUND action:
val filter : IntentFilter = IntentFilter(BluetoothDevice.ACTION_FOUND)
val receiver = object : BroadcastReceiver() override fun onReceive(context: Context?, intent: Intent?) when(intent?.action) BluetoothDevice.ACTION_FOUND -> val device :BluetoothDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
viewModel.addDevice(BluetoothDeviceGeneric(device, null))
>
>
>
Then in the onCreate() method of your Activity/onCreateView() method of your Fragment:
override fun onCreate(savedInstanceState: Bundle?) .
registerReceiver(receiver, filter)
>
You initiate the discovery process with startDiscovery() . In my case I kept the list of devices found in an ArrayList in the ViewModel and exposed it through LiveData:
private var availableDevices = ArrayList()
private var _availableDevicesLiveData = MutableLiveData()
val availableDevicesLiveData : LiveData = _availableDevicesLiveData
fun addDevice(device: BluetoothDeviceGeneric) if(!availableDevices.any predicate-> predicate.device.address.equals(device.device.address)>
&& device.device.bluetoothClass.majorDeviceClass == BluetoothClass.Device.Major.PHONE) availableDevices.add(device)
_availableDevicesLiveData.value = availableDevices
>
>
I’ll break it down:
MutableLiveData is a subclass of LiveData that exposes its’ postValue() method to trigger an update that will be observed in the UI.
BluetoothClass represents the general characteristics of a device. Its’ nested class, BluetoothClass.Device defines constants that represent a combination of major and minor device components. That class defines another nested class, BluetoothClass.Device.Major , which defines constants for all major devices, including PHONE , and that’s the only class of devices I want to deal with
BluetoothDeviceGeneric is simply a data class I use to map a device to a socket:
data class BluetoothDeviceGeneric(var device: BluetoothDevice,
var socket: BluetoothSocket? = null,
var connecting : Boolean = false) >
That’s all for I have to say concerning discovery.
Discoverability is less complex. You enable discoverability with startActivity() , and you can use the BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION extra field to set the duration of the discoverability period:
val discoverableIntent: Intent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE).apply putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300)
>
startActivity(discoverableIntent)
That’s it for discovery. The next step is establishing a connection, which is more involved.
Devices can act as either clients or servers, and you can choose to make it one-sided, meaning that one device solely acts as the server, and the other a client, or it could be two-sided, meaning that both devices can act as a server, then either one could connect to the other. I’ll talk about what’s needed on both ends first.
An established connection is represented by a BluetoothSocket . It contains both an InputStream and an OutputStream for sending raw bytes across the connection. In order to connect, you need a UUID to uniquely identify the server and the client needs the UUID to connect to it. This means that you can’t use randomUUID() like I initially thought, but instead get a UUID from the web with a tool like UUID Generator, save it in your code, and assign a variable to it using fromString() .
val uuid = UUID.fromString("38ec8b11-da4d-4ee7-813a-f0d4eb014ac6")
To start a connection, the server has to be started first, using listenUsingRfcommWithServiceRecord() . This method takes a String and a UUID . The String can be anything, maybe the name of your app. This method returns a BluetoothServerSocket , which in turn accepts incoming client connections with the accept() method. A very important point to note here is that this is a blocking call, meaning it will block the current thread, and so we’ll have to spawn a new Thread to use it. When our server socket object does accept successfully, it returns a BluetoothSocket object and from there we can close the server:
Thread bluetoothAdapter?.startDiscovery()
val server : BluetoothServerSocket? = bluetoothAdapter?.listenUsingRfcommWithServiceRecord("Bluetooth", uuid)
var loop = true
while (loop) Log.d("Server", "Printing")
val bluetoothSocket: BluetoothSocket? = try server?.accept()
> catch (e: IOException) Log.e("ServerSocket", "Socket's accept() failed", e)
e.printStackTrace()
null
>
bluetoothSocket?.let binder.socketConnectedCallback?.onSocketConnected(it)
>
server?.close()
loop = false
>
>.start()
The client connects to the server in a similar fashion, but calling createRfcommSocketToServiceRecord() , on the client’s BluetoothDevice object that we discovered. The method accepts a UUID , it has to be the one mentioned earlier for the connection to be made:
Thread val bluetoothSocket = device.device.createRfcommSocketToServiceRecord(uuid)
bluetoothAdapter?.cancelDiscovery()
val succeeded = try bluetoothSocket?.connect()
device.socket = bluetoothSocket
true
> catch (e: Exception) e.printStackTrace()
false
>
Handler(Looper.getMainLooper()).post onFinished.invoke(succeeded)
>
>.start()
onFinished is a lambda function passed to the method that spawns this thread. My implementation simply displays a Toast on error and starts the ChatActivity on success.
The main chat itself is relatively simple. I spawned one thread for reading from the InputStream since read() is a blocking call. I didn’t spawn one for writing to the OutputStream with write() because I didn’t notice any significant blocking behaviour while testing it out, but bear in mind that a thread has to be spawned to prevent blocking the UI thread. I created a callback interface called MessageHandler to handle reads with a buffer:
interface MessageHandler fun onBluetoothMessage(socket: BluetoothSocket, message: String)
>
I subclassed Thread so I could pass in a few constructor objects to be used, including the MessageHandler object:
private class ReadThread(var socket: BluetoothSocket, var handler: MessageHandler) : Thread() override fun run() val buffer = ByteArray(1024)
var byteCount: Int
val inStream: InputStream = socket.inputStream
var textBuffer = StringBuffer()
while (true) byteCount = try inStream.read(buffer)
> catch (e: IOException) e.printStackTrace()
break
>
textBuffer.append(String(buffer, 0, byteCount))
if(inStream.available() == 0) handler.onBluetoothMessage(socket, textBuffer.toString())
textBuffer = StringBuffer()
>
>
>
>
I added the if statement to prevent very long messages from making the message to be broken into multiple parts if the buffer was too small.
Sending messages wasn’t too stressful in comparison:
fun sendMessage(socket: BluetoothSocket, bytes: ByteArray, onFinished : (success : Boolean) -> Unit) socket.let val outStream = it.outputStream
var success = false
try outStream?.write(bytes)
success = true
> catch (e: Exception) e.printStackTrace()
Log.d("Write Bytes", "Error Occured while sending data")
> finally onFinished.invoke(success)
>
>
>
Note that this onFinished is different from the one mentioned earlier.
The UI is nearly a whole other topic. RecyclerView and XML layouts have lots of tutorials, and I didn’t bother making it look clean either, just functional. Here are a few screenshots of what I ended up with after everything:
That’s all I have to say for this tutorial. If you have any form of feedback, please let me know in the responses.
Edit: Here’s the GitHub repository for this project: link