diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java index 71e9d9265..9336cc4bb 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java @@ -87,6 +87,7 @@ import org.briarproject.briar.android.socialbackup.DistributedBackupActivity; import org.briarproject.briar.android.socialbackup.ExistingBackupFragment; import org.briarproject.briar.android.socialbackup.OwnerRecoveryModeExplainerFragment; import org.briarproject.briar.android.socialbackup.RecoverActivity; +import org.briarproject.briar.android.socialbackup.ShardQrCodeFragment; import org.briarproject.briar.android.socialbackup.ShardsSentFragment; import org.briarproject.briar.android.socialbackup.ThresholdSelectorFragment; import org.briarproject.briar.android.socialbackup.creation.CreateBackupModule; @@ -258,6 +259,8 @@ public interface ActivityComponent { void inject(ThresholdSelectorFragment thresholdSelectorFragment); + void inject(ShardQrCodeFragment shardQrCodeFragment); + void inject(DistributedBackupActivity distributedBackupActivity); void inject(RecoverActivity recoverActivity); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/CameraException.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/CameraException.java index 0bff14ed7..cce6bf207 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/CameraException.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/CameraException.java @@ -2,7 +2,7 @@ package org.briarproject.briar.android.keyagreement; import java.io.IOException; -class CameraException extends IOException { +public class CameraException extends IOException { CameraException(String message) { super(message); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeDecoder.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeDecoder.java index 91f6f9dda..2f4ab7ed5 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeDecoder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeDecoder.java @@ -30,7 +30,7 @@ import static java.util.logging.Level.WARNING; @SuppressWarnings("deprecation") @MethodsNotNullByDefault @ParametersNotNullByDefault -class QrCodeDecoder implements PreviewConsumer, PreviewCallback { +public class QrCodeDecoder implements PreviewConsumer, PreviewCallback { private static final Logger LOG = Logger.getLogger(QrCodeDecoder.class.getName()); @@ -41,7 +41,7 @@ class QrCodeDecoder implements PreviewConsumer, PreviewCallback { private Camera camera = null; private int cameraIndex = 0; - QrCodeDecoder(ResultCallback callback) { + public QrCodeDecoder(ResultCallback callback) { this.callback = callback; } @@ -142,7 +142,7 @@ class QrCodeDecoder implements PreviewConsumer, PreviewCallback { } @NotNullByDefault - interface ResultCallback { + public interface ResultCallback { void handleResult(Result result); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeUtils.java index 617f84b99..658cffa1e 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeUtils.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/keyagreement/QrCodeUtils.java @@ -21,13 +21,13 @@ import static java.util.logging.Level.WARNING; import static org.briarproject.bramble.util.LogUtils.logException; @NotNullByDefault -class QrCodeUtils { +public class QrCodeUtils { private static final Logger LOG = Logger.getLogger(QrCodeUtils.class.getName()); @Nullable - static Bitmap createQrCode(DisplayMetrics dm, String input) { + public static Bitmap createQrCode(DisplayMetrics dm, String input) { int smallestDimen = Math.min(dm.widthPixels, dm.heightPixels); try { // Generate QR code diff --git a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/RecoverActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/RecoverActivity.java index 3407ef795..04f07efb1 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/RecoverActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/RecoverActivity.java @@ -3,14 +3,59 @@ package org.briarproject.briar.android.socialbackup; import android.os.Bundle; import android.widget.Toast; +import org.briarproject.bramble.api.keyagreement.KeyAgreementResult; 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 javax.annotation.Nullable; + +import androidx.annotation.UiThread; +import androidx.core.app.ActivityCompat; + +import static android.Manifest.permission.CAMERA; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static org.briarproject.briar.android.activity.RequestCodes.REQUEST_PERMISSION_CAMERA_LOCATION; + public class RecoverActivity extends BaseActivity implements BaseFragment.BaseFragmentListener, ExplainerDismissedListener, - ScanQrButtonListener { + ScanQrButtonListener, ShardQrCodeFragment.ShardQrCodeEventListener { + + @Override + public void keyAgreementFailed() { + + } + + @Nullable + @Override + public String keyAgreementWaiting() { + return null; + } + + @Nullable + @Override + public String keyAgreementStarted() { + return null; + } + + @Override + public void keyAgreementAborted(boolean remoteAborted) { + + } + + @Nullable + @Override + public String keyAgreementFinished(KeyAgreementResult result) { + return null; + } + + private enum Permission { + UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED + } + + private Permission cameraPermission = Permission.UNKNOWN; + private int numRecovered; @@ -47,11 +92,79 @@ public class RecoverActivity extends BaseActivity implements @Override public void scanQrButtonClicked() { - // TODO - Toast.makeText(this, - "coming soon...", - Toast.LENGTH_SHORT).show(); - finish(); + if (checkPermissions()) showQrCodeFragment(); + } + + + private void showQrCodeFragment() { + ShardQrCodeFragment f = ShardQrCodeFragment.newInstance(); + showNextFragment(f); + } + + private void requestPermissions() { + String[] permissions = new String[] {CAMERA}; + ActivityCompat.requestPermissions(this, permissions, + REQUEST_PERMISSION_CAMERA_LOCATION); + } + + @Override + @UiThread + public void onRequestPermissionsResult(int requestCode, + String[] permissions, int[] grantResults) { + if (requestCode != REQUEST_PERMISSION_CAMERA_LOCATION) + throw new AssertionError(); + if (gotPermission(CAMERA, permissions, grantResults)) { + cameraPermission = Permission.GRANTED; + } else if (shouldShowRationale(CAMERA)) { + cameraPermission = Permission.SHOW_RATIONALE; + } else { + cameraPermission = Permission.PERMANENTLY_DENIED; + } + // If a permission dialog has been shown, showing the QR code fragment + // on this call path would cause a crash due to + // https://code.google.com/p/android/issues/detail?id=190966. + // In that case the isResumed flag prevents the fragment from being + // shown here, and showQrCodeFragmentIfAllowed() will be called again + // from onPostResume(). + if (checkPermissions()) showQrCodeFragment(); + } + + private boolean gotPermission(String permission, String[] permissions, + int[] grantResults) { + for (int i = 0; i < permissions.length; i++) { + if (permission.equals(permissions[i])) + return grantResults[i] == PERMISSION_GRANTED; + } + return false; + } + + private boolean shouldShowRationale(String permission) { + return ActivityCompat.shouldShowRequestPermissionRationale(this, + permission); + } + + private boolean checkPermissions() { + if (areEssentialPermissionsGranted()) return true; + // If an essential permission has been permanently denied, ask the + // user to change the setting + if (cameraPermission == Permission.PERMANENTLY_DENIED) { + Toast.makeText(this, + "camera permission is denied", + Toast.LENGTH_SHORT).show(); +// showDenialDialog(R.string.permission_camera_title, +// R.string.permission_camera_denied_body); + return false; + } + if (cameraPermission == Permission.SHOW_RATIONALE) { +// showRationale(R.string.permission_camera_title, +// R.string.permission_camera_request_body); + Toast.makeText(this, + "camera permission - show rationale", + Toast.LENGTH_SHORT).show(); + } else { + requestPermissions(); + } + return false; } @Override @@ -59,4 +172,8 @@ public class RecoverActivity extends BaseActivity implements public void runOnDbThread(Runnable runnable) { throw new RuntimeException("Don't use this deprecated method here."); } + + private boolean areEssentialPermissionsGranted() { + return cameraPermission == Permission.GRANTED; + } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/ShardQrCodeFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/ShardQrCodeFragment.java new file mode 100644 index 000000000..e1d8ed5ea --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/socialbackup/ShardQrCodeFragment.java @@ -0,0 +1,381 @@ +package org.briarproject.briar.android.socialbackup; + +import android.content.Context; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import com.google.zxing.Result; + +import org.briarproject.bramble.api.UnsupportedVersionException; +import org.briarproject.bramble.api.event.Event; +import org.briarproject.bramble.api.event.EventBus; +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.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; +import org.briarproject.briar.android.activity.ActivityComponent; +import org.briarproject.briar.android.fragment.BaseEventFragment; +import org.briarproject.briar.android.keyagreement.CameraException; +import org.briarproject.briar.android.keyagreement.CameraView; +import org.briarproject.briar.android.keyagreement.ContactExchangeErrorFragment; +import org.briarproject.briar.android.keyagreement.QrCodeDecoder; +import org.briarproject.briar.android.keyagreement.QrCodeUtils; +import org.briarproject.briar.android.view.QrCodeView; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Provider; + +import androidx.annotation.UiThread; + +import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_NOSENSOR; +import static android.view.View.INVISIBLE; +import static android.view.View.VISIBLE; +import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; +import static android.widget.LinearLayout.HORIZONTAL; +import static android.widget.Toast.LENGTH_LONG; +import static java.util.logging.Level.INFO; +import static java.util.logging.Level.WARNING; +import static org.briarproject.bramble.util.LogUtils.logException; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class ShardQrCodeFragment extends BaseEventFragment + implements QrCodeDecoder.ResultCallback, QrCodeView.FullscreenListener { + + static final String TAG = org.briarproject.briar.android.keyagreement.KeyAgreementFragment.class.getName(); + + private static final Logger LOG = Logger.getLogger(TAG); + @SuppressWarnings("CharsetObjectCanBeUsed") // Requires minSdkVersion >= 19 + private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1"); + + @Inject + Provider keyAgreementTaskProvider; + @Inject + PayloadEncoder payloadEncoder; + @Inject + PayloadParser payloadParser; + @Inject + @IoExecutor + Executor ioExecutor; + @Inject + EventBus eventBus; + + private CameraView cameraView; + private LinearLayout cameraOverlay; + private View statusView; + private QrCodeView qrCodeView; + private TextView status; + + private boolean gotRemotePayload; + private volatile boolean gotLocalPayload; + private KeyAgreementTask task; + private ShardQrCodeEventListener + listener; + + public static ShardQrCodeFragment newInstance() { + Bundle args = new Bundle(); + ShardQrCodeFragment + fragment = new ShardQrCodeFragment(); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + listener = (ShardQrCodeEventListener) context; + } + + @Override + public void injectFragment(ActivityComponent component) { + component.inject(this); + } + + @Override + public String getUniqueTag() { + return TAG; + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_keyagreement_qr, container, + false); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + cameraView = view.findViewById(R.id.camera_view); + cameraOverlay = view.findViewById(R.id.camera_overlay); + statusView = view.findViewById(R.id.status_container); + status = view.findViewById(R.id.connect_status); + qrCodeView = view.findViewById(R.id.qr_code_view); + qrCodeView.setFullscreenListener(this); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + requireActivity().setRequestedOrientation(SCREEN_ORIENTATION_NOSENSOR); + cameraView.setPreviewConsumer(new QrCodeDecoder(this)); + } + + @Override + public void onStart() { + super.onStart(); + try { + cameraView.start(); + } catch (CameraException e) { + logCameraExceptionAndFinish(e); + } + startListening(); + } + + @Override + public void setFullscreen(boolean fullscreen) { + LinearLayout.LayoutParams statusParams, qrCodeParams; + if (fullscreen) { + // Grow the QR code view to fill its parent + statusParams = new LinearLayout.LayoutParams(0, 0, 0f); + qrCodeParams = new LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT, 1f); + } else { + // Shrink the QR code view to fill half its parent + if (cameraOverlay.getOrientation() == HORIZONTAL) { + statusParams = new LinearLayout.LayoutParams(0, MATCH_PARENT, 1f); + qrCodeParams = new LinearLayout.LayoutParams(0, MATCH_PARENT, 1f); + } else { + statusParams = new LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f); + qrCodeParams = new LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f); + } + } + statusView.setLayoutParams(statusParams); + qrCodeView.setLayoutParams(qrCodeParams); + cameraOverlay.invalidate(); + } + + @Override + public void onStop() { + super.onStop(); + stopListening(); + try { + cameraView.stop(); + } catch (CameraException e) { + logCameraExceptionAndFinish(e); + } + } + + @UiThread + private void logCameraExceptionAndFinish(CameraException e) { + logException(LOG, WARNING, e); + Toast.makeText(getActivity(), R.string.camera_error, + LENGTH_LONG).show(); + finish(); + } + + @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(); + }); + } + + @UiThread + private void reset() { + // If we've stopped the camera view, restart it + if (gotRemotePayload) { + try { + cameraView.start(); + } catch (CameraException e) { + logCameraExceptionAndFinish(e); + return; + } + } + statusView.setVisibility(INVISIBLE); + cameraView.setVisibility(VISIBLE); + gotRemotePayload = false; + gotLocalPayload = false; + startListening(); + } + + @UiThread + private void qrCodeScanned(String content) { + try { + byte[] payloadBytes = content.getBytes(ISO_8859_1); + if (LOG.isLoggable(INFO)) + LOG.info("Remote payload is " + payloadBytes.length + " bytes"); + Payload remotePayload = payloadParser.parse(payloadBytes); + gotRemotePayload = true; + cameraView.stop(); + cameraView.setVisibility(INVISIBLE); + statusView.setVisibility(VISIBLE); + status.setText(R.string.connecting_to_device); + task.connectAndRunProtocol(remotePayload); + } catch (UnsupportedVersionException e) { + reset(); + String msg; + if (e.isTooOld()) { + 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(ContactExchangeErrorFragment.newInstance(msg)); + } catch (CameraException e) { + logCameraExceptionAndFinish(e); + } catch (IOException | IllegalArgumentException e) { + LOG.log(WARNING, "QR Code Invalid", e); + reset(); + Toast.makeText(getActivity(), R.string.qr_code_invalid, + LENGTH_LONG).show(); + } + } + + @Override + public void eventOccurred(Event e) { + if (e instanceof KeyAgreementListeningEvent) { + KeyAgreementListeningEvent event = (KeyAgreementListeningEvent) e; + gotLocalPayload = true; + setQrCode(event.getLocalPayload()); + } else if (e instanceof KeyAgreementFailedEvent) { + keyAgreementFailed(); + } else if (e instanceof KeyAgreementWaitingEvent) { + keyAgreementWaiting(); + } else if (e instanceof KeyAgreementStartedEvent) { + keyAgreementStarted(); + } else if (e instanceof KeyAgreementAbortedEvent) { + KeyAgreementAbortedEvent event = (KeyAgreementAbortedEvent) e; + keyAgreementAborted(event.didRemoteAbort()); + } else if (e instanceof KeyAgreementFinishedEvent) { + keyAgreementFinished(((KeyAgreementFinishedEvent) e).getResult()); + } + } + + @UiThread + private void keyAgreementFailed() { + reset(); + listener.keyAgreementFailed(); + } + + @UiThread + private void keyAgreementWaiting() { + status.setText(listener.keyAgreementWaiting()); + } + + @UiThread + private void keyAgreementStarted() { + qrCodeView.setVisibility(INVISIBLE); + statusView.setVisibility(VISIBLE); + status.setText(listener.keyAgreementStarted()); + } + + @UiThread + private void keyAgreementAborted(boolean remoteAborted) { + reset(); + listener.keyAgreementAborted(remoteAborted); + } + + @UiThread + private void keyAgreementFinished(KeyAgreementResult result) { + statusView.setVisibility(VISIBLE); + status.setText(listener.keyAgreementFinished(result)); + } + + private void setQrCode(Payload localPayload) { + Context context = getContext(); + if (context == null) return; + DisplayMetrics dm = context.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); + runOnUiThreadUnlessDestroyed(() -> qrCodeView.setQrCode(qrCode)); + }); + } + + @Override + public void handleResult(Result result) { + runOnUiThreadUnlessDestroyed(() -> { + LOG.info("Got result from decoder"); + // Ignore results until the KeyAgreementTask is ready + if (!gotLocalPayload) return; + if (!gotRemotePayload) qrCodeScanned(result.getText()); + }); + } + + @Override + protected void finish() { + getActivity().getSupportFragmentManager().popBackStack(); + } + + @NotNullByDefault + interface ShardQrCodeEventListener { + + @UiThread + void keyAgreementFailed(); + + // Should return a string to be displayed as status. + @UiThread + @Nullable + String keyAgreementWaiting(); + + // Should return a string to be displayed as status. + @UiThread + @Nullable + String keyAgreementStarted(); + + // Will show an error fragment. + @UiThread + void keyAgreementAborted(boolean remoteAborted); + + // Should return a string to be displayed as status. + @UiThread + @Nullable + String keyAgreementFinished(KeyAgreementResult result); + } +} diff --git a/briar-api/src/main/java/org/briarproject/briar/api/keyagreement/KeyAgreementUtils.java b/briar-api/src/main/java/org/briarproject/briar/api/keyagreement/KeyAgreementUtils.java new file mode 100644 index 000000000..2e7da2322 --- /dev/null +++ b/briar-api/src/main/java/org/briarproject/briar/api/keyagreement/KeyAgreementUtils.java @@ -0,0 +1,35 @@ +package org.briarproject.briar.api.keyagreement; + +public class KeyAgreementUtils { + + public 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 + } + + public enum Permission { + UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED + } +}