package dk.silverbullet.telemed.device.continua.android;
import java.io.IOException;
import java.util.regex.Pattern;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothHealth;
import android.bluetooth.BluetoothHealthAppConfiguration;
import android.bluetooth.BluetoothHealthCallback;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import dk.silverbullet.telemed.device.DeviceInitialisationException;
import dk.silverbullet.telemed.device.bluetooth.BluetoothConnector;
import dk.silverbullet.telemed.device.continua.HdpController;
import dk.silverbullet.telemed.device.continua.HdpListener;
import dk.silverbullet.telemed.device.continua.HdpProfile;
import dk.silverbullet.telemed.device.continua.PacketCollector;
import dk.silverbullet.telemed.utils.Util;
/**
* Handles all the Bluetooth-specific communication for communicating with an HDP device.
*/
public class AndroidHdpController implements HdpController, BluetoothProfile.ServiceListener {
private static final String TAG = Util.getTag(AndroidHdpController.class);
private static enum State {
SERVICE_NOT_CONNECTED, SERVICE_CONNECTED, APPLICATION_REGISTERED, DEVICE_CONNECTING, DEVICE_CONNECTED, DEVICE_DISCONNECTING, CLOSED
}
private BluetoothHealthAppConfiguration healthApplicationConfiguration;
private BluetoothHealth bluetoothHealth;
private BluetoothDevice device;
private int channelId;
private final BluetoothConnector connector = new BluetoothConnector();
private final Context context;
private final Object stateSemaphore = new Object();
private final BluetoothStreamer streamer;
private final Stopwatch stopwatch = new Stopwatch();
private HdpProfile hdpProfile;
private HdpListener listener;
private boolean pollForConnection;
private State currentState = State.SERVICE_NOT_CONNECTED;
private boolean shuttingDown;
private boolean applicationUnregistrationScheduled;
private long applicationShutdownTime;
/**
* @param context
* Is unfortunately required for setting up Bluetooth Health Profile
*/
public AndroidHdpController(Context context) {
this.context = context;
this.streamer = new BluetoothStreamer();
}
@Override
public void setHdpProfile(HdpProfile hdpProfile) {
this.hdpProfile = hdpProfile;
}
@Override
public void setPacketCollector(PacketCollector packetCollector) {
streamer.setPacketCollector(packetCollector);
}
@Override
public void setBluetoothListener(HdpListener listener) {
this.listener = listener;
}
public void setPollForConnection(boolean pollForConnection) {
this.pollForConnection = pollForConnection;
}
@Override
public void initiate(Pattern deviceNamePattern, String deviceMacAddressPrefix) throws DeviceInitialisationException {
connector.initiate();
device = connector.getDevice(deviceNamePattern, deviceMacAddressPrefix);
connector.openHdp(context, this);
}
@Override
public void terminate() {
synchronized (stateSemaphore) {
if (shuttingDown) {
Log.d(TAG, "Already closing");
} else {
Log.d(TAG, "Closing. Current state: " + currentState);
shuttingDown = true;
stopwatch.cancel();
if (currentState == State.DEVICE_CONNECTED) {
streamer.stopReading();
disconnectChannel();
} else if (currentState == State.APPLICATION_REGISTERED) {
scheduleApplicationUnregistration();
}
}
waitForState(State.CLOSED);
}
}
@Override
public void send(byte[] contents) throws IOException {
streamer.write(contents);
}
@Override
public void onServiceConnected(int profile, BluetoothProfile proxy) {
if (profile != BluetoothProfile.HEALTH) {
Log.d(TAG, "onServiceConnected profile==" + profile);
return;
}
Log.d(TAG, "onServiceConnected");
synchronized (stateSemaphore) {
bluetoothHealth = (BluetoothHealth) proxy;
goFromStateToState(State.SERVICE_NOT_CONNECTED, State.SERVICE_CONNECTED);
Log.d(TAG, "onServiceConnected to profile: " + profile);
if (shuttingDown) {
closeProfileProxy();
} else {
registerApplication();
}
}
}
@Override
public void onServiceDisconnected(int profile) {
if (profile != BluetoothProfile.HEALTH) {
Log.d(TAG, "onServiceDisconnected profile==" + profile);
return;
}
Log.d(TAG, "onServiceDisconnected");
synchronized (stateSemaphore) {
bluetoothHealth = null;
goFromStateToState(State.SERVICE_CONNECTED, State.CLOSED);
listener.serviceConnectionFailed();
}
}
private final BluetoothHealthCallback mHealthCallback = new BluetoothHealthCallback() {
// Callback to handle application registration and unregistration
// events. The service passes the status back to the UI client.
@Override
public void onHealthAppConfigurationStatusChange(BluetoothHealthAppConfiguration config, int status) {
Log.d(TAG, "onHealthAppConfigurationStatusChange config=" + config + " status=" + status);
if (healthApplicationConfiguration != null && !healthApplicationConfiguration.equals(config)) {
Log.d(TAG, "Ignoring status change because config was unexpected");
return;
}
synchronized (stateSemaphore) {
stopwatch.cancel();
switch (status) {
case BluetoothHealth.APP_CONFIG_REGISTRATION_SUCCESS:
healthApplicationConfiguration = config;
goFromStateToState(State.SERVICE_CONNECTED, State.APPLICATION_REGISTERED);
listener.applicationConfigurationRegistered();
if (shuttingDown) {
scheduleApplicationUnregistration();
} else {
streamer.startReading();
startStopwatch();
}
break;
case BluetoothHealth.APP_CONFIG_REGISTRATION_FAILURE:
listener.applicationConfigurationRegistrationFailed();
goFromStateToState(State.SERVICE_CONNECTED, State.SERVICE_CONNECTED);
closeProfileProxy();
break;
case BluetoothHealth.APP_CONFIG_UNREGISTRATION_SUCCESS:
healthApplicationConfiguration = null;
streamer.stopReading();
listener.applicationConfigurationUnregistered();
goFromStateToState(State.APPLICATION_REGISTERED, State.SERVICE_CONNECTED);
closeProfileProxy();
break;
case BluetoothHealth.APP_CONFIG_UNREGISTRATION_FAILURE:
healthApplicationConfiguration = null;
streamer.stopReading();
listener.applicationConfigurationUnregistrationFailed();
// Logically wrong state, but there's nothing else we can do, except getting stuck
goFromStateToState(State.APPLICATION_REGISTERED, State.SERVICE_CONNECTED);
Log.w(TAG, "Application unregistration failed");
closeProfileProxy();
break;
}
}
}
// Callback to handle channel connection state changes.
// Note that the logic of the state machine may need to be modified
// based on the HDP device.
// When the HDP device is connected, the received file descriptor is
// passed to the ReadThread to read the content.
@Override
public void onHealthChannelStateChange(BluetoothHealthAppConfiguration config, BluetoothDevice device,
int prevState, int newState, ParcelFileDescriptor parcelFileDescriptor, int channelId) {
Log.d(TAG, String.format("prevState\t%d ----------> newState\t%d", prevState, newState));
if (prevState == newState || !config.equals(healthApplicationConfiguration)) {
Log.d(TAG, "onHealthChannelStateChange was ignored!");
Log.d(TAG, "config: " + config);
Log.d(TAG, "mHealthAppConfig: " + healthApplicationConfiguration);
return;
}
synchronized (stateSemaphore) {
if (newState == BluetoothHealth.STATE_CHANNEL_CONNECTED) {
if (currentState == State.DEVICE_CONNECTED) {
return;
}
goFromStateToState(State.DEVICE_CONNECTING, State.DEVICE_CONNECTED);
AndroidHdpController.this.channelId = channelId;
stopwatch.cancel();
listener.connectionEstablished();
if (shuttingDown) {
disconnectChannel();
} else {
streamer.replugConnection(parcelFileDescriptor);
}
} else if (newState == BluetoothHealth.STATE_CHANNEL_DISCONNECTED) {
if (currentState == State.APPLICATION_REGISTERED) {
return;
}
if (shuttingDown) {
goFromStateToState(State.DEVICE_CONNECTING, State.APPLICATION_REGISTERED);
scheduleApplicationUnregistration();
} else {
if (currentState == State.DEVICE_CONNECTED || currentState == State.DEVICE_DISCONNECTING) {
listener.disconnected();
}
goFromStateToState(currentState, State.APPLICATION_REGISTERED);
streamer.unplugConnection();
startStopwatch();
}
} else if (newState == BluetoothHealth.STATE_CHANNEL_CONNECTING) {
goFromStateToState(State.APPLICATION_REGISTERED, State.DEVICE_CONNECTING);
} else if (newState == BluetoothHealth.STATE_CHANNEL_DISCONNECTING) {
goFromStateToState(State.DEVICE_CONNECTED, State.DEVICE_DISCONNECTING);
}
}
}
};
private void startStopwatch() {
if (pollForConnection) {
stopwatch.start(3000, new StopwatchListener() {
@Override
public void timeout() {
synchronized (stateSemaphore) {
if (currentState != State.APPLICATION_REGISTERED) {
Log.d(TAG, "Stopwatch not acting, since state is not APPLICATION_REGISTERED: "
+ currentState);
return;
} else if (shuttingDown) {
Log.d(TAG, "Stopwatch not acting, since we're shutting down");
return;
}
}
// Outside of the "synchronized" block because of potential deadlock
Log.d(TAG, "Polling for connection....");
connectChannelToSource();
}
});
}
}
private void goFromStateToState(State oldState, State newState) {
synchronized (stateSemaphore) {
if (currentState != oldState) {
Log.e(TAG, "Unexpected state: " + oldState + " - actual state " + currentState);
}
Log.d(TAG, "Going from state " + currentState + " to " + newState);
currentState = newState;
stateSemaphore.notifyAll();
}
}
private void waitForState(State state) {
synchronized (stateSemaphore) {
long startTime = System.currentTimeMillis();
while (currentState != state) {
try {
stateSemaphore.wait(500);
if (System.currentTimeMillis() > startTime + 40000) {
Log.d(TAG, "Timed out waiting for state " + state + ". Ending up in " + currentState);
return;
}
} catch (InterruptedException e) {
Log.d(TAG, "Interrupted while waiting for state " + state);
return;
}
}
}
}
private void registerApplication() {
Log.d(TAG, "registerApplication()");
runLater(new Runnable() {
@Override
public void run() {
Log.d(TAG, "bluetoothHealth.registerApplication()");
bluetoothHealth.registerSinkAppConfiguration(TAG, hdpProfile.getProfileId(), mHealthCallback);
}
});
}
private void connectChannelToSource() {
Log.d(TAG, "connectChannelToSource()");
bluetoothHealth.connectChannelToSource(device, healthApplicationConfiguration);
}
private void closeProfileProxy() {
Log.d(TAG, "closeProfileProxy() bluetoothHealth='" + bluetoothHealth + "'");
runLater(new Runnable() {
@Override
public void run() {
Log.d(TAG, "bluetoothAdapter.closeProfileProxy() bluetoothHealth='" + bluetoothHealth + "'");
connector.closeHdp(bluetoothHealth);
// In practice, our onServiceDisconnected never gets called by Android, so we need to
// jump to the next state ourselves
goFromStateToState(State.SERVICE_CONNECTED, State.CLOSED);
}
});
}
/**
* Schedules an unregistration of the application. If called multiple times within 500ms, makes sure that the
* application is shut down 500ms after the _last_ invocation.
*
* Apparently, we cannot unregisterApplication() inside an Android Bluetooth callback - or, rather, Android never
* sends a callback telling us that our application is unregistered. (Probably because of a deadlock in Android?) So
* we use this method in order to make sure everything "settles" in a period of 500ms.
*/
private void scheduleApplicationUnregistration() {
Log.d(TAG, "scheduleApplicationUnregistration() healthApplicationConfiguration='"
+ healthApplicationConfiguration + "'");
synchronized (stateSemaphore) {
applicationShutdownTime = System.currentTimeMillis() + 500;
if (applicationUnregistrationScheduled) {
Log.d(TAG, "Application shutdown already scheduled - setting new shutdown time");
return;
}
applicationUnregistrationScheduled = true;
runLater(new Runnable() {
@Override
public void run() {
synchronized (stateSemaphore) {
while (!shouldShutdownApplicationNow()) {
try {
stateSemaphore.wait(timeUntilApplicationShutdown());
} catch (InterruptedException e) {
Log.d(TAG, "Interrupted while waiting for application shutdown", e);
Thread.currentThread().interrupt();
}
}
Log.d(TAG, "bluetoothHealth.unregisterAppConfiguration('" + healthApplicationConfiguration
+ "')");
bluetoothHealth.unregisterAppConfiguration(healthApplicationConfiguration);
}
}
});
}
}
private boolean shouldShutdownApplicationNow() {
return timeUntilApplicationShutdown() == 0;
}
private long timeUntilApplicationShutdown() {
return Math.max(applicationShutdownTime - System.currentTimeMillis(), 0);
}
private void disconnectChannel() {
Log.d(TAG, "disconnectChannel()");
runLater(new Runnable() {
@Override
public void run() {
Log.d(TAG, "Disconnecting channel");
bluetoothHealth.disconnectChannel(device, healthApplicationConfiguration, channelId);
}
});
}
private void runLater(Runnable runnable) {
new Thread(runnable).start();
}
}