diff --git a/briar-android/AndroidManifest.xml b/briar-android/AndroidManifest.xml index cab5c80b94ca3db32355596785e7ee1a249a4138..cb723afa40e7011d2ce44d7eb0810328c5093bc1 100644 --- a/briar-android/AndroidManifest.xml +++ b/briar-android/AndroidManifest.xml @@ -14,11 +14,13 @@ /> <uses-feature android:name="android.hardware.bluetooth"/> + <uses-feature android:name="android.hardware.camera" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> + <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.READ_LOGS"/> <uses-permission android:name="android.permission.VIBRATE" /> @@ -165,6 +167,16 @@ android:value=".android.NavDrawerActivity" /> </activity> + <activity + android:name=".android.keyagreement.KeyAgreementActivity" + android:label="@string/add_contact_title" + android:theme="@style/BriarThemeNoActionBar.Default" + android:parentActivityName=".android.NavDrawerActivity"> + <meta-data + android:name="android.support.PARENT_ACTIVITY" + android:value=".android.NavDrawerActivity" + /> + </activity> <activity android:name=".android.StartupFailureActivity" android:label="@string/startup_failed_activity_title"> diff --git a/briar-android/build.gradle b/briar-android/build.gradle index 81228e91863e36d784813798f1c84c57615a3254..7e79c0dffb8beccb996572f080d61001d71f22e5 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 0000000000000000000000000000000000000000..31f15925b70a355c35019d28c9cd81b35d81be5d --- /dev/null +++ b/briar-android/res/layout/fragment_keyagreement_qr.xml @@ -0,0 +1,59 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + android:id="@+id/qr_layout" + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + android:weightSum="2"> + + <FrameLayout + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:background="@android:color/black" + android:gravity="center"> + + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@android:color/background_light"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:gravity="center" + android:orientation="vertical" + android:padding="@dimen/margin_medium"> + + <ProgressBar + style="?android:attr/progressBarStyleInverse" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:paddingTop="@dimen/margin_large"/> + + <TextView + android:id="@+id/connect_status" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center" + android:paddingTop="@dimen/margin_large" + tools:text="Connection failed"/> + </LinearLayout> + </RelativeLayout> + + <org.briarproject.android.util.CameraView + android:id="@+id/camera_view" + android:layout_width="match_parent" + android:layout_height="match_parent"/> + </FrameLayout> + + <ImageView + android:id="@+id/qr_code" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:scaleType="fitCenter"/> +</LinearLayout> \ No newline at end of file diff --git a/briar-android/res/values/strings.xml b/briar-android/res/values/strings.xml index cf63cf76bbcfb443dd4c21583f4121cfd6685ed5..f75d8a90d2a549e18c3bbd8fc1bd0938867c8f70 100644 --- a/briar-android/res/values/strings.xml +++ b/briar-android/res/values/strings.xml @@ -34,6 +34,7 @@ <string name="contact_list_title">Contacts</string> <string name="no_contacts">No contacts</string> <string name="add_contact_title">Add a Contact</string> + <string name="add_contact_title_step">Add a Contact - Step %1$d/%2$d</string> <string name="your_nickname">Choose the identity you want to use:</string> <string name="face_to_face">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.</string> <string name="continue_button">Continue</string> @@ -52,6 +53,14 @@ <string name="codes_do_not_match">Codes do not match</string> <string name="interfering">This could mean that someone is trying to interfere with your connection</string> <string name="contact_added_toast">Contact added: %s</string> + <string name="contact_already_exists">Contact %s already exists</string> + <string name="contact_exchange_failed">Contact exchange failed</string> + <string name="scan_qr_code">Scan QR code</string> + <string name="qr_code_invalid">The QR code is invalid</string> + <string name="connecting_to_device">Connecting to device\u2026</string> + <string name="authenticating_with_device">Authenticating with device\u2026</string> + <string name="connection_aborted_local">Connection aborted by us! This could mean that someone is trying to interfere with your connection</string> + <string name="connection_aborted_remote">Connection aborted by your contact! This could mean that someone is trying to interfere with your connection</string> <string name="no_private_messages">No messages</string> <string name="private_message_hint">Type message</string> <string name="message_sent_toast">Message sent</string> diff --git a/briar-android/src/org/briarproject/android/AndroidComponent.java b/briar-android/src/org/briarproject/android/AndroidComponent.java index 4db188d9f60f2fa9618ec5333aada3a0c4f44542..a00d6fb1293179d1ed9f9282416f6f7e8e7494b2 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 7b1e6ff535aa49cca6fc6778d06bd705daf62229..6bef438cf98803912918baaee7c8ab2ea79e916a 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 0000000000000000000000000000000000000000..4615cd728625345b440d55e4e4fa8695d3f92e38 --- /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<LocalAuthor> 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<LocalAuthor> 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 0000000000000000000000000000000000000000..516e4c6e5a8e58c5bc751bac92c6891cf2a110e7 --- /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 0000000000000000000000000000000000000000..7845033dfa01ddff738cf84edbd41ff2db54aebb --- /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<Void, Void, Camera> openTask = + new AsyncTask<Void, Void, Camera>() { + @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 0000000000000000000000000000000000000000..fcd9320a0705ca969476be2663918e5fa63598bb --- /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<String> sceneModes = params.getSupportedSceneModes(); + if (sceneModes == null) sceneModes = Collections.emptyList(); + List<String> 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<Size> 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 0000000000000000000000000000000000000000..e2fb6eed9556dc44bcb79246f9aaf6179ac112f9 --- /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 0000000000000000000000000000000000000000..5fdc54c39478a45b8c56ccd68aa8296406092252 --- /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<Void, Void, Void> { + + 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 0000000000000000000000000000000000000000..ef8ff6ec6714e4c6a8f444d6c26a011053a9f21d --- /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 7fe4201a76c6f6e93d5f4aa4857015fcdf92e0cf..94452e1e08860a5dfc8fd53f3bc6b2e4515e7402 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', ] }