Creating a Simple Bluetooth Chat App in 7 Days
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.
About the App
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.
Features
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.
Bluetooth Basics
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<BluetoothDevice> = bluetoothAdapter?.bondedDevices
Paired devices aren’t necessarily connected, though, so the discovery process is still important.
Discovery
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<BluetoothDeviceGeneric>()
private var _availableDevicesLiveData = MutableLiveData<ArrayList<BluetoothDeviceGeneric>>()
val availableDevicesLiveData : LiveData<ArrayList<BluetoothDeviceGeneric>> = _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
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.
Connections
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.
Client Connections
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()
Server Connections
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.
One-to-one Chat
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
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:
Conclusion
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