CS 491 Lecture 23 – BThwack
Agenda
BThwack
We were going to talk about incorporating a two-player Bluetooth mode into Bluecheckers, but it was getting out of hand. Instead, I looked back at the last of apps you folks expressed interest in back at the beginning of the semester. And there is was. Whack-a-mole. But not just any Whack-a-mole, Bluetooth-enabled Whack-a-mole. Or BThwack. We’ll have one device be the mole and one by the hammer.
Bluetooth
The Bluetooth standard was developed in the mid-90s as a mechanism for short-range wireless communications. It’s used to create personal area networks. Interestingly, it was named after a Danish king.
The view
Let’s start on the UI side of things. We’re not going to create a pretty-looking app today. Let’s just draw circles where the hammer and mole are at.
public class BThwackView extends View {
private static final float RADIUS = 30.0f;
private boolean hasMole;
private float moleX;
private float moleY;
private Paint molePaint;
private boolean hasHammer;
private float hammerX;
private float hammerY;
private Paint hammerPaint;
}
MainActivity
Our MainActivity will just make sure Bluetooth is enabled and allow the players to choose whether they are the hammer or the mole. In onResume, we’ll grab the device’s BluetoothAdapter with:
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
If the adapter is null, we don’t have Bluetooth. If the adapter is not enabled, we can issue an Intent to get it enabled:
Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
startActivityForResult(intent, REQUEST_ENABLE_BLUETOOTH);
We’ll get a tap when the enabling intent is handled:
@Override
protected void onActivityResult(int requestCode,
int resultCode,
Intent data) {
if (requestCode == REQUEST_ENABLE_BLUETOOTH) {
if (resultCode != RESULT_OK) {
Toast.makeText(this, "Bluetooth was not enabled.", Toast.LENGTH_LONG).show();
} else {
showOptions();
}
} else {
super.onActivityResult(requestCode, resultCode, data);
}
}
If the adapter is both extent and enabled, we can simple ask them what they want to be. Depending on their choice, we will spawn a new activity. Bluetooth networking is not unlike other networking mechanisms. One device must step forward to be the server, and one the client. Here we’ll have the hammer be the server, and the mole the client.
Permissions
Bluetooth requires permissions:
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
Hammer
Before we get start, let’s just flesh out the instance variables we’ll need:
public class HammerActivity extends Activity {
private static final int REQUEST_DISCOVERY = 83;
private Handler handler;
private BluetoothAdapter adapter;
private ProgressDialog progressDialog;
private BTReceiver btReceiver;
private BThwackView view;
private TalkThread talkThread;
...
}
The hammer./server activity’s understands its existence to be:
- Let moles see me.
- Wait for a mole to connect.
- Try and hit connected mole forever.
Being visible to moles is called being “discoverable” in Bluetooth terms. Generally we don’t want our device to be visible to just anyone. We must issue a request to be discoverable for a fixed amount of time, not more than 300 seconds:
Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300);
startActivityForResult(intent, REQUEST_DISCOVERY);
We’ll get a tap if it gets approved:
@Override
protected void onActivityResult(int requestCode,
int resultCode,
Intent data) {
if (requestCode == REQUEST_DISCOVERY) {
if (resultCode == RESULT_CANCELED) {
makeDiscoverable();
} else {
new ServerThread().start();
}
} else {
super.onActivityResult(requestCode, resultCode, data);
}
}
Once discoverable, we’ve got to wait for a client. We do so using a BluetoothServerSocket instance, but since the waiting blocks, we’ve got to do so in another thread:
private class ServerThread extends Thread {
private BluetoothServerSocket serverSocket;
public ServerThread() {
try {
serverSocket = adapter.listenUsingRfcommWithServiceRecord("BThwack", MainActivity.BTHWACK_UUID);
} catch (IOException e) {
}
}
...
}
That BTHWACK_UUID has to do with the way Bluetooth discovers services. We are offering a hammer service, which must be identified by a reasonably unique ID. In this case, I generated one from a browser-based generator:
static final UUID BTHWACK_UUID = UUID.fromString("56bd2ce0-20ea-11e1-bfc2-0800200c9a66");
Before we add the waiting code, let’s also pop up a progress dialog so the user knows what’s going on. Blocking calls require some feedback to the user, you know?
handler.post(new Runnable() {
@Override
public void run() {
progressDialog = ProgressDialog.show(HammerActivity.this, "", "Awaiting your mole...", true);
progressDialog.setCancelable(true);
progressDialog.setOnCancelListener(new DialogInterface.OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
finish();
}
});
}
});
Okay, now let’s wait for a client:
public void run() {
BluetoothSocket socket = null;
try {
while (socket == null) {
Log.d("BLUE", "going to accept");
try {
socket = serverSocket.accept();
talkThread = new TalkThread(socket);
talkThread.start();
handler.post(new Runnable() {
@Override
public void run() {
progressDialog.dismiss();
}
});
Log.d("BLUE", "accepted");
} catch (IOException e) {
Log.d("BLUE", "accept failed");
break;
}
}
} finally {
try {
serverSocket.close();
} catch (IOException e) {
Log.d("BLUE", "got connection, couldn't close serverSocket");
}
}
}
public void cancel() {
try {
serverSocket.close();
} catch (IOException e) {
Log.d("BLUE", "thread cancelled, couldn't close serverSocket");
}
}
The run method waits for client with accept(). Once accepted, we’re done with the server socket. We add a cancel method so that we can clean up the resources in onPause, for example. Now, we’ve got to listen for the mole popping out of holes. We make a new thread and let this one finish, mostly for the sake of organizing our code:
private class TalkThread extends Thread {
private BluetoothSocket socket;
private DataInputStream in;
private DataOutputStream out;
public TalkThread(BluetoothSocket socket) {
this.socket = socket;
try {
in = new DataInputStream(socket.getInputStream());
out = new DataOutputStream(socket.getOutputStream());
} catch (IOException e) {
}
}
...
Networking can’t be done without establish a protocol. What’s coming down the line? In this case, when the mole taps the screen, we’ll expect back two floats in [0, 1] representing the normalized mole coordinates (NMC). If the values are negative, the mole has gone underground. To read and write floats, we create some Data*putStreams. The raw *putStreams given by the socket only handle byte array I/O.
Now for the real listening:
public void run() {
while (true) {
try {
final float x = in.readFloat();
final float y = in.readFloat();
handler.post(new Runnable() {
@Override
public void run() {
view.setMole(x, y);
}
});
} catch (IOException e) {
break;
}
}
handler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(HammerActivity.this, "Connection was lost", Toast.LENGTH_LONG).show();
finish();
}
});
}
I’m not sure if that last bit needs to be run on the UI thread or not. Better safe than sorry, eh?
The kind Android folks that the reading should be the primary activity of the communication thread. Writing, on the other hand, tends not to block and apparently has few synchronization issues, so we can make the write a public method of the thread:
public void write(float x, float y) {
try {
Log.d("BLUE", "writing " + x + "," + y);
out.writeFloat(x);
out.writeFloat(y);
} catch (IOException e) {
}
}
public void cancel() {
try {
socket.close();
} catch (IOException e) {
}
}
I threw in a cancel there too which we should call on gameover.
Okay, now when the hammer touches the screen, let’s draw it locally and communicate the fact remotely:
@Override
public boolean onTouchEvent(MotionEvent event) {
Log.d("BLUE", "tap");
if (talkThread != null) {
if (event.getAction() == MotionEvent.ACTION_DOWN || event.getAction() == MotionEvent.ACTION_MOVE) {
float x = event.getX() / view.getWidth();
float y = event.getY() / view.getHeight();
view.setHammer(x, y);
talkThread.write(x, y);
} else if (event.getAction() == MotionEvent.ACTION_UP) {
view.unsetHammer();
talkThread.write(-1, -1);
}
}
return true;
}
Mole
The MoleActivity starts off looking for hammer services. This is called discovery. We’ll pop up a dialog with an adapter for all the devices we see right away:
public class MoleActivity extends Activity {
private ArrayAdapter<Device> devices;
private BluetoothAdapter adapter;
private BTReceiver btReceiver;
private TalkThread talkThread;
private BThwackView view;
private Handler handler;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
view = (BThwackView) findViewById(R.id.canvas);
handler = new Handler();
adapter = BluetoothAdapter.getDefaultAdapter();
devices = new ArrayAdapter<Device>(this, android.R.layout.simple_spinner_item);
btReceiver = new BTReceiver();
IntentFilter filter = new IntentFilter();
filter.addAction(BluetoothDevice.ACTION_FOUND);
registerReceiver(btReceiver, filter);
adapter.startDiscovery();
AlertDialog.Builder choices = new AlertDialog.Builder(MoleActivity.this);
choices.setTitle("Who's the hammer?");
choices.setAdapter(devices, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog,
int which) {
stopDiscovery();
new ClientThread(devices.getItem(which).getDevice()).start();
}
});
choices.setOnCancelListener(new OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
stopDiscovery();
}
});
choices.show();
}
private void stopDiscovery() {
adapter.cancelDiscovery();
unregisterReceiver(btReceiver);
}
}
The adapter.startDiscovery() will look for devices, and the receiver we created will get notified when one is seen. We can have it add the device to our adapter:
class BTReceiver extends BroadcastReceiver {
public void onReceive(Context context,
Intent intent) {
if (BluetoothDevice.ACTION_FOUND.equals(intent.getAction())) {
BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
devices.add(new Device(device.getName(), device.getAddress(), device));
}
}
};
I suppose we better define the Device class. This is just a wrapper so the adapter has readable entries. BluetoothDevice’s toString is just the serial number.
public class Device {
private String name;
private String address;
private BluetoothDevice device;
public Device(String name,
String address,
BluetoothDevice device) {
super();
this.name = name;
this.address = address;
this.device = device;
}
public String getName() {
return name;
}
public String getAddress() {
return address;
}
public BluetoothDevice getDevice() {
return device;
}
public String toString() {
return name + " - " + address;
}
}
The client thread hooks up to the server:
private class ClientThread extends Thread {
private BluetoothSocket socket;
public ClientThread(BluetoothDevice device) {
try {
socket = device.createRfcommSocketToServiceRecord(MainActivity.BTHWACK_UUID);
} catch (IOException e) {
}
}
public void run() {
try {
socket.connect();
} catch (IOException connectException) {
try {
socket.close();
} catch (IOException closeException) {
}
return;
}
talkThread = new TalkThread(socket);
talkThread.start();
}
public void cancel() {
try {
socket.close();
} catch (IOException e) {
}
}
}
Once connected, the communication is symmetric to the server’s:
private class TalkThread extends Thread {
private BluetoothSocket socket;
private DataInputStream in;
private DataOutputStream out;
public TalkThread(BluetoothSocket socket) {
this.socket = socket;
try {
in = new DataInputStream(socket.getInputStream());
out = new DataOutputStream(socket.getOutputStream());
} catch (IOException e) {
}
}
public void run() {
Log.d("BLUE", "running client");
while (true) {
try {
final float x = in.readFloat();
final float y = in.readFloat();
handler.post(new Runnable() {
@Override
public void run() {
view.setHammer(x, y);
}
});
} catch (IOException e) {
break;
}
}
handler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(MoleActivity.this, "Connection was lost", Toast.LENGTH_LONG).show();
finish();
}
});
}
public void write(float x, float y) {
try {
out.writeFloat(x);
out.writeFloat(y);
} catch (IOException e) {
}
}
public void cancel() {
try {
socket.close();
} catch (IOException e) {
}
}
}
And same with the touch event:
@Override
public boolean onTouchEvent(MotionEvent event) {
Log.d("BLUE", "tap");
if (talkThread != null) {
if (event.getAction() == MotionEvent.ACTION_DOWN || event.getAction() == MotionEvent.ACTION_MOVE) {
float x = event.getX() / view.getWidth();
float y = event.getY() / view.getHeight();
view.setMole(x, y);
talkThread.write(x, y);
} else if (event.getAction() == MotionEvent.ACTION_UP) {
view.unsetMole();
talkThread.write(-1, -1);
}
}
return true;
}