diff --git a/app/build.gradle b/app/build.gradle index 9e5bf62de..4277bc5b9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -155,6 +155,8 @@ android { exclude 'META-INF/LICENSE' exclude 'META-INF/NOTICE' exclude 'META-INF/proguard/androidx-annotations.pro' + exclude '/org/spongycastle/x509/CertPathReviewerMessages.properties' + exclude '/org/spongycastle/x509/CertPathReviewerMessages_de.properties' } buildTypes { @@ -355,6 +357,7 @@ dependencies { implementation project(':paging') implementation project(':core-util') implementation project(':video') + implementation project(':device-transfer') implementation 'org.signal:zkgroup-android:0.7.0' implementation 'org.whispersystems:signal-client-android:0.1.7' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7d0783e66..9e2d9a872 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -377,6 +377,16 @@ android:windowSoftInputMode="stateAlwaysHidden"> + + + + destination, @Nullable Intent nextIntent) { final Intent intent = new Intent(this, destination); if (nextIntent != null) intent.putExtra("next_intent", nextIntent); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java index 7bea78e1b..369ebca1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java @@ -26,8 +26,6 @@ import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; -import org.thoughtcrime.securesms.database.JobDatabase; -import org.thoughtcrime.securesms.database.KeyValueDatabase; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase; @@ -85,7 +83,7 @@ public class FullBackupExporter extends FullBackupBase { throws IOException { try (OutputStream outputStream = new FileOutputStream(output)) { - internalExport(context, attachmentSecret, input, outputStream, passphrase); + internalExport(context, attachmentSecret, input, outputStream, passphrase, true); } } @@ -98,15 +96,26 @@ public class FullBackupExporter extends FullBackupBase { throws IOException { try (OutputStream outputStream = Objects.requireNonNull(context.getContentResolver().openOutputStream(output.getUri()))) { - internalExport(context, attachmentSecret, input, outputStream, passphrase); + internalExport(context, attachmentSecret, input, outputStream, passphrase, true); } } + public static void transfer(@NonNull Context context, + @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase input, + @NonNull OutputStream outputStream, + @NonNull String passphrase) + throws IOException + { + internalExport(context, attachmentSecret, input, outputStream, passphrase, false); + } + private static void internalExport(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase input, @NonNull OutputStream fileOutputStream, - @NonNull String passphrase) + @NonNull String passphrase, + boolean closeOutputStream) throws IOException { BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase); @@ -155,7 +164,9 @@ public class FullBackupExporter extends FullBackupBase { outputStream.writeEnd(); } finally { - outputStream.close(); + if (closeOutputStream) { + outputStream.close(); + } EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java index 8a865a414..494472f8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java @@ -65,10 +65,19 @@ public class FullBackupImporter extends FullBackupBase { public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase) throws IOException + { + try (InputStream is = getInputStream(context, uri)) { + importFile(context, attachmentSecret, db, is, passphrase); + } + } + + public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase db, @NonNull InputStream is, @NonNull String passphrase) + throws IOException { int count = 0; - try (InputStream is = getInputStream(context, uri)) { + try { BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase); db.beginTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index 92aec9c3e..218b6b6fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -200,7 +200,7 @@ public class ApplicationDependencies { } } - public static void closeConnectionsAfterProxyFailure() { + public static void closeConnections() { synchronized (LOCK) { if (incomingMessageObserver != null) { incomingMessageObserver.terminateAsync(); @@ -220,7 +220,7 @@ public class ApplicationDependencies { public static void resetNetworkConnectionsAfterProxyChange() { synchronized (LOCK) { getPipeListener().reset(); - closeConnectionsAfterProxyFailure(); + closeConnections(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceSetupState.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceSetupState.java new file mode 100644 index 000000000..0439a0d6f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceSetupState.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.devicetransfer; + +import androidx.annotation.NonNull; + +import static org.thoughtcrime.securesms.devicetransfer.SetupStep.VERIFY; + +/** + * State representation of the current {@link SetupStep} in the setup flow and + * the SAS if one has been provided. + */ +public final class DeviceSetupState { + + private final SetupStep currentSetupStep; + private final int authenticationCode; + + public DeviceSetupState() { + this(SetupStep.INITIAL, 0); + } + + public DeviceSetupState(@NonNull SetupStep currentSetupStep, int authenticationCode) { + this.currentSetupStep = currentSetupStep; + this.authenticationCode = authenticationCode; + } + + public @NonNull SetupStep getCurrentSetupStep() { + return currentSetupStep; + } + + public int getAuthenticationCode() { + return authenticationCode; + } + + public @NonNull DeviceSetupState updateStep(@NonNull SetupStep currentSetupStep) { + return new DeviceSetupState(currentSetupStep, this.authenticationCode); + } + + public @NonNull DeviceSetupState updateVerificationRequired(int authenticationCode) { + return new DeviceSetupState(VERIFY, authenticationCode); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferFragment.java new file mode 100644 index 000000000..e8ed84788 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferFragment.java @@ -0,0 +1,142 @@ +package org.thoughtcrime.securesms.devicetransfer; + +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.devicetransfer.DeviceToDeviceTransferService; +import org.signal.devicetransfer.TransferStatus; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; + +/** + * Drives the UI for the actual device transfer progress. Shown after setup is complete + * and the two devices are transferring. + *

+ * Handles show progress and error state. + */ +public abstract class DeviceTransferFragment extends LoggingFragment { + + private final OnBackPressed onBackPressed = new OnBackPressed(); + private final TransferModeListener transferModeListener = new TransferModeListener(); + + protected TextView title; + protected View tryAgain; + protected Button cancel; + protected View progress; + protected View alert; + protected TextView status; + + public DeviceTransferFragment() { + super(R.layout.device_transfer_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + title = view.findViewById(R.id.device_transfer_fragment_title); + tryAgain = view.findViewById(R.id.device_transfer_fragment_try_again); + cancel = view.findViewById(R.id.device_transfer_fragment_cancel); + progress = view.findViewById(R.id.device_transfer_fragment_progress); + alert = view.findViewById(R.id.device_transfer_fragment_alert); + status = view.findViewById(R.id.device_transfer_fragment_status); + + cancel.setOnClickListener(v -> cancelActiveTransfer()); + tryAgain.setOnClickListener(v -> { + EventBus.getDefault().unregister(transferModeListener); + EventBus.getDefault().removeStickyEvent(TransferStatus.class); + navigateToRestartTransfer(); + }); + + EventBus.getDefault().register(transferModeListener); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressed); + } + + @Override + public void onDestroyView() { + EventBus.getDefault().unregister(transferModeListener); + super.onDestroyView(); + } + + private void cancelActiveTransfer() { + new AlertDialog.Builder(requireContext()).setTitle(R.string.DeviceTransfer__stop_transfer) + .setMessage(R.string.DeviceTransfer__all_transfer_progress_will_be_lost) + .setPositiveButton(R.string.DeviceTransfer__stop_transfer, (d, w) -> { + EventBus.getDefault().unregister(transferModeListener); + DeviceToDeviceTransferService.stop(requireContext()); + EventBus.getDefault().removeStickyEvent(TransferStatus.class); + navigateAwayFromTransfer(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + protected void ignoreTransferStatusEvents() { + EventBus.getDefault().unregister(transferModeListener); + } + + protected abstract void navigateToRestartTransfer(); + + protected abstract void navigateAwayFromTransfer(); + + private class TransferModeListener { + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + public void onEventMainThread(@NonNull TransferStatus event) { + if (event.getTransferMode() != TransferStatus.TransferMode.SERVICE_CONNECTED) { + abort(); + } + } + } + + protected void abort() { + abort(R.string.DeviceTransfer__transfer_failed); + } + + protected void abort(@StringRes int errorMessage) { + EventBus.getDefault().unregister(transferModeListener); + DeviceToDeviceTransferService.stop(requireContext()); + + progress.setVisibility(View.GONE); + alert.setVisibility(View.VISIBLE); + tryAgain.setVisibility(View.VISIBLE); + + title.setText(R.string.DeviceTransfer__unable_to_transfer); + status.setText(errorMessage); + cancel.setText(R.string.DeviceTransfer__cancel); + cancel.setOnClickListener(v -> navigateAwayFromTransfer()); + + onBackPressed.isActiveTransfer = false; + } + + protected class OnBackPressed extends OnBackPressedCallback { + + private boolean isActiveTransfer = true; + + public OnBackPressed() { + super(true); + } + + @Override + public void handleOnBackPressed() { + if (isActiveTransfer) { + cancelActiveTransfer(); + } else { + navigateAwayFromTransfer(); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupFragment.java new file mode 100644 index 000000000..375d55f85 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupFragment.java @@ -0,0 +1,393 @@ +package org.thoughtcrime.securesms.devicetransfer; + +import android.Manifest; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.location.LocationManager; +import android.net.wifi.WifiManager; +import android.os.Bundle; +import android.provider.Settings; +import android.text.method.LinkMovementMethod; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.constraintlayout.widget.Group; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProviders; +import androidx.navigation.fragment.NavHostFragment; + +import com.google.android.material.button.MaterialButton; + +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 org.signal.devicetransfer.DeviceToDeviceTransferService; +import org.signal.devicetransfer.TransferStatus; +import org.signal.devicetransfer.WifiDirect; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Responsible for driving the UI of all the legwork to startup Wi-Fi Direct and + * establish the connection between the two devices. It's capable of being used by both + * the new and old device, but delegates some of the UI (mostly strings and navigation) to + * a subclass for old or new device. + *

+ * Handles showing setup progress, verification codes, connecting states, error states, and troubleshooting. + *

+ * It's state driven by the view model so it's easy to transition from step to step in the + * process. + */ +public abstract class DeviceTransferSetupFragment extends LoggingFragment { + + private static final String TAG = Log.tag(DeviceTransferSetupFragment.class); + + private static final long PREPARE_TAKING_TOO_LONG_TIME = TimeUnit.SECONDS.toMillis(30); + private static final long WAITING_TAKING_TOO_LONG_TIME = TimeUnit.SECONDS.toMillis(90); + + private final OnBackPressed onBackPressed = new OnBackPressed(); + private DeviceTransferSetupViewModel viewModel; + private Runnable takingTooLong; + + public DeviceTransferSetupFragment() { + super(R.layout.device_transfer_setup_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Group progressGroup = view.findViewById(R.id.device_transfer_setup_fragment_progress_group); + Group errorGroup = view.findViewById(R.id.device_transfer_setup_fragment_error_group); + Group verifyGroup = view.findViewById(R.id.device_transfer_setup_fragment_verify_group); + View troubleshooting = view.findViewById(R.id.device_transfer_setup_fragment_troubleshooting); + TextView status = view.findViewById(R.id.device_transfer_setup_fragment_status); + TextView error = view.findViewById(R.id.device_transfer_setup_fragment_error); + MaterialButton errorResolve = view.findViewById(R.id.device_transfer_setup_fragment_error_resolve); + TextView sasNumber = view.findViewById(R.id.device_transfer_setup_fragment_sas_verify_code); + MaterialButton verifyNo = view.findViewById(R.id.device_transfer_setup_fragment_sas_verify_no); + MaterialButton verifyYes = view.findViewById(R.id.device_transfer_setup_fragment_sas_verify_yes); + + viewModel = ViewModelProviders.of(this).get(DeviceTransferSetupViewModel.class); + + viewModel.getState().observe(getViewLifecycleOwner(), state -> { + SetupStep step = state.getCurrentSetupStep(); + + progressGroup.setVisibility(step.isProgress() ? View.VISIBLE : View.GONE); + errorGroup.setVisibility(step.isError() ? View.VISIBLE : View.GONE); + verifyGroup.setVisibility(step == SetupStep.VERIFY ? View.VISIBLE : View.GONE); + troubleshooting.setVisibility(step == SetupStep.TROUBLESHOOTING ? View.VISIBLE : View.GONE); + + Log.i(TAG, "Handling step: " + step.name()); + switch (step) { + case INITIAL: + status.setText(""); + case PERMISSIONS_CHECK: + requestLocationPermission(); + break; + case PERMISSIONS_DENIED: + error.setText(getErrorTextForStep(step)); + errorResolve.setText(R.string.DeviceTransferSetup__grant_location_permission); + errorResolve.setOnClickListener(v -> viewModel.checkPermissions()); + break; + case LOCATION_CHECK: + verifyLocationEnabled(); + break; + case LOCATION_DISABLED: + error.setText(getErrorTextForStep(step)); + errorResolve.setText(R.string.DeviceTransferSetup__turn_on_location_services); + errorResolve.setOnClickListener(v -> openLocationServices()); + break; + case WIFI_CHECK: + verifyWifiEnabled(); + break; + case WIFI_DISABLED: + error.setText(getErrorTextForStep(step)); + errorResolve.setText(R.string.DeviceTransferSetup__turn_on_wifi); + errorResolve.setOnClickListener(v -> openWifiSettings()); + break; + case WIFI_DIRECT_CHECK: + verifyWifiDirectAvailable(); + break; + case WIFI_DIRECT_UNAVAILABLE: + error.setText(getErrorTextForStep(step)); + errorResolve.setText(getErrorResolveButtonTextForStep(step)); + errorResolve.setOnClickListener(v -> navigateWhenWifiDirectUnavailable()); + break; + case START: + status.setText(getStatusTextForStep(SetupStep.SETTING_UP, false)); + startTransfer(); + break; + case SETTING_UP: + status.setText(getStatusTextForStep(step, false)); + startTakingTooLong(() -> status.setText(getStatusTextForStep(step, true)), PREPARE_TAKING_TOO_LONG_TIME); + break; + case WAITING: + status.setText(getStatusTextForStep(step, false)); + cancelTakingTooLong(); + startTakingTooLong(() -> { + DeviceToDeviceTransferService.stop(requireContext()); + viewModel.onWaitingTookTooLong(); + }, WAITING_TAKING_TOO_LONG_TIME); + break; + case VERIFY: + cancelTakingTooLong(); + sasNumber.setText(String.format(Locale.US, "%07d", state.getAuthenticationCode())); + //noinspection CodeBlock2Expr + verifyNo.setOnClickListener(v -> { + new AlertDialog.Builder(requireContext()).setTitle(R.string.DeviceTransferSetup__the_numbers_do_not_match) + .setMessage(R.string.DeviceTransferSetup__if_the_numbers_on_your_devices_do_not_match_its_possible_you_connected_to_the_wrong_device) + .setPositiveButton(R.string.DeviceTransferSetup__stop_transfer, (d, w) -> { + EventBus.getDefault().unregister(this); + DeviceToDeviceTransferService.setAuthenticationCodeVerified(requireContext(), false); + DeviceToDeviceTransferService.stop(requireContext()); + EventBus.getDefault().removeStickyEvent(TransferStatus.class); + navigateAwayFromTransfer(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + }); + verifyYes.setOnClickListener(v -> { + DeviceToDeviceTransferService.setAuthenticationCodeVerified(requireContext(), true); + viewModel.onVerified(); + }); + break; + case CONNECTING: + status.setText(getStatusTextForStep(step, false)); + break; + case CONNECTED: + Log.d(TAG, "Connected! isNotShutdown: " + viewModel.isNotShutdown()); + if (viewModel.isNotShutdown()) { + navigateToTransferConnected(); + } + break; + case TROUBLESHOOTING: + TextView title = troubleshooting.findViewById(R.id.device_transfer_setup_fragment_troubleshooting_title); + title.setText(getStatusTextForStep(step, false)); + + int gapWidth = ViewUtil.dpToPx(12); + TextView step1 = troubleshooting.findViewById(R.id.device_transfer_setup_fragment_troubleshooting_step1); + step1.setText(SpanUtil.bullet(getString(R.string.DeviceTransferSetup__make_sure_the_following_permissions_are_enabled), gapWidth)); + TextView step2 = troubleshooting.findViewById(R.id.device_transfer_setup_fragment_troubleshooting_step2); + step2.setMovementMethod(LinkMovementMethod.getInstance()); + step2.setText(SpanUtil.clickSubstring(requireContext(), + SpanUtil.bullet(getString(R.string.DeviceTransferSetup__on_the_wifi_direct_screen_remove_all_remembered_groups_and_unlink_any_invited_or_connected_devices), gapWidth), + getString(R.string.DeviceTransferSetup__wifi_direct_screen), + v -> openWifiDirectSettings())); + TextView step3 = troubleshooting.findViewById(R.id.device_transfer_setup_fragment_troubleshooting_step3); + step3.setText(SpanUtil.bullet(getString(R.string.DeviceTransferSetup__try_turning_wifi_off_and_on_on_both_devices), gapWidth)); + TextView step4 = troubleshooting.findViewById(R.id.device_transfer_setup_fragment_troubleshooting_step4); + step4.setText(SpanUtil.bullet(getString(R.string.DeviceTransferSetup__make_sure_both_devices_are_in_transfer_mode), gapWidth)); + + troubleshooting.findViewById(R.id.device_transfer_setup_fragment_troubleshooting_location_permission) + .setOnClickListener(v -> openApplicationSystemSettings()); + troubleshooting.findViewById(R.id.device_transfer_setup_fragment_troubleshooting_location_services) + .setOnClickListener(v -> openLocationServices()); + troubleshooting.findViewById(R.id.device_transfer_setup_fragment_troubleshooting_wifi) + .setOnClickListener(v -> openWifiSettings()); + troubleshooting.findViewById(R.id.device_transfer_setup_fragment_troubleshooting_go_to_support) + .setOnClickListener(v -> gotoSupport()); + troubleshooting.findViewById(R.id.device_transfer_setup_fragment_troubleshooting_try_again) + .setOnClickListener(v -> viewModel.checkPermissions()); + break; + case ERROR: + error.setText(getErrorTextForStep(step)); + errorResolve.setText(R.string.DeviceTransferSetup__retry); + errorResolve.setOnClickListener(v -> viewModel.checkPermissions()); + DeviceToDeviceTransferService.stop(requireContext()); + cancelTakingTooLong(); + new AlertDialog.Builder(requireContext()).setTitle(R.string.DeviceTransferSetup__error_connecting) + .setMessage(getStatusTextForStep(step, false)) + .setPositiveButton(R.string.DeviceTransferSetup__retry, (d, w) -> viewModel.checkPermissions()) + .setNegativeButton(android.R.string.cancel, (d, w) -> { + EventBus.getDefault().unregister(this); + EventBus.getDefault().removeStickyEvent(TransferStatus.class); + navigateAwayFromTransfer(); + }) + .setNeutralButton(R.string.DeviceTransferSetup__submit_debug_logs, (d, w) -> { + EventBus.getDefault().unregister(this); + EventBus.getDefault().removeStickyEvent(TransferStatus.class); + navigateAwayFromTransfer(); + startActivity(new Intent(requireContext(), SubmitDebugLogActivity.class)); + }) + .setCancelable(false) + .show(); + break; + } + }); + } + + protected abstract @StringRes int getStatusTextForStep(@NonNull SetupStep step, boolean takingTooLongInStep); + + protected abstract @StringRes int getErrorTextForStep(@NonNull SetupStep step); + + protected abstract @StringRes int getErrorResolveButtonTextForStep(@NonNull SetupStep step); + + protected abstract void navigateWhenWifiDirectUnavailable(); + + protected abstract void startTransfer(); + + protected abstract void navigateToTransferConnected(); + + protected abstract void navigateAwayFromTransfer(); + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressed); + + TransferStatus event = EventBus.getDefault().getStickyEvent(TransferStatus.class); + if (event == null) { + viewModel.checkPermissions(); + } else { + Log.i(TAG, "Sticky event already exists for transfer, assuming service is running and we are reattaching"); + } + + EventBus.getDefault().register(this); + } + + @Override + public void onResume() { + super.onResume(); + viewModel.onResume(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @Override + public void onDestroyView() { + cancelTakingTooLong(); + EventBus.getDefault().unregister(this); + super.onDestroyView(); + } + + private void requestLocationPermission() { + Permissions.with(this) + .request(Manifest.permission.ACCESS_FINE_LOCATION) + .ifNecessary() + .withRationaleDialog(getString(getErrorTextForStep(SetupStep.PERMISSIONS_DENIED)), false, R.drawable.ic_location_on_white_24dp) + .withPermanentDenialDialog(getString(getErrorTextForStep(SetupStep.PERMISSIONS_DENIED))) + .onAllGranted(() -> viewModel.onPermissionsGranted()) + .onAnyDenied(() -> viewModel.onLocationPermissionDenied()) + .execute(); + } + + private void openApplicationSystemSettings() { + startActivity(Permissions.getApplicationSettingsIntent(requireContext())); + } + + private void verifyLocationEnabled() { + LocationManager locationManager = ContextCompat.getSystemService(requireContext(), LocationManager.class); + if (locationManager != null && locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + viewModel.onLocationEnabled(); + } else { + viewModel.onLocationDisabled(); + } + } + + private void openLocationServices() { + try { + startActivity(new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)); + } catch (ActivityNotFoundException e) { + Log.w(TAG, "No location settings", e); + Toast.makeText(requireContext(), R.string.DeviceTransferSetup__unable_to_open_wifi_settings, Toast.LENGTH_LONG).show(); + } + } + + private void verifyWifiEnabled() { + WifiManager wifiManager = ContextCompat.getSystemService(requireContext(), WifiManager.class); + if (wifiManager != null && wifiManager.isWifiEnabled()) { + viewModel.onWifiEnabled(); + } else { + viewModel.onWifiDisabled(wifiManager == null); + } + } + + private void openWifiSettings() { + try { + startActivity(new Intent(Settings.ACTION_WIFI_SETTINGS)); + } catch (ActivityNotFoundException e) { + Log.w(TAG, "No wifi settings", e); + Toast.makeText(requireContext(), R.string.DeviceTransferSetup__unable_to_open_wifi_settings, Toast.LENGTH_LONG).show(); + } + } + + private void openWifiDirectSettings() { + try { + Intent wifiDirect = new Intent(Intent.ACTION_MAIN); + wifiDirect.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setClassName("com.android.settings", "com.android.settings.Settings$WifiP2pSettingsActivity"); + + startActivity(wifiDirect); + } catch (ActivityNotFoundException e) { + Log.w(TAG, "Unable to open wifi direct settings", e); + openWifiSettings(); + } + } + + private void verifyWifiDirectAvailable() { + WifiDirect.AvailableStatus availability = WifiDirect.getAvailability(requireContext()); + if (availability != WifiDirect.AvailableStatus.AVAILABLE) { + viewModel.onWifiDirectUnavailable(availability); + } else { + viewModel.onWifiDirectAvailable(); + } + } + + private void gotoSupport() { + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.transfer_support_url)); + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + public void onEventMainThread(@NonNull TransferStatus event) { + viewModel.onTransferEvent(event); + } + + private void startTakingTooLong(@NonNull Runnable runnable, long tooLong) { + if (takingTooLong == null) { + takingTooLong = () -> { + takingTooLong = null; + runnable.run(); + }; + ThreadUtil.runOnMainDelayed(takingTooLong, tooLong); + } + } + + private void cancelTakingTooLong() { + if (takingTooLong != null) { + ThreadUtil.cancelRunnableOnMain(takingTooLong); + takingTooLong = null; + } + } + + private class OnBackPressed extends OnBackPressedCallback { + + public OnBackPressed() { + super(true); + } + + @Override + public void handleOnBackPressed() { + DeviceToDeviceTransferService.stop(requireContext()); + EventBus.getDefault().removeStickyEvent(TransferStatus.class); + NavHostFragment.findNavController(DeviceTransferSetupFragment.this).popBackStack(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupViewModel.java new file mode 100644 index 000000000..6f12ae1a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferSetupViewModel.java @@ -0,0 +1,137 @@ +package org.thoughtcrime.securesms.devicetransfer; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; + +import org.signal.core.util.logging.Log; +import org.signal.devicetransfer.TransferStatus; +import org.signal.devicetransfer.WifiDirect; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.util.livedata.Store; + +/** + * Drives and wraps the state of the transfer setup process. + */ +public final class DeviceTransferSetupViewModel extends ViewModel { + + private static final String TAG = Log.tag(DeviceTransferSetupViewModel.class); + + private final Store store; + private final LiveData distinctStepChanges; + + private boolean shutdown; + + public DeviceTransferSetupViewModel() { + this.store = new Store<>(new DeviceSetupState()); + this.distinctStepChanges = LiveDataUtil.distinctUntilChanged(this.store.getStateLiveData(), (current, next) -> current.getCurrentSetupStep() == next.getCurrentSetupStep()); + } + + public @NonNull LiveData getState() { + return distinctStepChanges; + } + + public boolean isNotShutdown() { + return !shutdown; + } + + public void onTransferEvent(@NonNull TransferStatus event) { + if (shutdown) { + return; + } + + Log.i(TAG, "Handling transferStatus: " + event.getTransferMode()); + switch (event.getTransferMode()) { + case UNAVAILABLE: + case NETWORK_CONNECTED: + Log.d(TAG, "Ignore event: " + event.getTransferMode()); + break; + case READY: + case STARTING_UP: + store.update(s -> s.updateStep(SetupStep.SETTING_UP)); + break; + case DISCOVERY: + store.update(s -> s.updateStep(SetupStep.WAITING)); + break; + case VERIFICATION_REQUIRED: + store.update(s -> s.updateVerificationRequired(event.getAuthenticationCode())); + break; + case SERVICE_CONNECTED: + store.update(s -> s.updateStep(SetupStep.CONNECTED)); + break; + case FAILED: + store.update(s -> s.updateStep(SetupStep.ERROR)); + break; + } + } + + public void onLocationPermissionDenied() { + Log.i(TAG, "Location permissions denied"); + store.update(s -> s.updateStep(SetupStep.PERMISSIONS_DENIED)); + } + + public void onWifiDisabled(boolean wifiManagerNotAvailable) { + Log.i(TAG, "Wifi disabled manager: " + wifiManagerNotAvailable); + store.update(s -> s.updateStep(SetupStep.WIFI_DISABLED)); + } + + public void onWifiDirectUnavailable(WifiDirect.AvailableStatus availability) { + Log.i(TAG, "Wifi Direct unavailable: " + availability); + if (availability == WifiDirect.AvailableStatus.FINE_LOCATION_PERMISSION_NOT_GRANTED) { + store.update(s -> s.updateStep(SetupStep.PERMISSIONS_CHECK)); + } else { + store.update(s -> s.updateStep(SetupStep.WIFI_DIRECT_UNAVAILABLE)); + } + } + + public void checkPermissions() { + Log.d(TAG, "Check for permissions"); + shutdown = false; + store.update(s -> s.updateStep(SetupStep.PERMISSIONS_CHECK)); + } + + public void onPermissionsGranted() { + Log.d(TAG, "Permissions granted"); + store.update(s -> s.updateStep(SetupStep.LOCATION_CHECK)); + } + + public void onLocationEnabled() { + Log.d(TAG, "Location enabled"); + store.update(s -> s.updateStep(SetupStep.WIFI_CHECK)); + } + + public void onLocationDisabled() { + Log.d(TAG, "Location disabled"); + store.update(s -> s.updateStep(SetupStep.LOCATION_DISABLED)); + } + + public void onWifiEnabled() { + Log.d(TAG, "Wifi enabled"); + store.update(s -> s.updateStep(SetupStep.WIFI_DIRECT_CHECK)); + } + + public void onWifiDirectAvailable() { + Log.d(TAG, "Wifi direct available"); + store.update(s -> s.updateStep(SetupStep.START)); + } + + public void onVerified() { + store.update(s -> s.updateStep(SetupStep.CONNECTING)); + } + + public void onResume() { + store.update(s -> { + if (s.getCurrentSetupStep() == SetupStep.WIFI_DISABLED) { + return s.updateStep(SetupStep.WIFI_CHECK); + } else if (s.getCurrentSetupStep() == SetupStep.LOCATION_DISABLED) { + return s.updateStep(SetupStep.LOCATION_CHECK); + } + return s; + }); + } + + public void onWaitingTookTooLong() { + shutdown = true; + store.update(s -> s.updateStep(SetupStep.TROUBLESHOOTING)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/SetupStep.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/SetupStep.java new file mode 100644 index 000000000..09dc274ef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/SetupStep.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.devicetransfer; + +/** + * The various steps involved in setting up a transfer connection. Each step has a + * corresponding UI. + */ +public enum SetupStep { + INITIAL(true, false), + PERMISSIONS_CHECK(true, false), + PERMISSIONS_DENIED(false, true), + LOCATION_CHECK(true, false), + LOCATION_DISABLED(false, true), + WIFI_CHECK(true, false), + WIFI_DISABLED(false, true), + WIFI_DIRECT_CHECK(true, false), + WIFI_DIRECT_UNAVAILABLE(false, true), + START(true, false), + SETTING_UP(true, false), + WAITING(true, false), + VERIFY(false, false), + CONNECTING(true, false), + CONNECTED(true, false), + TROUBLESHOOTING(false, false), + ERROR(false, true); + + private final boolean isProgress; + private final boolean isError; + + SetupStep(boolean isProgress, boolean isError) { + this.isProgress = isProgress; + this.isError = isError; + } + + public boolean isProgress() { + return isProgress; + } + + public boolean isError() { + return isError; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java new file mode 100644 index 000000000..bfb882954 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.devicetransfer.newdevice; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.logging.Log; +import org.signal.devicetransfer.ServerTask; +import org.thoughtcrime.securesms.AppInitialization; +import org.thoughtcrime.securesms.backup.BackupPassphrase; +import org.thoughtcrime.securesms.backup.FullBackupBase; +import org.thoughtcrime.securesms.backup.FullBackupImporter; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.notifications.NotificationChannels; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Performs the restore with the backup data coming in over the input stream. Used in + * conjunction with {@link org.signal.devicetransfer.DeviceToDeviceTransferService}. + */ +final class NewDeviceServerTask implements ServerTask { + + private static final String TAG = Log.tag(NewDeviceServerTask.class); + + @Override + public void run(@NonNull Context context, @NonNull InputStream inputStream) { + long start = System.currentTimeMillis(); + + Log.i(TAG, "Starting backup restore."); + + EventBus.getDefault().register(this); + try { + SQLiteDatabase database = DatabaseFactory.getBackupDatabase(context); + + String passphrase = "deadbeef"; + + BackupPassphrase.set(context, passphrase); + FullBackupImporter.importFile(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + database, + inputStream, + passphrase); + + DatabaseFactory.upgradeRestored(context, database); + NotificationChannels.restoreContactNotificationChannels(context); + + AppInitialization.onPostBackupRestore(context); + + Log.i(TAG, "Backup restore complete."); + } catch (FullBackupImporter.DatabaseDowngradeException e) { + Log.w(TAG, "Failed due to the backup being from a newer version of Signal.", e); + EventBus.getDefault().post(new Status(0, Status.State.FAILURE_VERSION_DOWNGRADE)); + } catch (IOException e) { + Log.w(TAG, e); + EventBus.getDefault().post(new Status(0, Status.State.FAILURE_UNKNOWN)); + } finally { + EventBus.getDefault().unregister(this); + } + + long end = System.currentTimeMillis(); + Log.i(TAG, "Receive took: " + (end - start)); + } + + @Subscribe(threadMode = ThreadMode.POSTING) + public void onEvent(FullBackupBase.BackupEvent event) { + if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { + EventBus.getDefault().post(new Status(event.getCount(), Status.State.IN_PROGRESS)); + } else if (event.getType() == FullBackupBase.BackupEvent.Type.FINISHED) { + EventBus.getDefault().post(new Status(event.getCount(), Status.State.SUCCESS)); + } + } + + public static final class Status { + private final long messageCount; + private final State state; + + public Status(long messageCount, State state) { + this.messageCount = messageCount; + this.state = state; + } + + public long getMessageCount() { + return messageCount; + } + + public @NonNull State getState() { + return state; + } + + public enum State { + IN_PROGRESS, + SUCCESS, + FAILURE_VERSION_DOWNGRADE, + FAILURE_UNKNOWN + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java new file mode 100644 index 000000000..674c72c88 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.devicetransfer.newdevice; + +import android.os.Bundle; +import android.view.View; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.navigation.fragment.NavHostFragment; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; + +/** + * Shown after the new device successfully completes receiving a backup from the old device. + */ +public final class NewDeviceTransferCompleteFragment extends LoggingFragment { + public NewDeviceTransferCompleteFragment() { + super(R.layout.new_device_transfer_complete_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + view.findViewById(R.id.new_device_transfer_complete_fragment_continue_registration) + .setOnClickListener(v -> NavHostFragment.findNavController(this) + .navigate(R.id.action_newDeviceTransferComplete_to_enterPhoneNumberFragment)); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.java new file mode 100644 index 000000000..3a8ebc13b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.devicetransfer.newdevice; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.navigation.fragment.NavHostFragment; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.devicetransfer.DeviceToDeviceTransferService; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.devicetransfer.DeviceTransferFragment; + +/** + * Shows transfer progress on the new device. Most logic is in {@link DeviceTransferFragment} + * and it delegates to this class for strings, navigation, and updating progress. + */ +public final class NewDeviceTransferFragment extends DeviceTransferFragment { + + private final ServerTaskListener serverTaskListener = new ServerTaskListener(); + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + EventBus.getDefault().register(serverTaskListener); + } + + @Override + public void onDestroyView() { + EventBus.getDefault().unregister(serverTaskListener); + super.onDestroyView(); + } + + @Override + protected void navigateToRestartTransfer() { + NavHostFragment.findNavController(this).navigate(R.id.action_newDeviceTransfer_to_newDeviceTransferInstructions); + } + + @Override + protected void navigateAwayFromTransfer() { + EventBus.getDefault().unregister(serverTaskListener); + NavHostFragment.findNavController(this) + .navigate(R.id.action_restart_to_welcomeFragment); + } + + private class ServerTaskListener { + @Subscribe(threadMode = ThreadMode.MAIN) + public void onEventMainThread(@NonNull NewDeviceServerTask.Status event) { + status.setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount())); + switch (event.getState()) { + case IN_PROGRESS: + break; + case SUCCESS: + DeviceToDeviceTransferService.stop(requireContext()); + NavHostFragment.findNavController(NewDeviceTransferFragment.this).navigate(R.id.action_newDeviceTransfer_to_newDeviceTransferComplete); + break; + case FAILURE_VERSION_DOWNGRADE: + abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal); + break; + case FAILURE_UNKNOWN: + abort(); + break; + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.java new file mode 100644 index 000000000..6fc34bcc4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.devicetransfer.newdevice; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.navigation.Navigation; + +import org.greenrobot.eventbus.EventBus; +import org.signal.devicetransfer.TransferStatus; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; + +/** + * Shows instructions for new device to being transfer. + */ +public final class NewDeviceTransferInstructionsFragment extends LoggingFragment { + public NewDeviceTransferInstructionsFragment() { + super(R.layout.new_device_transfer_instructions_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + view.findViewById(R.id.new_device_transfer_instructions_fragment_continue) + .setOnClickListener(v -> Navigation.findNavController(v).navigate(R.id.action_device_transfer_setup)); + } + + @Override + public void onResume() { + super.onResume(); + EventBus.getDefault().removeStickyEvent(TransferStatus.class); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java new file mode 100644 index 000000000..519efc911 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.devicetransfer.newdevice; + +import android.app.PendingIntent; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.navigation.fragment.NavHostFragment; + +import org.signal.devicetransfer.DeviceToDeviceTransferService; +import org.signal.devicetransfer.DeviceToDeviceTransferService.TransferNotificationData; +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.devicetransfer.SetupStep; +import org.thoughtcrime.securesms.devicetransfer.DeviceTransferSetupFragment; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.notifications.NotificationIds; + +/** + * Most responsibility is in {@link DeviceTransferSetupFragment} and delegates here + * for strings and behavior relevant to setting up device transfer for the new device. + * + * Also responsible for setting up {@link DeviceToDeviceTransferService}. + */ +public final class NewDeviceTransferSetupFragment extends DeviceTransferSetupFragment { + + @Override + protected void navigateAwayFromTransfer() { + NavHostFragment.findNavController(this) + .navigate(R.id.action_deviceTransferSetup_to_transferOrRestore); + } + + @Override + protected void navigateToTransferConnected() { + NavHostFragment.findNavController(this).navigate(R.id.action_new_device_transfer); + } + + @Override + protected @StringRes int getErrorTextForStep(@NonNull SetupStep step) { + switch (step) { + case PERMISSIONS_DENIED: + return R.string.NewDeviceTransferSetup__signal_needs_the_location_permission_to_discover_and_connect_with_your_old_device; + case LOCATION_DISABLED: + return R.string.NewDeviceTransferSetup__signal_needs_location_services_enabled_to_discover_and_connect_with_your_old_device; + case WIFI_DISABLED: + return R.string.NewDeviceTransferSetup__signal_needs_wifi_on_to_discover_and_connect_with_your_old_device; + case WIFI_DIRECT_UNAVAILABLE: + return R.string.NewDeviceTransferSetup__sorry_it_appears_your_device_does_not_support_wifi_direct; + case ERROR: + return R.string.NewDeviceTransferSetup__an_unexpected_error_occurred_while_attempting_to_connect_to_your_old_device; + } + throw new AssertionError("No error text for step: " + step); + } + + @Override + protected @StringRes int getErrorResolveButtonTextForStep(@NonNull SetupStep step) { + if (step == SetupStep.WIFI_DIRECT_UNAVAILABLE) { + return R.string.NewDeviceTransferSetup__restore_a_backup; + } + throw new AssertionError("No error resolve button text for step: " + step); + } + + @Override + protected @StringRes int getStatusTextForStep(@NonNull SetupStep step, boolean takingTooLongInStep) { + switch (step) { + case SETTING_UP: + return takingTooLongInStep ? R.string.NewDeviceTransferSetup__take_a_moment_should_be_ready_soon + : R.string.NewDeviceTransferSetup__preparing_to_connect_to_old_android_device; + case WAITING: + return R.string.NewDeviceTransferSetup__waiting_for_old_device_to_connect; + case CONNECTING: + return R.string.NewDeviceTransferSetup__connecting_to_old_android_device; + case ERROR: + return R.string.NewDeviceTransferSetup__an_unexpected_error_occurred_while_attempting_to_connect_to_your_old_device; + case TROUBLESHOOTING: + return R.string.DeviceTransferSetup__unable_to_discover_old_device; + } + throw new AssertionError("No status text for step: " + step); + } + + @Override + protected void navigateWhenWifiDirectUnavailable() { + NavHostFragment.findNavController(this) + .navigate(R.id.action_deviceTransferSetup_to_transferOrRestore); + } + + @Override + protected void startTransfer() { + PendingIntent pendingIntent = PendingIntent.getActivity(requireContext(), 0, MainActivity.clearTop(requireContext()), 0); + + TransferNotificationData notificationData = new TransferNotificationData(NotificationIds.DEVICE_TRANSFER, NotificationChannels.BACKUPS, R.drawable.ic_signal_backup); + DeviceToDeviceTransferService.startServer(requireContext(), new NewDeviceServerTask(), notificationData, pendingIntent); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java new file mode 100644 index 000000000..13e1ade30 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.devicetransfer.newdevice; + +import android.os.Bundle; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.SpanUtil; + +/** + * Simple jumping off menu to starts a device-to-device transfer or restore a backup. + */ +public final class TransferOrRestoreFragment extends LoggingFragment { + + public TransferOrRestoreFragment() { + super(R.layout.fragment_transfer_restore); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + view.findViewById(R.id.transfer_or_restore_fragment_transfer) + .setOnClickListener(v -> Navigation.findNavController(v).navigate(R.id.action_new_device_transfer_instructions)); + + view.findViewById(R.id.transfer_or_restore_fragment_restore) + .setOnClickListener(v -> Navigation.findNavController(v).navigate(R.id.action_choose_backup)); + + String description = getString(R.string.TransferOrRestoreFragment__transfer_your_account_and_message_history_from_your_old_android_device); + String toBold = getString(R.string.TransferOrRestoreFragment__you_must_have_access_to_your_old_device); + + TextView transferDescriptionView = view.findViewById(R.id.transfer_or_restore_fragment_transfer_description); + transferDescriptionView.setText(SpanUtil.boldSubstring(description, toBold)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java new file mode 100644 index 000000000..4c379f1b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.devicetransfer.olddevice; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.logging.Log; +import org.signal.devicetransfer.ClientTask; +import org.thoughtcrime.securesms.backup.FullBackupBase; +import org.thoughtcrime.securesms.backup.FullBackupExporter; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * Create the backup stream of the old device and sends it over the wire via the output stream. + * Used in conjunction with {@link org.signal.devicetransfer.DeviceToDeviceTransferService}. + */ +final class OldDeviceClientTask implements ClientTask { + + private static final String TAG = Log.tag(OldDeviceClientTask.class); + + private static final long PROGRESS_UPDATE_THROTTLE = 250; + + private long lastProgressUpdate = 0; + + @Override + public void run(@NonNull Context context, @NonNull OutputStream outputStream) throws IOException { + DeviceTransferBlockingInterceptor.getInstance().blockNetwork(); + + long start = System.currentTimeMillis(); + + EventBus.getDefault().register(this); + try { + FullBackupExporter.transfer(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + DatabaseFactory.getBackupDatabase(context), + outputStream, + "deadbeef"); + } catch (Exception e) { + DeviceTransferBlockingInterceptor.getInstance().unblockNetwork(); + throw e; + } finally { + EventBus.getDefault().unregister(this); + } + + long end = System.currentTimeMillis(); + Log.i(TAG, "Sending took: " + (end - start)); + } + + @Subscribe(threadMode = ThreadMode.POSTING) + public void onEvent(FullBackupBase.BackupEvent event) { + if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { + if (System.currentTimeMillis() > lastProgressUpdate + PROGRESS_UPDATE_THROTTLE) { + EventBus.getDefault().post(new Status(event.getCount(), false)); + lastProgressUpdate = System.currentTimeMillis(); + } + } + } + + @Override + public void success() { + EventBus.getDefault().post(new Status(0, true)); + } + + public static final class Status { + private final long messages; + private final boolean done; + + public Status(long messages, boolean done) { + this.messages = messages; + this.done = done; + } + + public long getMessageCount() { + return messages; + } + + public boolean isDone() { + return done; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceExitActivity.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceExitActivity.java new file mode 100644 index 000000000..67e825b0b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceExitActivity.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.devicetransfer.olddevice; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.appcompat.app.AppCompatActivity; + +public class OldDeviceExitActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + finish(); + } + + public static void exit(Context context) { + Intent intent = new Intent(context, OldDeviceExitActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); + context.startActivity(intent); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferActivity.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferActivity.java new file mode 100644 index 000000000..ab6ffa2f1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferActivity.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.devicetransfer.olddevice; + +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.navigation.NavController; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +/** + * Shell of an activity to hold the old device navigation graph. See the various + * fragments in this package for actual implementation. + */ +public final class OldDeviceTransferActivity extends PassphraseRequiredActivity { + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState, boolean ready) { + dynamicTheme.onCreate(this); + + setContentView(R.layout.old_device_transfer_activity); + + NavController controller = Navigation.findNavController(this, R.id.nav_host_fragment); + controller.setGraph(R.navigation.old_device_transfer); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferCompleteFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferCompleteFragment.java new file mode 100644 index 000000000..42b61ce76 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferCompleteFragment.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.devicetransfer.olddevice; + +import android.os.Bundle; +import android.view.View; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; + +/** + * Shown after the old device successfully completes sending a backup to the new device. + */ +public final class OldDeviceTransferCompleteFragment extends LoggingFragment { + public OldDeviceTransferCompleteFragment() { + super(R.layout.old_device_transfer_complete_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + view.findViewById(R.id.old_device_transfer_complete_fragment_close) + .setOnClickListener(v -> close()); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + close(); + } + }); + } + + private void close() { + OldDeviceExitActivity.exit(requireContext()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java new file mode 100644 index 000000000..d91083b2c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.devicetransfer.olddevice; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.navigation.fragment.NavHostFragment; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.devicetransfer.DeviceToDeviceTransferService; +import org.signal.devicetransfer.TransferStatus; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.devicetransfer.DeviceTransferFragment; + +/** + * Shows transfer progress on the old device. Most logic is in {@link DeviceTransferFragment} + * and it delegates to this class for strings, navigation, and updating progress. + */ +public final class OldDeviceTransferFragment extends DeviceTransferFragment { + + private final ClientTaskListener clientTaskListener = new ClientTaskListener(); + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + EventBus.getDefault().register(clientTaskListener); + } + + @Override + public void onDestroyView() { + EventBus.getDefault().unregister(clientTaskListener); + super.onDestroyView(); + } + + @Override + protected void navigateToRestartTransfer() { + NavHostFragment.findNavController(this).navigate(R.id.action_directly_to_oldDeviceTransferInstructions); + } + + @Override + protected void navigateAwayFromTransfer() { + EventBus.getDefault().unregister(clientTaskListener); + requireActivity().finish(); + } + + private class ClientTaskListener { + @Subscribe(threadMode = ThreadMode.MAIN) + public void onEventMainThread(@NonNull OldDeviceClientTask.Status event) { + if (event.isDone()) { + ignoreTransferStatusEvents(); + EventBus.getDefault().removeStickyEvent(TransferStatus.class); + DeviceToDeviceTransferService.stop(requireContext()); + NavHostFragment.findNavController(OldDeviceTransferFragment.this).navigate(R.id.action_oldDeviceTransfer_to_oldDeviceTransferComplete); + } else { + status.setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount())); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferInstructionsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferInstructionsFragment.java new file mode 100644 index 000000000..449796880 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferInstructionsFragment.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.devicetransfer.olddevice; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.navigation.Navigation; +import androidx.navigation.fragment.NavHostFragment; + +import org.greenrobot.eventbus.EventBus; +import org.signal.devicetransfer.DeviceToDeviceTransferService; +import org.signal.devicetransfer.TransferStatus; +import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.R; + +/** + * Provides instructions for the old device on how to start a device-to-device transfer. + */ +public final class OldDeviceTransferInstructionsFragment extends LoggingFragment { + + public OldDeviceTransferInstructionsFragment() { + super(R.layout.old_device_transfer_instructions_fragment); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Toolbar toolbar = view.findViewById(R.id.old_device_transfer_instructions_fragment_toolbar); + toolbar.setNavigationOnClickListener(v -> { + if (!Navigation.findNavController(v).popBackStack()) { + requireActivity().finish(); + } + }); + + view.findViewById(R.id.old_device_transfer_instructions_fragment_continue) + .setOnClickListener(v -> Navigation.findNavController(v) + .navigate(R.id.action_oldDeviceTransferInstructions_to_oldDeviceTransferSetup)); + } + + @Override + public void onResume() { + super.onResume(); + if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null) { + NavHostFragment.findNavController(this) + .navigate(R.id.action_oldDeviceTransferInstructions_to_oldDeviceTransferSetup); + } else { + DeviceToDeviceTransferService.stop(requireContext()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferSetupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferSetupFragment.java new file mode 100644 index 000000000..277e5191e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferSetupFragment.java @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.devicetransfer.olddevice; + +import android.app.PendingIntent; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.navigation.fragment.NavHostFragment; + +import org.signal.devicetransfer.DeviceToDeviceTransferService; +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.devicetransfer.DeviceTransferSetupFragment; +import org.thoughtcrime.securesms.devicetransfer.SetupStep; +import org.thoughtcrime.securesms.jobs.LocalBackupJob; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.notifications.NotificationIds; + +/** + * Most responsibility is in {@link DeviceTransferSetupFragment} and delegates here + * for strings and behavior relevant to setting up device transfer for the old device. + * + * Also responsible for setting up {@link DeviceToDeviceTransferService}. + */ +public final class OldDeviceTransferSetupFragment extends DeviceTransferSetupFragment { + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + ApplicationDependencies.getJobManager().cancelAllInQueue(LocalBackupJob.QUEUE); + } + + @Override + protected void navigateAwayFromTransfer() { + NavHostFragment.findNavController(this).popBackStack(); + } + + @Override + protected void navigateToTransferConnected() { + NavHostFragment.findNavController(this).navigate(R.id.action_oldDeviceTransferSetup_to_oldDeviceTransfer); + } + + @Override + protected void navigateWhenWifiDirectUnavailable() { + Intent intent = new Intent(requireContext(), ApplicationPreferencesActivity.class); + intent.putExtra(ApplicationPreferencesActivity.LAUNCH_TO_BACKUPS_FRAGMENT, true); + startActivity(intent); + requireActivity().finish(); + } + + @Override + protected void startTransfer() { + Intent intent = new Intent(requireContext(), OldDeviceTransferActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent pendingIntent = PendingIntent.getActivity(requireContext(), 0, intent, 0); + + DeviceToDeviceTransferService.TransferNotificationData notificationData = new DeviceToDeviceTransferService.TransferNotificationData(NotificationIds.DEVICE_TRANSFER, NotificationChannels.BACKUPS, R.drawable.ic_signal_backup); + DeviceToDeviceTransferService.startClient(requireContext(), new OldDeviceClientTask(), notificationData, pendingIntent); + } + + @Override + protected @StringRes int getErrorTextForStep(@NonNull SetupStep step) { + switch (step) { + case PERMISSIONS_DENIED: + return R.string.OldDeviceTransferSetup__signal_needs_the_location_permission_to_discover_and_connect_with_your_new_device; + case LOCATION_DISABLED: + return R.string.OldDeviceTransferSetup__signal_needs_location_services_enabled_to_discover_and_connect_with_your_new_device; + case WIFI_DISABLED: + return R.string.OldDeviceTransferSetup__signal_needs_wifi_on_to_discover_and_connect_with_your_new_device; + case WIFI_DIRECT_UNAVAILABLE: + return R.string.OldDeviceTransferSetup__sorry_it_appears_your_device_does_not_support_wifi_direct; + case ERROR: + return R.string.OldDeviceTransferSetup__an_unexpected_error_occurred_while_attempting_to_connect_to_your_old_device; + } + throw new AssertionError("No error text for step: " + step); + } + + @Override + protected @StringRes int getErrorResolveButtonTextForStep(@NonNull SetupStep step) { + if (step == SetupStep.WIFI_DIRECT_UNAVAILABLE) { + return R.string.OldDeviceTransferSetup__create_a_backup; + } + throw new AssertionError("No error resolve button text for step: " + step); + } + + @Override + protected @StringRes int getStatusTextForStep(@NonNull SetupStep step, boolean takingTooLongInStep) { + switch (step) { + case SETTING_UP: + case WAITING: + return R.string.OldDeviceTransferSetup__searching_for_your_new_android_device; + case CONNECTING: + return R.string.OldDeviceTransferSetup__connecting_to_new_android_device; + case ERROR: + return R.string.OldDeviceTransferSetup__an_unexpected_error_occurred_while_attempting_to_connect_to_your_old_device; + case TROUBLESHOOTING: + return R.string.DeviceTransferSetup__unable_to_discover_new_device; + } + throw new AssertionError("No status text for step: " + step); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java index 53542a12f..445f5082a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java @@ -37,7 +37,7 @@ public final class LocalBackupJob extends BaseJob { private static final String TAG = Log.tag(LocalBackupJob.class); - private static final String QUEUE = "__LOCAL_BACKUP__"; + public static final String QUEUE = "__LOCAL_BACKUP__"; public static final String TEMP_BACKUP_FILE_PREFIX = ".backup"; public static final String TEMP_BACKUP_FILE_SUFFIX = ".tmp"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/DeviceTransferBlockingInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/net/DeviceTransferBlockingInterceptor.java new file mode 100644 index 000000000..e5d79ad8c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/net/DeviceTransferBlockingInterceptor.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.net; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.Protocol; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * Provide a way to block network access while performing a device transfer. + */ +public final class DeviceTransferBlockingInterceptor implements Interceptor { + + private static final String TAG = Log.tag(DeviceTransferBlockingInterceptor.class); + + private static final DeviceTransferBlockingInterceptor INSTANCE = new DeviceTransferBlockingInterceptor(); + + private volatile boolean blockNetworking = false; + + public static DeviceTransferBlockingInterceptor getInstance() { + return INSTANCE; + } + + @Override + public @NonNull Response intercept(@NonNull Chain chain) throws IOException { + if (!blockNetworking) { + return chain.proceed(chain.request()); + } + + Log.w(TAG, "Preventing request because in transfer mode."); + return new Response.Builder().request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .receivedResponseAtMillis(System.currentTimeMillis()) + .message("") + .body(ResponseBody.create(null, "")) + .code(500) + .build(); + } + + public void blockNetwork() { + blockNetworking = true; + ApplicationDependencies.closeConnections(); + } + + public void unblockNetwork() { + blockNetworking = false; + ApplicationDependencies.getIncomingMessageObserver(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/PipeConnectivityListener.java b/app/src/main/java/org/thoughtcrime/securesms/net/PipeConnectivityListener.java index 628f878e2..dc837f300 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/PipeConnectivityListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/net/PipeConnectivityListener.java @@ -68,7 +68,7 @@ public class PipeConnectivityListener implements ConnectivityListener { if (SignalStore.proxy().isProxyEnabled()) { Log.w(TAG, "Encountered an error while we had a proxy set! Terminating the connection to prevent retry spam."); - ApplicationDependencies.closeConnectionsAfterProxyFailure(); + ApplicationDependencies.closeConnections(); return false; } else { return true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java index cafa86084..7aa5a89ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java @@ -10,6 +10,7 @@ public final class NotificationIds { public static final int PRE_REGISTRATION_SMS = 5050; public static final int THREAD = 50000; public static final int USER_NOTIFICATION_MIGRATION = 525600; + public static final int DEVICE_TRANSFER = 625420; private NotificationIds() { } diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java index 05b8ec884..acaee81df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java @@ -61,8 +61,9 @@ public class Permissions { private Consumer> someDeniedListener; private Consumer> somePermanentlyDeniedListener; - private @DrawableRes int[] rationalDialogHeader; - private String rationaleDialogMessage; + private @DrawableRes int[] rationalDialogHeader; + private String rationaleDialogMessage; + private boolean rationaleDialogCancelable; private boolean ifNecesary; @@ -89,8 +90,13 @@ public class Permissions { } public PermissionsBuilder withRationaleDialog(@NonNull String message, @NonNull @DrawableRes int... headers) { - this.rationalDialogHeader = headers; - this.rationaleDialogMessage = message; + return withRationaleDialog(message, true, headers); + } + + public PermissionsBuilder withRationaleDialog(@NonNull String message, boolean cancelable, @NonNull @DrawableRes int... headers) { + this.rationalDialogHeader = headers; + this.rationaleDialogMessage = message; + this.rationaleDialogCancelable = cancelable; return this; } @@ -159,6 +165,7 @@ public class Permissions { RationaleDialog.createFor(permissionObject.getContext(), rationaleDialogMessage, rationalDialogHeader) .setPositiveButton(R.string.Permissions_continue, (dialog, which) -> executePermissionsRequest(request)) .setNegativeButton(R.string.Permissions_not_now, (dialog, which) -> executeNoPermissionsRequest(request)) + .setCancelable(rationaleDialogCancelable) .show() .getWindow() .setLayout((int)(permissionObject.getWindowWidth() * .75), ViewGroup.LayoutParams.WRAP_CONTENT); @@ -248,7 +255,7 @@ public class Permissions { resultListener.onResult(permissions, grantResults, shouldShowRationaleDialog); } - private static Intent getApplicationSettingsIntent(@NonNull Context context) { + public static Intent getApplicationSettingsIntent(@NonNull Context context) { Intent intent = new Intent(); intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); Uri uri = Uri.fromParts("package", context.getPackageName(), null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java index a51a662b9..e39162648 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChatsPreferenceFragment.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.preferences; import android.content.Context; +import android.content.Intent; import android.os.Bundle; import androidx.annotation.NonNull; @@ -10,6 +11,7 @@ import androidx.preference.ListPreference; import org.greenrobot.eventbus.EventBus; import org.thoughtcrime.securesms.ApplicationPreferencesActivity; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.storage.StorageSyncHelper; @@ -34,6 +36,11 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { return true; }); + findPreference(TextSecurePreferences.TRANSFER).setOnPreferenceClickListener(unused -> { + goToTransferAccount(); + return true; + }); + findPreference(PREFER_SYSTEM_CONTACT_PHOTOS) .setOnPreferenceChangeListener((preference, newValue) -> { SignalStore.settings().setPreferSystemContactPhotos(newValue == Boolean.TRUE); @@ -71,6 +78,10 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment { ((ApplicationPreferencesActivity) requireActivity()).pushFragment(new BackupsPreferenceFragment()); } + private void goToTransferAccount() { + requireContext().startActivity(new Intent(requireContext(), OldDeviceTransferActivity.class)); + } + public static CharSequence getSummary(Context context) { return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java b/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java index b7a6db3e9..61d2ae2e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java +++ b/app/src/main/java/org/thoughtcrime/securesms/push/SignalServiceNetworkAccess.java @@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.net.CustomDns; import org.thoughtcrime.securesms.net.DeprecatedClientPreventionInterceptor; +import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor; import org.thoughtcrime.securesms.net.RemoteDeprecationDetectorInterceptor; import org.thoughtcrime.securesms.net.SequentialDns; import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor; @@ -180,7 +181,10 @@ public class SignalServiceNetworkAccess { final String[] fastUrls = {"https://cdn.sstatic.net", "https://github.githubassets.com", "https://pinterest.com", "https://open.scdn.co", "https://www.redditstatic.com"}; - final List interceptors = Arrays.asList(new StandardUserAgentInterceptor(), new RemoteDeprecationDetectorInterceptor(), new DeprecatedClientPreventionInterceptor()); + final List interceptors = Arrays.asList(new StandardUserAgentInterceptor(), + new RemoteDeprecationDetectorInterceptor(), + new DeprecatedClientPreventionInterceptor(), + DeviceTransferBlockingInterceptor.getInstance()); final Optional dns = Optional.of(DNS); final byte[] zkGroupServerPublicParams; diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java index 0f915a287..e7c8dbcb8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java @@ -42,17 +42,12 @@ public class ChooseBackupFragment extends BaseRegistrationFragment { @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - if (BackupUtil.isUserSelectionRequired(requireContext())) { - chooseBackupButton = view.findViewById(R.id.choose_backup_fragment_button); - chooseBackupButton.setOnClickListener(this::onChooseBackupSelected); + chooseBackupButton = view.findViewById(R.id.choose_backup_fragment_button); + chooseBackupButton.setOnClickListener(this::onChooseBackupSelected); - learnMore = view.findViewById(R.id.choose_backup_fragment_learn_more); - learnMore.setText(HtmlCompat.fromHtml(String.format("%s", getString(R.string.backup_support_url), getString(R.string.ChooseBackupFragment__learn_more)), 0)); - learnMore.setMovementMethod(LinkMovementMethod.getInstance()); - } else { - Log.i(TAG, "User Selection is not required. Skipping."); - Navigation.findNavController(requireView()).navigate(ChooseBackupFragmentDirections.actionSkip()); - } + learnMore = view.findViewById(R.id.choose_backup_fragment_learn_more); + learnMore.setText(HtmlCompat.fromHtml(String.format("%s", getString(R.string.backup_support_url), getString(R.string.ChooseBackupFragment__learn_more)), 0)); + learnMore.setMovementMethod(LinkMovementMethod.getInstance()); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java index 8b8ca00ba..e922c9f88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/WelcomeFragment.java @@ -9,6 +9,7 @@ import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; import android.widget.TextView; import androidx.annotation.NonNull; @@ -19,12 +20,16 @@ import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentActivity; import androidx.navigation.ActivityNavigator; import androidx.navigation.Navigation; +import androidx.navigation.fragment.NavHostFragment; import com.dd.CircularProgressButton; import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber; +import org.greenrobot.eventbus.EventBus; import org.signal.core.util.logging.Log; +import org.signal.devicetransfer.DeviceToDeviceTransferService; +import org.signal.devicetransfer.TransferStatus; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel; @@ -61,7 +66,7 @@ public final class WelcomeFragment extends BaseRegistrationFragment { private static final int[] HEADERS_API_29 = { R.drawable.ic_contacts_white_48dp }; private CircularProgressButton continueButton; - private View restoreFromBackup; + private Button restoreFromBackup; @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, @@ -104,15 +109,14 @@ public final class WelcomeFragment extends BaseRegistrationFragment { continueButton = view.findViewById(R.id.welcome_continue_button); continueButton.setOnClickListener(this::continueClicked); - restoreFromBackup = view.findViewById(R.id.welcome_restore_backup); + restoreFromBackup = view.findViewById(R.id.welcome_transfer_or_restore); restoreFromBackup.setOnClickListener(this::restoreFromBackupClicked); TextView welcomeTermsButton = view.findViewById(R.id.welcome_terms_button); welcomeTermsButton.setOnClickListener(v -> onTermsClicked()); - if (canUserSelectBackup()) { - restoreFromBackup.setVisibility(View.VISIBLE); - welcomeTermsButton.setTextColor(ContextCompat.getColor(requireActivity(), R.color.core_grey_60)); + if (!canUserSelectBackup()) { + restoreFromBackup.setText(R.string.registration_activity__transfer_account); } } } @@ -122,6 +126,17 @@ public final class WelcomeFragment extends BaseRegistrationFragment { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); } + @Override + public void onResume() { + super.onResume(); + if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null) { + Log.i(TAG, "Found existing transferStatus, redirect to transfer flow"); + NavHostFragment.findNavController(this).navigate(R.id.action_welcomeFragment_to_deviceTransferSetup); + } else { + DeviceToDeviceTransferService.stop(requireContext()); + } + } + private void continueClicked(@NonNull View view) { boolean isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext()); @@ -177,7 +192,7 @@ public final class WelcomeFragment extends BaseRegistrationFragment { initializeNumber(); Navigation.findNavController(view) - .navigate(WelcomeFragmentDirections.actionChooseBackup()); + .navigate(WelcomeFragmentDirections.actionTransferOrRestore()); } @SuppressLint("MissingPermission") diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.java index 4ab213303..a39f1dae3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SignalProxyUtil.java @@ -37,7 +37,7 @@ public final class SignalProxyUtil { public static void startListeningToWebsocket() { if (SignalStore.proxy().isProxyEnabled() && ApplicationDependencies.getPipeListener().getState().getValue() == PipeConnectivityListener.State.FAILURE) { Log.w(TAG, "Proxy is in a failed state. Restarting."); - ApplicationDependencies.closeConnectionsAfterProxyFailure(); + ApplicationDependencies.closeConnections(); } ApplicationDependencies.getIncomingMessageObserver(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java index ec5cd2f08..4581cc211 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java @@ -1,19 +1,28 @@ package org.thoughtcrime.securesms.util; +import android.content.Context; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Spannable; import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.TextUtils; import android.text.style.AbsoluteSizeSpan; import android.text.style.BulletSpan; +import android.text.style.ClickableSpan; import android.text.style.DynamicDrawableSpan; import android.text.style.ForegroundColorSpan; import android.text.style.ImageSpan; import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; +import android.view.View; import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.thoughtcrime.securesms.R; public class SpanUtil { @@ -45,6 +54,17 @@ public class SpanUtil { return spannable; } + public static CharSequence boldSubstring(CharSequence fullString, CharSequence substring) { + SpannableString spannable = new SpannableString(fullString); + int start = TextUtils.indexOf(fullString, substring); + int end = start + substring.length(); + + if (start >= 0 && end <= fullString.length()) { + spannable.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + return spannable; + } + public static CharSequence color(int color, CharSequence sequence) { SpannableString spannable = new SpannableString(sequence); spannable.setSpan(new ForegroundColorSpan(color), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -52,8 +72,12 @@ public class SpanUtil { } public static @NonNull CharSequence bullet(@NonNull CharSequence sequence) { + return bullet(sequence, BulletSpan.STANDARD_GAP_WIDTH); + } + + public static @NonNull CharSequence bullet(@NonNull CharSequence sequence, int gapWidth) { SpannableString spannable = new SpannableString(sequence); - spannable.setSpan(new BulletSpan(), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan(new BulletSpan(gapWidth), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); return spannable; } @@ -66,4 +90,30 @@ public class SpanUtil { return imageSpan; } + + public static CharSequence clickSubstring(@NonNull Context context, @NonNull CharSequence fullString, @NonNull CharSequence substring, @NonNull View.OnClickListener clickListener) { + ClickableSpan clickable = new ClickableSpan() { + @Override + public void updateDrawState(@NonNull TextPaint ds) { + super.updateDrawState(ds); + ds.setUnderlineText(false); + ds.setColor(ContextCompat.getColor(context, R.color.signal_accent_primary)); + } + + @Override + public void onClick(@NonNull View widget) { + clickListener.onClick(widget); + } + }; + + SpannableString spannable = new SpannableString(fullString); + int start = TextUtils.indexOf(fullString, substring); + int end = start + substring.length(); + + if (start >= 0 && end <= fullString.length()) { + spannable.setSpan(clickable, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + return spannable; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java index c763cce08..70e3e24d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -145,6 +145,8 @@ public class TextSecurePreferences { private static final String ENCRYPTED_BACKUP_PASSPHRASE = "pref_encrypted_backup_passphrase"; private static final String BACKUP_TIME = "pref_backup_next_time"; + public static final String TRANSFER = "pref_transfer"; + public static final String SCREEN_LOCK = "pref_android_screen_lock"; public static final String SCREEN_LOCK_TIMEOUT = "pref_android_screen_lock_timeout"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java index d19453ce0..bf76b0615 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java @@ -8,6 +8,7 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MediatorLiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; +import androidx.lifecycle.Transformations; import com.annimon.stream.function.Predicate; @@ -76,6 +77,13 @@ public final class LiveDataUtil { return outputLiveData; } + /** + * Performs a map operation on the source observable and then only emits the mapped item if it has changed since the previous emission. + */ + public static LiveData mapDistinct(@NonNull LiveData source, @NonNull androidx.arch.core.util.Function mapFunction) { + return Transformations.distinctUntilChanged(Transformations.map(source, mapFunction)); + } + /** * Once there is non-null data on both input {@link LiveData}, the {@link Combine} function is run * and produces a live data of the combined data. diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/Store.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/Store.java new file mode 100644 index 000000000..885609c87 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/Store.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.util.livedata; + +import androidx.annotation.AnyThread; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; + +import com.annimon.stream.function.Function; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.concurrent.SerialExecutor; + +import java.util.concurrent.Executor; + +/** + * Manages a state to be updated from a view model and provide direct and live access. Updates + * occur serially on the same executor to allow updating in a thread safe way. While not + * every state update is guaranteed to be emitted, no update action will be dropped and state + * that is emitted will be accurate. + */ +public class Store { + private final LiveDataStore liveStore; + + public Store(@NonNull State state) { + this.liveStore = new LiveDataStore(state); + } + + public @NonNull LiveData getStateLiveData() { + return liveStore; + } + + public @NonNull State getState() { + return liveStore.getState(); + } + + @AnyThread + public void update(@NonNull Function updater) { + liveStore.update(updater); + } + + @MainThread + public void update(@NonNull LiveData source, @NonNull Action action) { + liveStore.update(source, action); + } + + private final class LiveDataStore extends MediatorLiveData { + private State state; + private final Executor stateUpdater; + + LiveDataStore(@NonNull State state) { + this.stateUpdater = new SerialExecutor(SignalExecutors.BOUNDED); + setState(state); + } + + synchronized @NonNull State getState() { + return state; + } + + private synchronized void setState(@NonNull State state) { + this.state = state; + postValue(this.state); + } + + void update(@NonNull LiveData source, @NonNull Action action) { + addSource(source, input -> stateUpdater.execute(() -> setState(action.apply(input, getState())))); + } + + void update(@NonNull Function updater) { + stateUpdater.execute(() -> setState(updater.apply(getState()))); + } + } + + public interface Action { + State apply(Input input, State current); + } +} diff --git a/app/src/main/res/drawable-night/ic_transfer_account.xml b/app/src/main/res/drawable-night/ic_transfer_account.xml new file mode 100644 index 000000000..5bd38c3b9 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_transfer_account.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/selectable_rounded_background.xml b/app/src/main/res/drawable-v21/selectable_rounded_background.xml new file mode 100644 index 000000000..74d80bb6a --- /dev/null +++ b/app/src/main/res/drawable-v21/selectable_rounded_background.xml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_transfer_account.xml b/app/src/main/res/drawable/ic_transfer_account.xml new file mode 100644 index 000000000..49cb68306 --- /dev/null +++ b/app/src/main/res/drawable/ic_transfer_account.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_transfer_phone_outline_60.xml b/app/src/main/res/drawable/ic_transfer_phone_outline_60.xml new file mode 100644 index 000000000..ebb5e6af4 --- /dev/null +++ b/app/src/main/res/drawable/ic_transfer_phone_outline_60.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/rounded_outline.xml b/app/src/main/res/drawable/rounded_outline.xml new file mode 100644 index 000000000..5bb879446 --- /dev/null +++ b/app/src/main/res/drawable/rounded_outline.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_outline_pressed.xml b/app/src/main/res/drawable/rounded_outline_pressed.xml new file mode 100644 index 000000000..fcaed71fc --- /dev/null +++ b/app/src/main/res/drawable/rounded_outline_pressed.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selectable_rounded_background.xml b/app/src/main/res/drawable/selectable_rounded_background.xml new file mode 100644 index 000000000..1e1de4ea8 --- /dev/null +++ b/app/src/main/res/drawable/selectable_rounded_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/device_transfer_fragment.xml b/app/src/main/res/layout/device_transfer_fragment.xml new file mode 100644 index 000000000..aa30817cb --- /dev/null +++ b/app/src/main/res/layout/device_transfer_fragment.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/device_transfer_setup_fragment.xml b/app/src/main/res/layout/device_transfer_setup_fragment.xml new file mode 100644 index 000000000..2f59813f5 --- /dev/null +++ b/app/src/main/res/layout/device_transfer_setup_fragment.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/device_transfer_setup_troubleshooting_layout.xml b/app/src/main/res/layout/device_transfer_setup_troubleshooting_layout.xml new file mode 100644 index 000000000..8093b277a --- /dev/null +++ b/app/src/main/res/layout/device_transfer_setup_troubleshooting_layout.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_registration_welcome.xml b/app/src/main/res/layout/fragment_registration_welcome.xml index fa6eb3c92..304e77d84 100644 --- a/app/src/main/res/layout/fragment_registration_welcome.xml +++ b/app/src/main/res/layout/fragment_registration_welcome.xml @@ -37,7 +37,7 @@ android:layout_marginBottom="24dp" android:text="@string/RegistrationActivity_terms_and_privacy" android:gravity="center" - android:textColor="@color/core_ultramarine" + android:textColor="@color/core_grey_60" app:layout_constraintBottom_toTopOf="@+id/welcome_continue_button" app:layout_constraintEnd_toEndOf="@+id/welcome_continue_button" app:layout_constraintStart_toStartOf="@+id/welcome_continue_button" /> @@ -51,27 +51,22 @@ android:layout_marginEnd="32dp" android:layout_marginBottom="17dp" app:cpb_textIdle="@string/RegistrationActivity_continue" - app:layout_constraintBottom_toTopOf="@id/welcome_restore_backup" + app:layout_constraintBottom_toTopOf="@id/welcome_transfer_or_restore" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_goneMarginBottom="@dimen/registration_button_bottom_margin" /> - + app:layout_constraintStart_toStartOf="@+id/welcome_continue_button" /> diff --git a/app/src/main/res/layout/fragment_transfer_restore.xml b/app/src/main/res/layout/fragment_transfer_restore.xml new file mode 100644 index 000000000..f5a944c9d --- /dev/null +++ b/app/src/main/res/layout/fragment_transfer_restore.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/new_device_transfer_complete_fragment.xml b/app/src/main/res/layout/new_device_transfer_complete_fragment.xml new file mode 100644 index 000000000..32c6bad78 --- /dev/null +++ b/app/src/main/res/layout/new_device_transfer_complete_fragment.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/new_device_transfer_instructions_fragment.xml b/app/src/main/res/layout/new_device_transfer_instructions_fragment.xml new file mode 100644 index 000000000..6212fd1c6 --- /dev/null +++ b/app/src/main/res/layout/new_device_transfer_instructions_fragment.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/old_device_transfer_activity.xml b/app/src/main/res/layout/old_device_transfer_activity.xml new file mode 100644 index 000000000..8270ef9ec --- /dev/null +++ b/app/src/main/res/layout/old_device_transfer_activity.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/old_device_transfer_complete_fragment.xml b/app/src/main/res/layout/old_device_transfer_complete_fragment.xml new file mode 100644 index 000000000..042ca0840 --- /dev/null +++ b/app/src/main/res/layout/old_device_transfer_complete_fragment.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/old_device_transfer_instructions_fragment.xml b/app/src/main/res/layout/old_device_transfer_instructions_fragment.xml new file mode 100644 index 000000000..4f31145ae --- /dev/null +++ b/app/src/main/res/layout/old_device_transfer_instructions_fragment.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/old_device_transfer_setup_fragment.xml b/app/src/main/res/layout/old_device_transfer_setup_fragment.xml new file mode 100644 index 000000000..68ce959c2 --- /dev/null +++ b/app/src/main/res/layout/old_device_transfer_setup_fragment.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/old_device_transfer.xml b/app/src/main/res/navigation/old_device_transfer.xml new file mode 100644 index 000000000..ac64e9449 --- /dev/null +++ b/app/src/main/res/navigation/old_device_transfer.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/registration.xml b/app/src/main/res/navigation/registration.xml index 4fffd87df..1a1e61827 100644 --- a/app/src/main/res/navigation/registration.xml +++ b/app/src/main/res/navigation/registration.xml @@ -28,8 +28,16 @@ app:popExitAnim="@anim/nav_default_pop_exit_anim" /> + + + tools:layout="@layout/account_locked_fragment" /> - + @@ -281,4 +290,118 @@ android:label="fragment_registration_edit_proxy" tools:layout="@layout/edit_proxy_fragment" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/raw/lottie_settings_android.json b/app/src/main/res/raw/lottie_settings_android.json new file mode 100644 index 000000000..71a739462 --- /dev/null +++ b/app/src/main/res/raw/lottie_settings_android.json @@ -0,0 +1 @@ +{"v":"5.6.5","fr":30,"ip":0,"op":60,"w":600,"h":600,"nm":"Android","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Tap 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":19,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":20,"s":[70]},{"t":38,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[147.867,133.475,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":20,"s":[70,70,100]},{"t":38,"s":[190,190,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-28.314],[28.314,0],[0,28.314],[-28.314,0]],"o":[[0,28.314],[-28.314,0],[0,-28.314],[28.314,0]],"v":[[51.266,0],[0,51.266],[-51.266,0],[0,-51.266]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.172549024224,0.419607847929,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":60,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Tap","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[80]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":19,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":25,"s":[100]},{"t":35,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[147.867,133.475,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":14,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":20,"s":[70,70,100]},{"t":38,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-28.314],[28.314,0],[0,28.314],[-28.314,0]],"o":[[0,28.314],[-28.314,0],[0,-28.314],[28.314,0]],"v":[[51.266,0],[0,51.266],[-51.266,0],[0,-51.266]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.172549024224,0.419607847929,0.929411768913,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":60,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Scrim","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[299.949,507.503,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100.406,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-246.526,-62.288],[246.526,-62.288],[246.526,62.288],[-246.526,62.288]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Scrim","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,1,1,0.5,1,1,1,1,1,1,1,0,0,0.5,0.5,1,1],"ix":9}},"s":{"a":0,"k":[0,-20],"ix":5},"e":{"a":0,"k":[0,48],"ix":6},"t":1,"nm":"Gradient Fill 1","mn":"ADBE Vector Graphic - G-Fill","hd":false}],"ip":0,"op":60,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"List","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300,300,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-8.369,-6.906],[0,0],[0,11.69],[20.82,0],[0,-20.795]],"o":[[0,0],[8.369,-6.906],[0,-20.795],[-20.82,0],[0,11.69]],"v":[[-174.599,267.791],[-126.61,267.791],[-112.906,238.749],[-150.604,201.098],[-188.302,238.749]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.102,2.797],[2.8,0],[0,0],[0,-2.795],[-2.8,0],[0,0]],"o":[[0,-2.795],[0,0],[-2.8,0],[0,2.797],[0,0],[2.7,0]],"v":[[29.785,250.634],[24.786,245.642],[-78.608,245.642],[-83.608,250.634],[-78.608,255.628],[24.886,255.628]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.1,2.795],[2.8,0],[0,0],[0,-2.797],[-2.8,0],[0,0]],"o":[[0,-2.797],[0,0],[-2.8,0],[0,2.795],[0,0],[2.7,0]],"v":[[103.883,227.365],[98.882,222.371],[-78.608,222.371],[-83.608,227.365],[-78.608,232.358],[98.982,232.358]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.102,2.797],[2.8,0],[0,0],[0,-2.795],[-2.8,0],[0,0]],"o":[[0,-2.795],[0,0],[-2.8,0],[0,2.797],[0,0],[2.7,0]],"v":[[29.785,145.467],[24.786,140.475],[-78.608,140.475],[-83.608,145.467],[-78.608,150.461],[24.886,150.461]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.1,2.797],[2.8,0],[0,0],[0,-2.795],[-2.8,0],[0,0]],"o":[[0,-2.795],[0,0],[-2.8,0],[0,2.797],[0,0],[2.7,0]],"v":[[103.883,122.196],[98.882,117.204],[-78.608,117.204],[-83.608,122.196],[-78.608,127.191],[98.982,127.191]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[-20.82,0],[0,20.795],[20.82,0],[0,-20.795]],"o":[[20.82,0],[0,-20.795],[-20.82,0],[0,20.795]],"v":[[-150.604,171.236],[-112.906,133.582],[-150.604,95.931],[-188.302,133.582]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ind":6,"ty":"sh","ix":7,"ks":{"a":0,"k":{"i":[[0.102,2.797],[2.8,0],[0,0],[0,-2.797],[-2.8,0],[0,0]],"o":[[0,-2.797],[0,0],[-2.8,0],[0,2.797],[0,0],[2.7,0]],"v":[[29.785,40.3],[24.786,35.305],[-78.608,35.305],[-83.608,40.3],[-78.608,45.294],[24.886,45.294]],"c":true},"ix":2},"nm":"Path 7","mn":"ADBE Vector Shape - Group","hd":false},{"ind":7,"ty":"sh","ix":8,"ks":{"a":0,"k":{"i":[[0.1,2.795],[2.8,0],[0,0],[0,-2.797],[-2.8,0],[0,0]],"o":[[0,-2.797],[0,0],[-2.8,0],[0,2.795],[0,0],[2.7,0]],"v":[[103.883,16.929],[98.882,11.935],[-78.608,11.935],[-83.608,16.929],[-78.608,21.924],[98.982,21.924]],"c":true},"ix":2},"nm":"Path 8","mn":"ADBE Vector Shape - Group","hd":false},{"ind":8,"ty":"sh","ix":9,"ks":{"a":0,"k":{"i":[[-20.82,0],[0,20.793],[20.82,0],[0,-20.795]],"o":[[20.82,0],[0,-20.795],[-20.82,0],[0,20.793]],"v":[[-150.604,65.967],[-112.906,28.316],[-150.604,-9.338],[-188.302,28.316]],"c":true},"ix":2},"nm":"Path 9","mn":"ADBE Vector Shape - Group","hd":false},{"ind":9,"ty":"sh","ix":10,"ks":{"a":0,"k":{"i":[[0.102,2.796],[2.8,0],[0,0],[0,-2.796],[-2.8,0],[0,0]],"o":[[0,-2.796],[0,0],[-2.8,0],[0,2.796],[0,0],[2.7,0]],"v":[[29.785,-64.968],[24.786,-69.961],[-78.608,-69.961],[-83.608,-64.968],[-78.608,-59.975],[24.886,-59.975]],"c":true},"ix":2},"nm":"Path 10","mn":"ADBE Vector Shape - Group","hd":false},{"ind":10,"ty":"sh","ix":11,"ks":{"a":0,"k":{"i":[[0.1,2.796],[2.8,0],[0,0],[0,-2.797],[-2.8,0],[0,0]],"o":[[0,-2.797],[0,0],[-2.8,0],[0,2.796],[0,0],[2.7,0]],"v":[[103.883,-88.238],[98.882,-93.232],[-78.608,-93.232],[-83.608,-88.238],[-78.608,-83.245],[98.982,-83.245]],"c":true},"ix":2},"nm":"Path 11","mn":"ADBE Vector Shape - Group","hd":false},{"ind":11,"ty":"sh","ix":12,"ks":{"a":0,"k":{"i":[[-12.169,0],[0,12.154],[12.169,0],[0,-12.154]],"o":[[12.169,0],[0,-12.154],[-12.169,0],[0,12.154]],"v":[[-151.604,-145.055],[-129.571,-167.062],[-151.604,-189.069],[-173.638,-167.062]],"c":true},"ix":2},"nm":"Path 12","mn":"ADBE Vector Shape - Group","hd":false},{"ind":12,"ty":"sh","ix":13,"ks":{"a":0,"k":{"i":[[0,20.794],[-20.82,0],[0,-20.795],[20.82,0]],"o":[[0,-20.795],[20.82,0],[0,20.794],[-20.82,0]],"v":[[-188.302,-76.853],[-150.604,-114.505],[-112.906,-76.853],[-150.604,-39.2]],"c":true},"ix":2},"nm":"Path 13","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.858823537827,0.858823537827,0.858823537827,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"List","np":14,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":60,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Screen","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[300.794,308.02,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[32.349,0.08],[0,0],[0,0],[0,0],[0.08,-32.348],[0,0]],"o":[[0,0],[0.08,-32.348],[0,0],[0,0],[0,0],[-32.347,-0.08],[0,0],[0,0]],"v":[[224.363,259.771],[225.497,-200.235],[167.071,-258.95],[122.555,-259.06],[-119.77,-259.658],[-165.645,-259.771],[-224.361,-201.345],[-225.497,259.771]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Screen","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":60,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Border","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[299.949,298.254,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[48.481,0.149],[0,0],[0.092,-48.563],[0,0]],"o":[[0,0],[0.092,-48.48],[0,0],[-48.563,-0.15],[0,0],[0,0]],"v":[[245.689,269.537],[246.525,-180.545],[158.939,-268.564],[-157.56,-269.537],[-245.686,-181.845],[-246.526,269.537]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.870588243008,0.870588243008,0.870588243008,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Border","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":60,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml index e8956da24..3b10bb6a4 100644 --- a/app/src/main/res/values-land/dimens.xml +++ b/app/src/main/res/values-land/dimens.xml @@ -13,4 +13,7 @@ 20sp 16sp + 16dp + 8dp + 16dp \ No newline at end of file diff --git a/app/src/main/res/values-ldrtl/values.xml b/app/src/main/res/values-ldrtl/values.xml new file mode 100644 index 000000000..ce23c95fa --- /dev/null +++ b/app/src/main/res/values-ldrtl/values.xml @@ -0,0 +1,4 @@ + + + -1 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index c96041668..f54383ba5 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -166,4 +166,8 @@ 4dp 5dp 2dp + + 64dp + 32dp + 24dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 33863f8c4..88fb0c12c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ https://signal.org/install https://signal.org/donate https://support.signal.org/hc/articles/360007059752 + https://support.signal.org/hc/articles/360007059752 Yes No @@ -2667,9 +2668,13 @@ Folder I have written down this passphrase. Without it, I will be unable to restore a backup. Restore backup + Transfer or restore account + Transfer account Skip Chat backups Backup chats to external storage + Transfer account + Transfer account to a new Android device Enter backup passphrase Restore Cannot import backups from newer versions of Signal @@ -2744,6 +2749,123 @@ TAP TO UNLOCK Unknown + + Transfer or restore account + If you have previously registered a Signal account, you can transfer or restore your account and messages + Transfer from Android device + Transfer your account and message history from your old Android device. You must have access to your old device. + You must have access to your old device. + Restore from backup + Restore your messages and media from a local backup. If you don\'t restore now, you wouldn\'t be able to restore later. + + + Open Signal on your old Android phone + Continue + 1. + Tap on your profile photo in the top left to open Settings + 2. + Tap on "Chats" + 3. + Tap "Transfer Account" and then "Continue" on both devices + + + Preparing to connect to old Android device… + Taking a moment, should be ready soon + Waiting for old Android device to connect… + Connecting to old Android device… + Signal needs the location permission to discover and connect to your old Android device. + Signal needs location services enabled to discover and connect with your old Android device. + Signal needs Wi-Fi on to discover and connect with your old Android device. Wi-Fi needs to be on but it does not have to be connected to a Wi-Fi network. + Sorry, it appears this device does not support Wi-Fi Direct. Signal uses Wi-Fi Direct to discover and connect with your old Android device. You can still restore a backup to restore your account from your old Android device. + Restore a backup + An unexpected error occurred while attempting to connect to your old Android device. + + + Searching for your new Android device… + Connecting to new Android device… + Signal needs the location permission to discover and connect to your new Android device. + Signal needs location services enabled to discover and connect with your new Android device. + Signal needs Wi-Fi on to discover and connect with your new Android device. Wi-Fi needs to be on but it does not have to be connected to a Wi-Fi network. + Sorry, it appears this device does not support Wi-Fi Direct. Signal uses Wi-Fi Direct to discover and connect with your new Android device. You can still create a backup to restore your account on your new Android device. + Create a backup + An unexpected error occurred while attempting to connect to your new Android device. + + + Unable to open Wi-Fi Settings. Please turn on Wi-Fi manually. + Grant location permission + Turn on location services + Unable to open location settings. + Turn on Wi-Fi + Error Connecting + Retry + Submit debug logs + Verify code + Verify that the code below matches on both of your devices. Then tap continue. + The numbers do not match + Continue + Number is not the same + If the numbers on your devices do not match, it\'s possible you connected to the wrong device. To fix this, stop the transfer and try again, and keep both of your devices close. + Stop transfer + Unable to discover old device + Unable to discover new device + Make sure the following permissions and services are enabled: + Location permission + Location services + Wi-Fi + On the WiFi Direct screen, remove all remembered groups and unlink any invited or connected devices. + WiFi Direct screen + Try turning Wi-Fi off and on, on both devices. + Make sure both devices are in transfer mode. + Go to support page + Try again + + + Cannot transfer from a newer versions of Signal + + + Transferring data + Keep both devices near each other. Do not turn off the devices and keep Signal open. Transfers are end-to-end encrypted. + %1$d messages so far… + Cancel + Try again + Stop transfer? + Stop transfer + All transfer progress will be lost. + Transfer failed + Unable to transfer + + + Transfer Account + You can transfer your Signal account when setting up Signal on a new Android device. Before continuing: + 1. + Download Signal on your new Android device + 2. + Tap on "Transfer or restore account" + 3. + Select "Transfer from Android device" when prompted and then "Continue". Keep both devices nearby. + Continue + + + Transfer complete + Go to your new device + Your Signal data has been transferred to your new device. To complete the transfer process, you must continue registration on your new device. + Close + + + Transfer successful + Transfer complete + To complete the transfer process, you must continue registration. + Continue registration + + + Account transfer + Preparing to connect to your other Android device… + Preparing to connect to your other Android device… + Searching for your other Android device… + Connecting to your other Android device… + Verification required + Transferring account… + Block Unblock diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 369e27339..ae2db1bc8 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -2,4 +2,5 @@ true false + 1 diff --git a/app/src/main/res/xml/preferences_chats.xml b/app/src/main/res/xml/preferences_chats.xml index 3f6d55d0a..7c62d6a71 100644 --- a/app/src/main/res/xml/preferences_chats.xml +++ b/app/src/main/res/xml/preferences_chats.xml @@ -46,6 +46,12 @@ android:summary="@string/preferences_chats__backup_chats_to_external_storage" android:persistent="false" android:title="@string/preferences_chats__chat_backups" /> + + diff --git a/device-transfer/app/src/main/java/org/signal/devicetransfer/app/MainActivity.java b/device-transfer/app/src/main/java/org/signal/devicetransfer/app/MainActivity.java index c090e9301..e0be15757 100644 --- a/device-transfer/app/src/main/java/org/signal/devicetransfer/app/MainActivity.java +++ b/device-transfer/app/src/main/java/org/signal/devicetransfer/app/MainActivity.java @@ -101,7 +101,7 @@ public class MainActivity extends AppCompatActivity { if (event.getTransferMode() == TransferStatus.TransferMode.VERIFICATION_REQUIRED) { new AlertDialog.Builder(this).setTitle("Verification Required") - .setMessage("Code: " + ((TransferStatus.VerificationTransferStatus) event).getAuthenticationCode()) + .setMessage("Code: " + event.getAuthenticationCode()) .setPositiveButton("Yes, Same", (d, w) -> DeviceToDeviceTransferService.setAuthenticationCodeVerified(this, true)) .setNegativeButton("No, different", (d, w) -> DeviceToDeviceTransferService.setAuthenticationCodeVerified(this, false)) .setCancelable(false) diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceToDeviceTransferService.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceToDeviceTransferService.java index 84cfdf6e2..51ba1177a 100644 --- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceToDeviceTransferService.java +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceToDeviceTransferService.java @@ -19,6 +19,7 @@ import org.greenrobot.eventbus.ThreadMode; import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; +import java.io.Serializable; import java.util.Objects; /** @@ -195,11 +196,39 @@ public class DeviceToDeviceTransferService extends Service implements ShutdownCa private @NonNull Notification createNotification(@NonNull TransferStatus transferStatus, @NonNull TransferNotificationData notificationData) { NotificationCompat.Builder builder = new NotificationCompat.Builder(this, notificationData.channelId); - //TODO [cody] build notification to spec + 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: + 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("Device Transfer") - .setContentText("Status: " + transferStatus.getTransferMode().name()) + .setContentTitle(getString(R.string.DeviceToDeviceTransferService_content_title)) + .setContentText(contentText) .setContentIntent(pendingIntent); return builder.build(); diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferClient.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferClient.java index 71f741f71..1ee105e75 100644 --- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferClient.java +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferClient.java @@ -1,6 +1,7 @@ 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; @@ -8,6 +9,7 @@ import android.os.HandlerThread; import android.os.Message; import androidx.annotation.AnyThread; +import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -16,6 +18,7 @@ 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 @@ -39,15 +42,16 @@ 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 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; @@ -60,6 +64,9 @@ final class DeviceTransferClient implements Handler.Callback { 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); @@ -81,31 +88,31 @@ final class DeviceTransferClient implements Handler.Callback { }; } - @AnyThread - public synchronized void start() { - if (commandAndControlThread != null) { + @MainThread + public void start() { + if (started.compareAndSet(false, true)) { update(TransferStatus.ready()); handler.sendEmptyMessage(START_CLIENT); } } - @AnyThread - public synchronized void stop() { - if (commandAndControlThread != null) { + @MainThread + public void stop() { + if (stopped.compareAndSet(false, true)) { handler.sendEmptyMessage(STOP_CLIENT); } } - @AnyThread - public synchronized void setVerified(boolean isVerified) { - if (commandAndControlThread != null) { + @MainThread + public void setVerified(boolean isVerified) { + if (!stopped.get()) { handler.sendMessage(handler.obtainMessage(SET_VERIFIED, isVerified)); } } - private synchronized void shutdown() { + private void shutdown() { stopIpExchange(); - stopClient(); + stopNetworkClient(); stopWifiDirect(); if (commandAndControlThread != null) { @@ -115,7 +122,7 @@ final class DeviceTransferClient implements Handler.Callback { commandAndControlThread = null; } - update(TransferStatus.shutdown()); + EventBus.getDefault().removeStickyEvent(TransferStatus.class); } private void internalShutdown() { @@ -136,17 +143,17 @@ final class DeviceTransferClient implements Handler.Callback { shutdown(); break; case START_NETWORK_CLIENT: - startClient((String) message.obj); + startNetworkClient((String) message.obj); break; case NETWORK_DISCONNECTED: - stopClient(); + stopNetworkClient(); break; case CONNECT_TO_SERVICE: stopServiceDiscovery(); connectToService((String) message.obj, message.arg1); break; case RESTART_CLIENT: - stopClient(); + stopNetworkClient(); stopWifiDirect(); startWifiDirect(); break; @@ -161,6 +168,9 @@ final class DeviceTransferClient implements Handler.Callback { 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; @@ -231,7 +241,7 @@ final class DeviceTransferClient implements Handler.Callback { } } - private void startClient(@NonNull String serverHostAddress) { + private void startNetworkClient(@NonNull String serverHostAddress) { if (clientThread != null) { Log.i(TAG, "Client already running"); return; @@ -246,14 +256,14 @@ final class DeviceTransferClient implements Handler.Callback { clientThread.start(); } - private void stopClient() { + 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, "Server thread took too long to shutdown", e); + Log.i(TAG, "Client thread took too long to shutdown", e); } clientThread = null; } @@ -265,6 +275,11 @@ final class DeviceTransferClient implements Handler.Callback { return; } + if (clientThread != null) { + Log.i(TAG, "Client is running we shouldn't be connecting again"); + return; + } + handler.removeCallbacks(autoRestart); int tries = 5; @@ -288,6 +303,26 @@ final class DeviceTransferClient implements Handler.Callback { 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); } @@ -309,7 +344,7 @@ final class DeviceTransferClient implements Handler.Callback { handler.sendMessage(handler.obtainMessage(START_NETWORK_CLIENT, host)); } - public class WifiDirectListener implements WifiDirect.WifiDirectConnectionListener { + final class WifiDirectListener implements WifiDirect.WifiDirectConnectionListener { @Override public void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice, @NonNull String extraInfo) { @@ -325,17 +360,15 @@ final class DeviceTransferClient implements Handler.Callback { } } - @Override - public void onNetworkDisconnected() { - handler.sendEmptyMessage(NETWORK_DISCONNECTED); - } - @Override public void onNetworkFailure() { handler.sendEmptyMessage(NETWORK_DISCONNECTED); } @Override - public void onLocalDeviceChanged(@NonNull WifiP2pDevice localDevice) { } + public void onConnectionChanged(@NonNull NetworkInfo networkInfo) { + handler.sendMessage(handler.obtainMessage(NETWORK_CONNECTION_CHANGED, networkInfo.isConnected())); + } + } } diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferServer.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferServer.java index 6ec376b69..33c0f8c67 100644 --- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferServer.java +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/DeviceTransferServer.java @@ -1,6 +1,7 @@ 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; @@ -8,6 +9,7 @@ import android.os.HandlerThread; import android.os.Message; import androidx.annotation.AnyThread; +import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -17,6 +19,7 @@ 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 @@ -34,12 +37,13 @@ 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 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; @@ -50,6 +54,9 @@ final class DeviceTransferServer implements Handler.Callback { 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); @@ -67,29 +74,29 @@ final class DeviceTransferServer implements Handler.Callback { this.handler = new Handler(commandAndControlThread.getLooper(), this); } - @AnyThread - public synchronized void start() { - if (commandAndControlThread != null) { + @MainThread + public void start() { + if (started.compareAndSet(false, true)) { update(TransferStatus.ready()); handler.sendEmptyMessage(START_SERVER); } } - @AnyThread - public synchronized void stop() { - if (commandAndControlThread != null) { + @MainThread + public void stop() { + if (stopped.compareAndSet(false, true)) { handler.sendEmptyMessage(STOP_SERVER); } } - @AnyThread - public synchronized void setVerified(boolean isVerified) { - if (commandAndControlThread != null) { + @MainThread + public void setVerified(boolean isVerified) { + if (!stopped.get()) { handler.sendMessage(handler.obtainMessage(SET_VERIFIED, isVerified)); } } - private synchronized void shutdown() { + private void shutdown() { stopIpExchange(); stopServer(); stopWifiDirect(); @@ -101,7 +108,7 @@ final class DeviceTransferServer implements Handler.Callback { commandAndControlThread = null; } - update(TransferStatus.shutdown()); + EventBus.getDefault().removeStickyEvent(TransferStatus.class); } private void internalShutdown() { @@ -132,6 +139,9 @@ final class DeviceTransferServer implements Handler.Callback { serverThread.setVerified((Boolean) message.obj); } break; + case NETWORK_CONNECTION_CHANGED: + requestNetworkInfo((Boolean) message.obj); + break; case NetworkServerThread.NETWORK_SERVER_STARTED: startWifiDirect(message.arg1); break; @@ -201,6 +211,22 @@ final class DeviceTransferServer implements Handler.Callback { } } + 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"); @@ -253,7 +279,7 @@ final class DeviceTransferServer implements Handler.Callback { stopIpExchange(); } - public class WifiDirectListener implements WifiDirect.WifiDirectConnectionListener { + final class WifiDirectListener implements WifiDirect.WifiDirectConnectionListener { @Override public void onNetworkConnected(@NonNull WifiP2pInfo info) { @@ -262,16 +288,15 @@ final class DeviceTransferServer implements Handler.Callback { } } - @Override - public void onNetworkDisconnected() { } - @Override public void onNetworkFailure() { handler.sendEmptyMessage(NETWORK_FAILURE); } @Override - public void onLocalDeviceChanged(@NonNull WifiP2pDevice localDevice) { } + 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) { } diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkClientThread.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkClientThread.java index 25e1b1933..5868a54ab 100644 --- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkClientThread.java +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkClientThread.java @@ -15,14 +15,11 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.security.cert.X509Certificate; -import java.util.Arrays; import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLHandshakeException; import javax.net.ssl.SSLSocket; -import static org.signal.devicetransfer.DeviceTransferAuthentication.DIGEST_LENGTH; - /** * Performs the networking setup/tear down for the client. This includes * connecting to the server, performing the TLS/SAS verification, running an @@ -101,11 +98,14 @@ final class NetworkClientThread extends Thread { handler.sendEmptyMessage(NETWORK_CLIENT_CONNECTED); clientTask.run(context, outputStream); outputStream.flush(); - client.shutdownOutput(); Log.d(TAG, "Waiting for server to tell us they got everything"); - //noinspection ResultOfMethodCallIgnored - inputStream.read(); + 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) { @@ -117,6 +117,9 @@ final class NetworkClientThread extends Thread { Log.w(TAG, e); isRunning = false; } finally { + if (success) { + clientTask.success(); + } StreamUtil.close(client); handler.sendEmptyMessage(NETWORK_CLIENT_DISCONNECTED); } @@ -127,9 +130,6 @@ final class NetworkClientThread extends Thread { } Log.i(TAG, "Client exiting"); - if (success) { - clientTask.success(); - } handler.sendEmptyMessage(NETWORK_CLIENT_STOPPED); } diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkServerThread.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkServerThread.java index f0953888c..7eb74f297 100644 --- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkServerThread.java +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/NetworkServerThread.java @@ -78,6 +78,7 @@ final class NetworkServerThread extends Thread { outputStream.write(0x43); outputStream.flush(); serverTask.run(context, inputStream); + outputStream.write(0x53); outputStream.flush(); } catch (IOException e) { diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferStatus.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferStatus.java index 2869cfb26..581c4b23c 100644 --- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferStatus.java +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/TransferStatus.java @@ -63,10 +63,6 @@ public class TransferStatus { return new TransferStatus(TransferMode.FAILED); } - public static @NonNull TransferStatus shutdown() { - return new TransferStatus(TransferMode.SHUTDOWN); - } - public enum TransferMode { UNAVAILABLE, FAILED, @@ -76,7 +72,6 @@ public class TransferStatus { NETWORK_CONNECTED, VERIFICATION_REQUIRED, SERVICE_CONNECTED, - SERVICE_DISCONNECTED, - SHUTDOWN, + SERVICE_DISCONNECTED } } diff --git a/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java index d9dc3285d..a5c2acfd4 100644 --- a/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java +++ b/device-transfer/lib/src/main/java/org/signal/devicetransfer/WifiDirect.java @@ -46,10 +46,7 @@ public final class WifiDirect { private static final String TAG = Log.tag(WifiDirect.class); private static final IntentFilter intentFilter = new IntentFilter() {{ - addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION); - addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION); addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION); - addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION); }}; private static final String EXTRA_INFO_PLACEHOLDER = "%%EXTRA_INFO%%"; @@ -57,6 +54,9 @@ public final class WifiDirect { 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; @@ -115,7 +115,7 @@ public final class WifiDirect { throw new WifiDirectUnavailableException(Reason.WIFI_P2P_MANAGER); } - wifiDirectCallbacks = new WifiDirectCallbacks(); + wifiDirectCallbacks = new WifiDirectCallbacks(connectionListener); channel = manager.initialize(context, wifiDirectCallbacksHandler.getLooper(), wifiDirectCallbacks); if (channel == null) { Log.i(TAG, "Unable to initialize channel"); @@ -139,18 +139,23 @@ public final class WifiDirect { connectionListener = null; if (manager != null) { - retry(manager::clearServiceRequests, "clear service requests"); - retry(manager::stopPeerDiscovery, "stop peer discovery"); - retry(manager::clearLocalServices, "clear local services"); + 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) { + if (channel != null && Build.VERSION.SDK_INT >= 27) { channel.close(); channel = null; } if (wifiDirectCallbacks != null) { + wifiDirectCallbacks.clearConnectionListener(); context.unregisterReceiver(wifiDirectCallbacks); wifiDirectCallbacks = null; } @@ -173,10 +178,10 @@ public final class WifiDirect { WifiP2pDnsSdServiceInfo serviceInfo = WifiP2pDnsSdServiceInfo.newInstance(buildServiceInstanceName(extraInfo), SERVICE_REG_TYPE, Collections.emptyMap()); - SyncActionListener addLocalServiceListener = new SyncActionListener("add local service"); + SyncActionListener addLocalServiceListener = new SyncActionListener("add local service", SAFE_FOR_LONG_AWAIT_TIMEOUT); manager.addLocalService(channel, serviceInfo, addLocalServiceListener); - SyncActionListener discoverPeersListener = new SyncActionListener("discover peers"); + SyncActionListener discoverPeersListener = new SyncActionListener("discover peers", SAFE_FOR_LONG_AWAIT_TIMEOUT); manager.discoverPeers(channel, discoverPeersListener); if (!addLocalServiceListener.successful() || !discoverPeersListener.successful()) { @@ -190,8 +195,8 @@ public final class WifiDirect { synchronized void stopDiscoveryService() throws WifiDirectUnavailableException { ensureInitialized(); - retry(manager::stopPeerDiscovery, "stop peer discovery"); - retry(manager::clearLocalServices, "clear local services"); + retryAsync(manager::stopPeerDiscovery, "stop peer discovery"); + retryAsync(manager::clearLocalServices, "clear local services"); } /** @@ -214,8 +219,9 @@ public final class WifiDirect { String extraInfo = isInstanceNameMatching(instanceName); if (extraInfo != null) { Log.d(TAG, "Service found!"); - if (connectionListener != null) { - connectionListener.onServiceDiscovered(sourceDevice, extraInfo); + WifiDirectConnectionListener listener = connectionListener; + if (listener != null) { + listener.onServiceDiscovered(sourceDevice, extraInfo); } } else { Log.d(TAG, "Found unusable service, ignoring."); @@ -226,10 +232,10 @@ public final class WifiDirect { serviceRequest = WifiP2pDnsSdServiceRequest.newInstance(); - SyncActionListener addServiceListener = new SyncActionListener("add service request"); + SyncActionListener addServiceListener = new SyncActionListener("add service request", SAFE_FOR_LONG_AWAIT_TIMEOUT); manager.addServiceRequest(channel, serviceRequest, addServiceListener); - SyncActionListener startDiscovery = new SyncActionListener("discover services"); + SyncActionListener startDiscovery = new SyncActionListener("discover services", SAFE_FOR_LONG_AWAIT_TIMEOUT); manager.discoverServices(channel, startDiscovery); if (!addServiceListener.successful() || !startDiscovery.successful()) { @@ -245,7 +251,7 @@ public final class WifiDirect { synchronized void stopServiceDiscovery() throws WifiDirectUnavailableException { ensureInitialized(); - retry(manager::clearServiceRequests, "clear service requests"); + retryAsync(manager::clearServiceRequests, "clear service requests"); } /** @@ -268,7 +274,7 @@ public final class WifiDirect { serviceRequest = null; } - SyncActionListener listener = new SyncActionListener("service connect"); + SyncActionListener listener = new SyncActionListener("service connect", SAFE_FOR_LONG_AWAIT_TIMEOUT); manager.connect(channel, config, listener); if (listener.successful()) { @@ -278,19 +284,39 @@ public final class WifiDirect { } } - private synchronized void retry(@NonNull ManagerRetry retryFunction, @NonNull String message) { + public synchronized void requestNetworkInfo() throws WifiDirectUnavailableException { + ensureInitialized(); + + manager.requestConnectionInfo(channel, info -> { + Log.i(TAG, "Connection information available. group_formed: " + info.groupFormed + " group_owner: " + info.isGroupOwner); + 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) { - SyncActionListener listener = new SyncActionListener(message); + if (isNotInitialized()) { + return; + } + + SyncActionListener listener = new SyncActionListener(message, awaitTimeout); retryFunction.call(channel, listener); - if (listener.successful()) { + 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; } @@ -328,57 +354,41 @@ public final class WifiDirect { void call(@NonNull WifiP2pManager.Channel a, @NonNull WifiP2pManager.ActionListener b); } - private class WifiDirectCallbacks extends BroadcastReceiver implements WifiP2pManager.ChannelListener, WifiP2pManager.ConnectionInfoListener { + 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) { - switch (action) { - case WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION: - WifiP2pDevice localDevice = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE); - if (localDevice != null && connectionListener != null) { - connectionListener.onLocalDeviceChanged(localDevice); - } - break; - case WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION: - if (isNotInitialized()) { - Log.w(TAG, "WiFi P2P broadcast connection changed action without being initialized."); - return; - } + 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; + } - NetworkInfo networkInfo = intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO); - - if (networkInfo == null) { - Log.w(TAG, "WiFi P2P broadcast connection changed action with null network info."); - return; - } - - if (networkInfo.isConnected()) { - Log.i(TAG, "Connected to P2P network, requesting connection information."); - manager.requestConnectionInfo(channel, this); - } else { - Log.i(TAG, "Disconnected from P2P network"); - if (connectionListener != null) { - connectionListener.onNetworkDisconnected(); - } - } - break; + if (listener != null) { + listener.onConnectionChanged(networkInfo); + } } } } - @Override - public void onConnectionInfoAvailable(@NonNull WifiP2pInfo info) { - Log.i(TAG, "Connection information available. group_formed: " + info.groupFormed + " group_owner: " + info.isGroupOwner); - if (connectionListener != null) { - connectionListener.onNetworkConnected(info); - } - } - @Override public void onChannelDisconnected() { - if (connectionListener != null) { - connectionListener.onNetworkFailure(); + WifiDirectConnectionListener listener = connectionListener; + if (listener != null) { + listener.onNetworkFailure(); } } } @@ -388,13 +398,16 @@ public final class WifiDirect { */ private static class SyncActionListener extends LoggingActionListener { - private final CountDownLatch sync; + private static final int FAILURE_TIMEOUT = -2; - private volatile int failureReason = -1; + private final CountDownLatch sync; + private final long awaitTimeout; + private volatile int failureReason = -1; - public SyncActionListener(@NonNull String message) { + public SyncActionListener(@NonNull String message, long awaitTimeout) { super(message); - this.sync = new CountDownLatch(1); + this.awaitTimeout = awaitTimeout; + this.sync = new CountDownLatch(1); } @Override @@ -412,9 +425,14 @@ public final class WifiDirect { public boolean successful() { try { - sync.await(); + 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) { - throw new AssertionError(ie); + Log.i(TAG, "SyncListener [" + message + "] interrupted"); } return failureReason < 0; } @@ -422,7 +440,7 @@ public final class WifiDirect { private static class LoggingActionListener implements WifiP2pManager.ActionListener { - private final String message; + protected final String message; public static @NonNull LoggingActionListener message(@Nullable String message) { return new LoggingActionListener(message); @@ -452,14 +470,13 @@ public final class WifiDirect { } public interface WifiDirectConnectionListener { - void onLocalDeviceChanged(@NonNull WifiP2pDevice localDevice); void onServiceDiscovered(@NonNull WifiP2pDevice serviceDevice, @NonNull String extraInfo); void onNetworkConnected(@NonNull WifiP2pInfo info); - void onNetworkDisconnected(); - void onNetworkFailure(); + + void onConnectionChanged(@NonNull NetworkInfo networkInfo); } } diff --git a/device-transfer/lib/src/main/res/values/strings.xml b/device-transfer/lib/src/main/res/values/strings.xml new file mode 100644 index 000000000..5180bcdf3 --- /dev/null +++ b/device-transfer/lib/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file