+ * The client attempts multiple times to establish the network and deal with connectivity + * problems. It will also retry the task if an issue occurs while running it. + *
+ * The client is setup to retry indefinitely and will only bail on its own if it's + * unable to start {@link WifiDirect}. A call to {@link #shutdown()} is required to + * stop client from the "outside." + *
+ * Summary of mitigations: + *
+ * Once up an running, the server will continue to run until told to stop. Unlike the client the + * server has a harder time knowing there are problems and thus doesn't have mitigations to help + * with connectivity issues. Once connected to a client, the TCP server will run until told to stop. + * This means that multiple serial connections to it could be made if needed. + *
+ * Testing found that restarting the client worked better than restarting the server when having WiFi + * Direct setup issues. + */ +public final class DeviceTransferServer implements Handler.Callback { + + private static final String TAG = Log.tag(DeviceTransferServer.class); + private static final int START_SERVER = 0; + private static final int START_NETWORK_SERVER = 1; + private static final int NETWORK_DISCONNECTED = 2; + private static final int START_IP_EXCHANGE = 3; + private static final int IP_EXCHANGE_SUCCESS = 4; + + private ServerThread serverThread; + private HandlerThread commandAndControlThread; + private final Handler handler; + private WifiDirect wifiDirect; + private final Context context; + private final ServerTask serverTask; + private final int port; + private final ShutdownCallback shutdownCallback; + private IpExchange.IpExchangeThread ipExchangeThread; + + private static void update(@NonNull TransferMode transferMode) { + EventBus.getDefault().postSticky(transferMode); + } + + @AnyThread + public DeviceTransferServer(@NonNull Context context, @NonNull ServerTask serverTask, int port, @Nullable ShutdownCallback shutdownCallback) { + this.context = context; + this.serverTask = serverTask; + this.port = port; + this.shutdownCallback = shutdownCallback; + this.commandAndControlThread = SignalExecutors.getAndStartHandlerThread("server-cnc"); + this.handler = new Handler(commandAndControlThread.getLooper(), this); + } + + @AnyThread + public void start() { + handler.sendMessage(handler.obtainMessage(START_SERVER)); + } + + @AnyThread + public synchronized void shutdown() { + stopIpExchange(); + stopServer(); + stopWifiDirect(); + + if (commandAndControlThread != null) { + Log.i(TAG, "Shutting down command and control"); + commandAndControlThread.quit(); + commandAndControlThread.interrupt(); + commandAndControlThread = null; + } + } + + @Override + public boolean handleMessage(@NonNull Message message) { + switch (message.what) { + case START_SERVER: + startWifiDirect(); + break; + case START_NETWORK_SERVER: + startServer(); + break; + case NETWORK_DISCONNECTED: + stopServer(); + break; + case START_IP_EXCHANGE: + startIpExchange((String) message.obj); + break; + case IP_EXCHANGE_SUCCESS: + ipExchangeSuccessful(); + break; + default: + shutdown(); + if (shutdownCallback != null) { + shutdownCallback.shutdown(); + } + throw new AssertionError(); + } + return false; + } + + private void startWifiDirect() { + if (wifiDirect != null) { + Log.e(TAG, "Server already started"); + return; + } + + update(TransferMode.STARTING_UP); + + try { + wifiDirect = new WifiDirect(context); + wifiDirect.initialize(new WifiDirectListener()); + wifiDirect.startDiscoveryService(); + Log.i(TAG, "Started discovery service, waiting for connections..."); + update(TransferMode.DISCOVERY); + } catch (WifiDirectUnavailableException e) { + Log.e(TAG, e); + shutdown(); + update(TransferMode.FAILED); + if (shutdownCallback != null) { + shutdownCallback.shutdown(); + } + } + } + + private void stopWifiDirect() { + if (wifiDirect != null) { + Log.i(TAG, "Shutting down WiFi Direct"); + wifiDirect.shutdown(); + wifiDirect = null; + update(TransferMode.READY); + } + } + + private void startServer() { + if (serverThread != null) { + Log.i(TAG, "Server already running"); + return; + } + + Log.i(TAG, "Connection established, spinning up network server."); + serverThread = new ServerThread(context, serverTask, port); + serverThread.start(); + + update(TransferMode.NETWORK_CONNECTED); + } + + private void stopServer() { + if (serverThread != null) { + Log.i(TAG, "Shutting down ServerThread"); + serverThread.shutdown(); + serverThread = null; + } + } + + private void startIpExchange(@NonNull String groupOwnerHostAddress) { + ipExchangeThread = IpExchange.giveIp(groupOwnerHostAddress, port, handler, IP_EXCHANGE_SUCCESS); + } + + private void stopIpExchange() { + if (ipExchangeThread != null) { + ipExchangeThread.shutdown(); + ipExchangeThread = null; + } + } + + private void ipExchangeSuccessful() { + stopIpExchange(); + handler.sendEmptyMessage(START_NETWORK_SERVER); + } + + public static class ServerThread extends Thread { + + private final Context context; + private final ServerTask serverTask; + private final int port; + private volatile ServerSocket serverSocket; + private volatile boolean isRunning; + + public ServerThread(@NonNull Context context, @NonNull ServerTask serverTask, int port) { + this.context = context; + this.serverTask = serverTask; + this.port = port; + } + + @Override + public void run() { + Log.i(TAG, "Server thread running"); + isRunning = true; + + while (shouldKeepRunning()) { + Log.i(TAG, "Starting up server socket..."); + try { + serverSocket = new ServerSocket(port); + while (shouldKeepRunning() && !serverSocket.isClosed()) { + Log.i(TAG, "Waiting for client socket accept..."); + try { + handleClient(serverSocket.accept()); + } catch (IOException e) { + if (isRunning) { + Log.i(TAG, "Error connecting with client or server socket closed.", e); + } else { + Log.i(TAG, "Server shutting down..."); + } + } finally { + update(TransferMode.NETWORK_CONNECTED); + } + } + } catch (Exception e) { + Log.w(TAG, e); + } finally { + if (serverSocket != null && !serverSocket.isClosed()) { + try { + serverSocket.close(); + } catch (IOException ignored) {} + } + update(TransferMode.NETWORK_CONNECTED); + } + + if (shouldKeepRunning()) { + ThreadUtil.interruptableSleep(TimeUnit.SECONDS.toMillis(3)); + } + } + + Log.i(TAG, "Server exiting"); + } + + public void shutdown() { + isRunning = false; + try { + serverSocket.close(); + } catch (IOException e) { + Log.w(TAG, "Error shutting down server socket", e); + } + interrupt(); + } + + private void handleClient(@NonNull Socket clientSocket) throws IOException { + update(TransferMode.SERVICE_CONNECTED); + serverTask.run(context, clientSocket.getInputStream()); + clientSocket.close(); + } + + private boolean shouldKeepRunning() { + return !isInterrupted() && isRunning; + } + } + + public class WifiDirectListener implements WifiDirect.WifiDirectConnectionListener { + + @Override + public void onNetworkConnected(@NonNull WifiP2pInfo info) { + if (info.isGroupOwner) { + handler.sendEmptyMessage(START_NETWORK_SERVER); + } else { + handler.sendMessage(handler.obtainMessage(START_IP_EXCHANGE, info.groupOwnerAddress.getHostAddress())); + } + } + + @Override + public void onNetworkDisconnected() { + handler.sendEmptyMessage(NETWORK_DISCONNECTED); + } + + @Override + public void onNetworkFailure() { + handler.sendEmptyMessage(NETWORK_DISCONNECTED); + } + + @Override + public void onLocalDeviceChanged(@NonNull WifiP2pDevice localDevice) { } + + @Override + public void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice) { } + } +} diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/IpExchange.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/IpExchange.java new file mode 100644 index 000000000..0cf16f5cb --- /dev/null +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/IpExchange.java @@ -0,0 +1,148 @@ +package org.signal.devicetransfer; + +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.ThreadUtil; +import org.signal.core.util.logging.Log; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.TimeUnit; + +/** + * A WiFi Direct group is created auto-magically when connecting and randomly determines the group owner. + * Only the group owner's host address is exposed via the WiFi Direct APIs and thus sometimes the client + * is selected as the group owner and is unable to know the host address of the server. + * + * When this occurs, {@link #giveIp(String, int, Handler, int)} and {@link #getIp(String, int, Handler, int)} allow + * the two to connect briefly and use the connected socket to determine the host address of the other. + */ +public final class IpExchange { + + private IpExchange() { } + + public static @NonNull IpExchangeThread giveIp(@NonNull String host, int port, @NonNull Handler handler, int message) { + IpExchangeThread thread = new IpExchangeThread(host, port, false, handler, message); + thread.start(); + return thread; + } + + public static @NonNull IpExchangeThread getIp(@NonNull String host, int port, @NonNull Handler handler, int message) { + IpExchangeThread thread = new IpExchangeThread(host, port, true, handler, message); + thread.start(); + return thread; + } + + public static class IpExchangeThread extends Thread { + + private static final String TAG = Log.tag(IpExchangeThread.class); + + private volatile ServerSocket serverSocket; + private volatile Socket client; + private volatile boolean isRunning; + + private final String serverHostAddress; + private final int port; + private final boolean needsIp; + private final Handler handler; + private final int message; + + public IpExchangeThread(@NonNull String serverHostAddress, int port, boolean needsIp, @NonNull Handler handler, int message) { + this.serverHostAddress = serverHostAddress; + this.port = port; + this.needsIp = needsIp; + this.handler = handler; + this.message = message; + } + + @Override + public void run() { + Log.i(TAG, "Running..."); + isRunning = true; + + while (shouldKeepRunning()) { + Log.i(TAG, "Attempting to connect to server..."); + + try { + if (needsIp) { + getIp(); + } else { + sendIp(); + } + } catch (Exception e) { + Log.w(TAG, e); + } finally { + if (client != null && !client.isClosed()) { + try { + client.close(); + } catch (IOException ignored) {} + } + + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (IOException ignored) {} + } + } + + if (shouldKeepRunning()) { + ThreadUtil.interruptableSleep(TimeUnit.SECONDS.toMillis(3)); + } + } + + Log.i(TAG, "Exiting"); + } + + private void sendIp() throws IOException { + client = new Socket(); + client.bind(null); + client.connect(new InetSocketAddress(serverHostAddress, port), 10000); + handler.sendEmptyMessage(message); + Log.i(TAG, "Done!!"); + isRunning = false; + } + + private void getIp() throws IOException { + serverSocket = new ServerSocket(port); + while (shouldKeepRunning() && !serverSocket.isClosed()) { + Log.i(TAG, "Waiting for client socket accept..."); + try (Socket socket = serverSocket.accept()) { + Log.i(TAG, "Client connected, obtaining IP address"); + String peerHostAddress = socket.getInetAddress().getHostAddress(); + handler.sendMessage(handler.obtainMessage(message, peerHostAddress)); + } catch (IOException e) { + if (isRunning) { + Log.i(TAG, "Error connecting with client or server socket closed.", e); + } else { + Log.i(TAG, "Server shutting down..."); + } + } + } + } + + public void shutdown() { + isRunning = false; + try { + if (client != null) { + client.close(); + } + + if (serverSocket != null) { + serverSocket.close(); + } + } catch (IOException e) { + Log.w(TAG, "Error shutting down", e); + } + interrupt(); + } + + private boolean shouldKeepRunning() { + return !isInterrupted() && isRunning; + } + } +} diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/ServerTask.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/ServerTask.java new file mode 100644 index 000000000..ad3b2dc18 --- /dev/null +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/ServerTask.java @@ -0,0 +1,22 @@ +package org.signal.devicetransfer; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; + +/** + * Self-contained chunk of code to run once the {@link DeviceTransferServer} has a + * connected {@link DeviceTransferClient}. + */ +public interface ServerTask extends Serializable { + + /** + * @param context Android context, mostly like the foreground transfer service + * @param inputStream Input stream associated with socket connected to remote client. + */ + void run(@NonNull Context context, @NonNull InputStream inputStream) throws IOException; +} diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/ShutdownCallback.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/ShutdownCallback.java new file mode 100644 index 000000000..5d27b28c2 --- /dev/null +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/ShutdownCallback.java @@ -0,0 +1,10 @@ +package org.signal.devicetransfer; + +/** + * Allow {@link DeviceTransferClient} or {@link DeviceTransferServer} to indicate to the + * {@link DeviceToDeviceTransferService} that an internal issue caused a shutdown and the + * service should stop as well. + */ +public interface ShutdownCallback { + void shutdown(); +} diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferMode.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferMode.java new file mode 100644 index 000000000..9f2b767ef --- /dev/null +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferMode.java @@ -0,0 +1,12 @@ +package org.signal.devicetransfer; + +public enum TransferMode { + PERMISSIONS, + UNAVAILABLE, + FAILED, + READY, + STARTING_UP, + DISCOVERY, + NETWORK_CONNECTED, + SERVICE_CONNECTED +} diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java new file mode 100644 index 000000000..05dbb0261 --- /dev/null +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java @@ -0,0 +1,416 @@ +package org.signal.devicetransfer; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.net.NetworkInfo; +import android.net.wifi.WifiManager; +import android.net.wifi.WpsInfo; +import android.net.wifi.p2p.WifiP2pConfig; +import android.net.wifi.p2p.WifiP2pDevice; +import android.net.wifi.p2p.WifiP2pInfo; +import android.net.wifi.p2p.WifiP2pManager; +import android.net.wifi.p2p.nsd.WifiP2pDnsSdServiceInfo; +import android.net.wifi.p2p.nsd.WifiP2pDnsSdServiceRequest; +import android.os.Build; +import android.os.HandlerThread; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.core.content.ContextCompat; + +import org.signal.core.util.ThreadUtil; +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.signal.devicetransfer.WifiDirectUnavailableException.Reason; + +import java.util.Collections; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Provide the ability to spin up a WiFi Direct network, advertise a network service, + * discover a network service, and then connect two devices. + */ +@SuppressLint("MissingPermission") +public final class WifiDirect { + + private static final String TAG = Log.tag(WifiDirect.class); + + private static final IntentFilter intentFilter = new IntentFilter() {{ + addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION); + addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION); + addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION); + addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION); + }}; + + public static final String SERVICE_INSTANCE = "_devicetransfer._signal.org"; + public static final String SERVICE_REG_TYPE = "_presence._tcp"; + + private final Context context; + private WifiDirectConnectionListener connectionListener; + private WifiDirectCallbacks wifiDirectCallbacks; + private WifiP2pManager manager; + private WifiP2pManager.Channel channel; + private WifiP2pDnsSdServiceRequest serviceRequest; + private final HandlerThread wifiDirectCallbacksHandler; + + /** + * Determine the ability to use WiFi Direct by checking if the device supports WiFi Direct + * and the appropriate permissions have been granted. + */ + public static @NonNull AvailableStatus getAvailability(@NonNull Context context) { + if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT)) { + Log.i(TAG, "Feature not available"); + return AvailableStatus.FEATURE_NOT_AVAILABLE; + } + + WifiManager wifiManager = ContextCompat.getSystemService(context, WifiManager.class); + if (wifiManager == null) { + Log.i(TAG, "WifiManager not available"); + return AvailableStatus.WIFI_MANAGER_NOT_AVAILABLE; + } + + if (Build.VERSION.SDK_INT >= 23 && context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + Log.i(TAG, "Fine location permission required"); + return AvailableStatus.FINE_LOCATION_PERMISSION_NOT_GRANTED; + } + + return Build.VERSION.SDK_INT <= 23 || wifiManager.isP2pSupported() ? AvailableStatus.AVAILABLE + : AvailableStatus.WIFI_DIRECT_NOT_AVAILABLE; + } + + public WifiDirect(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.wifiDirectCallbacksHandler = SignalExecutors.getAndStartHandlerThread("wifi-direct-cb"); + } + + /** + * Initialize {@link WifiP2pManager} and {@link WifiP2pManager.Channel} needed to interact + * with the Android WiFi Direct APIs. This should have a matching call to {@link #shutdown()} to + * release the various resources used to establish and maintain a WiFi Direct network. + */ + public synchronized void initialize(@NonNull WifiDirectConnectionListener connectionListener) throws WifiDirectUnavailableException { + if (isInitialized()) { + Log.w(TAG, "Already initialized, do not need to initialize twice"); + return; + } + + this.connectionListener = connectionListener; + + manager = ContextCompat.getSystemService(context, WifiP2pManager.class); + if (manager == null) { + Log.i(TAG, "Unable to get WifiP2pManager"); + shutdown(); + throw new WifiDirectUnavailableException(Reason.WIFI_P2P_MANAGER); + } + + wifiDirectCallbacks = new WifiDirectCallbacks(); + channel = manager.initialize(context, wifiDirectCallbacksHandler.getLooper(), wifiDirectCallbacks); + if (channel == null) { + Log.i(TAG, "Unable to initialize channel"); + shutdown(); + throw new WifiDirectUnavailableException(Reason.CHANNEL_INITIALIZATION); + } + + context.registerReceiver(wifiDirectCallbacks, intentFilter); + } + + /** + * Clears and releases WiFi Direct resources that may have been created or in use. Also + * shuts down the WiFi Direct related {@link HandlerThread}. + *
+ * Note: After this call, the instance is no longer usable and an entirely new one will need to + * be created. + */ + public synchronized void shutdown() { + Log.d(TAG, "Shutting down"); + + connectionListener = null; + + if (manager != null) { + retry(manager::clearServiceRequests, "clear service requests"); + retry(manager::stopPeerDiscovery, "stop peer discovery"); + retry(manager::clearLocalServices, "clear local services"); + manager = null; + } + + if (channel != null) { + channel.close(); + channel = null; + } + + if (wifiDirectCallbacks != null) { + context.unregisterReceiver(wifiDirectCallbacks); + wifiDirectCallbacks = null; + } + + wifiDirectCallbacksHandler.quit(); + wifiDirectCallbacksHandler.interrupt(); + } + + /** + * Start advertising a transfer service that other devices can search for and decide + * to connect to. Call on an appropriate thread as this method synchronously calls WiFi Direct + * methods. + */ + @WorkerThread + public synchronized void startDiscoveryService() throws WifiDirectUnavailableException { + ensureInitialized(); + + WifiP2pDnsSdServiceInfo serviceInfo = WifiP2pDnsSdServiceInfo.newInstance(SERVICE_INSTANCE, SERVICE_REG_TYPE, Collections.emptyMap()); + + SyncActionListener addLocalServiceListener = new SyncActionListener("add local service"); + manager.addLocalService(channel, serviceInfo, addLocalServiceListener); + + SyncActionListener discoverPeersListener = new SyncActionListener("discover peers"); + manager.discoverPeers(channel, discoverPeersListener); + + if (!addLocalServiceListener.successful() || !discoverPeersListener.successful()) { + throw new WifiDirectUnavailableException(Reason.SERVICE_START); + } + } + + /** + * Start searching for a transfer service being advertised by another device. Call on an + * appropriate thread as this method synchronously calls WiFi Direct methods. + */ + @WorkerThread + public synchronized void discoverService() throws WifiDirectUnavailableException { + ensureInitialized(); + + if (serviceRequest != null) { + Log.w(TAG, "Discover service already called and active."); + return; + } + + WifiP2pManager.DnsSdTxtRecordListener txtListener = (fullDomain, record, device) -> {}; + + WifiP2pManager.DnsSdServiceResponseListener serviceListener = (instanceName, registrationType, sourceDevice) -> { + if (SERVICE_INSTANCE.equals(instanceName)) { + Log.d(TAG, "Service found!"); + if (connectionListener != null) { + connectionListener.onServiceDiscovered(sourceDevice); + } + } else { + Log.d(TAG, "Found unusable service, ignoring."); + } + }; + + manager.setDnsSdResponseListeners(channel, serviceListener, txtListener); + + serviceRequest = WifiP2pDnsSdServiceRequest.newInstance(); + + SyncActionListener addServiceListener = new SyncActionListener("add service request"); + manager.addServiceRequest(channel, serviceRequest, addServiceListener); + + SyncActionListener startDiscovery = new SyncActionListener("discover services"); + manager.discoverServices(channel, startDiscovery); + + if (!addServiceListener.successful() || !startDiscovery.successful()) { + manager.removeServiceRequest(channel, serviceRequest, null); + serviceRequest = null; + throw new WifiDirectUnavailableException(Reason.SERVICE_DISCOVERY_START); + } + } + + /** + * Establish a WiFi Direct network by connecting to the given device address (MAC). An + * address can be found by using {@link #discoverService()}. + * + * @param deviceAddress Device MAC address to establish a connection with + */ + public synchronized void connect(@NonNull String deviceAddress) throws WifiDirectUnavailableException { + ensureInitialized(); + + WifiP2pConfig config = new WifiP2pConfig(); + config.deviceAddress = deviceAddress; + config.wps.setup = WpsInfo.PBC; + + if (serviceRequest != null) { + manager.removeServiceRequest(channel, serviceRequest, LoggingActionListener.message("Remote service request")); + serviceRequest = null; + } + + SyncActionListener listener = new SyncActionListener("service connect"); + manager.connect(channel, config, listener); + + if (listener.successful()) { + Log.i(TAG, "Successfully connected to service."); + } else { + throw new WifiDirectUnavailableException(Reason.SERVICE_CONNECT_FAILURE); + } + } + + private synchronized void retry(@NonNull ManagerRetry retryFunction, @NonNull String message) { + int tries = 3; + + while ((tries--) > 0) { + SyncActionListener listener = new SyncActionListener(message); + retryFunction.call(channel, listener); + if (listener.successful()) { + return; + } + ThreadUtil.sleep(TimeUnit.SECONDS.toMillis(1)); + } + } + + private synchronized boolean isInitialized() { + return manager != null && channel != null; + } + + private synchronized boolean isNotInitialized() { + return manager == null || channel == null; + } + + private void ensureInitialized() throws WifiDirectUnavailableException { + if (isNotInitialized()) { + Log.w(TAG, "WiFi Direct has not been initialized."); + throw new WifiDirectUnavailableException(Reason.SERVICE_NOT_INITIALIZED); + } + } + + private interface ManagerRetry { + void call(@NonNull WifiP2pManager.Channel a, @NonNull WifiP2pManager.ActionListener b); + } + + private class WifiDirectCallbacks extends BroadcastReceiver implements WifiP2pManager.ChannelListener, WifiP2pManager.ConnectionInfoListener { + @Override + public void onReceive(@NonNull Context context, @NonNull Intent intent) { + String action = intent.getAction(); + if (action != null) { + switch (action) { + case WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION: + WifiP2pDevice localDevice = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE); + if (localDevice != null && connectionListener != null) { + connectionListener.onLocalDeviceChanged(localDevice); + } + break; + case WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION: + if (isNotInitialized()) { + Log.w(TAG, "WiFi P2P broadcast connection changed action without being initialized."); + return; + } + + NetworkInfo networkInfo = intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO); + + if (networkInfo == null) { + Log.w(TAG, "WiFi P2P broadcast connection changed action with null network info."); + return; + } + + if (networkInfo.isConnected()) { + Log.i(TAG, "Connected to P2P network, requesting connection information."); + manager.requestConnectionInfo(channel, this); + } else { + Log.i(TAG, "Disconnected from P2P network"); + if (connectionListener != null) { + connectionListener.onNetworkDisconnected(); + } + } + break; + } + } + } + + @Override + public void onConnectionInfoAvailable(@NonNull WifiP2pInfo info) { + Log.i(TAG, "Connection information available. group_formed: " + info.groupFormed + " group_owner: " + info.isGroupOwner); + if (connectionListener != null) { + connectionListener.onNetworkConnected(info); + } + } + + @Override + public void onChannelDisconnected() { + if (connectionListener != null) { + connectionListener.onNetworkFailure(); + } + } + } + + /** + * Provide a synchronous way to talking to Android's WiFi Direct code. + */ + private static class SyncActionListener extends LoggingActionListener { + + private final CountDownLatch sync; + + private volatile int failureReason = -1; + + public SyncActionListener(@NonNull String message) { + super(message); + this.sync = new CountDownLatch(1); + } + + @Override + public void onSuccess() { + super.onSuccess(); + sync.countDown(); + } + + @Override + public void onFailure(int reason) { + super.onFailure(reason); + failureReason = reason; + sync.countDown(); + } + + public boolean successful() { + try { + sync.await(); + } catch (InterruptedException ie) { + throw new AssertionError(ie); + } + return failureReason < 0; + } + } + + private static class LoggingActionListener implements WifiP2pManager.ActionListener { + + private final String message; + + public static @NonNull LoggingActionListener message(@Nullable String message) { + return new LoggingActionListener(message); + } + + public LoggingActionListener(@Nullable String message) { + this.message = message; + } + + @Override + public void onSuccess() { + Log.i(TAG, message + " success"); + } + + @Override + public void onFailure(int reason) { + Log.w(TAG, message + " failure_reason: " + reason); + } + } + + public enum AvailableStatus { + FEATURE_NOT_AVAILABLE, + WIFI_MANAGER_NOT_AVAILABLE, + FINE_LOCATION_PERMISSION_NOT_GRANTED, + WIFI_DIRECT_NOT_AVAILABLE, + AVAILABLE + } + + public interface WifiDirectConnectionListener { + void onLocalDeviceChanged(@NonNull WifiP2pDevice localDevice); + + void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice); + + void onNetworkConnected(@NonNull WifiP2pInfo info); + + void onNetworkDisconnected(); + + void onNetworkFailure(); + } +} diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirectUnavailableException.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirectUnavailableException.java new file mode 100644 index 000000000..e1cdd81f3 --- /dev/null +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirectUnavailableException.java @@ -0,0 +1,29 @@ +package org.signal.devicetransfer; + +import androidx.annotation.NonNull; + +/** + * Represents the various type of failure with creating a WiFi Direction connection. + */ +public final class WifiDirectUnavailableException extends Exception { + + private final Reason reason; + + public WifiDirectUnavailableException(@NonNull Reason reason) { + this.reason = reason; + } + + public @NonNull Reason getReason() { + return reason; + } + + public enum Reason { + WIFI_P2P_MANAGER, + CHANNEL_INITIALIZATION, + SERVICE_DISCOVERY_START, + SERVICE_START, + SERVICE_CONNECT_FAILURE, + SERVICE_CREATE_GROUP, + SERVICE_NOT_INITIALIZED + } +} diff --git a/device-transfer/lib/witness-verifications.gradle b/device-transfer/lib/witness-verifications.gradle new file mode 100644 index 000000000..cf44f2ad3 --- /dev/null +++ b/device-transfer/lib/witness-verifications.gradle @@ -0,0 +1,105 @@ +// Auto-generated, use ./gradlew calculateChecksums to regenerate + +dependencyVerification { + verify = [ + + ['androidx.activity:activity:1.0.0', + 'd1bc9842455c2e534415d88c44df4d52413b478db9093a1ba36324f705f44c3d'], + + ['androidx.annotation:annotation-experimental:1.0.0', + 'b219d2b568e7e4ba534e09f8c2fd242343df6ccbdfbbe938846f5d740e6b0b11'], + + ['androidx.annotation:annotation:1.1.0', + 'd38d63edb30f1467818d50aaf05f8a692dea8b31392a049bfa991b159ad5b692'], + + ['androidx.appcompat:appcompat-resources:1.2.0', + 'c470297c03ff3de1c3d15dacf0be0cae63abc10b52f021dd07ae28daa3100fe5'], + + ['androidx.appcompat:appcompat:1.2.0', + '3d2131a55a61a777322e2126e0018011efa6339e53b44153eb651b16020cca70'], + + ['androidx.arch.core:core-common:2.1.0', + 'fe1237bf029d063e7f29fe39aeaf73ef74c8b0a3658486fc29d3c54326653889'], + + ['androidx.arch.core:core-runtime:2.0.0', + '87e65fc767c712b437649c7cee2431ebb4bed6daef82e501d4125b3ed3f65f8e'], + + ['androidx.cardview:cardview:1.0.0', + '1193c04c22a3d6b5946dae9f4e8c59d6adde6a71b6bd5d87fb99d82dda1afec7'], + + ['androidx.collection:collection:1.1.0', + '632a0e5407461de774409352940e292a291037724207a787820c77daf7d33b72'], + + ['androidx.coordinatorlayout:coordinatorlayout:1.1.0', + '44a9e30abf56af1025c52a0af506fee9c4131aa55efda52f9fd9451211c5e8cb'], + + ['androidx.core:core:1.3.0', + '1c6b6626f15185d8f4bc7caac759412a1ab6e851ecf7526387d9b9fadcabdb63'], + + ['androidx.cursoradapter:cursoradapter:1.0.0', + 'a81c8fe78815fa47df5b749deb52727ad11f9397da58b16017f4eb2c11e28564'], + + ['androidx.customview:customview:1.0.0', + '20e5b8f6526a34595a604f56718da81167c0b40a7a94a57daa355663f2594df2'], + + ['androidx.drawerlayout:drawerlayout:1.0.0', + '9402442cdc5a43cf62fb14f8cf98c63342d4d9d9b805c8033c6cf7e802749ac1'], + + ['androidx.fragment:fragment:1.1.0', + 'a14c8b8f2153f128e800fbd266a6beab1c283982a29ec570d2cc05d307d81496'], + + ['androidx.interpolator:interpolator:1.0.0', + '33193135a64fe21fa2c35eec6688f1a76e512606c0fc83dc1b689e37add7732a'], + + ['androidx.lifecycle:lifecycle-common:2.1.0', + '76db6be533bd730fb361c2feb12a2c26d9952824746847da82601ef81f082643'], + + ['androidx.lifecycle:lifecycle-livedata-core:2.0.0', + 'fde334ec7e22744c0f5bfe7caf1a84c9d717327044400577bdf9bd921ec4f7bc'], + + ['androidx.lifecycle:lifecycle-livedata:2.0.0', + 'c82609ced8c498f0a701a30fb6771bb7480860daee84d82e0a81ee86edf7ba39'], + + ['androidx.lifecycle:lifecycle-runtime:2.1.0', + 'e5173897b965e870651e83d9d5af1742d3f532d58863223a390ce3a194c8312b'], + + ['androidx.lifecycle:lifecycle-viewmodel:2.1.0', + 'ba55fb7ac1b2828d5327cda8acf7085d990b2b4c43ef336caa67686249b8523d'], + + ['androidx.loader:loader:1.0.0', + '11f735cb3b55c458d470bed9e25254375b518b4b1bad6926783a7026db0f5025'], + + ['androidx.recyclerview:recyclerview:1.1.0', + 'f0d2b5a67d0a91ee1b1c73ef2b636a81f3563925ddd15a1d4e1c41ec28de7a4f'], + + ['androidx.savedstate:savedstate:1.0.0', + '2510a5619c37579c9ce1a04574faaf323cd0ffe2fc4e20fa8f8f01e5bb402e83'], + + ['androidx.transition:transition:1.2.0', + 'a1e059b3bc0b43a58dec0efecdcaa89c82d2bca552ea5bacf6656c46e853157e'], + + ['androidx.vectordrawable:vectordrawable-animated:1.1.0', + '76da2c502371d9c38054df5e2b248d00da87809ed058f3363eae87ce5e2403f8'], + + ['androidx.vectordrawable:vectordrawable:1.1.0', + '46fd633ac01b49b7fcabc263bf098c5a8b9e9a69774d234edcca04fb02df8e26'], + + ['androidx.versionedparcelable:versionedparcelable:1.1.0', + '9a1d77140ac222b7866b5054ee7d159bc1800987ed2d46dd6afdd145abb710c1'], + + ['androidx.viewpager2:viewpager2:1.0.0', + 'e95c0031d4cc247cd48196c6287e58d2cee54d9c79b85afea7c90920330275af'], + + ['androidx.viewpager:viewpager:1.0.0', + '147af4e14a1984010d8f155e5e19d781f03c1d70dfed02a8e0d18428b8fc8682'], + + ['com.google.android.material:material:1.2.1', + 'd3d0cc776f2341da8e572586c7d390a5b356ce39a0deb2768071dc40b364ac80'], + + ['com.google.protobuf:protobuf-javalite:3.10.0', + '215a94dbe100130295906b531bb72a26965c7ac8fcd9a75bf8054a8ac2abf4b4'], + + ['org.greenrobot:eventbus:3.0.0', + '180d4212467df06f2fbc9c8d8a2984533ac79c87769ad883bc421612f0b4e17c'], + ] +} diff --git a/settings.gradle b/settings.gradle index fe78b99a2..f34675477 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,11 +5,16 @@ include ':paging' include ':paging-app' include ':core-util' include ':video' +include ':device-transfer' +include ':device-transfer-app' project(':app').name = 'Signal-Android' project(':paging').projectDir = file('paging/lib') project(':paging-app').projectDir = file('paging/app') +project(':device-transfer').projectDir = file('device-transfer/lib') +project(':device-transfer-app').projectDir = file('device-transfer/app') + project(':libsignal-service').projectDir = file('libsignal/service') rootProject.name='Signal'