diff --git a/bramble-android/src/main/java/org/briarproject/bramble/plugin/AndroidPluginModule.java b/bramble-android/src/main/java/org/briarproject/bramble/plugin/AndroidPluginModule.java
index 0dbc81b3ec3ff890a0e2d3429c2f3a55d0774196..52b2a293d2a53759940e3902dd5b0e35e7c4809f 100644
--- a/bramble-android/src/main/java/org/briarproject/bramble/plugin/AndroidPluginModule.java
+++ b/bramble-android/src/main/java/org/briarproject/bramble/plugin/AndroidPluginModule.java
@@ -13,7 +13,7 @@ import org.briarproject.bramble.api.plugin.simplex.SimplexPluginFactory;
 import org.briarproject.bramble.api.reporting.DevReporter;
 import org.briarproject.bramble.api.system.AndroidExecutor;
 import org.briarproject.bramble.api.system.LocationUtils;
-import org.briarproject.bramble.plugin.droidtooth.DroidtoothPluginFactory;
+import org.briarproject.bramble.plugin.bluetooth.AndroidBluetoothPluginFactory;
 import org.briarproject.bramble.plugin.tcp.AndroidLanTcpPluginFactory;
 import org.briarproject.bramble.plugin.tor.TorPluginFactory;
 
@@ -38,8 +38,9 @@ public class AndroidPluginModule {
 			Application app, LocationUtils locationUtils, DevReporter reporter,
 			EventBus eventBus) {
 		Context appContext = app.getApplicationContext();
-		DuplexPluginFactory bluetooth = new DroidtoothPluginFactory(ioExecutor,
-				androidExecutor, appContext, random, eventBus, backoffFactory);
+		DuplexPluginFactory bluetooth =
+				new AndroidBluetoothPluginFactory(ioExecutor, androidExecutor,
+						appContext, random, eventBus, backoffFactory);
 		DuplexPluginFactory tor = new TorPluginFactory(ioExecutor, appContext,
 				locationUtils, reporter, eventBus, torSocketFactory,
 				backoffFactory);
diff --git a/bramble-android/src/main/java/org/briarproject/bramble/plugin/bluetooth/AndroidBluetoothPlugin.java b/bramble-android/src/main/java/org/briarproject/bramble/plugin/bluetooth/AndroidBluetoothPlugin.java
new file mode 100644
index 0000000000000000000000000000000000000000..8bbfe971d235a29797077a10eccc957655ed431d
--- /dev/null
+++ b/bramble-android/src/main/java/org/briarproject/bramble/plugin/bluetooth/AndroidBluetoothPlugin.java
@@ -0,0 +1,223 @@
+package org.briarproject.bramble.plugin.bluetooth;
+
+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;
+
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.event.EventListener;
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.bramble.api.plugin.Backoff;
+import org.briarproject.bramble.api.plugin.duplex.DuplexPluginCallback;
+import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
+import org.briarproject.bramble.api.plugin.event.DisableBluetoothEvent;
+import org.briarproject.bramble.api.plugin.event.EnableBluetoothEvent;
+import org.briarproject.bramble.api.system.AndroidExecutor;
+import org.briarproject.bramble.util.AndroidUtils;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+
+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 java.util.logging.Level.WARNING;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+class AndroidBluetoothPlugin extends BluetoothPlugin<BluetoothServerSocket>
+		implements EventListener {
+
+	private static final Logger LOG =
+			Logger.getLogger(AndroidBluetoothPlugin.class.getName());
+
+	private final AndroidExecutor androidExecutor;
+	private final Context appContext;
+
+	private volatile boolean wasEnabledByUs = false;
+	private volatile BluetoothStateReceiver receiver = null;
+
+	// Non-null if the plugin started successfully
+	private volatile BluetoothAdapter adapter = null;
+
+	AndroidBluetoothPlugin(Executor ioExecutor, AndroidExecutor androidExecutor,
+			Context appContext, SecureRandom secureRandom, Backoff backoff,
+			DuplexPluginCallback callback, int maxLatency) {
+		super(ioExecutor, secureRandom, backoff, callback, maxLatency);
+		this.androidExecutor = androidExecutor;
+		this.appContext = appContext;
+	}
+
+	@Override
+	void initialiseAdapter() throws IOException {
+		// BluetoothAdapter.getDefaultAdapter() must be called on a thread
+		// with a message queue, so submit it to the AndroidExecutor
+		try {
+			adapter = androidExecutor.runOnBackgroundThread(
+					BluetoothAdapter::getDefaultAdapter).get();
+		} catch (InterruptedException | ExecutionException e) {
+			throw new IOException(e);
+		}
+		if (adapter == null) {
+			LOG.info("Bluetooth is not supported");
+			throw new IOException();
+		}
+		// 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);
+	}
+
+	@Override
+	boolean isAdapterEnabled() {
+		return adapter.isEnabled();
+	}
+
+	@Override
+	void enableAdapter() {
+		if (adapter != null && !adapter.isEnabled()) {
+			if (adapter.enable()) {
+				LOG.info("Enabling Bluetooth");
+				wasEnabledByUs = true;
+			} else {
+				LOG.info("Could not enable Bluetooth");
+			}
+		}
+	}
+
+	@Override
+	public void stop() {
+		super.stop();
+		if (receiver != null) appContext.unregisterReceiver(receiver);
+		disableAdapter();
+	}
+
+	private void disableAdapter() {
+		if (adapter != null && adapter.isEnabled() && wasEnabledByUs) {
+			if (adapter.disable()) LOG.info("Disabling Bluetooth");
+			else LOG.info("Could not disable Bluetooth");
+		}
+	}
+
+	@Override
+	public boolean isRunning() {
+		return super.isRunning() && adapter != null && adapter.isEnabled();
+	}
+
+	@Override
+	@Nullable
+	String getBluetoothAddress() {
+		String address = AndroidUtils.getBluetoothAddress(appContext, adapter);
+		return address.isEmpty() ? null : address;
+	}
+
+	@Override
+	BluetoothServerSocket openServerSocket(String uuid) throws IOException {
+		return adapter.listenUsingInsecureRfcommWithServiceRecord(
+				"RFCOMM", UUID.fromString(uuid));
+	}
+
+	@Override
+	void tryToClose(@Nullable BluetoothServerSocket ss) {
+		try {
+			if (ss != null) ss.close();
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+		}
+	}
+
+	@Override
+	DuplexTransportConnection acceptConnection(BluetoothServerSocket ss)
+			throws IOException {
+		return wrapSocket(ss.accept());
+	}
+
+	private DuplexTransportConnection wrapSocket(BluetoothSocket s) {
+		return new AndroidBluetoothTransportConnection(this, s);
+	}
+
+	@Override
+	boolean isValidAddress(String address) {
+		return BluetoothAdapter.checkBluetoothAddress(address);
+	}
+
+	@Override
+	DuplexTransportConnection connectTo(String address, String uuid)
+			throws IOException {
+		BluetoothDevice d = adapter.getRemoteDevice(address);
+		UUID u = UUID.fromString(uuid);
+		BluetoothSocket s = null;
+		try {
+			s = d.createInsecureRfcommSocketToServiceRecord(u);
+			s.connect();
+			return wrapSocket(s);
+		} catch (IOException e) {
+			tryToClose(s);
+			throw e;
+		}
+	}
+
+	private void tryToClose(@Nullable Closeable c) {
+		try {
+			if (c != null) c.close();
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+		}
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof EnableBluetoothEvent) {
+			enableAdapterAsync();
+		} else if (e instanceof DisableBluetoothEvent) {
+			disableAdapterAsync();
+		}
+	}
+
+	private void enableAdapterAsync() {
+		ioExecutor.execute(this::enableAdapter);
+	}
+
+	private void disableAdapterAsync() {
+		ioExecutor.execute(this::disableAdapter);
+	}
+
+	private class BluetoothStateReceiver extends BroadcastReceiver {
+
+		@Override
+		public void onReceive(Context ctx, Intent intent) {
+			int state = intent.getIntExtra(EXTRA_STATE, 0);
+			if (state == STATE_ON) onAdapterEnabled();
+			else if (state == STATE_OFF) onAdapterDisabled();
+			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");
+			}
+		}
+	}
+}
diff --git a/bramble-android/src/main/java/org/briarproject/bramble/plugin/droidtooth/DroidtoothPluginFactory.java b/bramble-android/src/main/java/org/briarproject/bramble/plugin/bluetooth/AndroidBluetoothPluginFactory.java
similarity index 89%
rename from bramble-android/src/main/java/org/briarproject/bramble/plugin/droidtooth/DroidtoothPluginFactory.java
rename to bramble-android/src/main/java/org/briarproject/bramble/plugin/bluetooth/AndroidBluetoothPluginFactory.java
index 38c95abefeb60008cee01d8a608fd8bf9ac62e9f..57ae5caa89be61c17e0445409fb9aa32f7b9c313 100644
--- a/bramble-android/src/main/java/org/briarproject/bramble/plugin/droidtooth/DroidtoothPluginFactory.java
+++ b/bramble-android/src/main/java/org/briarproject/bramble/plugin/bluetooth/AndroidBluetoothPluginFactory.java
@@ -1,4 +1,4 @@
-package org.briarproject.bramble.plugin.droidtooth;
+package org.briarproject.bramble.plugin.bluetooth;
 
 import android.content.Context;
 
