From c2cbba451d8eeb8e28d7f9fd3de47d2a4fb6774d Mon Sep 17 00:00:00 2001 From: ameba23 Date: Thu, 25 Mar 2021 20:43:34 +0100 Subject: [PATCH] return shard activity and view model --- briar-android/src/main/AndroidManifest.xml | 2 +- .../android/account/NewOrRecoverActivity.java | 5 +- .../android/activity/ActivityComponent.java | 7 +- .../OwnerRecoveryModeExplainerFragment.java | 23 +- .../socialbackup/recover/RecoverActivity.java | 5 +- .../recover/ReturnShardActivity.java | 236 +++++++- .../recover/ReturnShardState.java | 78 ++- .../recover/ReturnShardViewModel.java | 524 +++++++++++++++++- 8 files changed, 860 insertions(+), 20 deletions(-) diff --git a/briar-android/src/main/AndroidManifest.xml b/briar-android/src/main/AndroidManifest.xml index f372502ce..b7f8eba96 100644 --- a/briar-android/src/main/AndroidManifest.xml +++ b/briar-android/src/main/AndroidManifest.xml @@ -157,7 +157,7 @@ listener.explainerDismissed()); + button.setOnClickListener(e -> viewModel.onContinueClicked()); return view; } - @Override - public void onAttach(Context context) { - super.onAttach(context); - listener = (ExplainerDismissedListener) context; - } - @Override public void injectFragment(ActivityComponent component) { component.inject(this); + viewModel = new ViewModelProvider(requireActivity(), viewModelFactory) + .get(ReturnShardViewModel.class); } @Override diff --git a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/RecoverActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/RecoverActivity.java index 3407ef795..e76d38efb 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/RecoverActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/RecoverActivity.java @@ -1,4 +1,4 @@ -package org.briarproject.briar.android.socialbackup; +package org.briarproject.briar.android.socialbackup.recover; import android.os.Bundle; import android.widget.Toast; @@ -7,6 +7,9 @@ import org.briarproject.briar.R; import org.briarproject.briar.android.activity.ActivityComponent; import org.briarproject.briar.android.activity.BaseActivity; import org.briarproject.briar.android.fragment.BaseFragment; +import org.briarproject.briar.android.socialbackup.ExplainerDismissedListener; +import org.briarproject.briar.android.socialbackup.OwnerRecoveryModeMainFragment; +import org.briarproject.briar.android.socialbackup.ScanQrButtonListener; public class RecoverActivity extends BaseActivity implements BaseFragment.BaseFragmentListener, ExplainerDismissedListener, diff --git a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardActivity.java index 42d1a929d..d997c8e15 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardActivity.java @@ -1,4 +1,238 @@ package org.briarproject.briar.android.socialbackup.recover; -public class ReturnShardActivity { +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; +import android.widget.Toast; + +import org.briarproject.bramble.api.identity.Author; +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; +import org.briarproject.briar.android.activity.ActivityComponent; +import org.briarproject.briar.android.activity.BriarActivity; +import org.briarproject.briar.android.contact.add.nearby.AddNearbyContactErrorFragment; +import org.briarproject.briar.android.contact.add.nearby.AddNearbyContactFragment; +import org.briarproject.briar.android.contact.add.nearby.AddNearbyContactPermissionManager; +import org.briarproject.briar.android.fragment.BaseFragment; +import org.briarproject.briar.android.util.RequestBluetoothDiscoverable; + +import java.util.logging.Logger; + +import javax.annotation.Nullable; +import javax.inject.Inject; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProvider; + +import static android.bluetooth.BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE; +import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP; +import static android.widget.Toast.LENGTH_LONG; +import static java.util.Objects.requireNonNull; +import static java.util.logging.Logger.getLogger; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class ReturnShardActivity extends BriarActivity + implements BaseFragment.BaseFragmentListener { + + private static final Logger LOG = + getLogger( + org.briarproject.briar.android.contact.add.nearby.AddNearbyContactActivity.class + .getName()); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private ReturnShardViewModel viewModel; + private AddNearbyContactPermissionManager permissionManager; + + private final ActivityResultLauncher permissionLauncher = + registerForActivityResult( + new ActivityResultContracts.RequestMultiplePermissions(), + r -> + permissionManager.onRequestPermissionResult(r, + viewModel::showQrCodeFragmentIfAllowed)); + private final ActivityResultLauncher bluetoothLauncher = + registerForActivityResult(new RequestBluetoothDiscoverable(), + this::onBluetoothDiscoverableResult); + + @Override + public void injectActivity(ActivityComponent component) { + component.inject(this); + viewModel = new ViewModelProvider(this, viewModelFactory) + .get(ReturnShardViewModel.class); + permissionManager = new AddNearbyContactPermissionManager(this, + permissionLauncher::launch, viewModel.isBluetoothSupported()); + } + + @Override + public void onCreate(@Nullable Bundle state) { + super.onCreate(state); + setContentView(R.layout.activity_fragment_container_toolbar); + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true); + if (state == null) { + showInitialFragment(getExplainerFragment()); + } + viewModel.getCheckPermissions().observeEvent(this, check -> + permissionManager.checkPermissions()); + viewModel.getRequestBluetoothDiscoverable().observeEvent(this, r -> + requestBluetoothDiscoverable()); // never false + viewModel.getShowQrCodeFragment().observeEvent(this, show -> { + if (show) showQrCodeFragment(); + }); + requireNonNull(getSupportActionBar()) + .setTitle(R.string.add_contact_title); + viewModel.getState() + .observe(this, this::onReturnShardStateChanged); + } + + public BaseFragment getExplainerFragment() { + return new OwnerRecoveryModeExplainerFragment(); + } + + @Override + public void onStart() { + super.onStart(); + // Permissions may have been granted manually while we were stopped + permissionManager.resetPermissions(); + } + + @Override + protected void onPostResume() { + super.onPostResume(); + viewModel.setIsActivityResumed(true); + } + + @Override + protected void onPause() { + super.onPause(); + viewModel.setIsActivityResumed(false); + } + + private void onBluetoothDiscoverableResult(boolean discoverable) { + if (discoverable) { + LOG.info("Bluetooth discoverability was accepted"); + viewModel.setBluetoothDecision( + ReturnShardViewModel.BluetoothDecision.ACCEPTED); + } else { + LOG.info("Bluetooth discoverability was refused"); + viewModel.setBluetoothDecision( + ReturnShardViewModel.BluetoothDecision.REFUSED); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onBackPressed() { + if (viewModel.getState() + .getValue() instanceof ReturnShardState.Failed) { + // re-create this activity when going back in failed state + Intent i = new Intent(this, ReturnShardActivity.class); + i.setFlags(FLAG_ACTIVITY_CLEAR_TOP); + startActivity(i); + } else { + super.onBackPressed(); + } + } + + private void requestBluetoothDiscoverable() { + if (!viewModel.isBluetoothSupported()) { + viewModel.setBluetoothDecision( + ReturnShardViewModel.BluetoothDecision.NO_ADAPTER); + } else { + Intent i = new Intent(ACTION_REQUEST_DISCOVERABLE); + if (i.resolveActivity(getPackageManager()) != null) { + LOG.info("Asking for Bluetooth discoverability"); + viewModel.setBluetoothDecision( + ReturnShardViewModel.BluetoothDecision.WAITING); + bluetoothLauncher.launch(120); // 2min discoverable + } else { + viewModel.setBluetoothDecision( + ReturnShardViewModel.BluetoothDecision.NO_ADAPTER); + } + } + } + + private void showQrCodeFragment() { + // FIXME #824 + FragmentManager fm = getSupportFragmentManager(); + if (fm.findFragmentByTag(AddNearbyContactFragment.TAG) == null) { + BaseFragment f = AddNearbyContactFragment.newInstance(); + fm.beginTransaction() + .replace(R.id.fragmentContainer, f, f.getUniqueTag()) + .addToBackStack(f.getUniqueTag()) + .commit(); + } + } + + private void onReturnShardStateChanged(ReturnShardState state) { + if (state instanceof ReturnShardState.ContactExchangeFinished) { + ReturnShardState.ContactExchangeResult result = + ((ReturnShardState.ContactExchangeFinished) state).result; + onContactExchangeResult(result); + } else if (state instanceof ReturnShardState.Failed) { + Boolean qrCodeTooOld = + ((ReturnShardState.Failed) state).qrCodeTooOld; + onAddingContactFailed(qrCodeTooOld); + } + } + + private void onContactExchangeResult( + ReturnShardState.ContactExchangeResult result) { + if (result instanceof ReturnShardState.ContactExchangeResult.Success) { + Author remoteAuthor = + ((ReturnShardState.ContactExchangeResult.Success) result).remoteAuthor; + String contactName = remoteAuthor.getName(); + String text = getString(R.string.contact_added_toast, contactName); + Toast.makeText(this, text, LENGTH_LONG).show(); + supportFinishAfterTransition(); + } else if (result instanceof ReturnShardState.ContactExchangeResult.Error) { + Author duplicateAuthor = + ((ReturnShardState.ContactExchangeResult.Error) result).duplicateAuthor; + if (duplicateAuthor == null) { + showErrorFragment(); + } else { + String contactName = duplicateAuthor.getName(); + String text = + getString(R.string.contact_already_exists, contactName); + Toast.makeText(this, text, LENGTH_LONG).show(); + supportFinishAfterTransition(); + } + } else throw new AssertionError(); + } + + private void onAddingContactFailed(@Nullable Boolean qrCodeTooOld) { + if (qrCodeTooOld == null) { + showErrorFragment(); + } else { + String msg; + if (qrCodeTooOld) { + msg = getString(R.string.qr_code_too_old, + getString(R.string.app_name)); + } else { + msg = getString(R.string.qr_code_too_new, + getString(R.string.app_name)); + } + showNextFragment(AddNearbyContactErrorFragment.newInstance(msg)); + } + } + + private void showErrorFragment() { + showNextFragment(new AddNearbyContactErrorFragment()); + } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardState.java b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardState.java index 25c678cd0..0066ef986 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardState.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardState.java @@ -1,4 +1,80 @@ package org.briarproject.briar.android.socialbackup.recover; -public class ReturnShardState { +import android.graphics.Bitmap; + +import org.briarproject.bramble.api.identity.Author; + +import androidx.annotation.Nullable; + +abstract class ReturnShardState { + + static class KeyAgreementListening extends + ReturnShardState { + final Bitmap qrCode; + + KeyAgreementListening(Bitmap qrCode) { + this.qrCode = qrCode; + } + } + + static class QrCodeScanned extends + ReturnShardState { + } + + static class KeyAgreementWaiting extends ReturnShardState { + } + + static class KeyAgreementStarted extends ReturnShardState { + } + + static class ContactExchangeStarted extends ReturnShardState { + } + + static class ContactExchangeFinished extends ReturnShardState { + final ContactExchangeResult + result; + + ContactExchangeFinished( + ContactExchangeResult result) { + this.result = result; + } + } + + static class Failed extends ReturnShardState { + /** + * Non-null if failed due to the scanned QR code version. + * True if the app producing the code is too old. + * False if the scanning app is too old. + */ + @Nullable + final Boolean qrCodeTooOld; + + Failed(@Nullable Boolean qrCodeTooOld) { + this.qrCodeTooOld = qrCodeTooOld; + } + + Failed() { + this(null); + } + } + + abstract static class ContactExchangeResult { + static class Success extends ContactExchangeResult { + final Author remoteAuthor; + + Success(Author remoteAuthor) { + this.remoteAuthor = remoteAuthor; + } + } + + static class Error extends ContactExchangeResult { + @Nullable + final Author duplicateAuthor; + + Error(@Nullable Author duplicateAuthor) { + this.duplicateAuthor = duplicateAuthor; + } + } + } // end ContactExchangeResult + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardViewModel.java index 20c0ea428..d40c63719 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/recover/ReturnShardViewModel.java @@ -1,4 +1,526 @@ package org.briarproject.briar.android.socialbackup.recover; -public class ReturnShardViewModel { +import android.app.Application; +import android.bluetooth.BluetoothAdapter; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Bitmap; +import android.util.DisplayMetrics; +import android.widget.Toast; + +import com.google.zxing.Result; + +import org.briarproject.bramble.api.UnsupportedVersionException; +import org.briarproject.bramble.api.connection.ConnectionManager; +import org.briarproject.bramble.api.contact.Contact; +import org.briarproject.bramble.api.contact.ContactExchangeManager; +import org.briarproject.bramble.api.crypto.SecretKey; +import org.briarproject.bramble.api.db.ContactExistsException; +import org.briarproject.bramble.api.db.DbException; +import org.briarproject.bramble.api.event.Event; +import org.briarproject.bramble.api.event.EventBus; +import org.briarproject.bramble.api.event.EventListener; +import org.briarproject.bramble.api.keyagreement.KeyAgreementResult; +import org.briarproject.bramble.api.keyagreement.KeyAgreementTask; +import org.briarproject.bramble.api.keyagreement.Payload; +import org.briarproject.bramble.api.keyagreement.PayloadEncoder; +import org.briarproject.bramble.api.keyagreement.PayloadParser; +import org.briarproject.bramble.api.keyagreement.event.KeyAgreementAbortedEvent; +import org.briarproject.bramble.api.keyagreement.event.KeyAgreementFailedEvent; +import org.briarproject.bramble.api.keyagreement.event.KeyAgreementFinishedEvent; +import org.briarproject.bramble.api.keyagreement.event.KeyAgreementListeningEvent; +import org.briarproject.bramble.api.keyagreement.event.KeyAgreementStartedEvent; +import org.briarproject.bramble.api.keyagreement.event.KeyAgreementWaitingEvent; +import org.briarproject.bramble.api.lifecycle.IoExecutor; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.plugin.BluetoothConstants; +import org.briarproject.bramble.api.plugin.LanTcpConstants; +import org.briarproject.bramble.api.plugin.Plugin; +import org.briarproject.bramble.api.plugin.PluginManager; +import org.briarproject.bramble.api.plugin.TransportId; +import org.briarproject.bramble.api.plugin.duplex.DuplexTransportConnection; +import org.briarproject.bramble.api.plugin.event.TransportStateEvent; +import org.briarproject.bramble.api.system.AndroidExecutor; +import org.briarproject.briar.R; +import org.briarproject.briar.android.contact.add.nearby.QrCodeDecoder; +import org.briarproject.briar.android.contact.add.nearby.QrCodeUtils; +import org.briarproject.briar.android.viewmodel.LiveEvent; +import org.briarproject.briar.android.viewmodel.MutableLiveEvent; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import javax.inject.Inject; +import javax.inject.Provider; + +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import static android.bluetooth.BluetoothAdapter.ACTION_SCAN_MODE_CHANGED; +import static android.bluetooth.BluetoothAdapter.EXTRA_SCAN_MODE; +import static android.bluetooth.BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE; +import static android.widget.Toast.LENGTH_LONG; +import static java.util.Objects.requireNonNull; +import static java.util.logging.Level.INFO; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.api.plugin.Plugin.State.ACTIVE; +import static org.briarproject.bramble.api.plugin.Plugin.State.DISABLED; +import static org.briarproject.bramble.api.plugin.Plugin.State.INACTIVE; +import static org.briarproject.bramble.api.plugin.Plugin.State.STARTING_STOPPING; +import static org.briarproject.bramble.util.LogUtils.logException; +import static org.briarproject.briar.android.contact.add.nearby.AddNearbyContactPermissionManager.areEssentialPermissionsGranted; + +@NotNullByDefault +class ReturnShardViewModel extends AndroidViewModel + implements EventListener, QrCodeDecoder.ResultCallback { + + private static final Logger LOG = + getLogger(ReturnShardViewModel.class.getName()); + + // TODO deduplicate + enum BluetoothDecision { + /** + * We haven't asked the user about Bluetooth discoverability. + */ + UNKNOWN, + + /** + * The device doesn't have a Bluetooth adapter. + */ + NO_ADAPTER, + + /** + * We're waiting for the user to accept or refuse discoverability. + */ + WAITING, + + /** + * The user has accepted discoverability. + */ + ACCEPTED, + + /** + * The user has refused discoverability. + */ + REFUSED + } + + @SuppressWarnings("CharsetObjectCanBeUsed") // Requires minSdkVersion >= 19 + private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1"); + + private final EventBus eventBus; + private final AndroidExecutor androidExecutor; + private final Executor ioExecutor; + private final PluginManager pluginManager; + private final PayloadEncoder payloadEncoder; + private final PayloadParser payloadParser; + private final Provider keyAgreementTaskProvider; + private final ContactExchangeManager contactExchangeManager; + private final ConnectionManager connectionManager; + + private final MutableLiveEvent checkPermissions = + new MutableLiveEvent<>(); + private final MutableLiveEvent requestBluetoothDiscoverable = + new MutableLiveEvent<>(); + private final MutableLiveEvent showQrCodeFragment = + new MutableLiveEvent<>(); + private final MutableLiveData state = + new MutableLiveData<>(); + + final QrCodeDecoder qrCodeDecoder; + final BroadcastReceiver + bluetoothReceiver = new BluetoothStateReceiver(); + + @Nullable + private final BluetoothAdapter bt; + @Nullable + private final Plugin wifiPlugin, bluetoothPlugin; + + // UiThread + private BluetoothDecision + bluetoothDecision = BluetoothDecision.UNKNOWN; + + private boolean wasContinueClicked = false; + private boolean isActivityResumed = false; + + /** + * Records whether we've enabled the wifi plugin so we don't enable it more + * than once. + */ + private boolean hasEnabledWifi = false; + + /** + * Records whether we've enabled the Bluetooth plugin so we don't enable it + * more than once. + */ + private boolean hasEnabledBluetooth = false; + + @Nullable + private KeyAgreementTask task; + private volatile boolean gotLocalPayload = false, gotRemotePayload = false; + + @Inject + ReturnShardViewModel(Application app, + EventBus eventBus, + AndroidExecutor androidExecutor, + @IoExecutor Executor ioExecutor, + PluginManager pluginManager, + PayloadEncoder payloadEncoder, + PayloadParser payloadParser, + Provider keyAgreementTaskProvider, + ContactExchangeManager contactExchangeManager, + ConnectionManager connectionManager) { + super(app); + this.eventBus = eventBus; + this.androidExecutor = androidExecutor; + this.ioExecutor = ioExecutor; + this.pluginManager = pluginManager; + this.payloadEncoder = payloadEncoder; + this.payloadParser = payloadParser; + this.keyAgreementTaskProvider = keyAgreementTaskProvider; + this.contactExchangeManager = contactExchangeManager; + this.connectionManager = connectionManager; + bt = BluetoothAdapter.getDefaultAdapter(); + wifiPlugin = pluginManager.getPlugin(LanTcpConstants.ID); + bluetoothPlugin = pluginManager.getPlugin(BluetoothConstants.ID); + qrCodeDecoder = new QrCodeDecoder(androidExecutor, ioExecutor, this); + eventBus.addListener(this); + IntentFilter filter = new IntentFilter(ACTION_SCAN_MODE_CHANGED); + getApplication().registerReceiver(bluetoothReceiver, filter); + } + + @Override + protected void onCleared() { + super.onCleared(); + getApplication().unregisterReceiver(bluetoothReceiver); + eventBus.removeListener(this); + stopListening(); + } + + @UiThread + void onContinueClicked() { + if (bluetoothDecision == BluetoothDecision.REFUSED) { + bluetoothDecision = BluetoothDecision.UNKNOWN; // Ask again + } + wasContinueClicked = true; + checkPermissions.setEvent(true); + showQrCodeFragmentIfAllowed(); + } + + @UiThread + boolean isBluetoothSupported() { + return bt != null && bluetoothPlugin != null; + } + + @UiThread + boolean isWifiReady() { + if (wifiPlugin == null) return true; // Continue without wifi + Plugin.State state = wifiPlugin.getState(); + // Wait for plugin to become enabled + return state == ACTIVE || state == INACTIVE; + } + + @UiThread + boolean isBluetoothReady() { + if (bt == null || bluetoothPlugin == null) { + // Continue without Bluetooth + return true; + } + if (bluetoothDecision == BluetoothDecision.UNKNOWN || + bluetoothDecision == BluetoothDecision.WAITING || + bluetoothDecision == BluetoothDecision.REFUSED) { + // Wait for user to accept + return false; + } + if (bt.getScanMode() != SCAN_MODE_CONNECTABLE_DISCOVERABLE) { + // Wait for adapter to become discoverable + return false; + } + // Wait for plugin to become active + return bluetoothPlugin.getState() == ACTIVE; + } + + @UiThread + void enableWifiIfWeShould() { + if (hasEnabledWifi) return; + if (wifiPlugin == null) return; + Plugin.State state = wifiPlugin.getState(); + if (state == STARTING_STOPPING || state == DISABLED) { + LOG.info("Enabling wifi plugin"); + hasEnabledWifi = true; + pluginManager.setPluginEnabled(LanTcpConstants.ID, true); + } + } + + @UiThread + void enableBluetoothIfWeShould() { + if (bluetoothDecision != BluetoothDecision.ACCEPTED) + return; + if (hasEnabledBluetooth) return; + if (bluetoothPlugin == null || !isBluetoothSupported()) return; + Plugin.State state = bluetoothPlugin.getState(); + if (state == STARTING_STOPPING || state == DISABLED) { + LOG.info("Enabling Bluetooth plugin"); + hasEnabledBluetooth = true; + pluginManager.setPluginEnabled(BluetoothConstants.ID, true); + } + } + + @UiThread + void startAddingContact() { + // If we return to the intro fragment, the continue button needs to be + // clicked again before showing the QR code fragment + wasContinueClicked = false; + // If we return to the intro fragment, ask for Bluetooth + // discoverability again before showing the QR code fragment + bluetoothDecision = BluetoothDecision.UNKNOWN; + // If we return to the intro fragment, we may need to enable wifi and + // Bluetooth again + hasEnabledWifi = false; + hasEnabledBluetooth = false; + // start to listen with a KeyAgreementTask + startListening(); + showQrCodeFragment.setEvent(true); + } + + /** + * Call this once Bluetooth and Wi-Fi are ready to be used. + * It is possible to call this more than once over the ViewModel's lifetime. + */ + @UiThread + private void startListening() { + KeyAgreementTask oldTask = task; + KeyAgreementTask newTask = keyAgreementTaskProvider.get(); + task = newTask; + ioExecutor.execute(() -> { + if (oldTask != null) oldTask.stopListening(); + newTask.listen(); + }); + } + + @UiThread + private void stopListening() { + KeyAgreementTask oldTask = task; + ioExecutor.execute(() -> { + if (oldTask != null) oldTask.stopListening(); + }); + } + + @Override + public void eventOccurred(Event e) { + if (e instanceof TransportStateEvent) { + TransportStateEvent t = (TransportStateEvent) e; + if (t.getTransportId().equals(BluetoothConstants.ID)) { + if (LOG.isLoggable(INFO)) { + LOG.info("Bluetooth state changed to " + t.getState()); + } + showQrCodeFragmentIfAllowed(); + } else if (t.getTransportId().equals(LanTcpConstants.ID)) { + if (LOG.isLoggable(INFO)) { + LOG.info("Wifi state changed to " + t.getState()); + } + showQrCodeFragmentIfAllowed(); + } + } else if (e instanceof KeyAgreementListeningEvent) { + LOG.info("KeyAgreementListeningEvent received"); + KeyAgreementListeningEvent event = (KeyAgreementListeningEvent) e; + onLocalPayloadReceived(event.getLocalPayload()); + } else if (e instanceof KeyAgreementWaitingEvent) { + LOG.info("KeyAgreementWaitingEvent received"); + state.setValue(new ReturnShardState.KeyAgreementWaiting()); + } else if (e instanceof KeyAgreementStartedEvent) { + LOG.info("KeyAgreementStartedEvent received"); + state.setValue(new ReturnShardState.KeyAgreementStarted()); + } else if (e instanceof KeyAgreementFinishedEvent) { + LOG.info("KeyAgreementFinishedEvent received"); + KeyAgreementResult result = + ((KeyAgreementFinishedEvent) e).getResult(); + startContactExchange(result); + state.setValue(new ReturnShardState.ContactExchangeStarted()); + } else if (e instanceof KeyAgreementAbortedEvent) { + LOG.info("KeyAgreementAbortedEvent received"); + resetPayloadFlags(); + state.setValue(new ReturnShardState.Failed()); + } else if (e instanceof KeyAgreementFailedEvent) { + LOG.info("KeyAgreementFailedEvent received"); + resetPayloadFlags(); + state.setValue(new ReturnShardState.Failed()); + } + } + + @SuppressWarnings("StatementWithEmptyBody") + @UiThread + void showQrCodeFragmentIfAllowed() { + boolean permissionsGranted = areEssentialPermissionsGranted( + getApplication(), isBluetoothSupported()); + if (isActivityResumed && wasContinueClicked && permissionsGranted) { + if (isWifiReady() && isBluetoothReady()) { + LOG.info("Wifi and Bluetooth are ready"); + startAddingContact(); + } else { + enableWifiIfWeShould(); + if (bluetoothDecision == BluetoothDecision.UNKNOWN) { + requestBluetoothDiscoverable.setEvent(true); + } else if (bluetoothDecision == BluetoothDecision.REFUSED) { + // Ask again when the user clicks "continue" + } else { + enableBluetoothIfWeShould(); + } + } + } + } + + /** + * This sets the QR code by setting the state to KeyAgreementListening. + */ + private void onLocalPayloadReceived(Payload localPayload) { + if (gotLocalPayload) return; + DisplayMetrics dm = getApplication().getResources().getDisplayMetrics(); + ioExecutor.execute(() -> { + byte[] payloadBytes = payloadEncoder.encode(localPayload); + if (LOG.isLoggable(INFO)) { + LOG.info("Local payload is " + payloadBytes.length + + " bytes"); + } + // Use ISO 8859-1 to encode bytes directly as a string + String content = new String(payloadBytes, ISO_8859_1); + Bitmap qrCode = QrCodeUtils.createQrCode(dm, content); + gotLocalPayload = true; + state.postValue(new ReturnShardState.KeyAgreementListening(qrCode)); + }); + } + + @Override + @IoExecutor + public void onQrCodeDecoded(Result result) { + LOG.info("Got result from decoder"); + // Ignore results until the KeyAgreementTask is ready + if (!gotLocalPayload || gotRemotePayload) return; + try { + byte[] payloadBytes = result.getText().getBytes(ISO_8859_1); + if (LOG.isLoggable(INFO)) + LOG.info("Remote payload is " + payloadBytes.length + " bytes"); + Payload remotePayload = payloadParser.parse(payloadBytes); + gotRemotePayload = true; + requireNonNull(task).connectAndRunProtocol(remotePayload); + state.postValue(new ReturnShardState.QrCodeScanned()); + } catch (UnsupportedVersionException e) { + resetPayloadFlags(); + state.postValue(new ReturnShardState.Failed(e.isTooOld())); + } catch (IOException | IllegalArgumentException e) { + LOG.log(WARNING, "QR Code Invalid", e); + androidExecutor.runOnUiThread(() -> Toast.makeText(getApplication(), + R.string.qr_code_invalid, LENGTH_LONG).show()); + resetPayloadFlags(); + state.postValue(new ReturnShardState.Failed()); + } + } + + private void resetPayloadFlags() { + gotRemotePayload = false; + gotLocalPayload = false; + } + + @UiThread + private void startContactExchange(KeyAgreementResult result) { + TransportId t = result.getTransportId(); + DuplexTransportConnection conn = result.getConnection(); + SecretKey masterKey = result.getMasterKey(); + boolean alice = result.wasAlice(); + ioExecutor.execute(() -> { + try { + Contact contact = contactExchangeManager.exchangeContacts(conn, + masterKey, alice, true); + // Reuse the connection as a transport connection + connectionManager + .manageOutgoingConnection(contact.getId(), t, conn); + ReturnShardState.ContactExchangeResult.Success + success = + new ReturnShardState.ContactExchangeResult.Success( + contact.getAuthor()); + state.postValue( + new ReturnShardState.ContactExchangeFinished(success)); + } catch (ContactExistsException e) { + tryToClose(conn); + ReturnShardState.ContactExchangeResult.Error + error = new ReturnShardState.ContactExchangeResult.Error( + e.getRemoteAuthor()); + state.postValue( + new ReturnShardState.ContactExchangeFinished(error)); + } catch (DbException | IOException e) { + tryToClose(conn); + logException(LOG, WARNING, e); + ReturnShardState.ContactExchangeResult.Error + error = + new ReturnShardState.ContactExchangeResult.Error(null); + state.postValue( + new ReturnShardState.ContactExchangeFinished(error)); + } + }); + } + + private class BluetoothStateReceiver extends BroadcastReceiver { + @UiThread + @Override + public void onReceive(Context context, Intent intent) { + int scanMode = intent.getIntExtra(EXTRA_SCAN_MODE, -1); + LOG.info("Bluetooth scan mode changed: " + scanMode); + showQrCodeFragmentIfAllowed(); + } + } + + private void tryToClose(DuplexTransportConnection conn) { + try { + conn.getReader().dispose(true, true); + conn.getWriter().dispose(true); + } catch (IOException e) { + logException(LOG, WARNING, e); + } + } + + /** + * Set to true in onPostResume() and false in onPause(). This prevents the + * QR code fragment from being shown if onRequestPermissionsResult() is + * called while the activity is paused, which could cause a crash due to + * https://issuetracker.google.com/issues/37067655. + * TODO check if this is still happening with new permission requesting + */ + @UiThread + void setIsActivityResumed(boolean resumed) { + isActivityResumed = resumed; + // Workaround for + // https://code.google.com/p/android/issues/detail?id=190966 + showQrCodeFragmentIfAllowed(); + } + + @UiThread + void setBluetoothDecision(BluetoothDecision decision) { + bluetoothDecision = decision; + showQrCodeFragmentIfAllowed(); + } + + LiveEvent getCheckPermissions() { + return checkPermissions; + } + + LiveEvent getRequestBluetoothDiscoverable() { + return requestBluetoothDiscoverable; + } + + LiveEvent getShowQrCodeFragment() { + return showQrCodeFragment; + } + + LiveData getState() { + return state; + } + }