Add Device Transfer via WiFi Direct groundwork.

This commit is contained in:
Cody Henthorne 2021-03-03 16:03:49 -05:00 committed by GitHub
parent fd9c420dc8
commit e7f233db5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 2232 additions and 0 deletions

View file

@ -87,4 +87,10 @@ public final class ThreadUtil {
throw new AssertionError(e);
}
}
public static void interruptableSleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ignored) { }
}
}

View file

@ -0,0 +1,30 @@
apply plugin: 'com.android.application'
android {
buildToolsVersion BUILD_TOOL_VERSION
compileSdkVersion COMPILE_SDK
defaultConfig {
applicationId "org.signal.devicetransfer.app"
versionCode 1
versionName "1.0"
minSdkVersion MINIMUM_SDK
targetSdkVersion TARGET_SDK
}
compileOptions {
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
testImplementation 'junit:junit:4.12'
implementation project(':device-transfer')
}

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="org.signal.devicetransfer.app">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DeviceTransferTest">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,152 @@
package org.signal.devicetransfer.app;
import android.Manifest;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.NotificationManagerCompat;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.devicetransfer.ClientTask;
import org.signal.devicetransfer.DeviceToDeviceTransferService;
import org.signal.devicetransfer.DeviceToDeviceTransferService.TransferNotificationData;
import org.signal.devicetransfer.ServerTask;
import org.signal.devicetransfer.TransferMode;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Random;
public class MainActivity extends AppCompatActivity {
private static final String TRANSFER_NOTIFICATION_CHANNEL = "DEVICE_TO_DEVICE_TRANSFER";
private LinearLayout list;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
if (Build.VERSION.SDK_INT > 26) {
NotificationChannel deviceTransfer = new NotificationChannel(TRANSFER_NOTIFICATION_CHANNEL, "Device Transfer", NotificationManager.IMPORTANCE_DEFAULT);
NotificationManagerCompat.from(this).createNotificationChannel(deviceTransfer);
}
list = findViewById(R.id.list);
final TransferNotificationData data = new TransferNotificationData(1337,
TRANSFER_NOTIFICATION_CHANNEL,
R.drawable.ic_refresh_20);
findViewById(R.id.start_server).setOnClickListener(v -> {
DeviceToDeviceTransferService.startServer(this,
8888,
new ServerReceiveRandomBytes(),
data,
PendingIntent.getActivity(this,
0,
new Intent(this, MainActivity.class),
0));
list.removeAllViews();
});
findViewById(R.id.start_client).setOnClickListener(v -> {
DeviceToDeviceTransferService.startClient(this,
8888,
new ClientSendRandomBytes(),
data,
PendingIntent.getActivity(this,
0,
new Intent(this, MainActivity.class),
0));
list.removeAllViews();
});
findViewById(R.id.stop).setOnClickListener(v -> {
DeviceToDeviceTransferService.stop(this);
});
findViewById(R.id.enable_permission).setOnClickListener(v -> {
if (Build.VERSION.SDK_INT >= 23 && checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, 420);
}
});
EventBus.getDefault().register(this);
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventMainThread(@NonNull TransferMode event) {
TextView text = new TextView(this);
text.setText(event.toString());
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
text.setLayoutParams(params);
list.addView(text);
}
private static class ClientSendRandomBytes implements ClientTask {
private static final String TAG = "ClientSend";
private final int rounds = 1000;
@Override
public void run(@NonNull Context context, @NonNull OutputStream outputStream) throws IOException {
Random r = new Random(System.currentTimeMillis());
byte[] data = new byte[8192];
r.nextBytes(data);
long start = System.currentTimeMillis();
Log.i(TAG, "Sending " + ((data.length * rounds) / 1024 / 1024) + "MB of random data!!!");
for (int i = 0; i < rounds; i++) {
outputStream.write(data);
outputStream.flush();
}
long end = System.currentTimeMillis();
Log.i(TAG, "Sending took: " + (end - start));
}
}
private static class ServerReceiveRandomBytes implements ServerTask {
private static final String TAG = "ServerReceive";
@Override
public void run(@NonNull Context context, @NonNull InputStream inputStream) throws IOException {
long start = System.currentTimeMillis();
byte[] data = new byte[8192];
int result = 0;
int i = 0;
Log.i(TAG, "Start drinking from the fire hose!");
while (result >= 0) {
result = inputStream.read(data, 0, 8192);
i++;
if (i % 10000 == 0) {
Log.i(TAG, "Round: " + i);
}
}
long end = System.currentTimeMillis();
Log.i(TAG, "Receive took: " + (end - start));
}
}
}

View file

@ -0,0 +1,31 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,171 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:fillColor="#FF000000"
android:pathData="M14.7,5.3A6.7,6.7 0,1 0,10 16.7a6.7,6.7 0,0 0,6.4 -5H14.7A5,5 0,1 1,10 5a4.9,4.9 0,0 1,3.5 1.5L10.8,9.2h5.9V3.3Z"/>
</vector>

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/start_server"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Start Server"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/stop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Stop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/start_client" />
<Button
android:id="@+id/enable_permission"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Permission"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/stop" />
<LinearLayout
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/enable_permission" />
<Button
android:id="@+id/start_client"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Start Client"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/start_server" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.DeviceTransferTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">DeviceTransferTest</string>
</resources>

View file

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.DeviceTransferTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,30 @@
apply plugin: 'com.android.library'
apply plugin: 'witness'
apply from: 'witness-verifications.gradle'
android {
buildToolsVersion BUILD_TOOL_VERSION
compileSdkVersion COMPILE_SDK
defaultConfig {
minSdkVersion MINIMUM_SDK
targetSdkVersion TARGET_SDK
}
compileOptions {
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
}
}
dependencyVerification {
configuration = '(debug|release)RuntimeClasspath'
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.2.1'
implementation project(':core-util')
api 'org.greenrobot:eventbus:3.0.0'
testImplementation 'junit:junit:4.12'
}

View 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>

View file

@ -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;
}

View file

@ -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];
}
};
}
}

View file

@ -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) { }
}
}

View file

@ -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) { }
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -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();
}

View file

@ -0,0 +1,12 @@
package org.signal.devicetransfer;
public enum TransferMode {
PERMISSIONS,
UNAVAILABLE,
FAILED,
READY,
STARTING_UP,
DISCOVERY,
NETWORK_CONNECTED,
SERVICE_CONNECTED
}

View file

@ -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();
}
}

View file

@ -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
}
}

View file

@ -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'],
]
}

View file

@ -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'