Source added
22
device-transfer/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
plugins {
|
||||
id("signal-sample-app")
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.devicetransfer.app"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.signal.devicetransfer.app"
|
||||
|
||||
ndk {
|
||||
abiFilters += setOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
}
|
||||
|
||||
buildConfigField("String", "LIBSIGNAL_VERSION", "\"libsignal ${libs.versions.libsignal.client.get()}\"")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":device-transfer"))
|
||||
}
|
||||
7
device-transfer/app/proguard/proguard.cfg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-dontoptimize
|
||||
-dontobfuscate
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keep class org.signal.devicetransfer.** { *; }
|
||||
-keepclassmembers class ** {
|
||||
public void onEvent*(**);
|
||||
}
|
||||
24
device-transfer/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<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"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
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.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.PendingIntentFlags;
|
||||
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.TransferStatus;
|
||||
|
||||
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,
|
||||
new ServerReceiveRandomBytes(),
|
||||
data,
|
||||
PendingIntent.getActivity(this,
|
||||
0,
|
||||
new Intent(this, MainActivity.class),
|
||||
PendingIntentFlags.mutable()));
|
||||
|
||||
list.removeAllViews();
|
||||
});
|
||||
|
||||
findViewById(R.id.start_client).setOnClickListener(v -> {
|
||||
DeviceToDeviceTransferService.startClient(this,
|
||||
new ClientSendRandomBytes(),
|
||||
data,
|
||||
PendingIntent.getActivity(this,
|
||||
0,
|
||||
new Intent(this, MainActivity.class),
|
||||
PendingIntentFlags.mutable()));
|
||||
|
||||
list.removeAllViews();
|
||||
});
|
||||
|
||||
findViewById(R.id.stop).setOnClickListener(v -> DeviceToDeviceTransferService.stop(this));
|
||||
|
||||
findViewById(R.id.enable_permission).setOnClickListener(v -> {
|
||||
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, 420);
|
||||
}
|
||||
});
|
||||
|
||||
final TextView libsignalVersion = findViewById(R.id.libsignal_version);
|
||||
libsignalVersion.setText(BuildConfig.LIBSIGNAL_VERSION);
|
||||
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventMainThread(@NonNull TransferStatus event) {
|
||||
TextView text = new TextView(this);
|
||||
text.setText(event.getTransferMode().toString());
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
text.setLayoutParams(params);
|
||||
list.addView(text);
|
||||
|
||||
if (event.getTransferMode() == TransferStatus.TransferMode.VERIFICATION_REQUIRED) {
|
||||
new MaterialAlertDialogBuilder(this).setTitle("Verification Required")
|
||||
.setMessage("Code: " + event.getAuthenticationCode())
|
||||
.setPositiveButton("Yes, Same", (d, w) -> DeviceToDeviceTransferService.setAuthenticationCodeVerified(this, true))
|
||||
.setNegativeButton("No, different", (d, w) -> DeviceToDeviceTransferService.setAuthenticationCodeVerified(this, false))
|
||||
.setCancelable(false)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
private static class ClientSendRandomBytes implements ClientTask {
|
||||
|
||||
private static final String TAG = "ClientSend";
|
||||
private static final int ROUNDS = 131072 / 4; // Use 131072 to send 1GB
|
||||
|
||||
@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));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void success() {
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
67
device-transfer/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?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/libsignal_version" />
|
||||
|
||||
<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" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/libsignal_version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="libsignal: unknown"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/enable_permission" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
BIN
device-transfer/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
BIN
device-transfer/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
BIN
device-transfer/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
BIN
device-transfer/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
BIN
device-transfer/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
16
device-transfer/app/src/main/res/values-night/themes.xml
Normal 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>
|
||||
10
device-transfer/app/src/main/res/values/colors.xml
Normal 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>
|
||||
3
device-transfer/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">DeviceTransferTest</string>
|
||||
</resources>
|
||||
16
device-transfer/app/src/main/res/values/themes.xml
Normal 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>
|
||||
18
device-transfer/lib/build.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
plugins {
|
||||
id("signal-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.devicetransfer"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core-util"))
|
||||
implementation(libs.libsignal.android)
|
||||
api(libs.greenrobot.eventbus)
|
||||
|
||||
testImplementation(testLibs.robolectric.robolectric) {
|
||||
exclude(group = "com.google.protobuf", module = "protobuf-java")
|
||||
}
|
||||
testImplementation(testFixtures(project(":libsignal-service")))
|
||||
}
|
||||
22
device-transfer/lib/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<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_COARSE_LOCATION" />
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".DeviceToDeviceTransferService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
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;
|
||||
|
||||
/**
|
||||
* Called after the output stream has been successfully flushed and closed.
|
||||
*/
|
||||
void success();
|
||||
}
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
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 android.os.PowerManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
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;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 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 String ACTION_START_SERVER = "start";
|
||||
private static final String ACTION_START_CLIENT = "start_client";
|
||||
private static final String ACTION_SET_VERIFIED = "set_verified";
|
||||
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_IS_VERIFIED = "is_verified";
|
||||
|
||||
private TransferNotificationData notificationData;
|
||||
private PendingIntent pendingIntent;
|
||||
private DeviceTransferServer server;
|
||||
private DeviceTransferClient client;
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
|
||||
public static void startServer(@NonNull Context context,
|
||||
@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_NOTIFICATION, transferNotificationData)
|
||||
.putExtra(EXTRA_PENDING_INTENT, pendingIntent);
|
||||
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
public static void startClient(@NonNull Context context,
|
||||
@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_NOTIFICATION, transferNotificationData)
|
||||
.putExtra(EXTRA_PENDING_INTENT, pendingIntent);
|
||||
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
public static void setAuthenticationCodeVerified(@NonNull Context context, boolean verified) {
|
||||
Intent intent = new Intent(context, DeviceToDeviceTransferService.class);
|
||||
intent.setAction(ACTION_SET_VERIFIED)
|
||||
.putExtra(EXTRA_IS_VERIFIED, verified);
|
||||
|
||||
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.v(TAG, "onCreate");
|
||||
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventMainThread(@NonNull TransferStatus event) {
|
||||
updateNotification(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
Log.v(TAG, "onDestroy");
|
||||
|
||||
EventBus.getDefault().unregister(this);
|
||||
|
||||
if (client != null) {
|
||||
client.stop();
|
||||
client = null;
|
||||
}
|
||||
|
||||
if (server != null) {
|
||||
server.stop();
|
||||
server = null;
|
||||
}
|
||||
|
||||
if (wakeLock != null) {
|
||||
wakeLock.release();
|
||||
}
|
||||
|
||||
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) {
|
||||
shutdown();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Action: " + action);
|
||||
switch (action) {
|
||||
case ACTION_START_SERVER: {
|
||||
if (server == null) {
|
||||
notificationData = intent.getParcelableExtra(EXTRA_NOTIFICATION);
|
||||
pendingIntent = intent.getParcelableExtra(EXTRA_PENDING_INTENT);
|
||||
server = new DeviceTransferServer(getApplicationContext(),
|
||||
(ServerTask) Objects.requireNonNull(intent.getSerializableExtra(EXTRA_TASK)),
|
||||
this);
|
||||
acquireWakeLock();
|
||||
server.start();
|
||||
} else {
|
||||
Log.i(TAG, "Can't start server, already started.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ACTION_START_CLIENT: {
|
||||
if (client == null) {
|
||||
notificationData = intent.getParcelableExtra(EXTRA_NOTIFICATION);
|
||||
pendingIntent = intent.getParcelableExtra(EXTRA_PENDING_INTENT);
|
||||
client = new DeviceTransferClient(getApplicationContext(),
|
||||
(ClientTask) Objects.requireNonNull(intent.getSerializableExtra(EXTRA_TASK)),
|
||||
this);
|
||||
acquireWakeLock();
|
||||
client.start();
|
||||
} else {
|
||||
Log.i(TAG, "Can't start client, client already started.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ACTION_SET_VERIFIED:
|
||||
boolean isVerified = intent.getBooleanExtra(EXTRA_IS_VERIFIED, false);
|
||||
if (server != null) {
|
||||
server.setVerified(isVerified);
|
||||
} else if (client != null) {
|
||||
client.setVerified(isVerified);
|
||||
}
|
||||
break;
|
||||
case ACTION_STOP:
|
||||
shutdown();
|
||||
break;
|
||||
}
|
||||
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
Log.i(TAG, "Shutdown");
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
});
|
||||
}
|
||||
|
||||
private void acquireWakeLock() {
|
||||
if (wakeLock == null) {
|
||||
PowerManager powerManager = ContextCompat.getSystemService(this, PowerManager.class);
|
||||
if (powerManager != null) {
|
||||
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "signal:d2dpartial");
|
||||
}
|
||||
}
|
||||
|
||||
if (!wakeLock.isHeld()) {
|
||||
wakeLock.acquire(TimeUnit.HOURS.toMillis(2));
|
||||
}
|
||||
}
|
||||
|
||||
private void updateNotification(@NonNull TransferStatus transferStatus) {
|
||||
if (notificationData != null && (client != null || server != null)) {
|
||||
startForeground(notificationData.notificationId, createNotification(transferStatus, notificationData));
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull Notification createNotification(@NonNull TransferStatus transferStatus, @NonNull TransferNotificationData notificationData) {
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, notificationData.channelId);
|
||||
|
||||
String contentText = "";
|
||||
switch (transferStatus.getTransferMode()) {
|
||||
case READY:
|
||||
contentText = getString(R.string.DeviceToDeviceTransferService_status_ready);
|
||||
break;
|
||||
case STARTING_UP:
|
||||
contentText = getString(R.string.DeviceToDeviceTransferService_status_starting_up);
|
||||
break;
|
||||
case DISCOVERY:
|
||||
contentText = getString(R.string.DeviceToDeviceTransferService_status_discovery);
|
||||
break;
|
||||
case NETWORK_CONNECTED:
|
||||
contentText = getString(R.string.DeviceToDeviceTransferService_status_network_connected);
|
||||
break;
|
||||
case VERIFICATION_REQUIRED:
|
||||
contentText = getString(R.string.DeviceToDeviceTransferService_status_verification_required);
|
||||
break;
|
||||
case SERVICE_CONNECTED:
|
||||
contentText = getString(R.string.DeviceToDeviceTransferService_status_service_connected);
|
||||
break;
|
||||
case UNAVAILABLE:
|
||||
case FAILED:
|
||||
case SERVICE_DISCONNECTED:
|
||||
case SHUTDOWN:
|
||||
Log.d(TAG, "Intentionally no notification text for: " + transferStatus.getTransferMode());
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError("No notification text for: " + transferStatus.getTransferMode());
|
||||
}
|
||||
|
||||
builder.setSmallIcon(notificationData.icon)
|
||||
.setOngoing(true)
|
||||
.setContentTitle(getString(R.string.DeviceToDeviceTransferService_content_title))
|
||||
.setContentText(contentText)
|
||||
.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,226 @@
|
|||
package org.signal.devicetransfer;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import org.signal.core.util.StreamUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Allows two parties to authenticate each other via short authentication strings (SAS).
|
||||
* <ol>
|
||||
* <li>Client generates a random data, and then MAC(k=random data, m=certificate) to get a commitment.</li>
|
||||
* <li>Client sends commitment to the server.</li>
|
||||
* <li>Server stores commitment and generates it's own random data.</li>
|
||||
* <li>Server sends it's random data to client.</li>
|
||||
* <li>Client stores server random data and sends it's random data to the server.</li>
|
||||
* <li>Server can then MAC(k=client random data, m=certificate) to verify the original commitment.</li>
|
||||
* <li>Client and Server can compute a SAS using the two randoms.</li>
|
||||
* </ol>
|
||||
*/
|
||||
final class DeviceTransferAuthentication {
|
||||
|
||||
public static final int DIGEST_LENGTH = 32;
|
||||
private static final String MAC_ALGORITHM = "HmacSHA256";
|
||||
|
||||
private DeviceTransferAuthentication() {}
|
||||
|
||||
/**
|
||||
* Perform the client side of the SAS generation via input and output streams.
|
||||
*
|
||||
* @param certificate x509 certificate of the TLS connection
|
||||
* @param inputStream stream to read data from the {@link Server}
|
||||
* @param outputStream stream to write data to the {@link Server}
|
||||
* @return Computed SAS
|
||||
* @throws DeviceTransferAuthenticationException When something in the code generation fails
|
||||
* @throws IOException When a communication issue occurs over one of the two streams
|
||||
*/
|
||||
public static int generateClientAuthenticationCode(@NonNull byte[] certificate,
|
||||
@NonNull InputStream inputStream,
|
||||
@NonNull OutputStream outputStream)
|
||||
throws DeviceTransferAuthenticationException, IOException
|
||||
{
|
||||
Client authentication = new Client(certificate);
|
||||
outputStream.write(authentication.getCommitment());
|
||||
outputStream.flush();
|
||||
|
||||
byte[] serverRandom = new byte[DeviceTransferAuthentication.DIGEST_LENGTH];
|
||||
StreamUtil.readFully(inputStream, serverRandom, serverRandom.length);
|
||||
|
||||
byte[] clientRandom = authentication.setServerRandomAndGetClientRandom(serverRandom);
|
||||
outputStream.write(clientRandom);
|
||||
outputStream.flush();
|
||||
|
||||
return authentication.computeShortAuthenticationCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the server side of the SAS generation via input and output streams.
|
||||
*
|
||||
* @param certificate x509 certificate of the TLS connection
|
||||
* @param inputStream stream to read data from the {@link Client}
|
||||
* @param outputStream stream to write data to the {@link Client}
|
||||
* @return Computed SAS
|
||||
* @throws DeviceTransferAuthenticationException When something in the code generation fails or the client
|
||||
* provided commitment does not match the computed version
|
||||
* @throws IOException When a communication issue occurs over one of the two streams
|
||||
*/
|
||||
public static int generateServerAuthenticationCode(@NonNull byte[] certificate,
|
||||
@NonNull InputStream inputStream,
|
||||
@NonNull OutputStream outputStream)
|
||||
throws DeviceTransferAuthenticationException, IOException
|
||||
{
|
||||
byte[] clientCommitment = new byte[DeviceTransferAuthentication.DIGEST_LENGTH];
|
||||
StreamUtil.readFully(inputStream, clientCommitment, clientCommitment.length);
|
||||
|
||||
DeviceTransferAuthentication.Server authentication = new DeviceTransferAuthentication.Server(certificate, clientCommitment);
|
||||
|
||||
outputStream.write(authentication.getRandom());
|
||||
outputStream.flush();
|
||||
|
||||
byte[] clientRandom = new byte[DeviceTransferAuthentication.DIGEST_LENGTH];
|
||||
StreamUtil.readFully(inputStream, clientRandom, clientRandom.length);
|
||||
authentication.setClientRandom(clientRandom);
|
||||
|
||||
return authentication.computeShortAuthenticationCode();
|
||||
}
|
||||
|
||||
private static @NonNull Mac getMac(@NonNull byte[] secret) throws DeviceTransferAuthenticationException {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(MAC_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(secret, MAC_ALGORITHM));
|
||||
return mac;
|
||||
} catch (Exception e) {
|
||||
throw new DeviceTransferAuthenticationException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static int computeShortAuthenticationCode(@NonNull byte[] clientRandom,
|
||||
@NonNull byte[] serverRandom)
|
||||
throws DeviceTransferAuthenticationException
|
||||
{
|
||||
byte[] authentication = getMac(clientRandom).doFinal(serverRandom);
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.wrap(authentication);
|
||||
buffer.order(ByteOrder.BIG_ENDIAN);
|
||||
return buffer.getInt(authentication.length - 4) & 0x007f_ffff;
|
||||
}
|
||||
|
||||
private static @NonNull byte[] copyOf(@NonNull byte[] input) {
|
||||
return Arrays.copyOf(input, input.length);
|
||||
}
|
||||
|
||||
private static void validateLength(@NonNull byte[] input) throws DeviceTransferAuthenticationException {
|
||||
if (input.length != DIGEST_LENGTH) {
|
||||
throw new DeviceTransferAuthenticationException("invalid digest length");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server side of authentication, responsible for verifying connecting
|
||||
* devices commitment and generating a code.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static final class Server {
|
||||
private final byte[] random;
|
||||
private final byte[] certificate;
|
||||
private final byte[] clientCommitment;
|
||||
private byte[] clientRandom;
|
||||
|
||||
public Server(@NonNull byte[] certificate, @NonNull byte[] clientCommitment) throws DeviceTransferAuthenticationException {
|
||||
validateLength(clientCommitment);
|
||||
|
||||
this.certificate = copyOf(certificate);
|
||||
this.clientCommitment = copyOf(clientCommitment);
|
||||
|
||||
SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
this.random = new byte[DIGEST_LENGTH];
|
||||
secureRandom.nextBytes(this.random);
|
||||
}
|
||||
|
||||
public @NonNull byte[] getRandom() {
|
||||
return copyOf(random);
|
||||
}
|
||||
|
||||
public void setClientRandom(@NonNull byte[] clientRandom) throws DeviceTransferAuthenticationException {
|
||||
validateLength(clientRandom);
|
||||
this.clientRandom = copyOf(clientRandom);
|
||||
}
|
||||
|
||||
public void verifyClientRandom() throws DeviceTransferAuthenticationException {
|
||||
if (clientRandom == null) {
|
||||
throw new DeviceTransferAuthenticationException("no client random set");
|
||||
}
|
||||
|
||||
byte[] computedCommitment = getMac(copyOf(clientRandom)).doFinal(copyOf(certificate));
|
||||
boolean commitmentsMatch = MessageDigest.isEqual(clientCommitment, computedCommitment);
|
||||
if (!commitmentsMatch) {
|
||||
throw new DeviceTransferAuthenticationException("commitments do not match, do not proceed");
|
||||
}
|
||||
}
|
||||
|
||||
public int computeShortAuthenticationCode() throws DeviceTransferAuthenticationException {
|
||||
verifyClientRandom();
|
||||
return DeviceTransferAuthentication.computeShortAuthenticationCode(copyOf(clientRandom), copyOf(random));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client side of authentication, responsible for starting authentication with server.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static final class Client {
|
||||
|
||||
private final byte[] random;
|
||||
private final byte[] commitment;
|
||||
private byte[] serverRandom;
|
||||
|
||||
public Client(@NonNull byte[] certificate) throws DeviceTransferAuthenticationException {
|
||||
SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
this.random = new byte[DIGEST_LENGTH];
|
||||
secureRandom.nextBytes(this.random);
|
||||
|
||||
commitment = getMac(copyOf(this.random)).doFinal(copyOf(certificate));
|
||||
}
|
||||
|
||||
public @NonNull byte[] getCommitment() {
|
||||
return copyOf(commitment);
|
||||
}
|
||||
|
||||
public @NonNull byte[] setServerRandomAndGetClientRandom(@NonNull byte[] serverRandom) throws DeviceTransferAuthenticationException {
|
||||
validateLength(serverRandom);
|
||||
this.serverRandom = copyOf(serverRandom);
|
||||
return copyOf(random);
|
||||
}
|
||||
|
||||
public int computeShortAuthenticationCode() throws DeviceTransferAuthenticationException {
|
||||
if (serverRandom == null) {
|
||||
throw new DeviceTransferAuthenticationException("no server random set");
|
||||
}
|
||||
return DeviceTransferAuthentication.computeShortAuthenticationCode(copyOf(random), copyOf(serverRandom));
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DeviceTransferAuthenticationException extends Exception {
|
||||
public DeviceTransferAuthenticationException(@NonNull String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public DeviceTransferAuthenticationException(@NonNull Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
package org.signal.devicetransfer;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.NetworkInfo;
|
||||
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.MainThread;
|
||||
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.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 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} or the network client connects and then completes
|
||||
* or failed. A call to {@link #stop()} 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>
|
||||
*/
|
||||
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 STOP_CLIENT = 1;
|
||||
private static final int START_NETWORK_CLIENT = 2;
|
||||
private static final int NETWORK_DISCONNECTED = 3;
|
||||
private static final int CONNECT_TO_SERVICE = 4;
|
||||
private static final int RESTART_CLIENT = 5;
|
||||
private static final int START_IP_EXCHANGE = 6;
|
||||
private static final int IP_EXCHANGE_SUCCESS = 7;
|
||||
private static final int SET_VERIFIED = 8;
|
||||
private static final int NETWORK_CONNECTION_CHANGED = 9;
|
||||
|
||||
private final Context context;
|
||||
private int remotePort;
|
||||
private HandlerThread commandAndControlThread;
|
||||
private final Handler handler;
|
||||
private final ClientTask clientTask;
|
||||
private final ShutdownCallback shutdownCallback;
|
||||
private WifiDirect wifiDirect;
|
||||
private NetworkClientThread clientThread;
|
||||
private final Runnable autoRestart;
|
||||
private IpExchange.IpExchangeThread ipExchangeThread;
|
||||
|
||||
private final AtomicBoolean started = new AtomicBoolean(false);
|
||||
private final AtomicBoolean stopped = new AtomicBoolean(false);
|
||||
|
||||
private static void update(@NonNull TransferStatus transferStatus) {
|
||||
Log.d(TAG, "transferStatus: " + transferStatus.getTransferMode().name());
|
||||
EventBus.getDefault().postSticky(transferStatus);
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public DeviceTransferClient(@NonNull Context context,
|
||||
@NonNull ClientTask clientTask,
|
||||
@Nullable ShutdownCallback shutdownCallback)
|
||||
{
|
||||
this.context = context;
|
||||
this.clientTask = clientTask;
|
||||
this.shutdownCallback = shutdownCallback;
|
||||
this.commandAndControlThread = SignalExecutors.getAndStartHandlerThread("client-cnc", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD);
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void start() {
|
||||
if (started.compareAndSet(false, true)) {
|
||||
update(TransferStatus.ready());
|
||||
handler.sendEmptyMessage(START_CLIENT);
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void stop() {
|
||||
if (stopped.compareAndSet(false, true)) {
|
||||
handler.sendEmptyMessage(STOP_CLIENT);
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void setVerified(boolean isVerified) {
|
||||
if (!stopped.get()) {
|
||||
handler.sendMessage(handler.obtainMessage(SET_VERIFIED, isVerified));
|
||||
}
|
||||
}
|
||||
|
||||
private void shutdown() {
|
||||
stopIpExchange();
|
||||
stopNetworkClient();
|
||||
stopWifiDirect();
|
||||
|
||||
if (commandAndControlThread != null) {
|
||||
Log.i(TAG, "Shutting down command and control");
|
||||
commandAndControlThread.quit();
|
||||
commandAndControlThread.interrupt();
|
||||
commandAndControlThread = null;
|
||||
}
|
||||
|
||||
EventBus.getDefault().removeStickyEvent(TransferStatus.class);
|
||||
}
|
||||
|
||||
private void internalShutdown() {
|
||||
shutdown();
|
||||
if (shutdownCallback != null) {
|
||||
shutdownCallback.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handleMessage(@NonNull Message message) {
|
||||
Log.d(TAG, "Handle message: " + message.what);
|
||||
switch (message.what) {
|
||||
case START_CLIENT:
|
||||
startWifiDirect();
|
||||
break;
|
||||
case STOP_CLIENT:
|
||||
shutdown();
|
||||
break;
|
||||
case START_NETWORK_CLIENT:
|
||||
startNetworkClient((String) message.obj);
|
||||
break;
|
||||
case NETWORK_DISCONNECTED:
|
||||
stopNetworkClient();
|
||||
break;
|
||||
case CONNECT_TO_SERVICE:
|
||||
stopServiceDiscovery();
|
||||
connectToService((String) message.obj, message.arg1);
|
||||
break;
|
||||
case RESTART_CLIENT:
|
||||
stopNetworkClient();
|
||||
stopWifiDirect();
|
||||
startWifiDirect();
|
||||
break;
|
||||
case START_IP_EXCHANGE:
|
||||
startIpExchange((String) message.obj);
|
||||
break;
|
||||
case IP_EXCHANGE_SUCCESS:
|
||||
ipExchangeSuccessful((String) message.obj);
|
||||
break;
|
||||
case SET_VERIFIED:
|
||||
if (clientThread != null) {
|
||||
clientThread.setVerified((Boolean) message.obj);
|
||||
}
|
||||
break;
|
||||
case NETWORK_CONNECTION_CHANGED:
|
||||
requestNetworkInfo((Boolean) message.obj);
|
||||
break;
|
||||
case NetworkClientThread.NETWORK_CLIENT_SSL_ESTABLISHED:
|
||||
update(TransferStatus.verificationRequired((Integer) message.obj));
|
||||
break;
|
||||
case NetworkClientThread.NETWORK_CLIENT_CONNECTED:
|
||||
update(TransferStatus.serviceConnected());
|
||||
break;
|
||||
case NetworkClientThread.NETWORK_CLIENT_DISCONNECTED:
|
||||
update(TransferStatus.networkConnected());
|
||||
break;
|
||||
case NetworkClientThread.NETWORK_CLIENT_STOPPED:
|
||||
update(TransferStatus.shutdown());
|
||||
internalShutdown();
|
||||
break;
|
||||
default:
|
||||
internalShutdown();
|
||||
throw new AssertionError("Unknown message: " + message.what);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void startWifiDirect() {
|
||||
if (wifiDirect != null) {
|
||||
Log.e(TAG, "Client already started");
|
||||
return;
|
||||
}
|
||||
|
||||
update(TransferStatus.startingUp());
|
||||
|
||||
try {
|
||||
wifiDirect = new WifiDirect(context);
|
||||
wifiDirect.initialize(new WifiDirectListener());
|
||||
wifiDirect.discoverService();
|
||||
Log.i(TAG, "Started service discovery, searching for service...");
|
||||
update(TransferStatus.discovery());
|
||||
handler.postDelayed(autoRestart, TimeUnit.SECONDS.toMillis(15));
|
||||
} catch (WifiDirectUnavailableException e) {
|
||||
Log.e(TAG, e);
|
||||
internalShutdown();
|
||||
if (e.getReason() == WifiDirectUnavailableException.Reason.CHANNEL_INITIALIZATION ||
|
||||
e.getReason() == WifiDirectUnavailableException.Reason.WIFI_P2P_MANAGER) {
|
||||
update(TransferStatus.unavailable());
|
||||
} else {
|
||||
update(TransferStatus.failed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stopServiceDiscovery() {
|
||||
if (wifiDirect == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Log.i(TAG, "Stopping service discovery");
|
||||
wifiDirect.stopServiceDiscovery();
|
||||
} catch (WifiDirectUnavailableException e) {
|
||||
internalShutdown();
|
||||
update(TransferStatus.failed());
|
||||
}
|
||||
}
|
||||
|
||||
private void stopWifiDirect() {
|
||||
handler.removeCallbacks(autoRestart);
|
||||
|
||||
if (wifiDirect != null) {
|
||||
Log.i(TAG, "Shutting down WiFi Direct");
|
||||
wifiDirect.shutdown();
|
||||
wifiDirect = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void startNetworkClient(@NonNull String serverHostAddress) {
|
||||
if (clientThread != null) {
|
||||
Log.i(TAG, "Client already running");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Connection established, spinning up network client.");
|
||||
clientThread = new NetworkClientThread(context,
|
||||
clientTask,
|
||||
serverHostAddress,
|
||||
remotePort,
|
||||
handler);
|
||||
clientThread.start();
|
||||
}
|
||||
|
||||
private void stopNetworkClient() {
|
||||
if (clientThread != null) {
|
||||
Log.i(TAG, "Shutting down ClientThread");
|
||||
clientThread.shutdown();
|
||||
try {
|
||||
clientThread.join(TimeUnit.SECONDS.toMillis(1));
|
||||
} catch (InterruptedException e) {
|
||||
Log.i(TAG, "Client thread took too long to shutdown", e);
|
||||
}
|
||||
clientThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void connectToService(@NonNull String deviceAddress, int port) {
|
||||
if (wifiDirect == null) {
|
||||
Log.w(TAG, "WifiDirect is not initialized, we shouldn't be here.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientThread != null) {
|
||||
Log.i(TAG, "Client is running we shouldn't be connecting again");
|
||||
return;
|
||||
}
|
||||
|
||||
handler.removeCallbacks(autoRestart);
|
||||
|
||||
int tries = 5;
|
||||
while ((tries--) > 0) {
|
||||
try {
|
||||
wifiDirect.connect(deviceAddress);
|
||||
update(TransferStatus.networkConnected());
|
||||
remotePort = port;
|
||||
return;
|
||||
} catch (WifiDirectUnavailableException e) {
|
||||
Log.w(TAG, "Unable to connect, tries: " + tries);
|
||||
try {
|
||||
Thread.sleep(TimeUnit.SECONDS.toMillis(2));
|
||||
} catch (InterruptedException ignored) {
|
||||
Log.i(TAG, "Interrupted while connecting to service, bail now!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handler.sendMessage(handler.obtainMessage(RESTART_CLIENT));
|
||||
}
|
||||
|
||||
private void requestNetworkInfo(boolean isNetworkConnected) {
|
||||
if (wifiDirect == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNetworkConnected) {
|
||||
Log.i(TAG, "Network connected, requesting network info");
|
||||
try {
|
||||
wifiDirect.requestNetworkInfo();
|
||||
} catch (WifiDirectUnavailableException e) {
|
||||
Log.e(TAG, e);
|
||||
internalShutdown();
|
||||
update(TransferStatus.failed());
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Network disconnected");
|
||||
handler.sendEmptyMessage(NETWORK_DISCONNECTED);
|
||||
}
|
||||
}
|
||||
|
||||
private void startIpExchange(@NonNull String groupOwnerHostAddress) {
|
||||
ipExchangeThread = IpExchange.getIp(groupOwnerHostAddress, remotePort, handler, IP_EXCHANGE_SUCCESS);
|
||||
}
|
||||
|
||||
private void stopIpExchange() {
|
||||
if (ipExchangeThread != null) {
|
||||
ipExchangeThread.shutdown();
|
||||
try {
|
||||
ipExchangeThread.join(TimeUnit.SECONDS.toMillis(1));
|
||||
} catch (InterruptedException e) {
|
||||
Log.i(TAG, "IP Exchange thread took too long to shutdown", e);
|
||||
}
|
||||
ipExchangeThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ipExchangeSuccessful(@NonNull String host) {
|
||||
stopIpExchange();
|
||||
handler.sendMessage(handler.obtainMessage(START_NETWORK_CLIENT, host));
|
||||
}
|
||||
|
||||
final class WifiDirectListener implements WifiDirect.WifiDirectConnectionListener {
|
||||
|
||||
@Override
|
||||
public void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice, @NonNull String extraInfo) {
|
||||
handler.sendMessage(handler.obtainMessage(CONNECT_TO_SERVICE, Integer.parseInt(extraInfo), 0, serviceDevice.deviceAddress));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNetworkConnected(@NonNull WifiP2pInfo info) {
|
||||
if (info.isGroupOwner) {
|
||||
handler.sendEmptyMessage(START_IP_EXCHANGE);
|
||||
} else if (info.groupOwnerAddress != null) {
|
||||
handler.sendMessage(handler.obtainMessage(START_NETWORK_CLIENT, info.groupOwnerAddress.getHostAddress()));
|
||||
} else {
|
||||
Log.d(TAG, "Group owner address null, re-requesting networking information.");
|
||||
handler.sendMessage(handler.obtainMessage(NETWORK_CONNECTION_CHANGED, true));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNetworkFailure() {
|
||||
handler.sendEmptyMessage(NETWORK_DISCONNECTED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionChanged(@NonNull NetworkInfo networkInfo) {
|
||||
handler.sendMessage(handler.obtainMessage(NETWORK_CONNECTION_CHANGED, networkInfo.isConnected()));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
package org.signal.devicetransfer;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.NetworkInfo;
|
||||
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.MainThread;
|
||||
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 org.signal.devicetransfer.SelfSignedIdentity.SelfSignedKeys;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 STOP_SERVER = 1;
|
||||
private static final int START_IP_EXCHANGE = 2;
|
||||
private static final int IP_EXCHANGE_SUCCESS = 3;
|
||||
private static final int NETWORK_FAILURE = 4;
|
||||
private static final int SET_VERIFIED = 5;
|
||||
private static final int NETWORK_CONNECTION_CHANGED = 6;
|
||||
|
||||
private NetworkServerThread serverThread;
|
||||
private HandlerThread commandAndControlThread;
|
||||
private final Handler handler;
|
||||
private WifiDirect wifiDirect;
|
||||
private final Context context;
|
||||
private final ServerTask serverTask;
|
||||
private final ShutdownCallback shutdownCallback;
|
||||
private IpExchange.IpExchangeThread ipExchangeThread;
|
||||
|
||||
private final AtomicBoolean started = new AtomicBoolean(false);
|
||||
private final AtomicBoolean stopped = new AtomicBoolean(false);
|
||||
|
||||
private static void update(@NonNull TransferStatus transferStatus) {
|
||||
Log.d(TAG, "transferStatus: " + transferStatus.getTransferMode().name());
|
||||
EventBus.getDefault().postSticky(transferStatus);
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public DeviceTransferServer(@NonNull Context context,
|
||||
@NonNull ServerTask serverTask,
|
||||
@Nullable ShutdownCallback shutdownCallback)
|
||||
{
|
||||
this.context = context;
|
||||
this.serverTask = serverTask;
|
||||
this.shutdownCallback = shutdownCallback;
|
||||
this.commandAndControlThread = SignalExecutors.getAndStartHandlerThread("server-cnc", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD);
|
||||
this.handler = new Handler(commandAndControlThread.getLooper(), this);
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void start() {
|
||||
if (started.compareAndSet(false, true)) {
|
||||
update(TransferStatus.ready());
|
||||
handler.sendEmptyMessage(START_SERVER);
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void stop() {
|
||||
if (stopped.compareAndSet(false, true)) {
|
||||
handler.sendEmptyMessage(STOP_SERVER);
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void setVerified(boolean isVerified) {
|
||||
if (!stopped.get()) {
|
||||
handler.sendMessage(handler.obtainMessage(SET_VERIFIED, isVerified));
|
||||
}
|
||||
}
|
||||
|
||||
private void shutdown() {
|
||||
stopIpExchange();
|
||||
stopServer();
|
||||
stopWifiDirect();
|
||||
|
||||
if (commandAndControlThread != null) {
|
||||
Log.i(TAG, "Shutting down command and control");
|
||||
commandAndControlThread.quit();
|
||||
commandAndControlThread.interrupt();
|
||||
commandAndControlThread = null;
|
||||
}
|
||||
|
||||
EventBus.getDefault().removeStickyEvent(TransferStatus.class);
|
||||
}
|
||||
|
||||
private void internalShutdown() {
|
||||
shutdown();
|
||||
if (shutdownCallback != null) {
|
||||
shutdownCallback.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handleMessage(@NonNull Message message) {
|
||||
Log.d(TAG, "Handle message: " + message.what);
|
||||
switch (message.what) {
|
||||
case START_SERVER:
|
||||
startNetworkServer();
|
||||
break;
|
||||
case STOP_SERVER:
|
||||
shutdown();
|
||||
break;
|
||||
case START_IP_EXCHANGE:
|
||||
startIpExchange((String) message.obj);
|
||||
break;
|
||||
case IP_EXCHANGE_SUCCESS:
|
||||
ipExchangeSuccessful();
|
||||
break;
|
||||
case SET_VERIFIED:
|
||||
if (serverThread != null) {
|
||||
serverThread.setVerified((Boolean) message.obj);
|
||||
}
|
||||
break;
|
||||
case NETWORK_CONNECTION_CHANGED:
|
||||
requestNetworkInfo((Boolean) message.obj);
|
||||
break;
|
||||
case NetworkServerThread.NETWORK_SERVER_STARTED:
|
||||
startWifiDirect(message.arg1);
|
||||
break;
|
||||
case NetworkServerThread.NETWORK_SERVER_STOPPED:
|
||||
update(TransferStatus.shutdown());
|
||||
internalShutdown();
|
||||
break;
|
||||
case NetworkServerThread.NETWORK_CLIENT_CONNECTED:
|
||||
stopDiscoveryService();
|
||||
update(TransferStatus.serviceConnected());
|
||||
break;
|
||||
case NetworkServerThread.NETWORK_CLIENT_DISCONNECTED:
|
||||
update(TransferStatus.networkConnected());
|
||||
break;
|
||||
case NetworkServerThread.NETWORK_CLIENT_SSL_ESTABLISHED:
|
||||
update(TransferStatus.verificationRequired((Integer) message.obj));
|
||||
break;
|
||||
default:
|
||||
internalShutdown();
|
||||
throw new AssertionError("Unknown message: " + message.what);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void startWifiDirect(int port) {
|
||||
if (wifiDirect != null) {
|
||||
Log.e(TAG, "Server already started");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
wifiDirect = new WifiDirect(context);
|
||||
wifiDirect.initialize(new WifiDirectListener());
|
||||
wifiDirect.startDiscoveryService(String.valueOf(port));
|
||||
Log.i(TAG, "Started discovery service, waiting for connections...");
|
||||
update(TransferStatus.discovery());
|
||||
} catch (WifiDirectUnavailableException e) {
|
||||
Log.e(TAG, e);
|
||||
internalShutdown();
|
||||
if (e.getReason() == WifiDirectUnavailableException.Reason.CHANNEL_INITIALIZATION ||
|
||||
e.getReason() == WifiDirectUnavailableException.Reason.WIFI_P2P_MANAGER) {
|
||||
update(TransferStatus.unavailable());
|
||||
} else {
|
||||
update(TransferStatus.failed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stopDiscoveryService() {
|
||||
if (wifiDirect == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Log.i(TAG, "Stopping discovery service");
|
||||
wifiDirect.stopDiscoveryService();
|
||||
} catch (WifiDirectUnavailableException e) {
|
||||
internalShutdown();
|
||||
update(TransferStatus.failed());
|
||||
}
|
||||
}
|
||||
|
||||
private void stopWifiDirect() {
|
||||
if (wifiDirect != null) {
|
||||
Log.i(TAG, "Shutting down WiFi Direct");
|
||||
wifiDirect.shutdown();
|
||||
wifiDirect = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void requestNetworkInfo(boolean isNetworkConnected) {
|
||||
if (wifiDirect == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNetworkConnected) {
|
||||
try {
|
||||
wifiDirect.requestNetworkInfo();
|
||||
} catch (WifiDirectUnavailableException e) {
|
||||
Log.e(TAG, e);
|
||||
internalShutdown();
|
||||
update(TransferStatus.failed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startNetworkServer() {
|
||||
if (serverThread != null) {
|
||||
Log.i(TAG, "Server already running");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
update(TransferStatus.startingUp());
|
||||
SelfSignedKeys keys = SelfSignedIdentity.create();
|
||||
Log.i(TAG, "Spinning up network server.");
|
||||
serverThread = new NetworkServerThread(context, serverTask, keys, handler);
|
||||
serverThread.start();
|
||||
} catch (KeyGenerationFailedException e) {
|
||||
Log.w(TAG, "Error generating keys", e);
|
||||
internalShutdown();
|
||||
update(TransferStatus.failed());
|
||||
}
|
||||
}
|
||||
|
||||
private void stopServer() {
|
||||
if (serverThread != null) {
|
||||
Log.i(TAG, "Shutting down ServerThread");
|
||||
serverThread.shutdown();
|
||||
try {
|
||||
serverThread.join(TimeUnit.SECONDS.toMillis(1));
|
||||
} catch (InterruptedException e) {
|
||||
Log.i(TAG, "Server thread took too long to shutdown", e);
|
||||
}
|
||||
serverThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void startIpExchange(@NonNull String groupOwnerHostAddress) {
|
||||
ipExchangeThread = IpExchange.giveIp(groupOwnerHostAddress, serverThread.getLocalPort(), handler, IP_EXCHANGE_SUCCESS);
|
||||
}
|
||||
|
||||
private void stopIpExchange() {
|
||||
if (ipExchangeThread != null) {
|
||||
ipExchangeThread.shutdown();
|
||||
try {
|
||||
ipExchangeThread.join(TimeUnit.SECONDS.toMillis(1));
|
||||
} catch (InterruptedException e) {
|
||||
Log.i(TAG, "IP Exchange thread took too long to shutdown", e);
|
||||
}
|
||||
ipExchangeThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ipExchangeSuccessful() {
|
||||
stopIpExchange();
|
||||
}
|
||||
|
||||
final class WifiDirectListener implements WifiDirect.WifiDirectConnectionListener {
|
||||
|
||||
@Override
|
||||
public void onNetworkConnected(@NonNull WifiP2pInfo info) {
|
||||
if (!info.isGroupOwner) {
|
||||
handler.sendMessage(handler.obtainMessage(START_IP_EXCHANGE, info.groupOwnerAddress.getHostAddress()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNetworkFailure() {
|
||||
handler.sendEmptyMessage(NETWORK_FAILURE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectionChanged(@NonNull NetworkInfo networkInfo) {
|
||||
handler.sendMessage(handler.obtainMessage(NETWORK_CONNECTION_CHANGED, networkInfo.isConnected()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice, @NonNull String extraInfo) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package org.signal.devicetransfer;
|
||||
|
||||
import android.os.Handler;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
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.
|
||||
*/
|
||||
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 startup networking...");
|
||||
|
||||
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,12 @@
|
|||
package org.signal.devicetransfer;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Thrown when there's an issue generating the self-signed certificates for TLS.
|
||||
*/
|
||||
final class KeyGenerationFailedException extends Throwable {
|
||||
public KeyGenerationFailedException(@NonNull Exception e) {
|
||||
super(e);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package org.signal.devicetransfer;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.StreamUtil;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
/**
|
||||
* Performs the networking setup/tear down for the client. This includes
|
||||
* connecting to the server, performing the TLS/SAS verification, running an
|
||||
* arbitrarily provided {@link ClientTask}, and then cleaning up.
|
||||
*/
|
||||
final class NetworkClientThread extends Thread {
|
||||
|
||||
private static final String TAG = Log.tag(NetworkClientThread.class);
|
||||
|
||||
public static final int NETWORK_CLIENT_CONNECTED = 1001;
|
||||
public static final int NETWORK_CLIENT_DISCONNECTED = 1002;
|
||||
public static final int NETWORK_CLIENT_SSL_ESTABLISHED = 1003;
|
||||
public static final int NETWORK_CLIENT_STOPPED = 1004;
|
||||
|
||||
private volatile SSLSocket client;
|
||||
private volatile boolean isRunning;
|
||||
private volatile Boolean isVerified;
|
||||
|
||||
private final Context context;
|
||||
private final ClientTask clientTask;
|
||||
private final String serverHostAddress;
|
||||
private final int port;
|
||||
private final Handler handler;
|
||||
private final Object verificationLock;
|
||||
private boolean success;
|
||||
|
||||
public NetworkClientThread(@NonNull Context context,
|
||||
@NonNull ClientTask clientTask,
|
||||
@NonNull String serverHostAddress,
|
||||
int port,
|
||||
@NonNull Handler handler)
|
||||
{
|
||||
this.context = context;
|
||||
this.clientTask = clientTask;
|
||||
this.serverHostAddress = serverHostAddress;
|
||||
this.port = port;
|
||||
this.handler = handler;
|
||||
this.verificationLock = new Object();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Log.i(TAG, "Client thread running");
|
||||
isRunning = true;
|
||||
|
||||
int validClientAttemptsRemaining = 3;
|
||||
while (shouldKeepRunning()) {
|
||||
Log.i(TAG, "Attempting to connect to server... tries: " + validClientAttemptsRemaining);
|
||||
|
||||
try {
|
||||
SelfSignedIdentity.ApprovingTrustManager trustManager = new SelfSignedIdentity.ApprovingTrustManager();
|
||||
client = (SSLSocket) SelfSignedIdentity.getApprovingSocketFactory(trustManager).createSocket();
|
||||
try {
|
||||
client.bind(null);
|
||||
client.connect(new InetSocketAddress(serverHostAddress, port), 10000);
|
||||
client.startHandshake();
|
||||
|
||||
X509Certificate x509 = trustManager.getX509Certificate();
|
||||
if (x509 == null) {
|
||||
isRunning = false;
|
||||
throw new SSLHandshakeException("no x509 after handshake");
|
||||
}
|
||||
|
||||
InputStream inputStream = client.getInputStream();
|
||||
OutputStream outputStream = client.getOutputStream();
|
||||
int authenticationCode = DeviceTransferAuthentication.generateClientAuthenticationCode(x509.getEncoded(), inputStream, outputStream);
|
||||
|
||||
handler.sendMessage(handler.obtainMessage(NETWORK_CLIENT_SSL_ESTABLISHED, authenticationCode));
|
||||
|
||||
Log.i(TAG, "Waiting for user to verify sas");
|
||||
awaitAuthenticationCodeVerification();
|
||||
Log.d(TAG, "Waiting for server to tell us they also verified");
|
||||
outputStream.write(0x43);
|
||||
outputStream.flush();
|
||||
try {
|
||||
int result = inputStream.read();
|
||||
if (result == -1) {
|
||||
Log.w(TAG, "Something happened waiting for server to verify");
|
||||
throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException("server disconnected while we waited");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Something happened waiting for server to verify", e);
|
||||
throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException(e);
|
||||
}
|
||||
|
||||
handler.sendEmptyMessage(NETWORK_CLIENT_CONNECTED);
|
||||
clientTask.run(context, outputStream);
|
||||
outputStream.flush();
|
||||
|
||||
Log.d(TAG, "Waiting for server to tell us they got everything");
|
||||
try {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
inputStream.read();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Something happened confirming with server, mostly like bad SSL shutdown state, assuming success", e);
|
||||
}
|
||||
success = true;
|
||||
isRunning = false;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Error connecting to server", e);
|
||||
validClientAttemptsRemaining--;
|
||||
isRunning = validClientAttemptsRemaining > 0;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, e);
|
||||
isRunning = false;
|
||||
} finally {
|
||||
if (success) {
|
||||
clientTask.success();
|
||||
}
|
||||
StreamUtil.close(client);
|
||||
handler.sendEmptyMessage(NETWORK_CLIENT_DISCONNECTED);
|
||||
}
|
||||
|
||||
if (shouldKeepRunning()) {
|
||||
ThreadUtil.interruptableSleep(TimeUnit.SECONDS.toMillis(3));
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Client exiting");
|
||||
handler.sendEmptyMessage(NETWORK_CLIENT_STOPPED);
|
||||
}
|
||||
|
||||
private void awaitAuthenticationCodeVerification() throws DeviceTransferAuthentication.DeviceTransferAuthenticationException {
|
||||
synchronized (verificationLock) {
|
||||
try {
|
||||
while (isVerified == null) {
|
||||
verificationLock.wait();
|
||||
}
|
||||
if (!isVerified) {
|
||||
throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException("User verification failed");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public void setVerified(boolean isVerified) {
|
||||
this.isVerified = isVerified;
|
||||
synchronized (verificationLock) {
|
||||
verificationLock.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public void shutdown() {
|
||||
isRunning = false;
|
||||
StreamUtil.close(client);
|
||||
interrupt();
|
||||
}
|
||||
|
||||
private boolean shouldKeepRunning() {
|
||||
return !isInterrupted() && isRunning;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
package org.signal.devicetransfer;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.StreamUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
|
||||
/**
|
||||
* Performs the networking setup/tear down for the server. This includes
|
||||
* connecting to the client, generating TLS keys, performing the TLS/SAS verification,
|
||||
* running an arbitrarily provided {@link ServerTask}, and then cleaning up.
|
||||
*/
|
||||
final class NetworkServerThread extends Thread {
|
||||
|
||||
private static final String TAG = Log.tag(NetworkServerThread.class);
|
||||
|
||||
public static final int NETWORK_SERVER_STARTED = 1001;
|
||||
public static final int NETWORK_SERVER_STOPPED = 1002;
|
||||
public static final int NETWORK_CLIENT_CONNECTED = 1003;
|
||||
public static final int NETWORK_CLIENT_DISCONNECTED = 1004;
|
||||
public static final int NETWORK_CLIENT_SSL_ESTABLISHED = 1005;
|
||||
|
||||
private volatile ServerSocket serverSocket;
|
||||
private volatile Socket clientSocket;
|
||||
private volatile boolean isRunning;
|
||||
private volatile Boolean isVerified;
|
||||
|
||||
private final Context context;
|
||||
private final ServerTask serverTask;
|
||||
private final SelfSignedIdentity.SelfSignedKeys keys;
|
||||
private final Handler handler;
|
||||
private final Object verificationLock;
|
||||
|
||||
public NetworkServerThread(@NonNull Context context,
|
||||
@NonNull ServerTask serverTask,
|
||||
@NonNull SelfSignedIdentity.SelfSignedKeys keys,
|
||||
@NonNull Handler handler)
|
||||
{
|
||||
this.context = context;
|
||||
this.serverTask = serverTask;
|
||||
this.keys = keys;
|
||||
this.handler = handler;
|
||||
this.verificationLock = new Object();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Log.i(TAG, "Server thread running");
|
||||
isRunning = true;
|
||||
|
||||
Log.i(TAG, "Starting up server socket...");
|
||||
try {
|
||||
serverSocket = SelfSignedIdentity.getServerSocketFactory(keys).createServerSocket(0);
|
||||
handler.sendMessage(handler.obtainMessage(NETWORK_SERVER_STARTED, serverSocket.getLocalPort(), 0));
|
||||
while (shouldKeepRunning() && !serverSocket.isClosed()) {
|
||||
Log.i(TAG, "Waiting for client socket accept...");
|
||||
try {
|
||||
clientSocket = serverSocket.accept();
|
||||
|
||||
if (!isRunning) {
|
||||
break;
|
||||
}
|
||||
|
||||
InputStream inputStream = clientSocket.getInputStream();
|
||||
OutputStream outputStream = clientSocket.getOutputStream();
|
||||
int authenticationCode = DeviceTransferAuthentication.generateServerAuthenticationCode(keys.getX509Encoded(), inputStream, outputStream);
|
||||
|
||||
handler.sendMessage(handler.obtainMessage(NETWORK_CLIENT_SSL_ESTABLISHED, authenticationCode));
|
||||
|
||||
Log.i(TAG, "Waiting for user to verify sas");
|
||||
awaitAuthenticationCodeVerification();
|
||||
Log.d(TAG, "Waiting for client to tell us they also verified");
|
||||
outputStream.write(0x43);
|
||||
outputStream.flush();
|
||||
try {
|
||||
int result = inputStream.read();
|
||||
if (result == -1) {
|
||||
Log.w(TAG, "Something happened waiting for client to verify");
|
||||
throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException("client disconnected while we waited");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Something happened waiting for client to verify", e);
|
||||
throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException(e);
|
||||
}
|
||||
|
||||
handler.sendEmptyMessage(NETWORK_CLIENT_CONNECTED);
|
||||
serverTask.run(context, inputStream);
|
||||
|
||||
outputStream.write(0x53);
|
||||
outputStream.flush();
|
||||
} 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 {
|
||||
StreamUtil.close(clientSocket);
|
||||
handler.sendEmptyMessage(NETWORK_CLIENT_DISCONNECTED);
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, e);
|
||||
} finally {
|
||||
StreamUtil.close(serverSocket);
|
||||
}
|
||||
|
||||
Log.i(TAG, "Server exiting");
|
||||
isRunning = false;
|
||||
handler.sendEmptyMessage(NETWORK_SERVER_STOPPED);
|
||||
}
|
||||
|
||||
private void awaitAuthenticationCodeVerification() throws DeviceTransferAuthentication.DeviceTransferAuthenticationException {
|
||||
synchronized (verificationLock) {
|
||||
try {
|
||||
while (isVerified == null) {
|
||||
verificationLock.wait();
|
||||
}
|
||||
if (!isVerified) {
|
||||
throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException("User verification failed");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
throw new DeviceTransferAuthentication.DeviceTransferAuthenticationException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldKeepRunning() {
|
||||
return !isInterrupted() && isRunning;
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public int getLocalPort() {
|
||||
ServerSocket localServerSocket = serverSocket;
|
||||
if (localServerSocket != null) {
|
||||
return localServerSocket.getLocalPort();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public void setVerified(boolean isVerified) {
|
||||
this.isVerified = isVerified;
|
||||
synchronized (verificationLock) {
|
||||
verificationLock.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
public void shutdown() {
|
||||
isRunning = false;
|
||||
StreamUtil.close(clientSocket);
|
||||
StreamUtil.close(serverSocket);
|
||||
interrupt();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package org.signal.devicetransfer;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.libsignal.devicetransfer.DeviceTransferKey;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyStore;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLServerSocketFactory;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
/**
|
||||
* Generate and configure use of self-signed x509 and private key for establishing a TLS connection.
|
||||
*/
|
||||
final class SelfSignedIdentity {
|
||||
|
||||
private static final String KEY_GENERATION_ALGORITHM = "RSA";
|
||||
private static final String SSL_CONTEXT_PROTOCOL = "TLS";
|
||||
private static final String CERTIFICATE_TYPE = "X509";
|
||||
private static final String KEYSTORE_TYPE = "BKS";
|
||||
|
||||
private SelfSignedIdentity() { }
|
||||
|
||||
public static @NonNull SelfSignedKeys create() throws KeyGenerationFailedException {
|
||||
try {
|
||||
DeviceTransferKey key = new DeviceTransferKey();
|
||||
byte[] x509 = key.generateCertificate("SignalTransfer", 1);
|
||||
PrivateKey privateKey = KeyFactory.getInstance(KEY_GENERATION_ALGORITHM).generatePrivate(new PKCS8EncodedKeySpec(key.keyMaterial()));
|
||||
return new SelfSignedKeys(x509, privateKey);
|
||||
} catch (InvalidKeySpecException | NoSuchAlgorithmException e) {
|
||||
throw new KeyGenerationFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull SSLServerSocketFactory getServerSocketFactory(@NonNull SelfSignedKeys keys)
|
||||
throws GeneralSecurityException, IOException
|
||||
{
|
||||
Certificate certificate = CertificateFactory.getInstance(CERTIFICATE_TYPE)
|
||||
.generateCertificate(new ByteArrayInputStream(keys.getX509Encoded()));
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE);
|
||||
keyStore.load(null);
|
||||
keyStore.setKeyEntry("client", keys.getPrivateKey(), null, new Certificate[] { certificate });
|
||||
|
||||
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
keyManagerFactory.init(keyStore, null);
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance(SSL_CONTEXT_PROTOCOL);
|
||||
sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom());
|
||||
|
||||
return sslContext.getServerSocketFactory();
|
||||
}
|
||||
|
||||
public static @NonNull SSLSocketFactory getApprovingSocketFactory(@NonNull ApprovingTrustManager trustManager)
|
||||
throws GeneralSecurityException
|
||||
{
|
||||
SSLContext sslContext = SSLContext.getInstance(SSL_CONTEXT_PROTOCOL);
|
||||
sslContext.init(null, new TrustManager[] { trustManager }, new SecureRandom());
|
||||
return sslContext.getSocketFactory();
|
||||
}
|
||||
|
||||
static final class SelfSignedKeys {
|
||||
private final byte[] x509Encoded;
|
||||
private final PrivateKey privateKey;
|
||||
|
||||
public SelfSignedKeys(@NonNull byte[] x509Encoded, @NonNull PrivateKey privateKey) {
|
||||
this.x509Encoded = x509Encoded;
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
public @NonNull byte[] getX509Encoded() {
|
||||
return x509Encoded;
|
||||
}
|
||||
|
||||
public @NonNull PrivateKey getPrivateKey() {
|
||||
return privateKey;
|
||||
}
|
||||
}
|
||||
|
||||
static final class ApprovingTrustManager implements X509TrustManager {
|
||||
|
||||
private @Nullable X509Certificate x509Certificate;
|
||||
|
||||
@Override
|
||||
public void checkClientTrusted(@NonNull X509Certificate[] x509Certificates, @NonNull String authType) throws CertificateException {
|
||||
throw new CertificateException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkServerTrusted(@NonNull X509Certificate[] x509Certificates, @NonNull String authType) throws CertificateException {
|
||||
if (x509Certificates.length != 1) {
|
||||
throw new CertificateException("More than 1 x509 certificate");
|
||||
}
|
||||
|
||||
this.x509Certificate = x509Certificates[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
|
||||
public @Nullable X509Certificate getX509Certificate() {
|
||||
return x509Certificate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
interface ShutdownCallback {
|
||||
void shutdown();
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package org.signal.devicetransfer;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Represents the status of the transfer.
|
||||
*/
|
||||
public class TransferStatus {
|
||||
|
||||
private final TransferMode transferMode;
|
||||
private final int authenticationCode;
|
||||
|
||||
private TransferStatus(@NonNull TransferMode transferMode) {
|
||||
this(transferMode, 0);
|
||||
}
|
||||
|
||||
private TransferStatus(int authenticationCode) {
|
||||
this(TransferMode.VERIFICATION_REQUIRED, authenticationCode);
|
||||
}
|
||||
|
||||
private TransferStatus(@NonNull TransferMode transferMode, int authenticationCode) {
|
||||
this.transferMode = transferMode;
|
||||
this.authenticationCode = authenticationCode;
|
||||
}
|
||||
|
||||
public @NonNull TransferMode getTransferMode() {
|
||||
return transferMode;
|
||||
}
|
||||
|
||||
public int getAuthenticationCode() {
|
||||
return authenticationCode;
|
||||
}
|
||||
|
||||
public static @NonNull TransferStatus ready() {
|
||||
return new TransferStatus(TransferMode.READY);
|
||||
}
|
||||
|
||||
public static @NonNull TransferStatus serviceConnected() {
|
||||
return new TransferStatus(TransferMode.SERVICE_CONNECTED);
|
||||
}
|
||||
|
||||
public static @NonNull TransferStatus networkConnected() {
|
||||
return new TransferStatus(TransferMode.NETWORK_CONNECTED);
|
||||
}
|
||||
|
||||
public static @NonNull TransferStatus verificationRequired(@NonNull Integer authenticationCode) {
|
||||
return new TransferStatus(authenticationCode);
|
||||
}
|
||||
|
||||
public static @NonNull TransferStatus startingUp() {
|
||||
return new TransferStatus(TransferMode.STARTING_UP);
|
||||
}
|
||||
|
||||
public static @NonNull TransferStatus discovery() {
|
||||
return new TransferStatus(TransferMode.DISCOVERY);
|
||||
}
|
||||
|
||||
public static @NonNull TransferStatus unavailable() {
|
||||
return new TransferStatus(TransferMode.UNAVAILABLE);
|
||||
}
|
||||
|
||||
public static @NonNull TransferStatus shutdown() {
|
||||
return new TransferStatus(TransferMode.SHUTDOWN);
|
||||
}
|
||||
|
||||
public static @NonNull TransferStatus failed() {
|
||||
return new TransferStatus(TransferMode.FAILED);
|
||||
}
|
||||
|
||||
public enum TransferMode {
|
||||
UNAVAILABLE,
|
||||
FAILED,
|
||||
READY,
|
||||
STARTING_UP,
|
||||
DISCOVERY,
|
||||
NETWORK_CONNECTED,
|
||||
VERIFICATION_REQUIRED,
|
||||
SERVICE_CONNECTED,
|
||||
SERVICE_DISCONNECTED,
|
||||
SHUTDOWN
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,493 @@
|
|||
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 android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
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;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Provide the ability to spin up a WiFi Direct network, advertise a network service,
|
||||
* discover a network service, and then connect two devices.
|
||||
*/
|
||||
public final class WifiDirect {
|
||||
|
||||
private static final String TAG = Log.tag(WifiDirect.class);
|
||||
|
||||
private static final IntentFilter intentFilter = new IntentFilter() {{
|
||||
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION);
|
||||
}};
|
||||
|
||||
private static final String EXTRA_INFO_PLACEHOLDER = "%%EXTRA_INFO%%";
|
||||
private static final String SERVICE_INSTANCE_TEMPLATE = "_devicetransfer" + EXTRA_INFO_PLACEHOLDER + "._signal.org";
|
||||
private static final Pattern SERVICE_INSTANCE_PATTERN = Pattern.compile("_devicetransfer(\\._(.+))?\\._signal\\.org");
|
||||
private static final String SERVICE_REG_TYPE = "_presence._tcp";
|
||||
|
||||
private static final long SAFE_FOR_LONG_AWAIT_TIMEOUT = TimeUnit.SECONDS.toMillis(5);
|
||||
private static final long NOT_SAFE_FOR_LONG_AWAIT_TIMEOUT = 50;
|
||||
|
||||
private final Context context;
|
||||
private WifiDirectConnectionListener connectionListener;
|
||||
private WifiDirectCallbacks wifiDirectCallbacks;
|
||||
private WifiP2pManager manager;
|
||||
private WifiP2pManager.Channel channel;
|
||||
private WifiP2pDnsSdServiceRequest serviceRequest;
|
||||
private final HandlerThread wifiDirectCallbacksHandler;
|
||||
|
||||
public static @NonNull String requiredPermission() {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
return Manifest.permission.NEARBY_WIFI_DEVICES;
|
||||
} else {
|
||||
return Manifest.permission.ACCESS_FINE_LOCATION;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 >= 33 && context.checkSelfPermission(Manifest.permission.NEARBY_WIFI_DEVICES) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.i(TAG, "Nearby Wifi permission required");
|
||||
return AvailableStatus.REQUIRED_PERMISSION_NOT_GRANTED;
|
||||
} else if (Build.VERSION.SDK_INT < 33 && context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.i(TAG, "Fine location permission required");
|
||||
return AvailableStatus.REQUIRED_PERMISSION_NOT_GRANTED;
|
||||
}
|
||||
|
||||
return wifiManager.isP2pSupported() ? AvailableStatus.AVAILABLE
|
||||
: AvailableStatus.WIFI_DIRECT_NOT_AVAILABLE;
|
||||
}
|
||||
|
||||
WifiDirect(@NonNull Context context) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.wifiDirectCallbacksHandler = SignalExecutors.getAndStartHandlerThread("wifi-direct-cb", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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(connectionListener);
|
||||
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>
|
||||
*/
|
||||
synchronized void shutdown() {
|
||||
Log.d(TAG, "Shutting down");
|
||||
|
||||
connectionListener = null;
|
||||
|
||||
if (manager != null) {
|
||||
retrySync(manager::clearServiceRequests, "clear service requests", SAFE_FOR_LONG_AWAIT_TIMEOUT);
|
||||
retrySync(manager::stopPeerDiscovery, "stop peer discovery", SAFE_FOR_LONG_AWAIT_TIMEOUT);
|
||||
retrySync(manager::clearLocalServices, "clear local services", SAFE_FOR_LONG_AWAIT_TIMEOUT);
|
||||
if (Build.VERSION.SDK_INT < 27) {
|
||||
retrySync(manager::removeGroup, "remove group", SAFE_FOR_LONG_AWAIT_TIMEOUT);
|
||||
channel = null;
|
||||
}
|
||||
manager = null;
|
||||
}
|
||||
|
||||
if (channel != null && Build.VERSION.SDK_INT >= 27) {
|
||||
channel.close();
|
||||
channel = null;
|
||||
}
|
||||
|
||||
if (wifiDirectCallbacks != null) {
|
||||
wifiDirectCallbacks.clearConnectionListener();
|
||||
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.
|
||||
*
|
||||
* @param extraInfo Extra info to include in the service instance name (e.g., server port)
|
||||
*/
|
||||
@WorkerThread
|
||||
@SuppressLint("MissingPermission")
|
||||
synchronized void startDiscoveryService(@NonNull String extraInfo) throws WifiDirectUnavailableException {
|
||||
ensureInitialized();
|
||||
|
||||
WifiP2pDnsSdServiceInfo serviceInfo = WifiP2pDnsSdServiceInfo.newInstance(buildServiceInstanceName(extraInfo), SERVICE_REG_TYPE, Collections.emptyMap());
|
||||
|
||||
SyncActionListener addLocalServiceListener = new SyncActionListener("add local service", SAFE_FOR_LONG_AWAIT_TIMEOUT);
|
||||
manager.addLocalService(channel, serviceInfo, addLocalServiceListener);
|
||||
|
||||
SyncActionListener discoverPeersListener = new SyncActionListener("discover peers", SAFE_FOR_LONG_AWAIT_TIMEOUT);
|
||||
manager.discoverPeers(channel, discoverPeersListener);
|
||||
|
||||
if (!addLocalServiceListener.successful() || !discoverPeersListener.successful()) {
|
||||
throw new WifiDirectUnavailableException(Reason.SERVICE_START);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all peer discovery and advertising services.
|
||||
*/
|
||||
synchronized void stopDiscoveryService() throws WifiDirectUnavailableException {
|
||||
ensureInitialized();
|
||||
|
||||
retryAsync(manager::stopPeerDiscovery, "stop peer discovery");
|
||||
retryAsync(manager::clearLocalServices, "clear local services");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@SuppressLint("MissingPermission")
|
||||
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) -> {
|
||||
String extraInfo = isInstanceNameMatching(instanceName);
|
||||
if (extraInfo != null) {
|
||||
Log.d(TAG, "Service found!");
|
||||
WifiDirectConnectionListener listener = connectionListener;
|
||||
if (listener != null) {
|
||||
listener.onServiceDiscovered(sourceDevice, extraInfo);
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Found unusable service, ignoring.");
|
||||
}
|
||||
};
|
||||
|
||||
manager.setDnsSdResponseListeners(channel, serviceListener, txtListener);
|
||||
|
||||
serviceRequest = WifiP2pDnsSdServiceRequest.newInstance();
|
||||
|
||||
SyncActionListener addServiceListener = new SyncActionListener("add service request", SAFE_FOR_LONG_AWAIT_TIMEOUT);
|
||||
manager.addServiceRequest(channel, serviceRequest, addServiceListener);
|
||||
|
||||
SyncActionListener startDiscovery = new SyncActionListener("discover services", SAFE_FOR_LONG_AWAIT_TIMEOUT);
|
||||
manager.discoverServices(channel, startDiscovery);
|
||||
|
||||
if (!addServiceListener.successful() || !startDiscovery.successful()) {
|
||||
manager.removeServiceRequest(channel, serviceRequest, null);
|
||||
serviceRequest = null;
|
||||
throw new WifiDirectUnavailableException(Reason.SERVICE_DISCOVERY_START);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop searching for transfer services.
|
||||
*/
|
||||
synchronized void stopServiceDiscovery() throws WifiDirectUnavailableException {
|
||||
ensureInitialized();
|
||||
|
||||
retryAsync(manager::clearServiceRequests, "clear service requests");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
synchronized void connect(@NonNull String deviceAddress) throws WifiDirectUnavailableException {
|
||||
ensureInitialized();
|
||||
|
||||
WifiP2pConfig config = new WifiP2pConfig();
|
||||
config.deviceAddress = deviceAddress;
|
||||
config.wps.setup = WpsInfo.PBC;
|
||||
config.groupOwnerIntent = 0;
|
||||
|
||||
if (serviceRequest != null) {
|
||||
manager.removeServiceRequest(channel, serviceRequest, LoggingActionListener.message("Remote service request"));
|
||||
serviceRequest = null;
|
||||
}
|
||||
|
||||
SyncActionListener listener = new SyncActionListener("service connect", SAFE_FOR_LONG_AWAIT_TIMEOUT);
|
||||
manager.connect(channel, config, listener);
|
||||
|
||||
if (listener.successful()) {
|
||||
Log.i(TAG, "Successfully connected to service.");
|
||||
} else {
|
||||
throw new WifiDirectUnavailableException(Reason.SERVICE_CONNECT_FAILURE);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void requestNetworkInfo() throws WifiDirectUnavailableException {
|
||||
ensureInitialized();
|
||||
|
||||
manager.requestConnectionInfo(channel, info -> {
|
||||
Log.i(TAG, "Connection information available. group_formed: " + info.groupFormed + " is_group_owner: " + info.isGroupOwner + " has_group_owner_address: " + (info.groupOwnerAddress != null));
|
||||
WifiDirectConnectionListener listener = connectionListener;
|
||||
if (listener != null) {
|
||||
listener.onNetworkConnected(info);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private synchronized void retrySync(@NonNull ManagerRetry retryFunction, @NonNull String message, long awaitTimeout) {
|
||||
int tries = 3;
|
||||
|
||||
while ((tries--) > 0) {
|
||||
if (isNotInitialized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
SyncActionListener listener = new SyncActionListener(message, awaitTimeout);
|
||||
retryFunction.call(channel, listener);
|
||||
if (listener.successful() || listener.failureReason == SyncActionListener.FAILURE_TIMEOUT) {
|
||||
return;
|
||||
}
|
||||
ThreadUtil.sleep(TimeUnit.SECONDS.toMillis(1));
|
||||
}
|
||||
}
|
||||
|
||||
private void retryAsync(@NonNull ManagerRetry retryFunction, @NonNull String message) {
|
||||
SignalExecutors.BOUNDED.execute(() -> retrySync(retryFunction, message, WifiDirect.NOT_SAFE_FOR_LONG_AWAIT_TIMEOUT));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static @NonNull String buildServiceInstanceName(@Nullable String extraInfo) {
|
||||
if (TextUtils.isEmpty(extraInfo)) {
|
||||
return SERVICE_INSTANCE_TEMPLATE.replace(EXTRA_INFO_PLACEHOLDER, "");
|
||||
}
|
||||
return SERVICE_INSTANCE_TEMPLATE.replace(EXTRA_INFO_PLACEHOLDER, "._" + extraInfo);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static @Nullable String isInstanceNameMatching(@NonNull String serviceInstanceName) {
|
||||
Matcher matcher = SERVICE_INSTANCE_PATTERN.matcher(serviceInstanceName);
|
||||
if (matcher.matches()) {
|
||||
String extraInfo = matcher.group(2);
|
||||
return TextUtils.isEmpty(extraInfo) ? "" : extraInfo;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private interface ManagerRetry {
|
||||
void call(@NonNull WifiP2pManager.Channel a, @NonNull WifiP2pManager.ActionListener b);
|
||||
}
|
||||
|
||||
private static class WifiDirectCallbacks extends BroadcastReceiver implements WifiP2pManager.ChannelListener {
|
||||
private WifiDirectConnectionListener connectionListener;
|
||||
|
||||
public WifiDirectCallbacks(@NonNull WifiDirectConnectionListener connectionListener) {
|
||||
this.connectionListener = connectionListener;
|
||||
}
|
||||
|
||||
public void clearConnectionListener() {
|
||||
connectionListener = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(@NonNull Context context, @NonNull Intent intent) {
|
||||
String action = intent.getAction();
|
||||
if (action != null) {
|
||||
if (WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION.equals(action)) {
|
||||
WifiDirectConnectionListener listener = connectionListener;
|
||||
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 (listener != null) {
|
||||
listener.onConnectionChanged(networkInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelDisconnected() {
|
||||
WifiDirectConnectionListener listener = connectionListener;
|
||||
if (listener != null) {
|
||||
listener.onNetworkFailure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a synchronous way to talking to Android's WiFi Direct code.
|
||||
*/
|
||||
private static class SyncActionListener extends LoggingActionListener {
|
||||
|
||||
private static final int FAILURE_TIMEOUT = -2;
|
||||
|
||||
private final CountDownLatch sync;
|
||||
private final long awaitTimeout;
|
||||
private volatile int failureReason = -1;
|
||||
|
||||
public SyncActionListener(@NonNull String message, long awaitTimeout) {
|
||||
super(message);
|
||||
this.awaitTimeout = awaitTimeout;
|
||||
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 {
|
||||
boolean completed = sync.await(awaitTimeout, TimeUnit.MILLISECONDS);
|
||||
if (!completed) {
|
||||
Log.i(TAG, "SyncListener [" + message + "] timed out after " + awaitTimeout + "ms");
|
||||
failureReason = FAILURE_TIMEOUT;
|
||||
return false;
|
||||
}
|
||||
} catch (InterruptedException ie) {
|
||||
Log.i(TAG, "SyncListener [" + message + "] interrupted");
|
||||
}
|
||||
return failureReason < 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static class LoggingActionListener implements WifiP2pManager.ActionListener {
|
||||
|
||||
protected 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,
|
||||
REQUIRED_PERMISSION_NOT_GRANTED,
|
||||
WIFI_DIRECT_NOT_AVAILABLE,
|
||||
AVAILABLE
|
||||
}
|
||||
|
||||
public interface WifiDirectConnectionListener {
|
||||
|
||||
void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice, @NonNull String extraInfo);
|
||||
|
||||
void onNetworkConnected(@NonNull WifiP2pInfo info);
|
||||
|
||||
void onNetworkFailure();
|
||||
|
||||
void onConnectionChanged(@NonNull NetworkInfo networkInfo);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package org.signal.devicetransfer;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Represents the various type of failure with creating a WiFi Direction connection.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
11
device-transfer/lib/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="DeviceToDeviceTransferService_content_title" type="string" />
|
||||
|
||||
<item name="DeviceToDeviceTransferService_status_ready" type="string" />
|
||||
<item name="DeviceToDeviceTransferService_status_starting_up" type="string" />
|
||||
<item name="DeviceToDeviceTransferService_status_discovery" type="string" />
|
||||
<item name="DeviceToDeviceTransferService_status_network_connected" type="string" />
|
||||
<item name="DeviceToDeviceTransferService_status_verification_required" type="string" />
|
||||
<item name="DeviceToDeviceTransferService_status_service_connected" type="string" />
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package org.signal.devicetransfer
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.signal.devicetransfer.DeviceTransferAuthentication.DeviceTransferAuthenticationException
|
||||
import org.whispersystems.signalservice.test.LibSignalLibraryUtil
|
||||
import kotlin.random.Random
|
||||
|
||||
class DeviceTransferAuthenticationTest {
|
||||
private lateinit var certificate: ByteArray
|
||||
|
||||
@Before
|
||||
fun ensureNativeSupported() {
|
||||
LibSignalLibraryUtil.assumeLibSignalSupportedOnOS()
|
||||
|
||||
certificate = SelfSignedIdentity.create().x509Encoded
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCompute_withNoChanges() {
|
||||
val client = DeviceTransferAuthentication.Client(certificate)
|
||||
val server = DeviceTransferAuthentication.Server(certificate, client.commitment)
|
||||
|
||||
val clientRandom = client.setServerRandomAndGetClientRandom(server.random)
|
||||
|
||||
server.setClientRandom(clientRandom)
|
||||
assertEquals(client.computeShortAuthenticationCode(), server.computeShortAuthenticationCode())
|
||||
}
|
||||
|
||||
@Test(expected = DeviceTransferAuthenticationException::class)
|
||||
fun testServerCompute_withChangedClientCertificate() {
|
||||
val badCertificate = SelfSignedIdentity.create().x509Encoded
|
||||
val client = DeviceTransferAuthentication.Client(badCertificate)
|
||||
val server = DeviceTransferAuthentication.Server(certificate, client.commitment)
|
||||
|
||||
val clientRandom = client.setServerRandomAndGetClientRandom(server.random)
|
||||
|
||||
server.setClientRandom(clientRandom)
|
||||
server.computeShortAuthenticationCode()
|
||||
}
|
||||
|
||||
@Test(expected = DeviceTransferAuthenticationException::class)
|
||||
fun testServerCompute_withChangedClientCommitment() {
|
||||
val client = DeviceTransferAuthentication.Client(certificate)
|
||||
val server = DeviceTransferAuthentication.Server(certificate, randomBytes())
|
||||
|
||||
val clientRandom = client.setServerRandomAndGetClientRandom(server.random)
|
||||
|
||||
server.setClientRandom(clientRandom)
|
||||
server.computeShortAuthenticationCode()
|
||||
}
|
||||
|
||||
@Test(expected = DeviceTransferAuthenticationException::class)
|
||||
fun testServerCompute_withChangedClientRandom() {
|
||||
val client = DeviceTransferAuthentication.Client(certificate)
|
||||
val server = DeviceTransferAuthentication.Server(certificate, client.commitment)
|
||||
|
||||
client.setServerRandomAndGetClientRandom(server.random)
|
||||
|
||||
server.setClientRandom(randomBytes())
|
||||
server.computeShortAuthenticationCode()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClientCompute_withChangedServerSecret() {
|
||||
val client = DeviceTransferAuthentication.Client(certificate)
|
||||
val server = DeviceTransferAuthentication.Server(certificate, client.commitment)
|
||||
|
||||
val clientRandom = client.setServerRandomAndGetClientRandom(randomBytes())
|
||||
|
||||
server.setClientRandom(clientRandom)
|
||||
assertNotEquals(client.computeShortAuthenticationCode(), server.computeShortAuthenticationCode())
|
||||
}
|
||||
|
||||
private fun randomBytes(): ByteArray {
|
||||
val bytes = ByteArray(32)
|
||||
Random.nextBytes(bytes)
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package org.signal.devicetransfer
|
||||
|
||||
import android.app.Application
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, application = Application::class)
|
||||
class WifiDirectTest {
|
||||
@Test
|
||||
fun instanceName_withExtraInfo() {
|
||||
val instanceName = WifiDirect.buildServiceInstanceName("knownothing")
|
||||
|
||||
assertEquals("_devicetransfer._knownothing._signal.org", instanceName)
|
||||
|
||||
val extractedExtraInfo = WifiDirect.isInstanceNameMatching(instanceName)
|
||||
assertEquals(extractedExtraInfo, "knownothing")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun instanceName_matchingWithoutExtraInfo() {
|
||||
val instanceName = WifiDirect.buildServiceInstanceName("")
|
||||
|
||||
assertEquals("_devicetransfer._signal.org", instanceName)
|
||||
|
||||
val extractedExtraInfo = WifiDirect.isInstanceNameMatching(instanceName)
|
||||
assertEquals(extractedExtraInfo, "")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun instanceName_notMatching() {
|
||||
val extractedExtraInfo = WifiDirect.isInstanceNameMatching("_whoknows._what.org")
|
||||
assertNull(extractedExtraInfo)
|
||||
}
|
||||
}
|
||||