package org.briarproject.plugins.droidtooth; import static android.bluetooth.BluetoothAdapter.ACTION_SCAN_MODE_CHANGED; import static android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED; import static android.bluetooth.BluetoothAdapter.EXTRA_SCAN_MODE; import static android.bluetooth.BluetoothAdapter.EXTRA_STATE; import static android.bluetooth.BluetoothAdapter.SCAN_MODE_CONNECTABLE; import static android.bluetooth.BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE; import static android.bluetooth.BluetoothAdapter.SCAN_MODE_NONE; import static android.bluetooth.BluetoothAdapter.STATE_OFF; import static android.bluetooth.BluetoothAdapter.STATE_ON; import static android.bluetooth.BluetoothDevice.EXTRA_DEVICE; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.logging.Level.INFO; import static java.util.logging.Level.WARNING; import java.io.IOException; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.logging.Logger; import org.briarproject.api.ContactId; import org.briarproject.api.TransportId; import org.briarproject.api.TransportProperties; import org.briarproject.api.android.AndroidExecutor; import org.briarproject.api.crypto.PseudoRandom; import org.briarproject.api.plugins.duplex.DuplexPlugin; import org.briarproject.api.plugins.duplex.DuplexPluginCallback; import org.briarproject.api.plugins.duplex.DuplexTransportConnection; import org.briarproject.api.system.Clock; import org.briarproject.util.LatchedReference; import org.briarproject.util.StringUtils; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothServerSocket; import android.bluetooth.BluetoothSocket; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; class DroidtoothPlugin implements DuplexPlugin { // Share an ID with the J2SE Bluetooth plugin static final TransportId ID = new TransportId("bt"); private static final Logger LOG = Logger.getLogger(DroidtoothPlugin.class.getName()); private static final int UUID_BYTES = 16; private static final String FOUND = "android.bluetooth.device.action.FOUND"; private static final String DISCOVERY_FINISHED = "android.bluetooth.adapter.action.DISCOVERY_FINISHED"; private final Executor pluginExecutor; private final AndroidExecutor androidExecutor; private final Context appContext; private final SecureRandom secureRandom; private final Clock clock; private final DuplexPluginCallback callback; private final int maxFrameLength; private final long maxLatency, pollingInterval; private volatile boolean running = false; private volatile boolean wasDisabled = false; private volatile BluetoothStateReceiver receiver = null; private volatile BluetoothServerSocket socket = null; // Non-null if the plugin started successfully private volatile BluetoothAdapter adapter = null; DroidtoothPlugin(Executor pluginExecutor, AndroidExecutor androidExecutor, Context appContext, SecureRandom secureRandom, Clock clock, DuplexPluginCallback callback, int maxFrameLength, long maxLatency, long pollingInterval) { this.pluginExecutor = pluginExecutor; this.androidExecutor = androidExecutor; this.appContext = appContext; this.secureRandom = secureRandom; this.clock = clock; this.callback = callback; this.maxFrameLength = maxFrameLength; this.maxLatency = maxLatency; this.pollingInterval = pollingInterval; } public TransportId getId() { return ID; } public int getMaxFrameLength() { return maxFrameLength; } public long getMaxLatency() { return maxLatency; } public boolean start() throws IOException { // BluetoothAdapter.getDefaultAdapter() must be called on a thread // with a message queue, so submit it to the AndroidExecutor try { adapter = androidExecutor.call(new Callable() { public BluetoothAdapter call() throws Exception { return BluetoothAdapter.getDefaultAdapter(); } }); } catch(InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("Interrupted while getting BluetoothAdapter"); } catch(ExecutionException e) { throw new IOException(e.toString()); } if(adapter == null) { LOG.info("Bluetooth is not supported"); return false; } running = true; // Listen for changes to the Bluetooth state IntentFilter filter = new IntentFilter(); filter.addAction(ACTION_STATE_CHANGED); filter.addAction(ACTION_SCAN_MODE_CHANGED); receiver = new BluetoothStateReceiver(); appContext.registerReceiver(receiver, filter); // If Bluetooth is enabled, bind a socket - otherwise enable it if(adapter.isEnabled()) { bind(); } else if(callback.getConfig().getBoolean("enable", true)) { wasDisabled = true; if(adapter.enable()) LOG.info("Enabling Bluetooth"); else LOG.info("Could not enable Bluetooth"); } else { LOG.info("Not enabling Bluetooth"); } return true; } private void bind() { pluginExecutor.execute(new Runnable() { public void run() { if(!isRunning()) return; if(LOG.isLoggable(INFO)) LOG.info("Local address " + adapter.getAddress()); // Advertise the Bluetooth address to contacts TransportProperties p = new TransportProperties(); p.put("address", adapter.getAddress()); callback.mergeLocalProperties(p); // Bind a server socket to accept connections from contacts BluetoothServerSocket ss = null; try { ss = InsecureBluetooth.listen(adapter, "RFCOMM", getUuid()); } catch(IOException e) { if(LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e); tryToClose(ss); return; } if(!isRunning()) { tryToClose(ss); return; } LOG.info("Socket bound"); socket = ss; callback.pollNow(); acceptContactConnections(); } }); } private UUID getUuid() { String uuid = callback.getLocalProperties().get("uuid"); if(uuid == null) { byte[] random = new byte[UUID_BYTES]; secureRandom.nextBytes(random); uuid = UUID.nameUUIDFromBytes(random).toString(); TransportProperties p = new TransportProperties(); p.put("uuid", uuid); callback.mergeLocalProperties(p); } return UUID.fromString(uuid); } private void tryToClose(BluetoothServerSocket ss) { try { if(ss != null) ss.close(); } catch(IOException e) { if(LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e); } } private void acceptContactConnections() { while(isRunning()) { BluetoothSocket s; try { s = socket.accept(); } catch(IOException e) { // This is expected when the socket is closed if(LOG.isLoggable(INFO)) LOG.info(e.toString()); tryToClose(socket); return; } if(LOG.isLoggable(INFO)) { String address = s.getRemoteDevice().getAddress(); LOG.info("Connection from " + address); } callback.incomingConnectionCreated(wrapSocket(s)); } } private DuplexTransportConnection wrapSocket(BluetoothSocket s) { return new DroidtoothTransportConnection(this, s); } public void stop() { running = false; if(receiver != null) appContext.unregisterReceiver(receiver); tryToClose(socket); // Disable Bluetooth if we enabled it and it's still enabled if(wasDisabled && adapter.isEnabled()) { if(adapter.disable()) LOG.info("Disabling Bluetooth"); else LOG.info("Could not disable Bluetooth"); } } public boolean isRunning() { return running && adapter.isEnabled(); } public boolean shouldPoll() { return true; } public long getPollingInterval() { return pollingInterval; } public void poll(Collection connected) { if(!isRunning()) return; // Try to connect to known devices in parallel Map remote = callback.getRemoteProperties(); for(Entry e : remote.entrySet()) { final ContactId c = e.getKey(); if(connected.contains(c)) continue; final String address = e.getValue().get("address"); if(StringUtils.isNullOrEmpty(address)) continue; final String uuid = e.getValue().get("uuid"); if(StringUtils.isNullOrEmpty(uuid)) continue; pluginExecutor.execute(new Runnable() { public void run() { if(!running) return; BluetoothSocket s = connect(address, uuid); if(s != null) callback.outgoingConnectionCreated(c, wrapSocket(s)); } }); } } private BluetoothSocket connect(String address, String uuid) { // Validate the address if(!BluetoothAdapter.checkBluetoothAddress(address)) { if(LOG.isLoggable(WARNING)) LOG.warning("Invalid address " + address); return null; } // Validate the UUID UUID u; try { u = UUID.fromString(uuid); } catch(IllegalArgumentException e) { if(LOG.isLoggable(WARNING)) LOG.warning("Invalid UUID " + uuid); return null; } // Try to connect BluetoothDevice d = adapter.getRemoteDevice(address); BluetoothSocket s = null; try { s = InsecureBluetooth.createSocket(d, u); if(LOG.isLoggable(INFO)) LOG.info("Connecting to " + address); s.connect(); if(LOG.isLoggable(INFO)) LOG.info("Connected to " + address); return s; } catch(IOException e) { if(LOG.isLoggable(INFO)) LOG.info("Failed to connect to " + address); tryToClose(s); return null; } } private void tryToClose(BluetoothSocket s) { try { if(s != null) s.close(); } catch(IOException e) { if(LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e); } } public DuplexTransportConnection createConnection(ContactId c) { if(!isRunning()) return null; TransportProperties p = callback.getRemoteProperties().get(c); if(p == null) return null; String address = p.get("address"); if(StringUtils.isNullOrEmpty(address)) return null; String uuid = p.get("uuid"); if(StringUtils.isNullOrEmpty(uuid)) return null; BluetoothSocket s = connect(address, uuid); if(s == null) return null; return new DroidtoothTransportConnection(this, s); } public boolean supportsInvitations() { return true; } public DuplexTransportConnection createInvitationConnection(PseudoRandom r, long timeout) { if(!isRunning()) return null; // Use the invitation codes to generate the UUID byte[] b = r.nextBytes(UUID_BYTES); UUID uuid = UUID.nameUUIDFromBytes(b); if(LOG.isLoggable(INFO)) LOG.info("Invitation UUID " + uuid); // Bind a server socket for receiving invitation connections BluetoothServerSocket ss = null; try { ss = InsecureBluetooth.listen(adapter, "RFCOMM", uuid); } catch(IOException e) { if(LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e); tryToClose(ss); return null; } // Start the background threads LatchedReference socketLatch = new LatchedReference(); new DiscoveryThread(socketLatch, uuid.toString(), timeout).start(); new BluetoothListenerThread(socketLatch, ss).start(); // Wait for an incoming or outgoing connection try { BluetoothSocket s = socketLatch.waitForReference(timeout); if(s != null) return new DroidtoothTransportConnection(this, s); } catch(InterruptedException e) { LOG.warning("Interrupted while exchanging invitations"); Thread.currentThread().interrupt(); } finally { // Closing the socket will terminate the listener thread tryToClose(ss); } return null; } private class BluetoothStateReceiver extends BroadcastReceiver { public void onReceive(Context ctx, Intent intent) { int state = intent.getIntExtra(EXTRA_STATE, 0); if(state == STATE_ON) { LOG.info("Bluetooth enabled"); bind(); } else if(state == STATE_OFF) { LOG.info("Bluetooth disabled"); tryToClose(socket); } int scanMode = intent.getIntExtra(EXTRA_SCAN_MODE, 0); if(scanMode == SCAN_MODE_NONE) { LOG.info("Scan mode: None"); } else if(scanMode == SCAN_MODE_CONNECTABLE) { LOG.info("Scan mode: Connectable"); } else if(scanMode == SCAN_MODE_CONNECTABLE_DISCOVERABLE) { LOG.info("Scan mode: Discoverable"); } } } private class DiscoveryThread extends Thread { private final LatchedReference socketLatch; private final String uuid; private final long timeout; private DiscoveryThread(LatchedReference socketLatch, String uuid, long timeout) { this.socketLatch = socketLatch; this.uuid = uuid; this.timeout = timeout; } @Override public void run() { long end = clock.currentTimeMillis() + timeout; while(!finished(end)) { // Discover nearby devices LOG.info("Discovering nearby devices"); List addresses; try { long now = clock.currentTimeMillis(); addresses = discoverDevices(end - now); } catch(InterruptedException e) { LOG.warning("Interrupted while discovering devices"); Thread.currentThread().interrupt(); return; } if(addresses.isEmpty()) { LOG.info("No devices discovered"); continue; } // Connect to any device with the right UUID for(String address : addresses) { if(finished(end)) return; BluetoothSocket s = connect(address, uuid); if(s != null) { LOG.info("Outgoing connection"); if(!socketLatch.set(s)) { LOG.info("Closing redundant connection"); tryToClose(s); } return; } } } } private boolean finished(long end) { long now = clock.currentTimeMillis(); return now >= end || !isRunning() || socketLatch.isSet(); } private List discoverDevices(long timeout) throws InterruptedException { IntentFilter filter = new IntentFilter(); filter.addAction(FOUND); filter.addAction(DISCOVERY_FINISHED); DiscoveryReceiver disco = new DiscoveryReceiver(); appContext.registerReceiver(disco, filter); LOG.info("Starting discovery"); adapter.startDiscovery(); return disco.waitForAddresses(timeout); } } private static class DiscoveryReceiver extends BroadcastReceiver { private final CountDownLatch finished = new CountDownLatch(1); private final List addresses = new ArrayList(); @Override public void onReceive(Context ctx, Intent intent) { String action = intent.getAction(); if(action.equals(DISCOVERY_FINISHED)) { LOG.info("Discovery finished"); ctx.unregisterReceiver(this); finished.countDown(); } else if(action.equals(FOUND)) { BluetoothDevice d = intent.getParcelableExtra(EXTRA_DEVICE); addresses.add(d.getAddress()); } } private List waitForAddresses(long timeout) throws InterruptedException { finished.await(timeout, MILLISECONDS); return Collections.unmodifiableList(addresses); } } private static class BluetoothListenerThread extends Thread { private final LatchedReference socketLatch; private final BluetoothServerSocket serverSocket; private BluetoothListenerThread( LatchedReference socketLatch, BluetoothServerSocket serverSocket) { this.socketLatch = socketLatch; this.serverSocket = serverSocket; } @Override public void run() { try { BluetoothSocket s = serverSocket.accept(); LOG.info("Incoming connection"); if(!socketLatch.set(s)) { LOG.info("Closing redundant connection"); s.close(); } } catch(IOException e) { // This is expected when the socket is closed if(LOG.isLoggable(INFO)) LOG.info(e.toString()); } } } }