mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-28 04:34:21 +01:00
Add Device Transfer via WiFi Direct groundwork.
This commit is contained in:
20
device-transfer/lib/src/main/AndroidManifest.xml
Normal file
20
device-transfer/lib/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.signal.devicetransfer">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".DeviceToDeviceTransferService"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.signal.devicetransfer;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Self-contained chunk of code to run once the {@link DeviceTransferClient} connects to a
|
||||
* {@link DeviceTransferServer}.
|
||||
*/
|
||||
public interface ClientTask extends Serializable {
|
||||
|
||||
/**
|
||||
* @param context Android context, mostly like the foreground transfer service
|
||||
* @param outputStream Output stream associated with socket connected to remote server.
|
||||
*/
|
||||
void run(@NonNull Context context, @NonNull OutputStream outputStream) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package org.signal.devicetransfer;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Foreground service to help manage interactions with the {@link DeviceTransferClient} and
|
||||
* {@link DeviceTransferServer}.
|
||||
*/
|
||||
public class DeviceToDeviceTransferService extends Service implements ShutdownCallback {
|
||||
|
||||
private static final String TAG = Log.tag(DeviceToDeviceTransferService.class);
|
||||
|
||||
private static final int INVALID_PORT = -1;
|
||||
|
||||
private static final String ACTION_START_SERVER = "start";
|
||||
private static final String ACTION_START_CLIENT = "start_client";
|
||||
private static final String ACTION_STOP = "stop";
|
||||
|
||||
private static final String EXTRA_PENDING_INTENT = "extra_pending_intent";
|
||||
private static final String EXTRA_TASK = "extra_task";
|
||||
private static final String EXTRA_NOTIFICATION = "extra_notification_data";
|
||||
private static final String EXTRA_PORT = "extra_port";
|
||||
|
||||
private TransferNotificationData notificationData;
|
||||
private PendingIntent pendingIntent;
|
||||
private DeviceTransferServer server;
|
||||
private DeviceTransferClient client;
|
||||
|
||||
public static void startServer(@NonNull Context context,
|
||||
int port,
|
||||
@NonNull ServerTask serverTask,
|
||||
@NonNull TransferNotificationData transferNotificationData,
|
||||
@Nullable PendingIntent pendingIntent)
|
||||
{
|
||||
Intent intent = new Intent(context, DeviceToDeviceTransferService.class);
|
||||
intent.setAction(ACTION_START_SERVER)
|
||||
.putExtra(EXTRA_TASK, serverTask)
|
||||
.putExtra(EXTRA_PORT, port)
|
||||
.putExtra(EXTRA_NOTIFICATION, transferNotificationData)
|
||||
.putExtra(EXTRA_PENDING_INTENT, pendingIntent);
|
||||
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
public static void startClient(@NonNull Context context,
|
||||
int port,
|
||||
@NonNull ClientTask clientTask,
|
||||
@NonNull TransferNotificationData transferNotificationData,
|
||||
@Nullable PendingIntent pendingIntent)
|
||||
{
|
||||
Intent intent = new Intent(context, DeviceToDeviceTransferService.class);
|
||||
intent.setAction(ACTION_START_CLIENT)
|
||||
.putExtra(EXTRA_TASK, clientTask)
|
||||
.putExtra(EXTRA_PORT, port)
|
||||
.putExtra(EXTRA_NOTIFICATION, transferNotificationData)
|
||||
.putExtra(EXTRA_PENDING_INTENT, pendingIntent);
|
||||
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
public static void stop(@NonNull Context context) {
|
||||
context.startService(new Intent(context, DeviceToDeviceTransferService.class).setAction(ACTION_STOP));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
Log.e(TAG, "onCreate");
|
||||
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventMainThread(@NonNull TransferMode event) {
|
||||
updateNotification(event);
|
||||
}
|
||||
|
||||
private void update(@NonNull TransferMode transferMode) {
|
||||
EventBus.getDefault().postSticky(transferMode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
Log.e(TAG, "onDestroy");
|
||||
|
||||
EventBus.getDefault().unregister(this);
|
||||
|
||||
if (client != null) {
|
||||
client.shutdown();
|
||||
client = null;
|
||||
}
|
||||
|
||||
if (server != null) {
|
||||
server.shutdown();
|
||||
server = null;
|
||||
}
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
|
||||
if (intent == null) {
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
final String action = intent.getAction();
|
||||
if (action == null) {
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
final WifiDirect.AvailableStatus availability = WifiDirect.getAvailability(this);
|
||||
if (availability != WifiDirect.AvailableStatus.AVAILABLE) {
|
||||
update(availability == WifiDirect.AvailableStatus.FINE_LOCATION_PERMISSION_NOT_GRANTED ? TransferMode.PERMISSIONS
|
||||
: TransferMode.UNAVAILABLE);
|
||||
shutdown();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case ACTION_START_SERVER: {
|
||||
int port = intent.getIntExtra(EXTRA_PORT, INVALID_PORT);
|
||||
if (server == null && port != -1) {
|
||||
notificationData = intent.getParcelableExtra(EXTRA_NOTIFICATION);
|
||||
pendingIntent = intent.getParcelableExtra(EXTRA_PENDING_INTENT);
|
||||
server = new DeviceTransferServer(getApplicationContext(),
|
||||
(ServerTask) Objects.requireNonNull(intent.getSerializableExtra(EXTRA_TASK)),
|
||||
port,
|
||||
this);
|
||||
updateNotification(TransferMode.READY);
|
||||
server.start();
|
||||
} else {
|
||||
Log.i(TAG, "Can't start server. already_started: " + (server != null) + " port: " + port);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ACTION_START_CLIENT: {
|
||||
int port = intent.getIntExtra(EXTRA_PORT, INVALID_PORT);
|
||||
if (client == null && port != -1) {
|
||||
notificationData = intent.getParcelableExtra(EXTRA_NOTIFICATION);
|
||||
pendingIntent = intent.getParcelableExtra(EXTRA_PENDING_INTENT);
|
||||
client = new DeviceTransferClient(getApplicationContext(),
|
||||
(ClientTask) Objects.requireNonNull(intent.getSerializableExtra(EXTRA_TASK)),
|
||||
port,
|
||||
this);
|
||||
updateNotification(TransferMode.READY);
|
||||
client.start();
|
||||
} else {
|
||||
Log.i(TAG, "Can't start client. already_started: " + (client != null) + " port: " + port);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ACTION_STOP:
|
||||
shutdown();
|
||||
break;
|
||||
}
|
||||
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
Log.i(TAG, "Shutdown");
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
});
|
||||
}
|
||||
|
||||
private void updateNotification(@NonNull TransferMode transferMode) {
|
||||
if (notificationData != null && (client != null || server != null)) {
|
||||
startForeground(notificationData.notificationId, createNotification(transferMode, notificationData));
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull Notification createNotification(@NonNull TransferMode transferMode, @NonNull TransferNotificationData notificationData) {
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, notificationData.channelId);
|
||||
|
||||
//TODO [cody] build notification to spec
|
||||
builder.setSmallIcon(notificationData.icon)
|
||||
.setOngoing(true)
|
||||
.setContentTitle("Device Transfer")
|
||||
.setContentText("Status: " + transferMode.name())
|
||||
.setContentIntent(pendingIntent);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable IBinder onBind(@NonNull Intent intent) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
public static class TransferNotificationData implements Parcelable {
|
||||
private final int notificationId;
|
||||
private final String channelId;
|
||||
private final int icon;
|
||||
|
||||
public TransferNotificationData(int notificationId, @NonNull String channelId, int icon) {
|
||||
this.notificationId = notificationId;
|
||||
this.channelId = channelId;
|
||||
this.icon = icon;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
dest.writeInt(notificationId);
|
||||
dest.writeString(channelId);
|
||||
dest.writeInt(icon);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<TransferNotificationData> CREATOR = new Creator<TransferNotificationData>() {
|
||||
@Override
|
||||
public @NonNull TransferNotificationData createFromParcel(@NonNull Parcel in) {
|
||||
return new TransferNotificationData(in.readInt(), in.readString(), in.readInt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull TransferNotificationData[] newArray(int size) {
|
||||
return new TransferNotificationData[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package org.signal.devicetransfer;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.wifi.p2p.WifiP2pDevice;
|
||||
import android.net.wifi.p2p.WifiP2pInfo;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.os.Message;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Encapsulates the logic to find and establish a WiFi Direct connection with another
|
||||
* device and then perform an arbitrary {@link ClientTask} with the TCP socket.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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."
|
||||
* <p>
|
||||
* Summary of mitigations:
|
||||
* <ul>
|
||||
* <li>Completely tear down and restart WiFi direct if no server is found within the timeout.</li>
|
||||
* <li>Retry connecting to the WiFi Direct network, and after all retries fail it does a complete tear down and restart.</li>
|
||||
* <li>Retry connecting to the server until successful, disconnected from WiFi Direct network, or told to stop.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class DeviceTransferClient implements Handler.Callback {
|
||||
|
||||
private static final String TAG = Log.tag(DeviceTransferClient.class);
|
||||
private static final int START_CLIENT = 0;
|
||||
private static final int START_NETWORK_CLIENT = 1;
|
||||
private static final int NETWORK_DISCONNECTED = 2;
|
||||
private static final int CONNECT_TO_SERVICE = 3;
|
||||
private static final int RESTART_CLIENT = 4;
|
||||
private static final int START_IP_EXCHANGE = 5;
|
||||
private static final int IP_EXCHANGE_SUCCESS = 6;
|
||||
|
||||
private final Context context;
|
||||
private final int port;
|
||||
private HandlerThread commandAndControlThread;
|
||||
private final Handler handler;
|
||||
private final ClientTask clientTask;
|
||||
private final ShutdownCallback shutdownCallback;
|
||||
private WifiDirect wifiDirect;
|
||||
private ClientThread clientThread;
|
||||
private final Runnable autoRestart;
|
||||
private IpExchange.IpExchangeThread ipExchangeThread;
|
||||
|
||||
private static void update(@NonNull TransferMode transferMode) {
|
||||
EventBus.getDefault().postSticky(transferMode);
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public DeviceTransferClient(@NonNull Context context, @NonNull ClientTask clientTask, int port, @Nullable ShutdownCallback shutdownCallback) {
|
||||
this.context = context;
|
||||
this.clientTask = clientTask;
|
||||
this.port = port;
|
||||
this.shutdownCallback = shutdownCallback;
|
||||
this.commandAndControlThread = SignalExecutors.getAndStartHandlerThread("client-cnc");
|
||||
this.handler = new Handler(commandAndControlThread.getLooper(), this);
|
||||
this.autoRestart = () -> {
|
||||
Log.i(TAG, "Restarting WiFi Direct since we haven't found anything yet and it could be us.");
|
||||
handler.sendEmptyMessage(RESTART_CLIENT);
|
||||
};
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public void start() {
|
||||
handler.sendMessage(handler.obtainMessage(START_CLIENT));
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public synchronized void shutdown() {
|
||||
stopIpExchange();
|
||||
stopClient();
|
||||
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_CLIENT:
|
||||
startWifiDirect();
|
||||
break;
|
||||
case START_NETWORK_CLIENT:
|
||||
startClient((String) message.obj);
|
||||
break;
|
||||
case NETWORK_DISCONNECTED:
|
||||
stopClient();
|
||||
break;
|
||||
case CONNECT_TO_SERVICE:
|
||||
connectToService((String) message.obj);
|
||||
break;
|
||||
case RESTART_CLIENT:
|
||||
stopClient();
|
||||
stopWifiDirect();
|
||||
startWifiDirect();
|
||||
break;
|
||||
case START_IP_EXCHANGE:
|
||||
startIpExchange((String) message.obj);
|
||||
break;
|
||||
case IP_EXCHANGE_SUCCESS:
|
||||
ipExchangeSuccessful((String) message.obj);
|
||||
break;
|
||||
default:
|
||||
shutdown();
|
||||
if (shutdownCallback != null) {
|
||||
shutdownCallback.shutdown();
|
||||
}
|
||||
throw new AssertionError();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void startWifiDirect() {
|
||||
if (wifiDirect != null) {
|
||||
Log.e(TAG, "Client already started");
|
||||
return;
|
||||
}
|
||||
|
||||
update(TransferMode.STARTING_UP);
|
||||
|
||||
try {
|
||||
wifiDirect = new WifiDirect(context);
|
||||
wifiDirect.initialize(new WifiDirectListener());
|
||||
wifiDirect.discoverService();
|
||||
Log.i(TAG, "Started service discovery, searching for service...");
|
||||
update(TransferMode.DISCOVERY);
|
||||
handler.postDelayed(autoRestart, TimeUnit.SECONDS.toMillis(15));
|
||||
} catch (WifiDirectUnavailableException e) {
|
||||
Log.e(TAG, e);
|
||||
shutdown();
|
||||
update(TransferMode.FAILED);
|
||||
if (shutdownCallback != null) {
|
||||
shutdownCallback.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stopWifiDirect() {
|
||||
handler.removeCallbacks(autoRestart);
|
||||
|
||||
if (wifiDirect != null) {
|
||||
Log.i(TAG, "Shutting down WiFi Direct");
|
||||
wifiDirect.shutdown();
|
||||
wifiDirect = null;
|
||||
update(TransferMode.READY);
|
||||
}
|
||||
}
|
||||
|
||||
private void startClient(@NonNull String serverHostAddress) {
|
||||
if (clientThread != null) {
|
||||
Log.i(TAG, "Client already running");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Connection established, spinning up network client.");
|
||||
clientThread = new ClientThread(context, clientTask, serverHostAddress, port);
|
||||
clientThread.start();
|
||||
}
|
||||
|
||||
private void stopClient() {
|
||||
if (clientThread != null) {
|
||||
Log.i(TAG, "Shutting down ClientThread");
|
||||
clientThread.shutdown();
|
||||
clientThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void connectToService(@NonNull String deviceAddress) {
|
||||
if (wifiDirect == null) {
|
||||
Log.w(TAG, "WifiDirect is not initialized, we shouldn't be here.");
|
||||
return;
|
||||
}
|
||||
|
||||
handler.removeCallbacks(autoRestart);
|
||||
|
||||
int tries = 5;
|
||||
while ((tries--) > 0) {
|
||||
try {
|
||||
wifiDirect.connect(deviceAddress);
|
||||
update(TransferMode.NETWORK_CONNECTED);
|
||||
return;
|
||||
} catch (WifiDirectUnavailableException e) {
|
||||
Log.w(TAG, "Unable to connect, tries: " + tries);
|
||||
ThreadUtil.sleep(TimeUnit.SECONDS.toMillis(2));
|
||||
}
|
||||
}
|
||||
|
||||
handler.sendMessage(handler.obtainMessage(RESTART_CLIENT));
|
||||
}
|
||||
|
||||
private void startIpExchange(@NonNull String groupOwnerHostAddress) {
|
||||
ipExchangeThread = IpExchange.getIp(groupOwnerHostAddress, port, handler, IP_EXCHANGE_SUCCESS);
|
||||
}
|
||||
|
||||
private void stopIpExchange() {
|
||||
if (ipExchangeThread != null) {
|
||||
ipExchangeThread.shutdown();
|
||||
ipExchangeThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ipExchangeSuccessful(@NonNull String host) {
|
||||
stopIpExchange();
|
||||
handler.sendMessage(handler.obtainMessage(START_NETWORK_CLIENT, host));
|
||||
}
|
||||
|
||||
private static class ClientThread extends Thread {
|
||||
|
||||
private volatile Socket client;
|
||||
private volatile boolean isRunning;
|
||||
|
||||
private final Context context;
|
||||
private final ClientTask clientTask;
|
||||
private final String serverHostAddress;
|
||||
private final int port;
|
||||
|
||||
public ClientThread(@NonNull Context context,
|
||||
@NonNull ClientTask clientTask,
|
||||
@NonNull String serverHostAddress,
|
||||
int port)
|
||||
{
|
||||
this.context = context;
|
||||
this.clientTask = clientTask;
|
||||
this.serverHostAddress = serverHostAddress;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Log.i(TAG, "Client thread running");
|
||||
isRunning = true;
|
||||
|
||||
while (shouldKeepRunning()) {
|
||||
Log.i(TAG, "Attempting to connect to server...");
|
||||
|
||||
try {
|
||||
client = new Socket();
|
||||
try {
|
||||
client.bind(null);
|
||||
client.connect(new InetSocketAddress(serverHostAddress, port), 10000);
|
||||
DeviceTransferClient.update(TransferMode.SERVICE_CONNECTED);
|
||||
|
||||
clientTask.run(context, client.getOutputStream());
|
||||
|
||||
Log.i(TAG, "Done!!");
|
||||
isRunning = false;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Error connecting to server", e);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, e);
|
||||
} finally {
|
||||
if (client != null && !client.isClosed()) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
DeviceTransferClient.update(TransferMode.NETWORK_CONNECTED);
|
||||
}
|
||||
|
||||
if (shouldKeepRunning()) {
|
||||
ThreadUtil.interruptableSleep(TimeUnit.SECONDS.toMillis(3));
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Client exiting");
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
isRunning = false;
|
||||
try {
|
||||
if (client != null) {
|
||||
client.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Error shutting down client socket", e);
|
||||
}
|
||||
interrupt();
|
||||
}
|
||||
|
||||
private boolean shouldKeepRunning() {
|
||||
return !isInterrupted() && isRunning;
|
||||
}
|
||||
}
|
||||
|
||||
public class WifiDirectListener implements WifiDirect.WifiDirectConnectionListener {
|
||||
|
||||
@Override
|
||||
public void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice) {
|
||||
handler.sendMessage(handler.obtainMessage(CONNECT_TO_SERVICE, serviceDevice.deviceAddress));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNetworkConnected(@NonNull WifiP2pInfo info) {
|
||||
if (info.isGroupOwner) {
|
||||
handler.sendEmptyMessage(START_IP_EXCHANGE);
|
||||
} else {
|
||||
handler.sendMessage(handler.obtainMessage(START_NETWORK_CLIENT, 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) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package org.signal.devicetransfer;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.wifi.p2p.WifiP2pDevice;
|
||||
import android.net.wifi.p2p.WifiP2pInfo;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.os.Message;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Encapsulates the logic to advertise the availability of a transfer service over a WiFi Direct
|
||||
* network, establish a WiFi Direct network, and then act as a TCP server for a {@link DeviceTransferClient}.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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) { }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.signal.devicetransfer;
|
||||
|
||||
public enum TransferMode {
|
||||
PERMISSIONS,
|
||||
UNAVAILABLE,
|
||||
FAILED,
|
||||
READY,
|
||||
STARTING_UP,
|
||||
DISCOVERY,
|
||||
NETWORK_CONNECTED,
|
||||
SERVICE_CONNECTED
|
||||
}
|
||||
@@ -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}.
|
||||
* <p>
|
||||
* <i>Note: After this call, the instance is no longer usable and an entirely new one will need to
|
||||
* be created.</i>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user