mirror of
https://code.briarproject.org/briar/briar.git
synced 2026-02-12 10:49:06 +01:00
Implement backend for connect via bluetooth
This commit is contained in:
@@ -59,8 +59,8 @@ import static org.briarproject.bramble.util.PrivacyUtils.scrubMacAddress;
|
||||
|
||||
@MethodsNotNullByDefault
|
||||
@ParametersNotNullByDefault
|
||||
class AndroidBluetoothPlugin
|
||||
extends BluetoothPlugin<BluetoothSocket, BluetoothServerSocket> {
|
||||
class AndroidBluetoothPlugin extends
|
||||
AbstractBluetoothPlugin<BluetoothSocket, BluetoothServerSocket> {
|
||||
|
||||
private static final Logger LOG =
|
||||
getLogger(AndroidBluetoothPlugin.class.getName());
|
||||
@@ -75,6 +75,7 @@ class AndroidBluetoothPlugin
|
||||
|
||||
// Non-null if the plugin started successfully
|
||||
private volatile BluetoothAdapter adapter = null;
|
||||
private volatile boolean stopDiscoverAndConnect;
|
||||
|
||||
AndroidBluetoothPlugin(BluetoothConnectionLimiter connectionLimiter,
|
||||
BluetoothConnectionFactory<BluetoothSocket> connectionFactory,
|
||||
@@ -187,22 +188,40 @@ class AndroidBluetoothPlugin
|
||||
@Nullable
|
||||
DuplexTransportConnection discoverAndConnect(String uuid) {
|
||||
if (adapter == null) return null;
|
||||
for (String address : discoverDevices()) {
|
||||
try {
|
||||
if (LOG.isLoggable(INFO))
|
||||
LOG.info("Connecting to " + scrubMacAddress(address));
|
||||
return connectTo(address, uuid);
|
||||
} catch (IOException e) {
|
||||
if (LOG.isLoggable(INFO)) {
|
||||
LOG.info("Could not connect to "
|
||||
+ scrubMacAddress(address));
|
||||
if (!discoverSemaphore.tryAcquire()) {
|
||||
LOG.info("Discover already running");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
stopDiscoverAndConnect = false;
|
||||
for (String address : discoverDevices()) {
|
||||
if (stopDiscoverAndConnect) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
if (LOG.isLoggable(INFO))
|
||||
LOG.info("Connecting to " + scrubMacAddress(address));
|
||||
return connectTo(address, uuid);
|
||||
} catch (IOException e) {
|
||||
if (LOG.isLoggable(INFO)) {
|
||||
LOG.info("Could not connect to "
|
||||
+ scrubMacAddress(address));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
discoverSemaphore.release();
|
||||
}
|
||||
LOG.info("Could not connect to any devices");
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stopDiscoverAndConnect() {
|
||||
stopDiscoverAndConnect = true;
|
||||
adapter.cancelDiscovery();
|
||||
}
|
||||
|
||||
private Collection<String> discoverDevices() {
|
||||
List<String> addresses = new ArrayList<>();
|
||||
BlockingQueue<Intent> intents = new LinkedBlockingQueue<>();
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
package org.briarproject.bramble.plugin.bluetooth;
|
||||
|
||||
import org.briarproject.bramble.api.FormatException;
|
||||
import org.briarproject.bramble.api.Multiset;
|
||||
import org.briarproject.bramble.api.Pair;
|
||||
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.keyagreement.event.KeyAgreementListeningEvent;
|
||||
import org.briarproject.bramble.api.keyagreement.event.KeyAgreementStoppedListeningEvent;
|
||||
import org.briarproject.bramble.api.lifecycle.IoExecutor;
|
||||
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
|
||||
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
|
||||
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
|
||||
import org.briarproject.bramble.api.plugin.Backoff;
|
||||
import org.briarproject.bramble.api.plugin.ConnectionHandler;
|
||||
import org.briarproject.bramble.api.plugin.PluginCallback;
|
||||
import org.briarproject.bramble.api.plugin.PluginException;
|
||||
import org.briarproject.bramble.api.plugin.TransportId;
|
||||
import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
|
||||
import org.briarproject.bramble.api.properties.TransportProperties;
|
||||
import org.briarproject.bramble.api.properties.event.RemoteTransportPropertiesUpdatedEvent;
|
||||
import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
|
||||
import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
|
||||
import org.briarproject.bramble.api.settings.Settings;
|
||||
import org.briarproject.bramble.api.settings.event.SettingsUpdatedEvent;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import javax.annotation.concurrent.GuardedBy;
|
||||
import javax.annotation.concurrent.ThreadSafe;
|
||||
|
||||
import static java.util.logging.Level.INFO;
|
||||
import static java.util.logging.Level.WARNING;
|
||||
import static java.util.logging.Logger.getLogger;
|
||||
import static org.briarproject.bramble.api.keyagreement.KeyAgreementConstants.TRANSPORT_ID_BLUETOOTH;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.DEFAULT_PREF_ADDRESS_IS_REFLECTED;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.DEFAULT_PREF_EVER_CONNECTED;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.DEFAULT_PREF_PLUGIN_ENABLE;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.ID;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.PREF_ADDRESS_IS_REFLECTED;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.PREF_EVER_CONNECTED;
|
||||
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.api.plugin.Plugin.State.ACTIVE;
|
||||
import static org.briarproject.bramble.api.plugin.Plugin.State.DISABLED;
|
||||
import static org.briarproject.bramble.api.plugin.Plugin.State.INACTIVE;
|
||||
import static org.briarproject.bramble.api.plugin.Plugin.State.STARTING_STOPPING;
|
||||
import static org.briarproject.bramble.api.properties.TransportPropertyConstants.REFLECTED_PROPERTY_PREFIX;
|
||||
import static org.briarproject.bramble.util.LogUtils.logException;
|
||||
import static org.briarproject.bramble.util.PrivacyUtils.scrubMacAddress;
|
||||
import static org.briarproject.bramble.util.StringUtils.isNullOrEmpty;
|
||||
import static org.briarproject.bramble.util.StringUtils.macToBytes;
|
||||
import static org.briarproject.bramble.util.StringUtils.macToString;
|
||||
|
||||
@MethodsNotNullByDefault
|
||||
@ParametersNotNullByDefault
|
||||
abstract class AbstractBluetoothPlugin<S, SS> implements BluetoothPlugin,
|
||||
EventListener {
|
||||
|
||||
private static final Logger LOG =
|
||||
getLogger(AbstractBluetoothPlugin.class.getName());
|
||||
|
||||
private final BluetoothConnectionLimiter connectionLimiter;
|
||||
final BluetoothConnectionFactory<S> connectionFactory;
|
||||
|
||||
private final Executor ioExecutor, wakefulIoExecutor;
|
||||
private final SecureRandom secureRandom;
|
||||
private final Backoff backoff;
|
||||
private final PluginCallback callback;
|
||||
private final int maxLatency, maxIdleTime;
|
||||
private final AtomicBoolean used = new AtomicBoolean(false);
|
||||
private final AtomicBoolean everConnected = new AtomicBoolean(false);
|
||||
|
||||
protected final PluginState state = new PluginState();
|
||||
protected final Semaphore discoverSemaphore = new Semaphore(1);
|
||||
|
||||
private volatile String contactConnectionsUuid = null;
|
||||
|
||||
abstract void initialiseAdapter() throws IOException;
|
||||
|
||||
abstract boolean isAdapterEnabled();
|
||||
|
||||
/**
|
||||
* Returns the local Bluetooth address, or null if no valid address can
|
||||
* be found.
|
||||
*/
|
||||
@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;
|
||||
|
||||
@Nullable
|
||||
abstract DuplexTransportConnection discoverAndConnect(String uuid);
|
||||
|
||||
AbstractBluetoothPlugin(BluetoothConnectionLimiter connectionLimiter,
|
||||
BluetoothConnectionFactory<S> connectionFactory,
|
||||
Executor ioExecutor,
|
||||
Executor wakefulIoExecutor,
|
||||
SecureRandom secureRandom,
|
||||
Backoff backoff,
|
||||
PluginCallback callback,
|
||||
int maxLatency,
|
||||
int maxIdleTime) {
|
||||
this.connectionLimiter = connectionLimiter;
|
||||
this.connectionFactory = connectionFactory;
|
||||
this.ioExecutor = ioExecutor;
|
||||
this.wakefulIoExecutor = wakefulIoExecutor;
|
||||
this.secureRandom = secureRandom;
|
||||
this.backoff = backoff;
|
||||
this.callback = callback;
|
||||
this.maxLatency = maxLatency;
|
||||
this.maxIdleTime = maxIdleTime;
|
||||
}
|
||||
|
||||
void onAdapterEnabled() {
|
||||
LOG.info("Bluetooth enabled");
|
||||
// We may not have been able to get the local address before
|
||||
ioExecutor.execute(this::updateProperties);
|
||||
if (getState() == INACTIVE) bind();
|
||||
}
|
||||
|
||||
void onAdapterDisabled() {
|
||||
LOG.info("Bluetooth disabled");
|
||||
connectionLimiter.allConnectionsClosed();
|
||||
// The server socket may not have been closed automatically
|
||||
SS ss = state.clearServerSocket();
|
||||
if (ss != null) {
|
||||
LOG.info("Closing server socket");
|
||||
tryToClose(ss);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportId getId() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxLatency() {
|
||||
return maxLatency;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxIdleTime() {
|
||||
return maxIdleTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() throws PluginException {
|
||||
if (used.getAndSet(true)) throw new IllegalStateException();
|
||||
Settings settings = callback.getSettings();
|
||||
boolean enabledByUser = settings.getBoolean(PREF_PLUGIN_ENABLE,
|
||||
DEFAULT_PREF_PLUGIN_ENABLE);
|
||||
everConnected.set(settings.getBoolean(PREF_EVER_CONNECTED,
|
||||
DEFAULT_PREF_EVER_CONNECTED));
|
||||
state.setStarted(enabledByUser);
|
||||
try {
|
||||
initialiseAdapter();
|
||||
} catch (IOException e) {
|
||||
throw new PluginException(e);
|
||||
}
|
||||
updateProperties();
|
||||
if (enabledByUser && isAdapterEnabled()) bind();
|
||||
}
|
||||
|
||||
private void bind() {
|
||||
ioExecutor.execute(() -> {
|
||||
if (getState() != INACTIVE) return;
|
||||
// Bind a server socket to accept connections from contacts
|
||||
SS ss;
|
||||
try {
|
||||
ss = openServerSocket(contactConnectionsUuid);
|
||||
} catch (IOException e) {
|
||||
logException(LOG, WARNING, e);
|
||||
return;
|
||||
}
|
||||
if (!state.setServerSocket(ss)) {
|
||||
LOG.info("Closing redundant server socket");
|
||||
tryToClose(ss);
|
||||
return;
|
||||
}
|
||||
backoff.reset();
|
||||
acceptContactConnections(ss);
|
||||
});
|
||||
}
|
||||
|
||||
private void updateProperties() {
|
||||
TransportProperties p = callback.getLocalProperties();
|
||||
String address = p.get(PROP_ADDRESS);
|
||||
String uuid = p.get(PROP_UUID);
|
||||
Settings s = callback.getSettings();
|
||||
boolean isReflected = s.getBoolean(PREF_ADDRESS_IS_REFLECTED,
|
||||
DEFAULT_PREF_ADDRESS_IS_REFLECTED);
|
||||
boolean changed = false;
|
||||
if (address == null || isReflected) {
|
||||
address = getBluetoothAddress();
|
||||
if (LOG.isLoggable(INFO)) {
|
||||
LOG.info("Local address " + scrubMacAddress(address));
|
||||
}
|
||||
if (address == null) {
|
||||
if (everConnected.get()) {
|
||||
address = getReflectedAddress();
|
||||
if (LOG.isLoggable(INFO)) {
|
||||
LOG.info("Reflected address " +
|
||||
scrubMacAddress(address));
|
||||
}
|
||||
if (address != null) {
|
||||
changed = true;
|
||||
isReflected = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
changed = true;
|
||||
isReflected = false;
|
||||
}
|
||||
}
|
||||
if (uuid == null) {
|
||||
byte[] random = new byte[UUID_BYTES];
|
||||
secureRandom.nextBytes(random);
|
||||
uuid = UUID.nameUUIDFromBytes(random).toString();
|
||||
changed = true;
|
||||
}
|
||||
contactConnectionsUuid = uuid;
|
||||
if (changed) {
|
||||
p = new TransportProperties();
|
||||
// If we previously used a reflected address and there's no longer
|
||||
// a reflected address with enough votes to be used, we'll continue
|
||||
// to use the old reflected address until there's a new winner
|
||||
if (address != null) p.put(PROP_ADDRESS, address);
|
||||
p.put(PROP_UUID, uuid);
|
||||
callback.mergeLocalProperties(p);
|
||||
s = new Settings();
|
||||
s.putBoolean(PREF_ADDRESS_IS_REFLECTED, isReflected);
|
||||
callback.mergeSettings(s);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String getReflectedAddress() {
|
||||
// Count the number of votes for each reflected address
|
||||
String key = REFLECTED_PROPERTY_PREFIX + PROP_ADDRESS;
|
||||
Multiset<String> votes = new Multiset<>();
|
||||
for (TransportProperties p : callback.getRemoteProperties()) {
|
||||
String address = p.get(key);
|
||||
if (address != null && isValidAddress(address)) votes.add(address);
|
||||
}
|
||||
// If an address gets more than half of the votes, accept it
|
||||
int total = votes.getTotal();
|
||||
for (String address : votes.keySet()) {
|
||||
if (votes.getCount(address) * 2 > total) return address;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void acceptContactConnections(SS ss) {
|
||||
while (true) {
|
||||
DuplexTransportConnection conn;
|
||||
try {
|
||||
conn = acceptConnection(ss);
|
||||
} catch (IOException e) {
|
||||
// This is expected when the server socket is closed
|
||||
LOG.info("Server socket closed");
|
||||
state.clearServerSocket();
|
||||
return;
|
||||
}
|
||||
LOG.info("Connection received");
|
||||
connectionLimiter.connectionOpened(conn);
|
||||
backoff.reset();
|
||||
setEverConnected();
|
||||
callback.handleConnection(conn);
|
||||
}
|
||||
}
|
||||
|
||||
private void setEverConnected() {
|
||||
if (!everConnected.getAndSet(true)) {
|
||||
ioExecutor.execute(() -> {
|
||||
Settings s = new Settings();
|
||||
s.putBoolean(PREF_EVER_CONNECTED, true);
|
||||
callback.mergeSettings(s);
|
||||
// Contacts may already have sent a reflected address
|
||||
updateProperties();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
SS ss = state.setStopped();
|
||||
tryToClose(ss);
|
||||
}
|
||||
|
||||
@Override
|
||||
public State getState() {
|
||||
return state.getState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getReasonsDisabled() {
|
||||
return state.getReasonsDisabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldPoll() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPollingInterval() {
|
||||
return backoff.getPollingInterval();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void poll(Collection<Pair<TransportProperties, ConnectionHandler>>
|
||||
properties) {
|
||||
if (getState() != ACTIVE) return;
|
||||
backoff.increment();
|
||||
for (Pair<TransportProperties, ConnectionHandler> p : properties) {
|
||||
connect(p.getFirst(), p.getSecond());
|
||||
}
|
||||
}
|
||||
|
||||
private void connect(TransportProperties p, ConnectionHandler h) {
|
||||
String address = p.get(PROP_ADDRESS);
|
||||
if (isNullOrEmpty(address)) return;
|
||||
String uuid = p.get(PROP_UUID);
|
||||
if (isNullOrEmpty(uuid)) return;
|
||||
wakefulIoExecutor.execute(() -> {
|
||||
DuplexTransportConnection d = createConnection(p);
|
||||
if (d != null) {
|
||||
backoff.reset();
|
||||
setEverConnected();
|
||||
h.handleConnection(d);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Nullable
|
||||
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 {
|
||||
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 " + scrubMacAddress(address));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public DuplexTransportConnection createConnection(TransportProperties p) {
|
||||
if (getState() != ACTIVE) return null;
|
||||
if (!connectionLimiter.canOpenContactConnection()) return null;
|
||||
String address = p.get(PROP_ADDRESS);
|
||||
if (isNullOrEmpty(address)) return null;
|
||||
String uuid = p.get(PROP_UUID);
|
||||
if (isNullOrEmpty(uuid)) return null;
|
||||
DuplexTransportConnection conn = connect(address, uuid);
|
||||
if (conn != null) connectionLimiter.connectionOpened(conn);
|
||||
return conn;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsKeyAgreement() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyAgreementListener createKeyAgreementListener(byte[] commitment) {
|
||||
if (getState() != ACTIVE) 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);
|
||||
// Bind a server socket for receiving key agreement connections
|
||||
SS ss;
|
||||
try {
|
||||
ss = openServerSocket(uuid);
|
||||
} catch (IOException e) {
|
||||
logException(LOG, WARNING, e);
|
||||
return null;
|
||||
}
|
||||
if (getState() != ACTIVE) {
|
||||
tryToClose(ss);
|
||||
return null;
|
||||
}
|
||||
BdfList descriptor = new BdfList();
|
||||
descriptor.add(TRANSPORT_ID_BLUETOOTH);
|
||||
String address = getBluetoothAddress();
|
||||
if (address != null) descriptor.add(macToBytes(address));
|
||||
return new BluetoothKeyAgreementListener(descriptor, ss);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DuplexTransportConnection createKeyAgreementConnection(
|
||||
byte[] commitment, BdfList descriptor) {
|
||||
if (getState() != ACTIVE) return null;
|
||||
// No truncation necessary because COMMIT_LENGTH = 16
|
||||
String uuid = UUID.nameUUIDFromBytes(commitment).toString();
|
||||
DuplexTransportConnection conn;
|
||||
if (descriptor.size() == 1) {
|
||||
if (LOG.isLoggable(INFO)) {
|
||||
LOG.info("Discovering address for key agreement UUID " +
|
||||
uuid);
|
||||
}
|
||||
conn = discoverAndConnect(uuid);
|
||||
} else {
|
||||
String address;
|
||||
try {
|
||||
address = parseAddress(descriptor);
|
||||
} catch (FormatException e) {
|
||||
LOG.info("Invalid address in key agreement descriptor");
|
||||
return null;
|
||||
}
|
||||
if (LOG.isLoggable(INFO))
|
||||
LOG.info("Connecting to key agreement UUID " + uuid);
|
||||
conn = connect(address, uuid);
|
||||
}
|
||||
if (conn != null) {
|
||||
connectionLimiter.connectionOpened(conn);
|
||||
setEverConnected();
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
private String parseAddress(BdfList descriptor) throws FormatException {
|
||||
byte[] mac = descriptor.getRaw(1);
|
||||
if (mac.length != 6) throw new FormatException();
|
||||
return macToString(mac);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDiscovering() {
|
||||
return discoverSemaphore.availablePermits() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DuplexTransportConnection discoverAndConnectForSetup(String uuid) {
|
||||
DuplexTransportConnection conn = discoverAndConnect(uuid);
|
||||
if (conn != null) {
|
||||
connectionLimiter.connectionOpened(conn);
|
||||
setEverConnected();
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsRendezvous() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RendezvousEndpoint createRendezvousEndpoint(KeyMaterialSource k,
|
||||
boolean alice, ConnectionHandler incoming) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void eventOccurred(Event e) {
|
||||
if (e instanceof SettingsUpdatedEvent) {
|
||||
SettingsUpdatedEvent s = (SettingsUpdatedEvent) e;
|
||||
if (s.getNamespace().equals(ID.getString()))
|
||||
ioExecutor.execute(() -> onSettingsUpdated(s.getSettings()));
|
||||
} else if (e instanceof KeyAgreementListeningEvent) {
|
||||
ioExecutor.execute(connectionLimiter::keyAgreementStarted);
|
||||
} else if (e instanceof KeyAgreementStoppedListeningEvent) {
|
||||
ioExecutor.execute(connectionLimiter::keyAgreementEnded);
|
||||
} else if (e instanceof RemoteTransportPropertiesUpdatedEvent) {
|
||||
RemoteTransportPropertiesUpdatedEvent r =
|
||||
(RemoteTransportPropertiesUpdatedEvent) e;
|
||||
if (r.getTransportId().equals(ID)) {
|
||||
ioExecutor.execute(this::updateProperties);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IoExecutor
|
||||
private void onSettingsUpdated(Settings settings) {
|
||||
boolean enabledByUser = settings.getBoolean(PREF_PLUGIN_ENABLE,
|
||||
DEFAULT_PREF_PLUGIN_ENABLE);
|
||||
SS ss = state.setEnabledByUser(enabledByUser);
|
||||
State s = getState();
|
||||
if (ss != null) {
|
||||
LOG.info("Disabled by user, closing server socket");
|
||||
tryToClose(ss);
|
||||
} else if (s == INACTIVE) {
|
||||
if (isAdapterEnabled()) {
|
||||
LOG.info("Enabled by user, opening server socket");
|
||||
bind();
|
||||
} else {
|
||||
LOG.info("Enabled by user but adapter is disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BluetoothKeyAgreementListener extends KeyAgreementListener {
|
||||
|
||||
private final SS ss;
|
||||
|
||||
private BluetoothKeyAgreementListener(BdfList descriptor, SS ss) {
|
||||
super(descriptor);
|
||||
this.ss = ss;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyAgreementConnection accept() throws IOException {
|
||||
DuplexTransportConnection conn = acceptConnection(ss);
|
||||
if (LOG.isLoggable(INFO)) LOG.info(ID + ": Incoming connection");
|
||||
connectionLimiter.connectionOpened(conn);
|
||||
return new KeyAgreementConnection(conn, ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
tryToClose(ss);
|
||||
}
|
||||
}
|
||||
|
||||
@ThreadSafe
|
||||
@NotNullByDefault
|
||||
private class PluginState {
|
||||
|
||||
@GuardedBy("this")
|
||||
private boolean started = false,
|
||||
stopped = false,
|
||||
enabledByUser = false;
|
||||
|
||||
@GuardedBy("this")
|
||||
@Nullable
|
||||
private SS serverSocket = null;
|
||||
|
||||
private synchronized void setStarted(boolean enabledByUser) {
|
||||
started = true;
|
||||
this.enabledByUser = enabledByUser;
|
||||
callback.pluginStateChanged(getState());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private synchronized SS setStopped() {
|
||||
stopped = true;
|
||||
SS ss = serverSocket;
|
||||
serverSocket = null;
|
||||
callback.pluginStateChanged(getState());
|
||||
return ss;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private synchronized SS setEnabledByUser(boolean enabledByUser) {
|
||||
this.enabledByUser = enabledByUser;
|
||||
SS ss = null;
|
||||
if (!enabledByUser) {
|
||||
ss = serverSocket;
|
||||
serverSocket = null;
|
||||
}
|
||||
callback.pluginStateChanged(getState());
|
||||
return ss;
|
||||
}
|
||||
|
||||
private synchronized boolean setServerSocket(SS ss) {
|
||||
if (stopped || serverSocket != null) return false;
|
||||
serverSocket = ss;
|
||||
callback.pluginStateChanged(getState());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private synchronized SS clearServerSocket() {
|
||||
SS ss = serverSocket;
|
||||
serverSocket = null;
|
||||
callback.pluginStateChanged(getState());
|
||||
return ss;
|
||||
}
|
||||
|
||||
private synchronized State getState() {
|
||||
if (!started || stopped) return STARTING_STOPPING;
|
||||
if (!enabledByUser) return DISABLED;
|
||||
return serverSocket == null ? INACTIVE : ACTIVE;
|
||||
}
|
||||
|
||||
private synchronized int getReasonsDisabled() {
|
||||
return getState() == DISABLED ? REASON_USER : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,606 +1,18 @@
|
||||
package org.briarproject.bramble.plugin.bluetooth;
|
||||
|
||||
import org.briarproject.bramble.api.FormatException;
|
||||
import org.briarproject.bramble.api.Multiset;
|
||||
import org.briarproject.bramble.api.Pair;
|
||||
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.keyagreement.event.KeyAgreementListeningEvent;
|
||||
import org.briarproject.bramble.api.keyagreement.event.KeyAgreementStoppedListeningEvent;
|
||||
import org.briarproject.bramble.api.lifecycle.IoExecutor;
|
||||
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
|
||||
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
|
||||
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
|
||||
import org.briarproject.bramble.api.plugin.Backoff;
|
||||
import org.briarproject.bramble.api.plugin.ConnectionHandler;
|
||||
import org.briarproject.bramble.api.plugin.PluginCallback;
|
||||
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.DuplexTransportConnection;
|
||||
import org.briarproject.bramble.api.properties.TransportProperties;
|
||||
import org.briarproject.bramble.api.properties.event.RemoteTransportPropertiesUpdatedEvent;
|
||||
import org.briarproject.bramble.api.rendezvous.KeyMaterialSource;
|
||||
import org.briarproject.bramble.api.rendezvous.RendezvousEndpoint;
|
||||
import org.briarproject.bramble.api.settings.Settings;
|
||||
import org.briarproject.bramble.api.settings.event.SettingsUpdatedEvent;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import javax.annotation.concurrent.GuardedBy;
|
||||
import javax.annotation.concurrent.ThreadSafe;
|
||||
|
||||
import static java.util.logging.Level.INFO;
|
||||
import static java.util.logging.Level.WARNING;
|
||||
import static java.util.logging.Logger.getLogger;
|
||||
import static org.briarproject.bramble.api.keyagreement.KeyAgreementConstants.TRANSPORT_ID_BLUETOOTH;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.DEFAULT_PREF_ADDRESS_IS_REFLECTED;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.DEFAULT_PREF_EVER_CONNECTED;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.DEFAULT_PREF_PLUGIN_ENABLE;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.ID;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.PREF_ADDRESS_IS_REFLECTED;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.PREF_EVER_CONNECTED;
|
||||
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.api.plugin.Plugin.State.ACTIVE;
|
||||
import static org.briarproject.bramble.api.plugin.Plugin.State.DISABLED;
|
||||
import static org.briarproject.bramble.api.plugin.Plugin.State.INACTIVE;
|
||||
import static org.briarproject.bramble.api.plugin.Plugin.State.STARTING_STOPPING;
|
||||
import static org.briarproject.bramble.api.properties.TransportPropertyConstants.REFLECTED_PROPERTY_PREFIX;
|
||||
import static org.briarproject.bramble.util.LogUtils.logException;
|
||||
import static org.briarproject.bramble.util.PrivacyUtils.scrubMacAddress;
|
||||
import static org.briarproject.bramble.util.StringUtils.isNullOrEmpty;
|
||||
import static org.briarproject.bramble.util.StringUtils.macToBytes;
|
||||
import static org.briarproject.bramble.util.StringUtils.macToString;
|
||||
@NotNullByDefault
|
||||
public interface BluetoothPlugin extends DuplexPlugin {
|
||||
|
||||
@MethodsNotNullByDefault
|
||||
@ParametersNotNullByDefault
|
||||
abstract class BluetoothPlugin<S, SS> implements DuplexPlugin, EventListener {
|
||||
|
||||
private static final Logger LOG =
|
||||
getLogger(BluetoothPlugin.class.getName());
|
||||
|
||||
final BluetoothConnectionLimiter connectionLimiter;
|
||||
final BluetoothConnectionFactory<S> connectionFactory;
|
||||
|
||||
private final Executor ioExecutor, wakefulIoExecutor;
|
||||
private final SecureRandom secureRandom;
|
||||
private final Backoff backoff;
|
||||
private final PluginCallback callback;
|
||||
private final int maxLatency, maxIdleTime;
|
||||
private final AtomicBoolean used = new AtomicBoolean(false);
|
||||
private final AtomicBoolean everConnected = new AtomicBoolean(false);
|
||||
|
||||
protected final PluginState state = new PluginState();
|
||||
|
||||
private volatile String contactConnectionsUuid = null;
|
||||
|
||||
abstract void initialiseAdapter() throws IOException;
|
||||
|
||||
abstract boolean isAdapterEnabled();
|
||||
|
||||
/**
|
||||
* Returns the local Bluetooth address, or null if no valid address can
|
||||
* be found.
|
||||
*/
|
||||
@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;
|
||||
boolean isDiscovering();
|
||||
|
||||
@Nullable
|
||||
abstract DuplexTransportConnection discoverAndConnect(String uuid);
|
||||
DuplexTransportConnection discoverAndConnectForSetup(String uuid);
|
||||
|
||||
BluetoothPlugin(BluetoothConnectionLimiter connectionLimiter,
|
||||
BluetoothConnectionFactory<S> connectionFactory,
|
||||
Executor ioExecutor,
|
||||
Executor wakefulIoExecutor,
|
||||
SecureRandom secureRandom,
|
||||
Backoff backoff,
|
||||
PluginCallback callback,
|
||||
int maxLatency,
|
||||
int maxIdleTime) {
|
||||
this.connectionLimiter = connectionLimiter;
|
||||
this.connectionFactory = connectionFactory;
|
||||
this.ioExecutor = ioExecutor;
|
||||
this.wakefulIoExecutor = wakefulIoExecutor;
|
||||
this.secureRandom = secureRandom;
|
||||
this.backoff = backoff;
|
||||
this.callback = callback;
|
||||
this.maxLatency = maxLatency;
|
||||
this.maxIdleTime = maxIdleTime;
|
||||
}
|
||||
|
||||
void onAdapterEnabled() {
|
||||
LOG.info("Bluetooth enabled");
|
||||
// We may not have been able to get the local address before
|
||||
ioExecutor.execute(this::updateProperties);
|
||||
if (getState() == INACTIVE) bind();
|
||||
}
|
||||
|
||||
void onAdapterDisabled() {
|
||||
LOG.info("Bluetooth disabled");
|
||||
connectionLimiter.allConnectionsClosed();
|
||||
// The server socket may not have been closed automatically
|
||||
SS ss = state.clearServerSocket();
|
||||
if (ss != null) {
|
||||
LOG.info("Closing server socket");
|
||||
tryToClose(ss);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportId getId() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxLatency() {
|
||||
return maxLatency;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxIdleTime() {
|
||||
return maxIdleTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() throws PluginException {
|
||||
if (used.getAndSet(true)) throw new IllegalStateException();
|
||||
Settings settings = callback.getSettings();
|
||||
boolean enabledByUser = settings.getBoolean(PREF_PLUGIN_ENABLE,
|
||||
DEFAULT_PREF_PLUGIN_ENABLE);
|
||||
everConnected.set(settings.getBoolean(PREF_EVER_CONNECTED,
|
||||
DEFAULT_PREF_EVER_CONNECTED));
|
||||
state.setStarted(enabledByUser);
|
||||
try {
|
||||
initialiseAdapter();
|
||||
} catch (IOException e) {
|
||||
throw new PluginException(e);
|
||||
}
|
||||
updateProperties();
|
||||
if (enabledByUser && isAdapterEnabled()) bind();
|
||||
}
|
||||
|
||||
private void bind() {
|
||||
ioExecutor.execute(() -> {
|
||||
if (getState() != INACTIVE) return;
|
||||
// Bind a server socket to accept connections from contacts
|
||||
SS ss;
|
||||
try {
|
||||
ss = openServerSocket(contactConnectionsUuid);
|
||||
} catch (IOException e) {
|
||||
logException(LOG, WARNING, e);
|
||||
return;
|
||||
}
|
||||
if (!state.setServerSocket(ss)) {
|
||||
LOG.info("Closing redundant server socket");
|
||||
tryToClose(ss);
|
||||
return;
|
||||
}
|
||||
backoff.reset();
|
||||
acceptContactConnections(ss);
|
||||
});
|
||||
}
|
||||
|
||||
private void updateProperties() {
|
||||
TransportProperties p = callback.getLocalProperties();
|
||||
String address = p.get(PROP_ADDRESS);
|
||||
String uuid = p.get(PROP_UUID);
|
||||
Settings s = callback.getSettings();
|
||||
boolean isReflected = s.getBoolean(PREF_ADDRESS_IS_REFLECTED,
|
||||
DEFAULT_PREF_ADDRESS_IS_REFLECTED);
|
||||
boolean changed = false;
|
||||
if (address == null || isReflected) {
|
||||
address = getBluetoothAddress();
|
||||
if (LOG.isLoggable(INFO)) {
|
||||
LOG.info("Local address " + scrubMacAddress(address));
|
||||
}
|
||||
if (address == null) {
|
||||
if (everConnected.get()) {
|
||||
address = getReflectedAddress();
|
||||
if (LOG.isLoggable(INFO)) {
|
||||
LOG.info("Reflected address " +
|
||||
scrubMacAddress(address));
|
||||
}
|
||||
if (address != null) {
|
||||
changed = true;
|
||||
isReflected = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
changed = true;
|
||||
isReflected = false;
|
||||
}
|
||||
}
|
||||
if (uuid == null) {
|
||||
byte[] random = new byte[UUID_BYTES];
|
||||
secureRandom.nextBytes(random);
|
||||
uuid = UUID.nameUUIDFromBytes(random).toString();
|
||||
changed = true;
|
||||
}
|
||||
contactConnectionsUuid = uuid;
|
||||
if (changed) {
|
||||
p = new TransportProperties();
|
||||
// If we previously used a reflected address and there's no longer
|
||||
// a reflected address with enough votes to be used, we'll continue
|
||||
// to use the old reflected address until there's a new winner
|
||||
if (address != null) p.put(PROP_ADDRESS, address);
|
||||
p.put(PROP_UUID, uuid);
|
||||
callback.mergeLocalProperties(p);
|
||||
s = new Settings();
|
||||
s.putBoolean(PREF_ADDRESS_IS_REFLECTED, isReflected);
|
||||
callback.mergeSettings(s);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String getReflectedAddress() {
|
||||
// Count the number of votes for each reflected address
|
||||
String key = REFLECTED_PROPERTY_PREFIX + PROP_ADDRESS;
|
||||
Multiset<String> votes = new Multiset<>();
|
||||
for (TransportProperties p : callback.getRemoteProperties()) {
|
||||
String address = p.get(key);
|
||||
if (address != null && isValidAddress(address)) votes.add(address);
|
||||
}
|
||||
// If an address gets more than half of the votes, accept it
|
||||
int total = votes.getTotal();
|
||||
for (String address : votes.keySet()) {
|
||||
if (votes.getCount(address) * 2 > total) return address;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void acceptContactConnections(SS ss) {
|
||||
while (true) {
|
||||
DuplexTransportConnection conn;
|
||||
try {
|
||||
conn = acceptConnection(ss);
|
||||
} catch (IOException e) {
|
||||
// This is expected when the server socket is closed
|
||||
LOG.info("Server socket closed");
|
||||
state.clearServerSocket();
|
||||
return;
|
||||
}
|
||||
LOG.info("Connection received");
|
||||
connectionLimiter.connectionOpened(conn);
|
||||
backoff.reset();
|
||||
setEverConnected();
|
||||
callback.handleConnection(conn);
|
||||
}
|
||||
}
|
||||
|
||||
private void setEverConnected() {
|
||||
if (!everConnected.getAndSet(true)) {
|
||||
ioExecutor.execute(() -> {
|
||||
Settings s = new Settings();
|
||||
s.putBoolean(PREF_EVER_CONNECTED, true);
|
||||
callback.mergeSettings(s);
|
||||
// Contacts may already have sent a reflected address
|
||||
updateProperties();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
SS ss = state.setStopped();
|
||||
tryToClose(ss);
|
||||
}
|
||||
|
||||
@Override
|
||||
public State getState() {
|
||||
return state.getState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getReasonsDisabled() {
|
||||
return state.getReasonsDisabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldPoll() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPollingInterval() {
|
||||
return backoff.getPollingInterval();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void poll(Collection<Pair<TransportProperties, ConnectionHandler>>
|
||||
properties) {
|
||||
if (getState() != ACTIVE) return;
|
||||
backoff.increment();
|
||||
for (Pair<TransportProperties, ConnectionHandler> p : properties) {
|
||||
connect(p.getFirst(), p.getSecond());
|
||||
}
|
||||
}
|
||||
|
||||
private void connect(TransportProperties p, ConnectionHandler h) {
|
||||
String address = p.get(PROP_ADDRESS);
|
||||
if (isNullOrEmpty(address)) return;
|
||||
String uuid = p.get(PROP_UUID);
|
||||
if (isNullOrEmpty(uuid)) return;
|
||||
wakefulIoExecutor.execute(() -> {
|
||||
DuplexTransportConnection d = createConnection(p);
|
||||
if (d != null) {
|
||||
backoff.reset();
|
||||
setEverConnected();
|
||||
h.handleConnection(d);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Nullable
|
||||
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 {
|
||||
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 " + scrubMacAddress(address));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public DuplexTransportConnection createConnection(TransportProperties p) {
|
||||
if (getState() != ACTIVE) return null;
|
||||
if (!connectionLimiter.canOpenContactConnection()) return null;
|
||||
String address = p.get(PROP_ADDRESS);
|
||||
if (isNullOrEmpty(address)) return null;
|
||||
String uuid = p.get(PROP_UUID);
|
||||
if (isNullOrEmpty(uuid)) return null;
|
||||
DuplexTransportConnection conn = connect(address, uuid);
|
||||
if (conn != null) connectionLimiter.connectionOpened(conn);
|
||||
return conn;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsKeyAgreement() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyAgreementListener createKeyAgreementListener(byte[] commitment) {
|
||||
if (getState() != ACTIVE) 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);
|
||||
// Bind a server socket for receiving key agreement connections
|
||||
SS ss;
|
||||
try {
|
||||
ss = openServerSocket(uuid);
|
||||
} catch (IOException e) {
|
||||
logException(LOG, WARNING, e);
|
||||
return null;
|
||||
}
|
||||
if (getState() != ACTIVE) {
|
||||
tryToClose(ss);
|
||||
return null;
|
||||
}
|
||||
BdfList descriptor = new BdfList();
|
||||
descriptor.add(TRANSPORT_ID_BLUETOOTH);
|
||||
String address = getBluetoothAddress();
|
||||
if (address != null) descriptor.add(macToBytes(address));
|
||||
return new BluetoothKeyAgreementListener(descriptor, ss);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DuplexTransportConnection createKeyAgreementConnection(
|
||||
byte[] commitment, BdfList descriptor) {
|
||||
if (getState() != ACTIVE) return null;
|
||||
// No truncation necessary because COMMIT_LENGTH = 16
|
||||
String uuid = UUID.nameUUIDFromBytes(commitment).toString();
|
||||
DuplexTransportConnection conn;
|
||||
if (descriptor.size() == 1) {
|
||||
if (LOG.isLoggable(INFO)) {
|
||||
LOG.info("Discovering address for key agreement UUID " +
|
||||
uuid);
|
||||
}
|
||||
conn = discoverAndConnect(uuid);
|
||||
} else {
|
||||
String address;
|
||||
try {
|
||||
address = parseAddress(descriptor);
|
||||
} catch (FormatException e) {
|
||||
LOG.info("Invalid address in key agreement descriptor");
|
||||
return null;
|
||||
}
|
||||
if (LOG.isLoggable(INFO))
|
||||
LOG.info("Connecting to key agreement UUID " + uuid);
|
||||
conn = connect(address, uuid);
|
||||
}
|
||||
if (conn != null) {
|
||||
connectionLimiter.connectionOpened(conn);
|
||||
setEverConnected();
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
private String parseAddress(BdfList descriptor) throws FormatException {
|
||||
byte[] mac = descriptor.getRaw(1);
|
||||
if (mac.length != 6) throw new FormatException();
|
||||
return macToString(mac);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsRendezvous() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RendezvousEndpoint createRendezvousEndpoint(KeyMaterialSource k,
|
||||
boolean alice, ConnectionHandler incoming) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void eventOccurred(Event e) {
|
||||
if (e instanceof SettingsUpdatedEvent) {
|
||||
SettingsUpdatedEvent s = (SettingsUpdatedEvent) e;
|
||||
if (s.getNamespace().equals(ID.getString()))
|
||||
ioExecutor.execute(() -> onSettingsUpdated(s.getSettings()));
|
||||
} else if (e instanceof KeyAgreementListeningEvent) {
|
||||
ioExecutor.execute(connectionLimiter::keyAgreementStarted);
|
||||
} else if (e instanceof KeyAgreementStoppedListeningEvent) {
|
||||
ioExecutor.execute(connectionLimiter::keyAgreementEnded);
|
||||
} else if (e instanceof RemoteTransportPropertiesUpdatedEvent) {
|
||||
RemoteTransportPropertiesUpdatedEvent r =
|
||||
(RemoteTransportPropertiesUpdatedEvent) e;
|
||||
if (r.getTransportId().equals(ID)) {
|
||||
ioExecutor.execute(this::updateProperties);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IoExecutor
|
||||
private void onSettingsUpdated(Settings settings) {
|
||||
boolean enabledByUser = settings.getBoolean(PREF_PLUGIN_ENABLE,
|
||||
DEFAULT_PREF_PLUGIN_ENABLE);
|
||||
SS ss = state.setEnabledByUser(enabledByUser);
|
||||
State s = getState();
|
||||
if (ss != null) {
|
||||
LOG.info("Disabled by user, closing server socket");
|
||||
tryToClose(ss);
|
||||
} else if (s == INACTIVE) {
|
||||
if (isAdapterEnabled()) {
|
||||
LOG.info("Enabled by user, opening server socket");
|
||||
bind();
|
||||
} else {
|
||||
LOG.info("Enabled by user but adapter is disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BluetoothKeyAgreementListener extends KeyAgreementListener {
|
||||
|
||||
private final SS ss;
|
||||
|
||||
private BluetoothKeyAgreementListener(BdfList descriptor, SS ss) {
|
||||
super(descriptor);
|
||||
this.ss = ss;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyAgreementConnection accept() throws IOException {
|
||||
DuplexTransportConnection conn = acceptConnection(ss);
|
||||
if (LOG.isLoggable(INFO)) LOG.info(ID + ": Incoming connection");
|
||||
connectionLimiter.connectionOpened(conn);
|
||||
return new KeyAgreementConnection(conn, ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
tryToClose(ss);
|
||||
}
|
||||
}
|
||||
|
||||
@ThreadSafe
|
||||
@NotNullByDefault
|
||||
protected class PluginState {
|
||||
|
||||
@GuardedBy("this")
|
||||
private boolean started = false,
|
||||
stopped = false,
|
||||
enabledByUser = false;
|
||||
|
||||
@GuardedBy("this")
|
||||
@Nullable
|
||||
private SS serverSocket = null;
|
||||
|
||||
synchronized void setStarted(boolean enabledByUser) {
|
||||
started = true;
|
||||
this.enabledByUser = enabledByUser;
|
||||
callback.pluginStateChanged(getState());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
synchronized SS setStopped() {
|
||||
stopped = true;
|
||||
SS ss = serverSocket;
|
||||
serverSocket = null;
|
||||
callback.pluginStateChanged(getState());
|
||||
return ss;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
synchronized SS setEnabledByUser(boolean enabledByUser) {
|
||||
this.enabledByUser = enabledByUser;
|
||||
SS ss = null;
|
||||
if (!enabledByUser) {
|
||||
ss = serverSocket;
|
||||
serverSocket = null;
|
||||
}
|
||||
callback.pluginStateChanged(getState());
|
||||
return ss;
|
||||
}
|
||||
|
||||
synchronized boolean setServerSocket(SS ss) {
|
||||
if (stopped || serverSocket != null) return false;
|
||||
serverSocket = ss;
|
||||
callback.pluginStateChanged(getState());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
synchronized SS clearServerSocket() {
|
||||
SS ss = serverSocket;
|
||||
serverSocket = null;
|
||||
callback.pluginStateChanged(getState());
|
||||
return ss;
|
||||
}
|
||||
|
||||
synchronized State getState() {
|
||||
if (!started || stopped) return STARTING_STOPPING;
|
||||
if (!enabledByUser) return DISABLED;
|
||||
return serverSocket == null ? INACTIVE : ACTIVE;
|
||||
}
|
||||
|
||||
synchronized int getReasonsDisabled() {
|
||||
return getState() == DISABLED ? REASON_USER : 0;
|
||||
}
|
||||
}
|
||||
void stopDiscoverAndConnect();
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import static org.briarproject.bramble.util.StringUtils.isValidMac;
|
||||
|
||||
@MethodsNotNullByDefault
|
||||
@ParametersNotNullByDefault
|
||||
class JavaBluetoothPlugin
|
||||
extends BluetoothPlugin<StreamConnection, StreamConnectionNotifier> {
|
||||
class JavaBluetoothPlugin extends
|
||||
AbstractBluetoothPlugin<StreamConnection, StreamConnectionNotifier> {
|
||||
|
||||
private static final Logger LOG =
|
||||
getLogger(JavaBluetoothPlugin.class.getName());
|
||||
@@ -108,6 +108,11 @@ class JavaBluetoothPlugin
|
||||
return null; // TODO
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stopDiscoverAndConnect() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
private String makeUrl(String address, String uuid) {
|
||||
return "btspp://" + address + ":" + uuid + ";name=RFCOMM";
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ public class AddNearbyContactIntroFragment extends BaseFragment {
|
||||
scrollView = v.findViewById(R.id.scrollView);
|
||||
View button = v.findViewById(R.id.continueButton);
|
||||
button.setOnClickListener(view -> {
|
||||
viewModel.stopDiscovery();
|
||||
viewModel.onContinueClicked();
|
||||
if (permissionManager.checkPermissions()) {
|
||||
viewModel.showQrCodeFragmentIfAllowed();
|
||||
|
||||
@@ -44,6 +44,7 @@ import org.briarproject.bramble.api.plugin.TransportId;
|
||||
import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
|
||||
import org.briarproject.bramble.api.plugin.event.TransportStateEvent;
|
||||
import org.briarproject.bramble.api.system.AndroidExecutor;
|
||||
import org.briarproject.bramble.plugin.bluetooth.BluetoothPlugin;
|
||||
import org.briarproject.briar.R;
|
||||
import org.briarproject.briar.android.contact.add.nearby.AddContactState.ContactExchangeFinished;
|
||||
import org.briarproject.briar.android.contact.add.nearby.AddContactState.ContactExchangeResult.Error;
|
||||
@@ -149,7 +150,9 @@ class AddNearbyContactViewModel extends AndroidViewModel
|
||||
@Nullable
|
||||
private final BluetoothAdapter bt;
|
||||
@Nullable // UiThread
|
||||
private Plugin wifiPlugin, bluetoothPlugin;
|
||||
private Plugin wifiPlugin;
|
||||
@Nullable // UiThread
|
||||
private BluetoothPlugin bluetoothPlugin;
|
||||
|
||||
// UiThread
|
||||
private BluetoothDecision bluetoothDecision = BluetoothDecision.UNKNOWN;
|
||||
@@ -195,7 +198,8 @@ class AddNearbyContactViewModel extends AndroidViewModel
|
||||
this.connectionManager = connectionManager;
|
||||
bt = BluetoothAdapter.getDefaultAdapter();
|
||||
wifiPlugin = pluginManager.getPlugin(LanTcpConstants.ID);
|
||||
bluetoothPlugin = pluginManager.getPlugin(BluetoothConstants.ID);
|
||||
bluetoothPlugin = (BluetoothPlugin) pluginManager
|
||||
.getPlugin(BluetoothConstants.ID);
|
||||
qrCodeDecoder = new QrCodeDecoder(androidExecutor, ioExecutor, this);
|
||||
eventBus.addListener(this);
|
||||
IntentFilter filter = new IntentFilter(ACTION_SCAN_MODE_CHANGED);
|
||||
@@ -218,7 +222,8 @@ class AddNearbyContactViewModel extends AndroidViewModel
|
||||
@UiThread
|
||||
void resetPlugins() {
|
||||
wifiPlugin = pluginManager.getPlugin(LanTcpConstants.ID);
|
||||
bluetoothPlugin = pluginManager.getPlugin(BluetoothConstants.ID);
|
||||
bluetoothPlugin = (BluetoothPlugin) pluginManager
|
||||
.getPlugin(BluetoothConstants.ID);
|
||||
}
|
||||
|
||||
@UiThread
|
||||
@@ -375,6 +380,13 @@ class AddNearbyContactViewModel extends AndroidViewModel
|
||||
}
|
||||
}
|
||||
|
||||
void stopDiscovery() {
|
||||
if (!isBluetoothSupported() || !bluetoothPlugin.isDiscovering()) {
|
||||
return;
|
||||
}
|
||||
bluetoothPlugin.stopDiscoverAndConnect();
|
||||
}
|
||||
|
||||
@SuppressWarnings("StatementWithEmptyBody")
|
||||
@UiThread
|
||||
void showQrCodeFragmentIfAllowed() {
|
||||
|
||||
@@ -6,23 +6,30 @@ import android.bluetooth.BluetoothAdapter;
|
||||
import android.content.Context;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.briarproject.bramble.api.connection.ConnectionManager;
|
||||
import org.briarproject.bramble.api.connection.ConnectionRegistry;
|
||||
import org.briarproject.bramble.api.contact.ContactId;
|
||||
import org.briarproject.bramble.api.db.DbException;
|
||||
import org.briarproject.bramble.api.event.Event;
|
||||
import org.briarproject.bramble.api.event.EventBus;
|
||||
import org.briarproject.bramble.api.event.EventListener;
|
||||
import org.briarproject.bramble.api.lifecycle.IoExecutor;
|
||||
import org.briarproject.bramble.api.plugin.BluetoothConstants;
|
||||
import org.briarproject.bramble.api.plugin.Plugin;
|
||||
import org.briarproject.bramble.api.plugin.PluginManager;
|
||||
import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection;
|
||||
import org.briarproject.bramble.api.plugin.event.ConnectionOpenedEvent;
|
||||
import org.briarproject.bramble.api.properties.TransportPropertyManager;
|
||||
import org.briarproject.bramble.api.system.AndroidExecutor;
|
||||
import org.briarproject.bramble.plugin.bluetooth.BluetoothPlugin;
|
||||
import org.briarproject.briar.R;
|
||||
import org.briarproject.briar.android.contact.ContactItem;
|
||||
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.annotation.UiThread;
|
||||
@@ -31,17 +38,24 @@ import androidx.appcompat.app.AlertDialog;
|
||||
import static android.Manifest.permission.ACCESS_FINE_LOCATION;
|
||||
import static android.os.Build.VERSION.SDK_INT;
|
||||
import static androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale;
|
||||
import static java.util.concurrent.TimeUnit.SECONDS;
|
||||
import static java.util.logging.Level.WARNING;
|
||||
import static java.util.logging.Logger.getLogger;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.ID;
|
||||
import static org.briarproject.bramble.api.plugin.BluetoothConstants.PROP_UUID;
|
||||
import static org.briarproject.bramble.api.plugin.Plugin.State.ACTIVE;
|
||||
import static org.briarproject.bramble.util.LogUtils.logException;
|
||||
import static org.briarproject.bramble.util.StringUtils.isNullOrEmpty;
|
||||
import static org.briarproject.briar.android.util.UiUtils.getGoToSettingsListener;
|
||||
import static org.briarproject.briar.android.util.UiUtils.isLocationEnabled;
|
||||
import static org.briarproject.briar.android.util.UiUtils.showLocationDialog;
|
||||
|
||||
class BluetoothConnecter {
|
||||
class BluetoothConnecter implements EventListener {
|
||||
|
||||
private final Logger LOG = getLogger(BluetoothConnecter.class.getName());
|
||||
|
||||
private final long BT_ACTIVE_TIMEOUT = SECONDS.toMillis(5);
|
||||
|
||||
private enum Permission {
|
||||
UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED
|
||||
}
|
||||
@@ -52,32 +66,41 @@ class BluetoothConnecter {
|
||||
private final AndroidExecutor androidExecutor;
|
||||
private final ConnectionRegistry connectionRegistry;
|
||||
private final BluetoothAdapter bt = BluetoothAdapter.getDefaultAdapter();
|
||||
private final EventBus eventBus;
|
||||
private final TransportPropertyManager transportPropertyManager;
|
||||
private final ConnectionManager connectionManager;
|
||||
|
||||
private volatile Plugin bluetoothPlugin;
|
||||
private volatile BluetoothPlugin bluetoothPlugin;
|
||||
|
||||
private Permission locationPermission = Permission.UNKNOWN;
|
||||
private ContactId contactId = null;
|
||||
|
||||
@Inject
|
||||
BluetoothConnecter(Application app,
|
||||
PluginManager pluginManager,
|
||||
@IoExecutor Executor ioExecutor,
|
||||
AndroidExecutor androidExecutor,
|
||||
ConnectionRegistry connectionRegistry) {
|
||||
ConnectionRegistry connectionRegistry,
|
||||
EventBus eventBus,
|
||||
TransportPropertyManager transportPropertyManager,
|
||||
ConnectionManager connectionManager) {
|
||||
this.app = app;
|
||||
this.pluginManager = pluginManager;
|
||||
this.ioExecutor = ioExecutor;
|
||||
this.androidExecutor = androidExecutor;
|
||||
this.bluetoothPlugin = pluginManager.getPlugin(BluetoothConstants.ID);
|
||||
this.bluetoothPlugin = (BluetoothPlugin) pluginManager.getPlugin(ID);
|
||||
this.connectionRegistry = connectionRegistry;
|
||||
this.eventBus = eventBus;
|
||||
this.transportPropertyManager = transportPropertyManager;
|
||||
this.connectionManager = connectionManager;
|
||||
}
|
||||
|
||||
boolean isConnectedViaBluetooth(ContactId contactId) {
|
||||
return connectionRegistry.isConnected(contactId, BluetoothConstants.ID);
|
||||
return connectionRegistry.isConnected(contactId, ID);
|
||||
}
|
||||
|
||||
boolean isDiscovering() {
|
||||
// TODO bluetoothPlugin.isDiscovering()
|
||||
return false;
|
||||
return bluetoothPlugin.isDiscovering();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +112,7 @@ class BluetoothConnecter {
|
||||
// When this class is instantiated before we are logged in
|
||||
// (like when returning to a killed activity), bluetoothPlugin would be
|
||||
// null and we consider bluetooth not supported. So reset here.
|
||||
bluetoothPlugin = pluginManager.getPlugin(BluetoothConstants.ID);
|
||||
bluetoothPlugin = (BluetoothPlugin) pluginManager.getPlugin(ID);
|
||||
}
|
||||
|
||||
@UiThread
|
||||
@@ -149,30 +172,84 @@ class BluetoothConnecter {
|
||||
|
||||
@UiThread
|
||||
void onBluetoothDiscoverable(ContactItem contact) {
|
||||
connect(contact.getContact().getId());
|
||||
contactId = contact.getContact().getId();
|
||||
connect();
|
||||
}
|
||||
|
||||
private void connect(ContactId contactId) {
|
||||
// TODO
|
||||
// * enable bluetooth connections setting, if not enabled
|
||||
// * wait for plugin to become active
|
||||
ioExecutor.execute(() -> {
|
||||
Random r = new Random();
|
||||
try {
|
||||
showToast(R.string.toast_connect_via_bluetooth_start);
|
||||
// TODO do real work here
|
||||
Thread.sleep(r.nextInt(3000) + 3000);
|
||||
if (r.nextBoolean()) {
|
||||
showToast(R.string.toast_connect_via_bluetooth_success);
|
||||
} else {
|
||||
showToast(R.string.toast_connect_via_bluetooth_error);
|
||||
@Override
|
||||
public void eventOccurred(@NonNull Event e) {
|
||||
if (e instanceof ConnectionOpenedEvent) {
|
||||
ConnectionOpenedEvent c = (ConnectionOpenedEvent) e;
|
||||
if (c.getContactId().equals(contactId) && c.isIncoming() &&
|
||||
c.getTransportId() == ID) {
|
||||
if (bluetoothPlugin != null) {
|
||||
bluetoothPlugin.stopDiscoverAndConnect();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
logException(LOG, WARNING, e);
|
||||
LOG.info("Contact connected to us");
|
||||
showToast(R.string.toast_connect_via_bluetooth_success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void connect() {
|
||||
pluginManager.setPluginEnabled(ID, true);
|
||||
|
||||
ioExecutor.execute(() -> {
|
||||
if (!waitForBluetoothActive()) {
|
||||
showToast(R.string.bt_plugin_status_inactive);
|
||||
LOG.warning("Bluetooth plugin didn't become active");
|
||||
return;
|
||||
}
|
||||
showToast(R.string.toast_connect_via_bluetooth_start);
|
||||
eventBus.addListener(this);
|
||||
try {
|
||||
String uuid = null;
|
||||
try {
|
||||
uuid = transportPropertyManager
|
||||
.getRemoteProperties(contactId, ID).get(PROP_UUID);
|
||||
} catch (DbException e) {
|
||||
logException(LOG, WARNING, e);
|
||||
}
|
||||
if (isNullOrEmpty(uuid)) {
|
||||
LOG.warning("PROP_UUID missing for contact");
|
||||
return;
|
||||
}
|
||||
DuplexTransportConnection conn = bluetoothPlugin
|
||||
.discoverAndConnectForSetup(uuid);
|
||||
if (conn == null) {
|
||||
if (!isConnectedViaBluetooth(contactId)) {
|
||||
LOG.warning("Failed to connect");
|
||||
showToast(R.string.toast_connect_via_bluetooth_error);
|
||||
} else {
|
||||
LOG.info("Failed to connect, but contact connected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
connectionManager.manageOutgoingConnection(contactId, ID, conn);
|
||||
showToast(R.string.toast_connect_via_bluetooth_success);
|
||||
} finally {
|
||||
eventBus.removeListener(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean waitForBluetoothActive() {
|
||||
long left = BT_ACTIVE_TIMEOUT;
|
||||
final long sleep = 250;
|
||||
try {
|
||||
while (left > 0) {
|
||||
if (bluetoothPlugin.getState() == ACTIVE) {
|
||||
return true;
|
||||
}
|
||||
Thread.sleep(sleep);
|
||||
left -= sleep;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return (bluetoothPlugin.getState() == ACTIVE);
|
||||
}
|
||||
|
||||
private void showToast(@StringRes int res) {
|
||||
androidExecutor.runOnUiThread(() ->
|
||||
Toast.makeText(app, res, Toast.LENGTH_LONG).show()
|
||||
|
||||
@@ -91,7 +91,7 @@ public class BluetoothConnecterDialogFragment extends DialogFragment {
|
||||
return;
|
||||
}
|
||||
if (bluetoothConnecter.isDiscovering()) {
|
||||
// TODO showToast(R.string.toast_connect_via_bluetooth_discovering);
|
||||
showToast(R.string.toast_connect_via_bluetooth_already_discovering);
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import org.briarproject.bramble.api.event.EventBus;
|
||||
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.BluetoothConstants;
|
||||
import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent;
|
||||
import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent;
|
||||
import org.briarproject.bramble.api.sync.ClientId;
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
<string name="menu_item_connect_via_bluetooth">Connect via Bluetooth</string>
|
||||
<string name="dialog_title_connect_via_bluetooth">Connect via Bluetooth</string>
|
||||
<string name="dialog_message_connect_via_bluetooth">Your contact needs to be nearby for this to work.\n\nYou and your contact should both press \"Start\" at the same time.</string>
|
||||
<string name="toast_connect_via_bluetooth_already_discovering">Already trying to connect via Bluetooth</string>
|
||||
<string name="toast_connect_via_bluetooth_not_discoverable">Cannot continue without Bluetooth</string>
|
||||
<string name="toast_connect_via_bluetooth_no_location_permission">Cannot continue without location permission</string>
|
||||
<string name="toast_connect_via_bluetooth_start">Connecting via Bluetooth…</string>
|
||||
|
||||
Reference in New Issue
Block a user