@@ -21,7 +21,7 @@ import static org.briarproject.bramble.api.plugin.BluetoothConstants.ID;
 
 @Immutable
 @NotNullByDefault
-public class DroidtoothPluginFactory implements DuplexPluginFactory {
+public class AndroidBluetoothPluginFactory implements DuplexPluginFactory {
 
 	private static final int MAX_LATENCY = 30 * 1000; // 30 seconds
 	private static final int MIN_POLLING_INTERVAL = 60 * 1000; // 1 minute
@@ -35,7 +35,7 @@ public class DroidtoothPluginFactory implements DuplexPluginFactory {
 	private final EventBus eventBus;
 	private final BackoffFactory backoffFactory;
 
-	public DroidtoothPluginFactory(Executor ioExecutor,
+	public AndroidBluetoothPluginFactory(Executor ioExecutor,
 			AndroidExecutor androidExecutor, Context appContext,
 			SecureRandom secureRandom, EventBus eventBus,
 			BackoffFactory backoffFactory) {
@@ -61,7 +61,7 @@ public class DroidtoothPluginFactory implements DuplexPluginFactory {
 	public DuplexPlugin createPlugin(DuplexPluginCallback callback) {
 		Backoff backoff = backoffFactory.createBackoff(MIN_POLLING_INTERVAL,
 				MAX_POLLING_INTERVAL, BACKOFF_BASE);
-		DroidtoothPlugin plugin = new DroidtoothPlugin(ioExecutor,
+		AndroidBluetoothPlugin plugin = new AndroidBluetoothPlugin(ioExecutor,
 				androidExecutor, appContext, secureRandom, backoff, callback,
 				MAX_LATENCY);
 		eventBus.addListener(plugin);
diff --git a/bramble-android/src/main/java/org/briarproject/bramble/plugin/droidtooth/DroidtoothTransportConnection.java b/bramble-android/src/main/java/org/briarproject/bramble/plugin/bluetooth/AndroidBluetoothTransportConnection.java
similarity index 77%
rename from bramble-android/src/main/java/org/briarproject/bramble/plugin/droidtooth/DroidtoothTransportConnection.java
rename to bramble-android/src/main/java/org/briarproject/bramble/plugin/bluetooth/AndroidBluetoothTransportConnection.java
index bebae98ef719d6ab28a431a8e0cba064ba18df43..e9b615e0b1d1a66ca3e3166fd521ef58af7a19c4 100644
--- a/bramble-android/src/main/java/org/briarproject/bramble/plugin/droidtooth/DroidtoothTransportConnection.java
+++ b/bramble-android/src/main/java/org/briarproject/bramble/plugin/bluetooth/AndroidBluetoothTransportConnection.java
@@ -1,4 +1,4 @@
-package org.briarproject.bramble.plugin.droidtooth;
+package org.briarproject.bramble.plugin.bluetooth;
 
 import android.bluetooth.BluetoothSocket;
 
@@ -11,11 +11,12 @@ import java.io.InputStream;
 import java.io.OutputStream;
 
 @NotNullByDefault
-class DroidtoothTransportConnection extends AbstractDuplexTransportConnection {
+class AndroidBluetoothTransportConnection
+		extends AbstractDuplexTransportConnection {
 
 	private final BluetoothSocket socket;
 
-	DroidtoothTransportConnection(Plugin plugin, BluetoothSocket socket) {
+	AndroidBluetoothTransportConnection(Plugin plugin, BluetoothSocket socket) {
 		super(plugin);
 		this.socket = socket;
 	}
diff --git a/bramble-android/src/main/java/org/briarproject/bramble/plugin/droidtooth/DroidtoothPlugin.java b/bramble-android/src/main/java/org/briarproject/bramble/plugin/droidtooth/DroidtoothPlugin.java
deleted file mode 100644
index 580c5834508642e8f1d0892ee67ee607ca0b7e9c..0000000000000000000000000000000000000000
--- a/bramble-android/src/main/java/org/briarproject/bramble/plugin/droidtooth/DroidtoothPlugin.java
+++ /dev/null
@@ -1,490 +0,0 @@
-package org.briarproject.bramble.plugin.droidtooth;
-
-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;
-
-import org.briarproject.bramble.api.FormatException;
-import org.briarproject.bramble.api.contact.ContactId;
-import org.briarproject.bramble.api.data.BdfList;
-import org.briarproject.bramble.api.event.Event;
-import org.briarproject.bramble.api.event.EventListener;
-import org.briarproject.bramble.api.keyagreement.KeyAgreementConnection;
-import org.briarproject.bramble.api.keyagreement.KeyAgreementListener;
-import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
-import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
-import org.briarproject.bramble.api.plugin.Backoff;
-import org.briarproject.bramble.api.plugin.PluginException;
-import org.briarproject.bramble.api.plugin.TransportId;
-import org.briarproject.bramble.api.plugin.duplex.DuplexPlugin;
-import org.briarproject.bramble.api.plugin.duplex.DuplexPluginCallback;
-import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
-import org.briarproject.bramble.api.plugin.event.DisableBluetoothEvent;
-import org.briarproject.bramble.api.plugin.event.EnableBluetoothEvent;
-import org.briarproject.bramble.api.properties.TransportProperties;
-import org.briarproject.bramble.api.system.AndroidExecutor;
-import org.briarproject.bramble.util.AndroidUtils;
-import org.briarproject.bramble.util.StringUtils;
-
-import java.io.Closeable;
-import java.io.IOException;
-import java.security.SecureRandom;
-import java.util.Collection;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.UUID;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Executor;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.logging.Logger;
-
-import javax.annotation.Nullable;
-
-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 java.util.logging.Level.INFO;
-import static java.util.logging.Level.WARNING;
-import static org.briarproject.bramble.api.keyagreement.KeyAgreementConstants.TRANSPORT_ID_BLUETOOTH;
-import static org.briarproject.bramble.api.plugin.BluetoothConstants.ID;
-import static org.briarproject.bramble.api.plugin.BluetoothConstants.PREF_BT_ENABLE;
-import static org.briarproject.bramble.api.plugin.BluetoothConstants.PROP_ADDRESS;
-import static org.briarproject.bramble.api.plugin.BluetoothConstants.PROP_UUID;
-import static org.briarproject.bramble.api.plugin.BluetoothConstants.UUID_BYTES;
-import static org.briarproject.bramble.util.PrivacyUtils.scrubMacAddress;
-
-@MethodsNotNullByDefault
-@ParametersNotNullByDefault
-class DroidtoothPlugin implements DuplexPlugin, EventListener {
-
-	private static final Logger LOG =
-			Logger.getLogger(DroidtoothPlugin.class.getName());
-
-	private final Executor ioExecutor;
-	private final AndroidExecutor androidExecutor;
-	private final Context appContext;
-	private final SecureRandom secureRandom;
-	private final Backoff backoff;
-	private final DuplexPluginCallback callback;
-	private final int maxLatency;
-	private final AtomicBoolean used = new AtomicBoolean(false);
-
-	private volatile boolean running = false;
-	private volatile boolean wasEnabledByUs = 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 ioExecutor, AndroidExecutor androidExecutor,
-			Context appContext, SecureRandom secureRandom, Backoff backoff,
-			DuplexPluginCallback callback, int maxLatency) {
-		this.ioExecutor = ioExecutor;
-		this.androidExecutor = androidExecutor;
-		this.appContext = appContext;
-		this.secureRandom = secureRandom;
-		this.backoff = backoff;
-		this.callback = callback;
-		this.maxLatency = maxLatency;
-	}
-
-	@Override
-	public TransportId getId() {
-		return ID;
-	}
-
-	@Override
-	public int getMaxLatency() {
-		return maxLatency;
-	}
-
-	@Override
-	public int getMaxIdleTime() {
-		// Bluetooth detects dead connections so we don't need keepalives
-		return Integer.MAX_VALUE;
-	}
-
-	@Override
-	public void start() throws PluginException {
-		if (used.getAndSet(true)) throw new IllegalStateException();
-		// BluetoothAdapter.getDefaultAdapter() must be called on a thread
-		// with a message queue, so submit it to the AndroidExecutor
-		try {
-			adapter = androidExecutor.runOnBackgroundThread(
-					BluetoothAdapter::getDefaultAdapter).get();
-		} catch (InterruptedException e) {
-			Thread.currentThread().interrupt();
-			LOG.warning("Interrupted while getting BluetoothAdapter");
-			throw new PluginException(e);
-		} catch (ExecutionException e) {
-			throw new PluginException(e);
-		}
-		if (adapter == null) {
-			LOG.info("Bluetooth is not supported");
-			throw new PluginException();
-		}
-		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
-		if (adapter.isEnabled()) {
-			bind();
-		} else {
-			// Enable Bluetooth if settings allow
-			if (callback.getSettings().getBoolean(PREF_BT_ENABLE, false)) {
-				enableAdapter();
-			} else {
-				LOG.info("Not enabling Bluetooth");
-			}
-		}
-	}
-
-	private void bind() {
-		ioExecutor.execute(() -> {
-			if (!isRunning()) return;
-			String address = AndroidUtils.getBluetoothAddress(appContext,
-					adapter);
-			if (LOG.isLoggable(INFO))
-				LOG.info("Local address " + scrubMacAddress(address));
-			if (!StringUtils.isNullOrEmpty(address)) {
-				// Advertise the Bluetooth address to contacts
-				TransportProperties p = new TransportProperties();
-				p.put(PROP_ADDRESS, address);
-				callback.mergeLocalProperties(p);
-			}
-			// Bind a server socket to accept connections from contacts
-			BluetoothServerSocket ss;
-			try {
-				ss = adapter.listenUsingInsecureRfcommWithServiceRecord(
-						"RFCOMM", getUuid());
-			} catch (IOException e) {
-				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-				return;
-			}
-			if (!isRunning()) {
-				tryToClose(ss);
-				return;
-			}
-			LOG.info("Socket bound");
-			socket = ss;
-			backoff.reset();
-			callback.transportEnabled();
-			acceptContactConnections();
-		});
-	}
-
-	private UUID getUuid() {
-		String uuid = callback.getLocalProperties().get(PROP_UUID);
-		if (uuid == null) {
-			byte[] random = new byte[UUID_BYTES];
-			secureRandom.nextBytes(random);
-			uuid = UUID.nameUUIDFromBytes(random).toString();
-			TransportProperties p = new TransportProperties();
-			p.put(PROP_UUID, uuid);
-			callback.mergeLocalProperties(p);
-		}
-		return UUID.fromString(uuid);
-	}
-
-	private void tryToClose(@Nullable BluetoothServerSocket ss) {
-		try {
-			if (ss != null) ss.close();
-		} catch (IOException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-		} finally {
-			callback.transportDisabled();
-		}
-	}
-
-	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());
-				return;
-			}
-			if (LOG.isLoggable(INFO)) {
-				String address = s.getRemoteDevice().getAddress();
-				LOG.info("Connection from " + scrubMacAddress(address));
-			}
-			backoff.reset();
-			callback.incomingConnectionCreated(wrapSocket(s));
-		}
-	}
-
-	private DuplexTransportConnection wrapSocket(BluetoothSocket s) {
-		return new DroidtoothTransportConnection(this, s);
-	}
-
-	private void enableAdapter() {
-		if (adapter != null && !adapter.isEnabled()) {
-			if (adapter.enable()) {
-				LOG.info("Enabling Bluetooth");
-				wasEnabledByUs = true;
-			} else {
-				LOG.info("Could not enable Bluetooth");
-			}
-		}
-	}
-
-	@Override
-	public void stop() {
-		running = false;
-		if (receiver != null) appContext.unregisterReceiver(receiver);
-		tryToClose(socket);
-		disableAdapter();
-	}
-
-	private void disableAdapter() {
-		if (adapter != null && adapter.isEnabled() && wasEnabledByUs) {
-			if (adapter.disable()) LOG.info("Disabling Bluetooth");
-			else LOG.info("Could not disable Bluetooth");
-		}
-	}
-
-	@Override
-	public boolean isRunning() {
-		return running && adapter != null && adapter.isEnabled();
-	}
-
-	@Override
-	public boolean shouldPoll() {
-		return true;
-	}
-
-	@Override
-	public int getPollingInterval() {
-		return backoff.getPollingInterval();
-	}
-
-	@Override
-	public void poll(Collection<ContactId> connected) {
-		if (!isRunning()) return;
-		backoff.increment();
-		// Try to connect to known devices in parallel
-		Map<ContactId, TransportProperties> remote =
-				callback.getRemoteProperties();
-		for (Entry<ContactId, TransportProperties> e : remote.entrySet()) {
-			ContactId c = e.getKey();
-			if (connected.contains(c)) continue;
-			String address = e.getValue().get(PROP_ADDRESS);
-			if (StringUtils.isNullOrEmpty(address)) continue;
-			String uuid = e.getValue().get(PROP_UUID);
-			if (StringUtils.isNullOrEmpty(uuid)) continue;
-			ioExecutor.execute(() -> {
-				if (!running) return;
-				BluetoothSocket s = connect(address, uuid);
-				if (s != null) {
-					backoff.reset();
-					callback.outgoingConnectionCreated(c, wrapSocket(s));
-				}
-			});
-		}
-	}
-
-	@Nullable
-	private BluetoothSocket connect(String address, String uuid) {
-		// Validate the address
-		if (!BluetoothAdapter.checkBluetoothAddress(address)) {
-			if (LOG.isLoggable(WARNING))
-				// not scrubbing here to be able to figure out the problem
-				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 = d.createInsecureRfcommSocketToServiceRecord(u);
-			if (LOG.isLoggable(INFO))
-				LOG.info("Connecting to " + scrubMacAddress(address));
-			s.connect();
-			if (LOG.isLoggable(INFO))
-				LOG.info("Connected to " + scrubMacAddress(address));
-			return s;
-		} catch (IOException e) {
-			if (LOG.isLoggable(INFO)) {
-				LOG.info("Failed to connect to " + scrubMacAddress(address)
-						+ ": " + e);
-			}
-			tryToClose(s);
-			return null;
-		}
-	}
-
-	private void tryToClose(@Nullable Closeable c) {
-		try {
-			if (c != null) c.close();
-		} catch (IOException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-		}
-	}
-
-	@Override
-	public DuplexTransportConnection createConnection(ContactId c) {
-		if (!isRunning()) return null;
-		TransportProperties p = callback.getRemoteProperties(c);
-		String address = p.get(PROP_ADDRESS);
-		if (StringUtils.isNullOrEmpty(address)) return null;
-		String uuid = p.get(PROP_UUID);
-		if (StringUtils.isNullOrEmpty(uuid)) return null;
-		BluetoothSocket s = connect(address, uuid);
-		if (s == null) return null;
-		return new DroidtoothTransportConnection(this, s);
-	}
-
-	@Override
-	public boolean supportsKeyAgreement() {
-		return true;
-	}
-
-	@Override
-	public KeyAgreementListener createKeyAgreementListener(byte[] commitment) {
-		if (!isRunning()) return null;
-		// There's no point listening if we can't discover our own address
-		String address = AndroidUtils.getBluetoothAddress(appContext, adapter);
-		if (address.isEmpty()) return null;
-		// No truncation necessary because COMMIT_LENGTH = 16
-		UUID uuid = UUID.nameUUIDFromBytes(commitment);
-		if (LOG.isLoggable(INFO)) LOG.info("Key agreement UUID " + uuid);
-		// Bind a server socket for receiving key agreement connections
-		BluetoothServerSocket ss;
-		try {
-			ss = adapter.listenUsingInsecureRfcommWithServiceRecord(
-					"RFCOMM", uuid);
-		} catch (IOException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-			return null;
-		}
-		BdfList descriptor = new BdfList();
-		descriptor.add(TRANSPORT_ID_BLUETOOTH);
-		descriptor.add(StringUtils.macToBytes(address));
-		return new BluetoothKeyAgreementListener(descriptor, ss);
-	}
-
-	@Override
-	public DuplexTransportConnection createKeyAgreementConnection(
-			byte[] commitment, BdfList descriptor, long timeout) {
-		if (!isRunning()) return null;
-		String address;
-		try {
-			address = parseAddress(descriptor);
-		} catch (FormatException e) {
-			LOG.info("Invalid address in key agreement descriptor");
-			return null;
-		}
-		// No truncation necessary because COMMIT_LENGTH = 16
-		UUID uuid = UUID.nameUUIDFromBytes(commitment);
-		if (LOG.isLoggable(INFO))
-			LOG.info("Connecting to key agreement UUID " + uuid);
-		BluetoothSocket s = connect(address, uuid.toString());
-		if (s == null) return null;
-		return new DroidtoothTransportConnection(this, s);
-	}
-
-	private String parseAddress(BdfList descriptor) throws FormatException {
-		byte[] mac = descriptor.getRaw(1);
-		if (mac.length != 6) throw new FormatException();
-		return StringUtils.macToString(mac);
-	}
-
-	@Override
-	public void eventOccurred(Event e) {
-		if (e instanceof EnableBluetoothEvent) {
-			enableAdapterAsync();
-		} else if (e instanceof DisableBluetoothEvent) {
-			disableAdapterAsync();
-		}
-	}
-
-	private void enableAdapterAsync() {
-		ioExecutor.execute(this::enableAdapter);
-	}
-
-	private void disableAdapterAsync() {
-		ioExecutor.execute(this::disableAdapter);
-	}
-
-	private class BluetoothStateReceiver extends BroadcastReceiver {
-
-		@Override
-		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 BluetoothKeyAgreementListener extends KeyAgreementListener {
-
-		private final BluetoothServerSocket ss;
-
-		private BluetoothKeyAgreementListener(BdfList descriptor,
-				BluetoothServerSocket ss) {
-			super(descriptor);
-			this.ss = ss;
-		}
-
-		@Override
-		public Callable<KeyAgreementConnection> listen() {
-			return () -> {
-				BluetoothSocket s = ss.accept();
-				if (LOG.isLoggable(INFO))
-					LOG.info(ID.getString() + ": Incoming connection");
-				return new KeyAgreementConnection(
-						new DroidtoothTransportConnection(
-								DroidtoothPlugin.this, s), ID);
-			};
-		}
-
-		@Override
-		public void close() {
-			try {
-				ss.close();
-			} catch (IOException e) {
-				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-			}
-		}
-	}
-}
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/util/StringUtils.java b/bramble-api/src/main/java/org/briarproject/bramble/util/StringUtils.java
index 7e1556fb51aa6f1ac6ef51f525fc20616cc99eb0..b6d082098e0fada88f2712414d224d25325187db 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/util/StringUtils.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/util/StringUtils.java
@@ -126,6 +126,10 @@ public class StringUtils {
 		return toUtf8(s).length > maxLength;
 	}
 
+	public static boolean isValidMac(String mac) {
+		return MAC.matcher(mac).matches();
+	}
+
 	public static byte[] macToBytes(String mac) {
 		if (!MAC.matcher(mac).matches()) throw new IllegalArgumentException();
 		return fromHexString(mac.replaceAll(":", ""));
diff --git a/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPlugin.java b/bramble-core/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPlugin.java
similarity index 65%
rename from bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPlugin.java
rename to bramble-core/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPlugin.java
index 6e119909a85b2bbb324f10165e3abac213219d79..4da5916f4a00bb2df604716bc9cda395aaf5aec3 100644
--- a/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPlugin.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPlugin.java
@@ -14,7 +14,6 @@ import org.briarproject.bramble.api.plugin.duplex.DuplexPlugin;
 import org.briarproject.bramble.api.plugin.duplex.DuplexPluginCallback;
 import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
 import org.briarproject.bramble.api.properties.TransportProperties;
-import org.briarproject.bramble.util.OsUtils;
 import org.briarproject.bramble.util.StringUtils;
 
 import java.io.IOException;
@@ -29,29 +28,26 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.logging.Logger;
 
 import javax.annotation.Nullable;
-import javax.bluetooth.BluetoothStateException;
-import javax.bluetooth.LocalDevice;
-import javax.microedition.io.Connector;
-import javax.microedition.io.StreamConnection;
-import javax.microedition.io.StreamConnectionNotifier;
 
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
-import static javax.bluetooth.DiscoveryAgent.GIAC;
 import static org.briarproject.bramble.api.keyagreement.KeyAgreementConstants.TRANSPORT_ID_BLUETOOTH;
 import static org.briarproject.bramble.api.plugin.BluetoothConstants.ID;
+import static org.briarproject.bramble.api.plugin.BluetoothConstants.PREF_BT_ENABLE;
 import static org.briarproject.bramble.api.plugin.BluetoothConstants.PROP_ADDRESS;
 import static org.briarproject.bramble.api.plugin.BluetoothConstants.PROP_UUID;
 import static org.briarproject.bramble.api.plugin.BluetoothConstants.UUID_BYTES;
+import static org.briarproject.bramble.util.PrivacyUtils.scrubMacAddress;
 
 @MethodsNotNullByDefault
 @ParametersNotNullByDefault
-class BluetoothPlugin implements DuplexPlugin {
+abstract class BluetoothPlugin<SS> implements DuplexPlugin {
 
 	private static final Logger LOG =
 			Logger.getLogger(BluetoothPlugin.class.getName());
 
-	private final Executor ioExecutor;
+	final Executor ioExecutor;
+
 	private final SecureRandom secureRandom;
 	private final Backoff backoff;
 	private final DuplexPluginCallback callback;
@@ -59,8 +55,28 @@ class BluetoothPlugin implements DuplexPlugin {
 	private final AtomicBoolean used = new AtomicBoolean(false);
 
 	private volatile boolean running = false;
-	private volatile StreamConnectionNotifier socket = null;
-	private volatile LocalDevice localDevice = null;
+	private volatile SS socket = null;
+
+	abstract void initialiseAdapter() throws IOException;
+
+	abstract boolean isAdapterEnabled();
+
+	abstract void enableAdapter();
+
+	@Nullable
+	abstract String getBluetoothAddress();
+
+	abstract SS openServerSocket(String uuid) throws IOException;
+
+	abstract void tryToClose(@Nullable SS ss);
+
+	abstract DuplexTransportConnection acceptConnection(SS ss)
+			throws IOException;
+
+	abstract boolean isValidAddress(String address);
+
+	abstract DuplexTransportConnection connectTo(String address, String uuid)
+			throws IOException;
 
 	BluetoothPlugin(Executor ioExecutor, SecureRandom secureRandom,
 			Backoff backoff, DuplexPluginCallback callback, int maxLatency) {
@@ -71,6 +87,17 @@ class BluetoothPlugin implements DuplexPlugin {
 		this.maxLatency = maxLatency;
 	}
 
+	void onAdapterEnabled() {
+		LOG.info("Bluetooth enabled");
+		bind();
+	}
+
+	void onAdapterDisabled() {
+		LOG.info("Bluetooth disabled");
+		tryToClose(socket);
+		callback.transportDisabled();
+	}
+
 	@Override
 	public TransportId getId() {
 		return ID;
@@ -90,55 +117,56 @@ class BluetoothPlugin implements DuplexPlugin {
 	@Override
 	public void start() throws PluginException {
 		if (used.getAndSet(true)) throw new IllegalStateException();
-		// Initialise the Bluetooth stack
 		try {
-			localDevice = LocalDevice.getLocalDevice();
-		} catch (UnsatisfiedLinkError e) {
-			// On Linux the user may need to install libbluetooth-dev
-			if (OsUtils.isLinux())
-				callback.showMessage("BLUETOOTH_INSTALL_LIBS");
-			throw new PluginException(e);
-		} catch (BluetoothStateException e) {
+			initialiseAdapter();
+		} catch (IOException e) {
 			throw new PluginException(e);
 		}
-		if (LOG.isLoggable(INFO))
-			LOG.info("Local address " + localDevice.getBluetoothAddress());
 		running = true;
-		bind();
+		// If Bluetooth is enabled, bind a socket
+		if (isAdapterEnabled()) {
+			bind();
+		} else {
+			// Enable Bluetooth if settings allow
+			if (callback.getSettings().getBoolean(PREF_BT_ENABLE, false)) {
+				enableAdapter();
+			} else {
+				LOG.info("Not enabling Bluetooth");
+			}
+		}
 	}
 
 	private void bind() {
 		ioExecutor.execute(() -> {
-			if (!running) return;
-			// Advertise the Bluetooth address to contacts
-			TransportProperties p = new TransportProperties();
-			p.put(PROP_ADDRESS, localDevice.getBluetoothAddress());
-			callback.mergeLocalProperties(p);
+			if (!isRunning()) return;
+			String address = getBluetoothAddress();
+			if (LOG.isLoggable(INFO))
+				LOG.info("Local address " + scrubMacAddress(address));
+			if (!StringUtils.isNullOrEmpty(address)) {
+				// Advertise our Bluetooth address to contacts
+				TransportProperties p = new TransportProperties();
+				p.put(PROP_ADDRESS, address);
+				callback.mergeLocalProperties(p);
+			}
 			// Bind a server socket to accept connections from contacts
-			String url = makeUrl("localhost", getUuid());
-			StreamConnectionNotifier ss;
+			SS ss;
 			try {
-				ss = (StreamConnectionNotifier) Connector.open(url);
+				ss = openServerSocket(getUuid());
 			} catch (IOException e) {
-				if (LOG.isLoggable(WARNING))
-					LOG.log(WARNING, e.toString(), e);
+				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
 				return;
 			}
-			if (!running) {
+			if (!isRunning()) {
 				tryToClose(ss);
 				return;
 			}
 			socket = ss;
 			backoff.reset();
 			callback.transportEnabled();
-			acceptContactConnections(ss);
+			acceptContactConnections();
 		});
 	}
 
-	private String makeUrl(String address, String uuid) {
-		return "btspp://" + address + ":" + uuid + ";name=RFCOMM";
-	}
-
 	private String getUuid() {
 		String uuid = callback.getLocalProperties().get(PROP_UUID);
 		if (uuid == null) {
@@ -152,40 +180,27 @@ class BluetoothPlugin implements DuplexPlugin {
 		return uuid;
 	}
 
-	private void tryToClose(@Nullable StreamConnectionNotifier ss) {
-		try {
-			if (ss != null) ss.close();
-		} catch (IOException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-		} finally {
-			callback.transportDisabled();
-		}
-	}
-
-	private void acceptContactConnections(StreamConnectionNotifier ss) {
+	private void acceptContactConnections() {
 		while (true) {
-			StreamConnection s;
+			DuplexTransportConnection conn;
 			try {
-				s = ss.acceptAndOpen();
+				conn = acceptConnection(socket);
 			} catch (IOException e) {
 				// This is expected when the socket is closed
 				if (LOG.isLoggable(INFO)) LOG.info(e.toString());
 				return;
 			}
 			backoff.reset();
-			callback.incomingConnectionCreated(wrapSocket(s));
+			callback.incomingConnectionCreated(conn);
 			if (!running) return;
 		}
 	}
 
-	private DuplexTransportConnection wrapSocket(StreamConnection s) {
-		return new BluetoothTransportConnection(this, s);
-	}
-
 	@Override
 	public void stop() {
 		running = false;
 		tryToClose(socket);
+		callback.transportDisabled();
 	}
 
 	@Override
@@ -205,7 +220,7 @@ class BluetoothPlugin implements DuplexPlugin {
 
 	@Override
 	public void poll(Collection<ContactId> connected) {
-		if (!running) return;
+		if (!isRunning()) return;
 		backoff.increment();
 		// Try to connect to known devices in parallel
 		Map<ContactId, TransportProperties> remote =
@@ -218,41 +233,56 @@ class BluetoothPlugin implements DuplexPlugin {
 			String uuid = e.getValue().get(PROP_UUID);
 			if (StringUtils.isNullOrEmpty(uuid)) continue;
 			ioExecutor.execute(() -> {
-				if (!running) return;
-				StreamConnection s = connect(makeUrl(address, uuid));
-				if (s != null) {
+				if (!isRunning()) return;
+				DuplexTransportConnection conn = connect(address, uuid);
+				if (conn != null) {
 					backoff.reset();
-					callback.outgoingConnectionCreated(c, wrapSocket(s));
+					callback.outgoingConnectionCreated(c, conn);
 				}
 			});
 		}
 	}
 
 	@Nullable
-	private StreamConnection connect(String url) {
-		if (LOG.isLoggable(INFO)) LOG.info("Connecting to " + url);
+	private DuplexTransportConnection connect(String address, String uuid) {
+		// Validate the address
+		if (!isValidAddress(address)) {
+			if (LOG.isLoggable(WARNING))
+				// Not scrubbing here to be able to figure out the problem
+				LOG.warning("Invalid address " + address);
+			return null;
+		}
+		// Validate the UUID
+		try {
+			//noinspection ResultOfMethodCallIgnored
+			UUID.fromString(uuid);
+		} catch (IllegalArgumentException e) {
+			if (LOG.isLoggable(WARNING)) LOG.warning("Invalid UUID " + uuid);
+			return null;
+		}
+		if (LOG.isLoggable(INFO))
+			LOG.info("Connecting to " + scrubMacAddress(address));
 		try {
-			StreamConnection s = (StreamConnection) Connector.open(url);
-			if (LOG.isLoggable(INFO)) LOG.info("Connected to " + url);
-			return s;
+			DuplexTransportConnection conn = connectTo(address, uuid);
+			if (LOG.isLoggable(INFO))
+				LOG.info("Connected to " + scrubMacAddress(address));
+			return conn;
 		} catch (IOException e) {
-			if (LOG.isLoggable(INFO)) LOG.info("Could not connect to " + url);
+			if (LOG.isLoggable(INFO))
+				LOG.info("Could not connect to " + scrubMacAddress(address));
 			return null;
 		}
 	}
 
 	@Override
 	public DuplexTransportConnection createConnection(ContactId c) {
-		if (!running) return null;
+		if (!isRunning()) return null;
 		TransportProperties p = callback.getRemoteProperties(c);
 		String address = p.get(PROP_ADDRESS);
 		if (StringUtils.isNullOrEmpty(address)) return null;
 		String uuid = p.get(PROP_UUID);
 		if (StringUtils.isNullOrEmpty(uuid)) return null;
-		String url = makeUrl(address, uuid);
-		StreamConnection s = connect(url);
-		if (s == null) return null;
-		return new BluetoothTransportConnection(this, s);
+		return connect(address, uuid);
 	}
 
 	@Override
@@ -262,28 +292,27 @@ class BluetoothPlugin implements DuplexPlugin {
 
 	@Override
 	public KeyAgreementListener createKeyAgreementListener(byte[] commitment) {
-		if (!running) return null;
+		if (!isRunning()) return null;
+		// There's no point listening if we can't discover our own address
+		String address = getBluetoothAddress();
+		if (address == null) return null;
 		// No truncation necessary because COMMIT_LENGTH = 16
 		String uuid = UUID.nameUUIDFromBytes(commitment).toString();
 		if (LOG.isLoggable(INFO)) LOG.info("Key agreement UUID " + uuid);
-		String url = makeUrl("localhost", uuid);
-		// Make the device discoverable if possible
-		makeDeviceDiscoverable();
-		// Bind a server socket for receiving key agreementconnections
-		StreamConnectionNotifier ss;
+		// Bind a server socket for receiving key agreement connections
+		SS ss;
 		try {
-			ss = (StreamConnectionNotifier) Connector.open(url);
+			ss = openServerSocket(uuid);
 		} catch (IOException e) {
 			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
 			return null;
 		}
-		if (!running) {
+		if (!isRunning()) {
 			tryToClose(ss);
 			return null;
 		}
 		BdfList descriptor = new BdfList();
 		descriptor.add(TRANSPORT_ID_BLUETOOTH);
-		String address = localDevice.getBluetoothAddress();
 		descriptor.add(StringUtils.macToBytes(address));
 		return new BluetoothKeyAgreementListener(descriptor, ss);
 	}
@@ -303,10 +332,7 @@ class BluetoothPlugin implements DuplexPlugin {
 		String uuid = UUID.nameUUIDFromBytes(commitment).toString();
 		if (LOG.isLoggable(INFO))
 			LOG.info("Connecting to key agreement UUID " + uuid);
-		String url = makeUrl(address, uuid);
-		StreamConnection s = connect(url);
-		if (s == null) return null;
-		return new BluetoothTransportConnection(this, s);
+		return connect(address, uuid);
 	}
 
 	private String parseAddress(BdfList descriptor) throws FormatException {
@@ -315,21 +341,11 @@ class BluetoothPlugin implements DuplexPlugin {
 		return StringUtils.macToString(mac);
 	}
 
-	private void makeDeviceDiscoverable() {
-		// Try to make the device discoverable (requires root on Linux)
-		try {
-			localDevice.setDiscoverable(GIAC);
-		} catch (BluetoothStateException e) {
-			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-		}
-	}
-
 	private class BluetoothKeyAgreementListener extends KeyAgreementListener {
 
-		private final StreamConnectionNotifier ss;
+		private final SS ss;
 
-		private BluetoothKeyAgreementListener(BdfList descriptor,
-				StreamConnectionNotifier ss) {
+		private BluetoothKeyAgreementListener(BdfList descriptor, SS ss) {
 			super(descriptor);
 			this.ss = ss;
 		}
@@ -337,22 +353,16 @@ class BluetoothPlugin implements DuplexPlugin {
 		@Override
 		public Callable<KeyAgreementConnection> listen() {
 			return () -> {
-				StreamConnection s = ss.acceptAndOpen();
+				DuplexTransportConnection conn = acceptConnection(ss);
 				if (LOG.isLoggable(INFO))
 					LOG.info(ID.getString() + ": Incoming connection");
-				return new KeyAgreementConnection(
-						new BluetoothTransportConnection(
-								BluetoothPlugin.this, s), ID);
+				return new KeyAgreementConnection(conn, ID);
 			};
 		}
 
 		@Override
 		public void close() {
-			try {
-				ss.close();
-			} catch (IOException e) {
-				if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
-			}
+			tryToClose(ss);
 		}
 	}
 }
diff --git a/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/DesktopPluginModule.java b/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/DesktopPluginModule.java
index a2a8f0b8d2b96911b876b786f3ee5c6a9f1698f6..fdd1b4fb9c3e1cf053b9aeb876c9fa4985aabe2b 100644
--- a/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/DesktopPluginModule.java
+++ b/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/DesktopPluginModule.java
@@ -8,7 +8,7 @@ import org.briarproject.bramble.api.plugin.PluginConfig;
 import org.briarproject.bramble.api.plugin.duplex.DuplexPluginFactory;
 import org.briarproject.bramble.api.plugin.simplex.SimplexPluginFactory;
 import org.briarproject.bramble.api.reliability.ReliabilityLayerFactory;
-import org.briarproject.bramble.plugin.bluetooth.BluetoothPluginFactory;
+import org.briarproject.bramble.plugin.bluetooth.JavaBluetoothPluginFactory;
 import org.briarproject.bramble.plugin.file.RemovableDrivePluginFactory;
 import org.briarproject.bramble.plugin.modem.ModemPluginFactory;
 import org.briarproject.bramble.plugin.tcp.LanTcpPluginFactory;
@@ -31,8 +31,9 @@ public class DesktopPluginModule extends PluginModule {
 			SecureRandom random, BackoffFactory backoffFactory,
 			ReliabilityLayerFactory reliabilityFactory,
 			ShutdownManager shutdownManager) {
-		DuplexPluginFactory bluetooth = new BluetoothPluginFactory(ioExecutor,
-				random, backoffFactory);
+		DuplexPluginFactory bluetooth =
+				new JavaBluetoothPluginFactory(ioExecutor, random,
+						backoffFactory);
 		DuplexPluginFactory modem = new ModemPluginFactory(ioExecutor,
 				reliabilityFactory);
 		DuplexPluginFactory lan = new LanTcpPluginFactory(ioExecutor,
diff --git a/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/JavaBluetoothPlugin.java b/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/JavaBluetoothPlugin.java
new file mode 100644
index 0000000000000000000000000000000000000000..02bf0e8f09710ff74144c798d2f7fd8e7b42d117
--- /dev/null
+++ b/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/JavaBluetoothPlugin.java
@@ -0,0 +1,105 @@
+package org.briarproject.bramble.plugin.bluetooth;
+
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.bramble.api.plugin.Backoff;
+import org.briarproject.bramble.api.plugin.duplex.DuplexPluginCallback;
+import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
+
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+import javax.bluetooth.BluetoothStateException;
+import javax.bluetooth.LocalDevice;
+import javax.microedition.io.Connector;
+import javax.microedition.io.StreamConnection;
+import javax.microedition.io.StreamConnectionNotifier;
+
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.bramble.util.StringUtils.isValidMac;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+class JavaBluetoothPlugin extends BluetoothPlugin<StreamConnectionNotifier> {
+
+	private static final Logger LOG =
+			Logger.getLogger(JavaBluetoothPlugin.class.getName());
+
+	// Non-null if the plugin started successfully
+	private volatile LocalDevice localDevice = null;
+
+	JavaBluetoothPlugin(Executor ioExecutor, SecureRandom secureRandom,
+			Backoff backoff, DuplexPluginCallback callback, int maxLatency) {
+		super(ioExecutor, secureRandom, backoff, callback, maxLatency);
+	}
+
+	@Override
+	void initialiseAdapter() throws IOException {
+		try {
+			localDevice = LocalDevice.getLocalDevice();
+		} catch (UnsatisfiedLinkError | BluetoothStateException e) {
+			throw new IOException(e);
+		}
+	}
+
+	@Override
+	boolean isAdapterEnabled() {
+		return LocalDevice.isPowerOn();
+	}
+
+	@Override
+	void enableAdapter() {
+		// Nothing we can do on this platform
+		LOG.info("Could not enable Bluetooth");
+	}
+
+	@Nullable
+	@Override
+	String getBluetoothAddress() {
+		return localDevice.getBluetoothAddress();
+	}
+
+	@Override
+	StreamConnectionNotifier openServerSocket(String uuid) throws IOException {
+		String url = makeUrl("localhost", uuid);
+		return (StreamConnectionNotifier) Connector.open(url);
+	}
+
+	@Override
+	void tryToClose(@Nullable StreamConnectionNotifier ss) {
+		try {
+			if (ss != null) ss.close();
+		} catch (IOException e) {
+			if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e);
+		}
+	}
+
+	@Override
+	DuplexTransportConnection acceptConnection(StreamConnectionNotifier ss)
+			throws IOException {
+		return wrapSocket(ss.acceptAndOpen());
+	}
+
+	@Override
+	boolean isValidAddress(String address) {
+		return isValidMac(address);
+	}
+
+	@Override
+	DuplexTransportConnection connectTo(String address, String uuid)
+			throws IOException {
+		String url = makeUrl(address, uuid);
+		return wrapSocket((StreamConnection) Connector.open(url));
+	}
+
+	private String makeUrl(String address, String uuid) {
+		return "btspp://" + address + ":" + uuid + ";name=RFCOMM";
+	}
+
+	private DuplexTransportConnection wrapSocket(StreamConnection s) {
+		return new JavaBluetoothTransportConnection(this, s);
+	}
+}
diff --git a/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPluginFactory.java b/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/JavaBluetoothPluginFactory.java
similarity index 87%
rename from bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPluginFactory.java
rename to bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/JavaBluetoothPluginFactory.java
index e8dc0a754a90b914e015afcf7f08795a614914fc..f5ddaed332b5cc3d5194696deb68bd7fc349b45a 100644
--- a/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothPluginFactory.java
+++ b/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/JavaBluetoothPluginFactory.java
@@ -17,7 +17,7 @@ import static org.briarproject.bramble.api.plugin.BluetoothConstants.ID;
 
 @Immutable
 @NotNullByDefault
-public class BluetoothPluginFactory implements DuplexPluginFactory {
+public class JavaBluetoothPluginFactory implements DuplexPluginFactory {
 
 	private static final int MAX_LATENCY = 30 * 1000; // 30 seconds
 	private static final int MIN_POLLING_INTERVAL = 60 * 1000; // 1 minute
@@ -28,7 +28,7 @@ public class BluetoothPluginFactory implements DuplexPluginFactory {
 	private final SecureRandom secureRandom;
 	private final BackoffFactory backoffFactory;
 
-	public BluetoothPluginFactory(Executor ioExecutor,
+	public JavaBluetoothPluginFactory(Executor ioExecutor,
 			SecureRandom secureRandom, BackoffFactory backoffFactory) {
 		this.ioExecutor = ioExecutor;
 		this.secureRandom = secureRandom;
@@ -49,7 +49,7 @@ public class BluetoothPluginFactory implements DuplexPluginFactory {
 	public DuplexPlugin createPlugin(DuplexPluginCallback callback) {
 		Backoff backoff = backoffFactory.createBackoff(MIN_POLLING_INTERVAL,
 				MAX_POLLING_INTERVAL, BACKOFF_BASE);
-		return new BluetoothPlugin(ioExecutor, secureRandom, backoff, callback,
-				MAX_LATENCY);
+		return new JavaBluetoothPlugin(ioExecutor, secureRandom, backoff,
+				callback, MAX_LATENCY);
 	}
 }
diff --git a/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothTransportConnection.java b/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/JavaBluetoothTransportConnection.java
similarity index 83%
rename from bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothTransportConnection.java
rename to bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/JavaBluetoothTransportConnection.java
index 4a8849c7b0561478f4699c2f757afeacb5dc48ef..ffb0f6d4f823e74f95d337e9357cd2c0b9f2b04e 100644
--- a/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/BluetoothTransportConnection.java
+++ b/bramble-j2se/src/main/java/org/briarproject/bramble/plugin/bluetooth/JavaBluetoothTransportConnection.java
@@ -11,11 +11,12 @@ import java.io.OutputStream;
 import javax.microedition.io.StreamConnection;
 
 @NotNullByDefault
-class BluetoothTransportConnection extends AbstractDuplexTransportConnection {
+class JavaBluetoothTransportConnection
+		extends AbstractDuplexTransportConnection {
 
 	private final StreamConnection stream;
 
-	BluetoothTransportConnection(Plugin plugin, StreamConnection stream) {
+	JavaBluetoothTransportConnection(Plugin plugin, StreamConnection stream) {
 		super(plugin);
 		this.stream = stream;
 	}