diff --git a/briar-android/AndroidManifest.xml b/briar-android/AndroidManifest.xml
index cab5c80b9..cb723afa4 100644
--- a/briar-android/AndroidManifest.xml
+++ b/briar-android/AndroidManifest.xml
@@ -14,11 +14,13 @@
/>
+
+
@@ -165,6 +167,16 @@
android:value=".android.NavDrawerActivity"
/>
+
+
+
diff --git a/briar-android/build.gradle b/briar-android/build.gradle
index 81228e918..7e79c0dff 100644
--- a/briar-android/build.gradle
+++ b/briar-android/build.gradle
@@ -46,7 +46,7 @@ dependencyVerification {
'com.android.support:recyclerview-v7:7606373da0931a1e62588335465a0e390cd676c98117edab29220317495faefd',
'info.guardianproject.panic:panic:a7ed9439826db2e9901649892cf9afbe76f00991b768d8f4c26332d7c9406cb2',
'info.guardianproject.trustedintents:trustedintents:6221456d8821a8d974c2acf86306900237cf6afaaa94a4c9c44e161350f80f3e',
- 'com.android.support:support-annotations:f347a35b9748a4103b39a6714a77e2100f488d623fd6268e259c177b200e9d82'
+ 'de.hdodenhof:circleimageview:c76d936395b50705a3f98c9220c22d2599aeb9e609f559f6048975cfc1f686b8',
]
}
@@ -97,4 +97,4 @@ android {
lintOptions {
abortOnError false
}
-}
\ No newline at end of file
+}
diff --git a/briar-android/res/layout/fragment_keyagreement_qr.xml b/briar-android/res/layout/fragment_keyagreement_qr.xml
new file mode 100644
index 000000000..31f15925b
--- /dev/null
+++ b/briar-android/res/layout/fragment_keyagreement_qr.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/briar-android/res/values/strings.xml b/briar-android/res/values/strings.xml
index cf63cf76b..f75d8a90d 100644
--- a/briar-android/res/values/strings.xml
+++ b/briar-android/res/values/strings.xml
@@ -34,6 +34,7 @@
Contacts
No contacts
Add a Contact
+ Add a Contact - Step %1$d/%2$d
Choose the identity you want to use:
You must be face-to-face with the person you want to add as a contact. This will prevent anyone from impersonating you or reading your messages in future.
Continue
@@ -52,6 +53,14 @@
Codes do not match
This could mean that someone is trying to interfere with your connection
Contact added: %s
+ Contact %s already exists
+ Contact exchange failed
+ Scan QR code
+ The QR code is invalid
+ Connecting to device\u2026
+ Authenticating with device\u2026
+ Connection aborted by us! This could mean that someone is trying to interfere with your connection
+ Connection aborted by your contact! This could mean that someone is trying to interfere with your connection
No messages
Type message
Message sent
diff --git a/briar-android/src/org/briarproject/android/AndroidComponent.java b/briar-android/src/org/briarproject/android/AndroidComponent.java
index 4db188d9f..a00d6fb12 100644
--- a/briar-android/src/org/briarproject/android/AndroidComponent.java
+++ b/briar-android/src/org/briarproject/android/AndroidComponent.java
@@ -13,6 +13,9 @@ import org.briarproject.android.forum.ShareForumActivity;
import org.briarproject.android.forum.WriteForumPostActivity;
import org.briarproject.android.identity.CreateIdentityActivity;
import org.briarproject.android.invitation.AddContactActivity;
+import org.briarproject.android.keyagreement.ChooseIdentityFragment;
+import org.briarproject.android.keyagreement.KeyAgreementActivity;
+import org.briarproject.android.keyagreement.ShowQrCodeFragment;
import org.briarproject.android.panic.PanicPreferencesActivity;
import org.briarproject.android.panic.PanicResponderActivity;
import org.briarproject.plugins.AndroidPluginsModule;
@@ -44,6 +47,8 @@ public interface AndroidComponent extends CoreEagerSingletons {
void inject(AddContactActivity activity);
+ void inject(KeyAgreementActivity activity);
+
void inject(ConversationActivity activity);
void inject(CreateIdentityActivity activity);
@@ -68,6 +73,10 @@ public interface AndroidComponent extends CoreEagerSingletons {
void inject(ForumListFragment fragment);
+ void inject(ChooseIdentityFragment fragment);
+
+ void inject(ShowQrCodeFragment fragment);
+
// Eager singleton load
void inject(AndroidModule.EagerSingletons init);
diff --git a/briar-android/src/org/briarproject/android/BriarFragmentActivity.java b/briar-android/src/org/briarproject/android/BriarFragmentActivity.java
index 7b1e6ff53..6bef438cf 100644
--- a/briar-android/src/org/briarproject/android/BriarFragmentActivity.java
+++ b/briar-android/src/org/briarproject/android/BriarFragmentActivity.java
@@ -42,7 +42,8 @@ public abstract class BriarFragmentActivity extends BriarActivity {
@Override
public void onBackPressed() {
- if (getSupportFragmentManager().getBackStackEntryCount() == 0 &&
+ if (this instanceof NavDrawerActivity &&
+ getSupportFragmentManager().getBackStackEntryCount() == 0 &&
getSupportFragmentManager()
.findFragmentByTag(ContactListFragment.TAG) == null) {
/*
diff --git a/briar-android/src/org/briarproject/android/keyagreement/ChooseIdentityFragment.java b/briar-android/src/org/briarproject/android/keyagreement/ChooseIdentityFragment.java
new file mode 100644
index 000000000..4615cd728
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/keyagreement/ChooseIdentityFragment.java
@@ -0,0 +1,193 @@
+package org.briarproject.android.keyagreement;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.Spinner;
+
+import org.briarproject.R;
+import org.briarproject.android.AndroidComponent;
+import org.briarproject.android.fragment.BaseFragment;
+import org.briarproject.android.identity.CreateIdentityActivity;
+import org.briarproject.android.identity.LocalAuthorItem;
+import org.briarproject.android.identity.LocalAuthorItemComparator;
+import org.briarproject.android.identity.LocalAuthorSpinnerAdapter;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
+
+import java.util.Collection;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import static android.app.Activity.RESULT_OK;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+import static org.briarproject.android.identity.LocalAuthorItem.NEW;
+
+public class ChooseIdentityFragment extends BaseFragment
+ implements OnItemSelectedListener {
+
+ interface IdentitySelectedListener {
+ void identitySelected(AuthorId localAuthorId);
+ }
+
+ private static final Logger LOG =
+ Logger.getLogger(ChooseIdentityFragment.class.getName());
+
+ public static final String TAG = "ChooseIdentityFragment";
+
+ private static final int REQUEST_CREATE_IDENTITY = 1;
+
+ private IdentitySelectedListener lsnr;
+ private LocalAuthorSpinnerAdapter adapter;
+ private Spinner spinner;
+ private View button;
+
+ private AuthorId localAuthorId;
+
+ // Fields that are accessed from background threads must be volatile
+ @Inject
+ protected volatile IdentityManager identityManager;
+
+ public static ChooseIdentityFragment newInstance() {
+ Bundle args = new Bundle();
+ ChooseIdentityFragment fragment = new ChooseIdentityFragment();
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ try {
+ lsnr = (IdentitySelectedListener) context;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(
+ "Using class must implement IdentitySelectedListener");
+ }
+ }
+
+ @Override
+ public String getUniqueTag() {
+ return TAG;
+ }
+
+ @Override
+ public void injectActivity(AndroidComponent component) {
+ component.inject(this);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.invitation_bluetooth_start, container,
+ false);
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ adapter = new LocalAuthorSpinnerAdapter(getActivity(), false);
+ spinner = (Spinner) view.findViewById(R.id.spinner);
+ spinner.setAdapter(adapter);
+ spinner.setOnItemSelectedListener(this);
+
+ button = view.findViewById(R.id.continueButton);
+ button.setEnabled(false);
+ button.setOnClickListener(
+ new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ lsnr.identitySelected(localAuthorId);
+ }
+ });
+
+ loadLocalAuthors();
+ }
+
+ private void loadLocalAuthors() {
+ listener.runOnDbThread(new Runnable() {
+ public void run() {
+ try {
+ long now = System.currentTimeMillis();
+ Collection authors =
+ identityManager.getLocalAuthors();
+ long duration = System.currentTimeMillis() - now;
+ if (LOG.isLoggable(INFO))
+ LOG.info("Loading authors took " + duration + " ms");
+ displayLocalAuthors(authors);
+ } catch (DbException e) {
+ if (LOG.isLoggable(WARNING))
+ LOG.log(WARNING, e.toString(), e);
+ }
+ }
+ });
+ }
+
+ private void displayLocalAuthors(final Collection authors) {
+ listener.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ adapter.clear();
+ for (LocalAuthor a : authors)
+ adapter.add(new LocalAuthorItem(a));
+ adapter.sort(LocalAuthorItemComparator.INSTANCE);
+ // If a local author has been selected, select it again
+ if (localAuthorId == null) return;
+ int count = adapter.getCount();
+ for (int i = 0; i < count; i++) {
+ LocalAuthorItem item = adapter.getItem(i);
+ if (item == NEW) continue;
+ if (item.getLocalAuthor().getId().equals(localAuthorId)) {
+ spinner.setSelection(i);
+ return;
+ }
+ }
+ }
+ });
+ }
+
+ private void setLocalAuthorId(AuthorId authorId) {
+ localAuthorId = authorId;
+ button.setEnabled(localAuthorId != null);
+ }
+
+ public void onItemSelected(AdapterView> parent, View view, int position,
+ long id) {
+ LocalAuthorItem item = adapter.getItem(position);
+ if (item == NEW) {
+ setLocalAuthorId(null);
+ Intent i = new Intent(getActivity(), CreateIdentityActivity.class);
+ startActivityForResult(i, REQUEST_CREATE_IDENTITY);
+ } else {
+ setLocalAuthorId(item.getLocalAuthor().getId());
+ }
+ }
+
+ public void onNothingSelected(AdapterView> parent) {
+ setLocalAuthorId(null);
+ }
+
+ @Override
+ public void onActivityResult(int request, int result, Intent data) {
+ if (request == REQUEST_CREATE_IDENTITY && result == RESULT_OK) {
+ byte[] b = data.getByteArrayExtra("briar.LOCAL_AUTHOR_ID");
+ if (b == null) throw new IllegalStateException();
+ setLocalAuthorId(new AuthorId(b));
+ loadLocalAuthors();
+ } else
+ super.onActivityResult(request, result, data);
+ }
+}
diff --git a/briar-android/src/org/briarproject/android/keyagreement/KeyAgreementActivity.java b/briar-android/src/org/briarproject/android/keyagreement/KeyAgreementActivity.java
new file mode 100644
index 000000000..516e4c6e5
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/keyagreement/KeyAgreementActivity.java
@@ -0,0 +1,230 @@
+package org.briarproject.android.keyagreement;
+
+import android.os.Bundle;
+import android.support.v7.widget.Toolbar;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.briarproject.R;
+import org.briarproject.android.AndroidComponent;
+import org.briarproject.android.BriarFragmentActivity;
+import org.briarproject.android.fragment.BaseFragment;
+import org.briarproject.android.util.CustomAnimations;
+import org.briarproject.api.contact.ContactExchangeListener;
+import org.briarproject.api.contact.ContactExchangeTask;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.event.Event;
+import org.briarproject.api.event.EventBus;
+import org.briarproject.api.event.EventListener;
+import org.briarproject.api.event.KeyAgreementFinishedEvent;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorId;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.keyagreement.KeyAgreementResult;
+import org.briarproject.api.settings.SettingsManager;
+
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import static android.widget.Toast.LENGTH_LONG;
+import static java.util.logging.Level.WARNING;
+
+public class KeyAgreementActivity extends BriarFragmentActivity implements
+ BaseFragment.BaseFragmentListener,
+ ChooseIdentityFragment.IdentitySelectedListener, EventListener,
+ ContactExchangeListener {
+
+ private static final Logger LOG =
+ Logger.getLogger(KeyAgreementActivity.class.getName());
+
+ private static final String LOCAL_AUTHOR_ID = "briar.LOCAL_AUTHOR_ID";
+
+ private static final int STEP_ID = 1;
+ private static final int STEP_QR = 2;
+ private static final int STEPS = 2;
+
+ @Inject
+ protected EventBus eventBus;
+ @Inject
+ protected SettingsManager settingsManager;
+
+ private Toolbar toolbar;
+ private View progressContainer;
+ private TextView progressTitle;
+
+ private AuthorId localAuthorId;
+
+ @Inject
+ protected volatile ContactExchangeTask contactExchangeTask;
+ @Inject
+ protected volatile IdentityManager identityManager;
+
+ @Override
+ public void injectActivity(AndroidComponent component) {
+ component.inject(this);
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ @Override
+ public void onCreate(Bundle state) {
+ super.onCreate(state);
+ setContentView(R.layout.activity_with_loading);
+
+ toolbar = (Toolbar) findViewById(R.id.toolbar);
+ progressContainer = findViewById(R.id.container_progress);
+ progressTitle = (TextView) findViewById(R.id.title_progress_bar);
+
+ setSupportActionBar(toolbar);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ if (state != null) {
+ byte[] b = state.getByteArray(LOCAL_AUTHOR_ID);
+ if (b != null)
+ localAuthorId = new AuthorId(b);
+ }
+
+ showStep(localAuthorId == null ? STEP_ID : STEP_QR);
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ private void showStep(int step) {
+ getSupportActionBar().setTitle(
+ String.format(getString(R.string.add_contact_title_step), step,
+ STEPS));
+ switch (step) {
+ case STEP_QR:
+ startFragment(ShowQrCodeFragment.newInstance());
+ break;
+ case STEP_ID:
+ default:
+ startFragment(ChooseIdentityFragment.newInstance());
+ break;
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ eventBus.addListener(this);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ eventBus.removeListener(this);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle state) {
+ super.onSaveInstanceState(state);
+ if (localAuthorId != null) {
+ byte[] b = localAuthorId.getBytes();
+ state.putByteArray(LOCAL_AUTHOR_ID, b);
+ }
+ }
+
+ @Override
+ public void showLoadingScreen(boolean isBlocking, int stringId) {
+ if (isBlocking) {
+ CustomAnimations.animateHeight(toolbar, false, 250);
+ }
+ progressTitle.setText(stringId);
+ progressContainer.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void hideLoadingScreen() {
+ CustomAnimations.animateHeight(toolbar, true, 250);
+ progressContainer.setVisibility(View.INVISIBLE);
+ }
+
+ @Override
+ public void identitySelected(AuthorId localAuthorId) {
+ this.localAuthorId = localAuthorId;
+ showStep(STEP_QR);
+ }
+
+ @Override
+ public void eventOccurred(Event e) {
+ if (e instanceof KeyAgreementFinishedEvent) {
+ KeyAgreementFinishedEvent event = (KeyAgreementFinishedEvent) e;
+ keyAgreementFinished(event.getResult());
+ }
+ }
+
+ private void keyAgreementFinished(final KeyAgreementResult result) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ showLoadingScreen(false, R.string.exchanging_contact_details);
+ startContactExchange(result);
+ }
+ });
+ }
+
+ private void startContactExchange(final KeyAgreementResult result) {
+ runOnDbThread(new Runnable() {
+ @Override
+ public void run() {
+ LocalAuthor localAuthor;
+ // Load the local pseudonym
+ try {
+ localAuthor = identityManager.getLocalAuthor(localAuthorId);
+ } catch (DbException e) {
+ if (LOG.isLoggable(WARNING))
+ LOG.log(WARNING, e.toString(), e);
+ contactExchangeFailed();
+ return;
+ }
+
+ // Exchange contact details
+ contactExchangeTask.startExchange(KeyAgreementActivity.this,
+ localAuthor, result.getMasterKey(),
+ result.getConnection(), result.getTransportId(),
+ result.wasAlice(), true);
+ }
+ });
+ }
+
+ @Override
+ public void contactExchangeSucceeded(final Author remoteAuthor) {
+ runOnUiThread(new Runnable() {
+ public void run() {
+ String contactName = remoteAuthor.getName();
+ String format = getString(R.string.contact_added_toast);
+ String text = String.format(format, contactName);
+ Toast.makeText(KeyAgreementActivity.this, text, LENGTH_LONG)
+ .show();
+ finish();
+ }
+ });
+ }
+
+ @Override
+ public void duplicateContact(final Author remoteAuthor) {
+ runOnUiThread(new Runnable() {
+ public void run() {
+ String contactName = remoteAuthor.getName();
+ String format = getString(R.string.contact_already_exists);
+ String text = String.format(format, contactName);
+ Toast.makeText(KeyAgreementActivity.this, text, LENGTH_LONG)
+ .show();
+ finish();
+ }
+ });
+ }
+
+ @Override
+ public void contactExchangeFailed() {
+ runOnUiThread(new Runnable() {
+ public void run() {
+ Toast.makeText(KeyAgreementActivity.this,
+ R.string.contact_exchange_failed, LENGTH_LONG).show();
+ finish();
+ }
+ });
+ }
+}
diff --git a/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java b/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java
new file mode 100644
index 000000000..7845033df
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/keyagreement/ShowQrCodeFragment.java
@@ -0,0 +1,384 @@
+package org.briarproject.android.keyagreement;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.hardware.Camera;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.util.Base64;
+import android.view.Display;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.AlphaAnimation;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.google.zxing.Result;
+
+import org.briarproject.R;
+import org.briarproject.android.AndroidComponent;
+import org.briarproject.android.fragment.BaseEventFragment;
+import org.briarproject.android.util.AndroidUtils;
+import org.briarproject.android.util.CameraView;
+import org.briarproject.android.util.QrCodeUtils;
+import org.briarproject.android.util.QrCodeDecoder;
+import org.briarproject.api.event.Event;
+import org.briarproject.api.event.KeyAgreementAbortedEvent;
+import org.briarproject.api.event.KeyAgreementFailedEvent;
+import org.briarproject.api.event.KeyAgreementFinishedEvent;
+import org.briarproject.api.event.KeyAgreementListeningEvent;
+import org.briarproject.api.event.KeyAgreementStartedEvent;
+import org.briarproject.api.event.KeyAgreementWaitingEvent;
+import org.briarproject.api.keyagreement.KeyAgreementTask;
+import org.briarproject.api.keyagreement.KeyAgreementTaskFactory;
+import org.briarproject.api.keyagreement.Payload;
+import org.briarproject.api.keyagreement.PayloadEncoder;
+import org.briarproject.api.keyagreement.PayloadParser;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import static android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED;
+import static android.bluetooth.BluetoothAdapter.EXTRA_STATE;
+import static android.bluetooth.BluetoothAdapter.STATE_ON;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
+import static android.widget.LinearLayout.HORIZONTAL;
+import static android.widget.LinearLayout.VERTICAL;
+import static android.widget.Toast.LENGTH_LONG;
+import static java.util.logging.Level.WARNING;
+
+public class ShowQrCodeFragment extends BaseEventFragment
+ implements QrCodeDecoder.ResultCallback {
+
+ private static final Logger LOG =
+ Logger.getLogger(ShowQrCodeFragment.class.getName());
+
+ public static final String TAG = "ShowQrCodeFragment";
+
+ @Inject
+ protected KeyAgreementTaskFactory keyAgreementTaskFactory;
+ @Inject
+ protected PayloadEncoder payloadEncoder;
+ @Inject
+ protected PayloadParser payloadParser;
+
+ private LinearLayout qrLayout;
+ private CameraView cameraView;
+ private TextView status;
+ private ImageView qrCode;
+
+ private volatile KeyAgreementTask task;
+ private volatile boolean toggleBluetooth;
+ private volatile BluetoothAdapter adapter;
+ private BluetoothStateReceiver receiver;
+ private AtomicBoolean waitingForBluetooth = new AtomicBoolean();
+ private QrCodeDecoder decoder;
+ private boolean gotRemotePayload;
+
+ public static ShowQrCodeFragment newInstance() {
+ Bundle args = new Bundle();
+ ShowQrCodeFragment fragment = new ShowQrCodeFragment();
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public String getUniqueTag() {
+ return TAG;
+ }
+
+ @Override
+ public void injectActivity(AndroidComponent component) {
+ component.inject(this);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_keyagreement_qr, container,
+ false);
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ qrLayout = (LinearLayout) view.findViewById(R.id.qr_layout);
+ cameraView = (CameraView) view.findViewById(R.id.camera_view);
+ status = (TextView) view.findViewById(R.id.connect_status);
+ qrCode = (ImageView) view.findViewById(R.id.qr_code);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ getActivity().setRequestedOrientation(SCREEN_ORIENTATION_NOSENSOR);
+
+ decoder = new QrCodeDecoder(this);
+
+ Display display = getActivity().getWindowManager().getDefaultDisplay();
+ boolean portrait = display.getWidth() < display.getHeight();
+ qrLayout.setOrientation(portrait ? VERTICAL : HORIZONTAL);
+
+ // Only enable BT adapter if it is not already on.
+ adapter = BluetoothAdapter.getDefaultAdapter();
+ if (adapter != null)
+ toggleBluetooth = !adapter.isEnabled();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+
+ // Listen for changes to the Bluetooth state
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(ACTION_STATE_CHANGED);
+ receiver = new BluetoothStateReceiver();
+ getActivity().registerReceiver(receiver, filter);
+
+ if (adapter != null && toggleBluetooth) {
+ waitingForBluetooth.set(true);
+ toggleBluetooth(true);
+ } else
+ startListening();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (!gotRemotePayload) openCamera();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (!gotRemotePayload) releaseCamera();
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ stopListening();
+ if (receiver != null) getActivity().unregisterReceiver(receiver);
+ }
+
+ private void startListening() {
+ task = keyAgreementTaskFactory.getTask();
+ gotRemotePayload = false;
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ task.listen();
+ }
+ }).start();
+ }
+
+ private void stopListening() {
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ task.stopListening();
+ if (toggleBluetooth) toggleBluetooth(false);
+ }
+ }).start();
+ }
+
+ private void toggleBluetooth(boolean enable) {
+ if (adapter != null) {
+ AndroidUtils.enableBluetooth(adapter, enable);
+ }
+ }
+
+ private void openCamera() {
+ AsyncTask openTask =
+ new AsyncTask() {
+ @Override
+ protected Camera doInBackground(Void... unused) {
+ LOG.info("Opening camera");
+ try {
+ return Camera.open();
+ } catch (RuntimeException e) {
+ LOG.log(WARNING,
+ "Error opening camera, trying again", e);
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e2) {
+ LOG.info("Interrupted before second attempt");
+ return null;
+ }
+ try {
+ return Camera.open();
+ } catch (RuntimeException e2) {
+ LOG.log(WARNING, "Error opening camera", e2);
+ return null;
+ }
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Camera camera) {
+ if (camera == null) {
+ // TODO better solution?
+ getActivity().finish();
+ } else {
+ cameraView.start(camera, decoder, 0);
+ }
+ }
+ };
+ openTask.execute();
+ }
+
+ private void releaseCamera() {
+ LOG.info("Releasing camera");
+ try {
+ cameraView.stop();
+ } catch (RuntimeException e) {
+ LOG.log(WARNING, "Error releasing camera", e);
+ // TODO better solution
+ getActivity().finish();
+ }
+ }
+
+ private void reset() {
+ cameraView.setVisibility(View.VISIBLE);
+ startListening();
+ openCamera();
+ }
+
+ private void qrCodeScanned(String content) {
+ try {
+ // TODO use Base32
+ Payload remotePayload = payloadParser.parse(
+ Base64.decode(content, 0));
+ cameraView.setVisibility(View.GONE);
+ status.setText(R.string.connecting_to_device);
+ task.connectAndRunProtocol(remotePayload);
+ } catch (IOException e) {
+ // TODO show failure
+ 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;
+ 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) {
+ // We want to reuse the connection, so don't disable Bluetooth
+ toggleBluetooth = false;
+ }
+ }
+
+ private void setQrCode(final Payload localPayload) {
+ listener.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // TODO use Base32
+ String input = Base64.encodeToString(
+ payloadEncoder.encode(localPayload), 0);
+ qrCode.setImageBitmap(
+ QrCodeUtils.createQrCode(getActivity(), input));
+ // Simple fade-in animation
+ AlphaAnimation anim = new AlphaAnimation(0.0f, 1.0f);
+ anim.setDuration(200);
+ qrCode.startAnimation(anim);
+ }
+ });
+ }
+
+ private void keyAgreementFailed() {
+ listener.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ reset();
+ // TODO show failure somewhere persistent?
+ Toast.makeText(getActivity(), R.string.connection_failed,
+ LENGTH_LONG).show();
+ }
+ });
+ }
+
+ private void keyAgreementWaiting() {
+ listener.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ status.setText(R.string.waiting_for_contact);
+ }
+ });
+ }
+
+ private void keyAgreementStarted() {
+ listener.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ listener.showLoadingScreen(false,
+ R.string.authenticating_with_device);
+ }
+ });
+ }
+
+ private void keyAgreementAborted(final boolean remoteAborted) {
+ listener.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ reset();
+ listener.hideLoadingScreen();
+ // TODO show abort somewhere persistent?
+ Toast.makeText(getActivity(),
+ remoteAborted ? R.string.connection_aborted_remote :
+ R.string.connection_aborted_local, LENGTH_LONG)
+ .show();
+ }
+ });
+ }
+
+ @Override
+ public void handleResult(final Result result) {
+ listener.runOnUiThread(new Runnable() {
+ public void run() {
+ LOG.info("Got result from decoder");
+ if (!gotRemotePayload) {
+ gotRemotePayload = true;
+ releaseCamera();
+ qrCodeScanned(result.getText());
+ }
+ }
+ });
+ }
+
+ private class BluetoothStateReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(Context ctx, Intent intent) {
+ int state = intent.getIntExtra(EXTRA_STATE, 0);
+ if (state == STATE_ON && waitingForBluetooth.get()) {
+ LOG.info("Bluetooth enabled");
+ waitingForBluetooth.set(false);
+ startListening();
+ }
+ }
+ }
+}
diff --git a/briar-android/src/org/briarproject/android/util/CameraView.java b/briar-android/src/org/briarproject/android/util/CameraView.java
new file mode 100644
index 000000000..fcd9320a0
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/CameraView.java
@@ -0,0 +1,258 @@
+package org.briarproject.android.util;
+
+import android.content.Context;
+import android.hardware.Camera;
+import android.hardware.Camera.AutoFocusCallback;
+import android.hardware.Camera.CameraInfo;
+import android.hardware.Camera.Parameters;
+import android.hardware.Camera.Size;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.logging.Logger;
+
+import static android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_AUTO;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_EDOF;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_FIXED;
+import static android.hardware.Camera.Parameters.FOCUS_MODE_MACRO;
+import static android.hardware.Camera.Parameters.SCENE_MODE_BARCODE;
+import static android.view.SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+
+@SuppressWarnings("deprecation")
+public class CameraView extends SurfaceView implements SurfaceHolder.Callback,
+ AutoFocusCallback {
+
+ private static final int AUTO_FOCUS_RETRY_DELAY = 5000; // Milliseconds
+ private static final Logger LOG =
+ Logger.getLogger(CameraView.class.getName());
+
+ private Camera camera = null;
+ private PreviewConsumer previewConsumer = null;
+ private int displayOrientation = 0, surfaceWidth = 0, surfaceHeight = 0;
+ private boolean autoFocus = false, surfaceExists = false;
+
+ public CameraView(Context context) {
+ super(context);
+ initialize();
+ }
+
+ public CameraView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize();
+ }
+
+ public CameraView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ initialize();
+ }
+
+ private void initialize() {
+ setKeepScreenOn(true);
+ SurfaceHolder holder = getHolder();
+ if (Build.VERSION.SDK_INT < 11)
+ holder.setType(SURFACE_TYPE_PUSH_BUFFERS);
+ holder.addCallback(this);
+ }
+
+ public void start(Camera camera, PreviewConsumer previewConsumer,
+ int rotationDegrees) {
+ this.camera = camera;
+ this.previewConsumer = previewConsumer;
+ setDisplayOrientation(rotationDegrees);
+ Parameters params = camera.getParameters();
+ setFocusMode(params);
+ setPreviewSize(params);
+ applyParameters(params);
+ if (surfaceExists) startPreview(getHolder());
+ }
+
+ public void stop() {
+ stopPreview();
+ try {
+ camera.release();
+ } catch (RuntimeException e) {
+ LOG.log(WARNING, "Error releasing camera", e);
+ }
+ camera = null;
+ }
+
+ private void startPreview(SurfaceHolder holder) {
+ try {
+ camera.setPreviewDisplay(holder);
+ camera.startPreview();
+ if (autoFocus) camera.autoFocus(this);
+ previewConsumer.start(camera);
+ } catch (IOException e) {
+ LOG.log(WARNING, "Error starting camera preview", e);
+ }
+ }
+
+ private void stopPreview() {
+ try {
+ previewConsumer.stop();
+ if (autoFocus) camera.cancelAutoFocus();
+ camera.stopPreview();
+ } catch (RuntimeException e) {
+ LOG.log(WARNING, "Error stopping camera preview", e);
+ }
+ }
+
+ private void setDisplayOrientation(int rotationDegrees) {
+ int orientation;
+ CameraInfo info = new CameraInfo();
+ Camera.getCameraInfo(0, info);
+ if (info.facing == CAMERA_FACING_FRONT) {
+ orientation = (info.orientation + rotationDegrees) % 360;
+ orientation = (360 - orientation) % 360;
+ } else {
+ orientation = (info.orientation - rotationDegrees + 360) % 360;
+ }
+ if(LOG.isLoggable(INFO))
+ LOG.info("Display orientation " + orientation + " degrees");
+ camera.setDisplayOrientation(orientation);
+ displayOrientation = orientation;
+ }
+
+ private void setFocusMode(Parameters params) {
+ if (Build.VERSION.SDK_INT >= 15 &&
+ params.isVideoStabilizationSupported()) {
+ LOG.info("Enabling video stabilisation");
+ params.setVideoStabilization(true);
+ }
+ // This returns null on the HTC Wildfire S
+ List sceneModes = params.getSupportedSceneModes();
+ if (sceneModes == null) sceneModes = Collections.emptyList();
+ List focusModes = params.getSupportedFocusModes();
+ if (LOG.isLoggable(INFO)) {
+ LOG.info("Scene modes: " + sceneModes);
+ LOG.info("Focus modes: " + focusModes);
+ }
+ if (sceneModes.contains(SCENE_MODE_BARCODE)) {
+ LOG.info("Setting scene mode to barcode");
+ params.setSceneMode(SCENE_MODE_BARCODE);
+ } else if (Build.VERSION.SDK_INT >= 14 &&
+ focusModes.contains(FOCUS_MODE_CONTINUOUS_PICTURE)) {
+ LOG.info("Setting focus mode to continuous picture");
+ params.setFocusMode(FOCUS_MODE_CONTINUOUS_PICTURE);
+ } else if (focusModes.contains(FOCUS_MODE_CONTINUOUS_VIDEO)) {
+ LOG.info("Setting focus mode to continuous video");
+ params.setFocusMode(FOCUS_MODE_CONTINUOUS_VIDEO);
+ } else if (focusModes.contains(FOCUS_MODE_EDOF)) {
+ LOG.info("Setting focus mode to EDOF");
+ params.setFocusMode(FOCUS_MODE_EDOF);
+ } else if (focusModes.contains(FOCUS_MODE_MACRO)) {
+ LOG.info("Setting focus mode to macro");
+ params.setFocusMode(FOCUS_MODE_MACRO);
+ autoFocus = true;
+ } else if (focusModes.contains(FOCUS_MODE_AUTO)) {
+ LOG.info("Setting focus mode to auto");
+ params.setFocusMode(FOCUS_MODE_AUTO);
+ autoFocus = true;
+ } else if (focusModes.contains(FOCUS_MODE_FIXED)) {
+ LOG.info("Setting focus mode to fixed");
+ params.setFocusMode(FOCUS_MODE_FIXED);
+ } else {
+ LOG.info("No suitable focus mode");
+ }
+ params.setZoom(0);
+ }
+
+ private void setPreviewSize(Parameters params) {
+ if (surfaceWidth == 0 || surfaceHeight == 0) return;
+ float idealRatio = (float) surfaceWidth / surfaceHeight;
+ DisplayMetrics screen = getContext().getResources().getDisplayMetrics();
+ int screenMax = Math.max(screen.widthPixels, screen.heightPixels);
+ boolean rotatePreview = displayOrientation % 180 == 90;
+ List sizes = params.getSupportedPreviewSizes();
+ Size bestSize = null;
+ float bestScore = 0;
+ for (Size size : sizes) {
+ int width = rotatePreview ? size.height : size.width;
+ int height = rotatePreview ? size.width : size.height;
+ float ratio = (float) width / height;
+ float stretch = Math.max(ratio / idealRatio, idealRatio / ratio);
+ int pixels = width * height;
+ float score = width * height / stretch;
+ if (LOG.isLoggable(INFO)) {
+ LOG.info("Size " + size.width + "x" + size.height
+ + ", stretch " + stretch + ", pixels " + pixels
+ + ", score " + score);
+ }
+ // Large preview sizes can crash older devices
+ int maxDimension = Math.max(width, height);
+ if (Build.VERSION.SDK_INT < 14 && maxDimension > screenMax) {
+ LOG.info("Too large for screen");
+ continue;
+ }
+ if (bestSize == null || score > bestScore) {
+ bestSize = size;
+ bestScore = score;
+ }
+ }
+ if (bestSize != null) {
+ if(LOG.isLoggable(INFO))
+ LOG.info("Best size " + bestSize.width + "x" + bestSize.height);
+ params.setPreviewSize(bestSize.width, bestSize.height);
+ }
+ }
+
+ private void applyParameters(Parameters params) {
+ try {
+ camera.setParameters(params);
+ } catch (RuntimeException e) {
+ LOG.log(WARNING, "Error setting camera parameters", e);
+ }
+ }
+
+ public void surfaceCreated(SurfaceHolder holder) {
+ LOG.info("Surface created");
+ surfaceExists = true;
+ if (camera != null) startPreview(holder);
+ }
+
+ public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
+ if (LOG.isLoggable(INFO)) LOG.info("Surface changed: " + w + "x" + h);
+ surfaceWidth = w;
+ surfaceHeight = h;
+ if (camera == null) return; // We are stopped
+ stopPreview();
+ Parameters params = camera.getParameters();
+ setPreviewSize(params);
+ applyParameters(params);
+ startPreview(holder);
+ }
+
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ LOG.info("Surface destroyed");
+ surfaceExists = false;
+ holder.removeCallback(this);
+ }
+
+ public void onAutoFocus(boolean success, final Camera camera) {
+ LOG.info("Auto focus succeeded: " + success);
+ postDelayed(new Runnable() {
+ public void run() {
+ retryAutoFocus();
+ }
+ }, AUTO_FOCUS_RETRY_DELAY);
+ }
+
+ private void retryAutoFocus() {
+ try {
+ if (camera != null) camera.autoFocus(this);
+ } catch (RuntimeException e) {
+ LOG.log(WARNING, "Error retrying auto focus", e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/briar-android/src/org/briarproject/android/util/PreviewConsumer.java b/briar-android/src/org/briarproject/android/util/PreviewConsumer.java
new file mode 100644
index 000000000..e2fb6eed9
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/PreviewConsumer.java
@@ -0,0 +1,11 @@
+package org.briarproject.android.util;
+
+import android.hardware.Camera;
+
+@SuppressWarnings("deprecation")
+public interface PreviewConsumer {
+
+ void start(Camera camera);
+
+ void stop();
+}
diff --git a/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java b/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java
new file mode 100644
index 000000000..5fdc54c39
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/QrCodeDecoder.java
@@ -0,0 +1,100 @@
+package org.briarproject.android.util;
+
+import android.hardware.Camera;
+import android.hardware.Camera.PreviewCallback;
+import android.hardware.Camera.Size;
+import android.os.AsyncTask;
+
+import com.google.zxing.BinaryBitmap;
+import com.google.zxing.LuminanceSource;
+import com.google.zxing.PlanarYUVLuminanceSource;
+import com.google.zxing.Reader;
+import com.google.zxing.ReaderException;
+import com.google.zxing.Result;
+import com.google.zxing.common.HybridBinarizer;
+import com.google.zxing.qrcode.QRCodeReader;
+
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.INFO;
+
+@SuppressWarnings("deprecation")
+public class QrCodeDecoder implements PreviewConsumer, PreviewCallback {
+
+ private static final Logger LOG =
+ Logger.getLogger(QrCodeDecoder.class.getName());
+
+ private final Reader reader = new QRCodeReader();
+ private final ResultCallback callback;
+
+ private boolean stopped = false;
+
+ public QrCodeDecoder(ResultCallback callback) {
+ this.callback = callback;
+ }
+
+ public void start(Camera camera) {
+ stopped = false;
+ askForPreviewFrame(camera);
+ }
+
+ public void stop() {
+ stopped = true;
+ }
+
+ private void askForPreviewFrame(Camera camera) {
+ if (!stopped) camera.setOneShotPreviewCallback(this);
+ }
+
+ public void onPreviewFrame(byte[] data, Camera camera) {
+ if (!stopped) {
+ Size size = camera.getParameters().getPreviewSize();
+ new DecoderTask(camera, data, size.width, size.height).execute();
+ }
+ }
+
+ private class DecoderTask extends AsyncTask {
+
+ final Camera camera;
+ final byte[] data;
+ final int width, height;
+
+ DecoderTask(Camera camera, byte[] data, int width, int height) {
+ this.camera = camera;
+ this.data = data;
+ this.width = width;
+ this.height = height;
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ long now = System.currentTimeMillis();
+ LuminanceSource src = new PlanarYUVLuminanceSource(data, width,
+ height, 0, 0, width, height, false);
+ BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(src));
+ Result result = null;
+ try {
+ result = reader.decode(bitmap);
+ } catch (ReaderException e) {
+ return null; // No barcode found
+ } finally {
+ reader.reset();
+ }
+ long duration = System.currentTimeMillis() - now;
+ if (LOG.isLoggable(INFO))
+ LOG.info("Decoding barcode took " + duration + " ms");
+ callback.handleResult(result);
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ askForPreviewFrame(camera);
+ }
+ }
+
+ public interface ResultCallback {
+
+ void handleResult(Result result);
+ }
+}
diff --git a/briar-android/src/org/briarproject/android/util/QrCodeUtils.java b/briar-android/src/org/briarproject/android/util/QrCodeUtils.java
new file mode 100644
index 000000000..ef8ff6ec6
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/util/QrCodeUtils.java
@@ -0,0 +1,51 @@
+package org.briarproject.android.util;
+
+import android.app.Activity;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.util.DisplayMetrics;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.WARNING;
+
+public class QrCodeUtils {
+
+ private static final Logger LOG =
+ Logger.getLogger(QrCodeUtils.class.getName());
+
+ public static Bitmap createQrCode(Activity activity, String input) {
+ // Get narrowest screen dimension
+ DisplayMetrics dm = new DisplayMetrics();
+ activity.getWindowManager().getDefaultDisplay().getMetrics(dm);
+ int smallestDimen = Math.min(dm.widthPixels, dm.heightPixels);
+ try {
+ // Generate QR code
+ final BitMatrix encoded = new QRCodeWriter().encode(
+ input, BarcodeFormat.QR_CODE, smallestDimen, smallestDimen);
+ // Convert QR code to Bitmap
+ int width = encoded.getWidth();
+ int height = encoded.getHeight();
+ int[] pixels = new int[width * height];
+ for (int x = 0; x < width; x++) {
+ for (int y = 0; y < height; y++) {
+ pixels[y * width + x] =
+ encoded.get(x, y) ? Color.BLACK : Color.WHITE;
+ }
+ }
+ Bitmap qr = Bitmap.createBitmap(width, height,
+ Bitmap.Config.ARGB_8888);
+ qr.setPixels(pixels, 0, width, 0, 0, width, height);
+ return qr;
+ } catch (WriterException e) {
+ if (LOG.isLoggable(WARNING))
+ LOG.log(WARNING, e.toString(), e);
+ return null;
+ }
+ }
+}
diff --git a/briar-core/build.gradle b/briar-core/build.gradle
index 7fe4201a7..94452e1e0 100644
--- a/briar-core/build.gradle
+++ b/briar-core/build.gradle
@@ -13,12 +13,14 @@ dependencies {
compile fileTree(dir: 'libs', include: '*.jar')
compile "com.madgag.spongycastle:core:1.54.0.0"
compile "com.h2database:h2:1.4.190"
+ compile "com.google.zxing:core:3.2.1"
}
dependencyVerification {
verify = [
'com.madgag.spongycastle:core:1e7fa4b19ccccd1011364ab838d0b4702470c178bbbdd94c5c90b2d4d749ea1e',
'com.h2database:h2:23ba495a07bbbb3bd6c3084d10a96dad7a23741b8b6d64b213459a784195a98c',
+ 'com.google.zxing:core:b4d82452e7a6bf6ec2698904b332431717ed8f9a850224f295aec89de80f2259',
]
}