diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 538f960447..3045165bbf 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -192,6 +192,10 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/device_list_item_view.xml b/res/layout/device_list_item_view.xml new file mode 100644 index 0000000000..d62de7fbc6 --- /dev/null +++ b/res/layout/device_list_item_view.xml @@ -0,0 +1,33 @@ + + + + + + + + + + \ No newline at end of file diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 9ca060e1fc..1c3d9b7639 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -122,6 +122,7 @@ + diff --git a/res/values/strings.xml b/res/values/strings.xml index 8aa13a829b..eada056c18 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -166,6 +166,20 @@ Now %d min + + Disconnect \'%s\'? + By disconnecting this device, it will no longer be able to send or receive messages. + Network connection failed... + Try again + Disconnecting device.. + Disconnecting device + Network failed! + + + Unnamed device + Created %s + Last active %s + Share with @@ -283,6 +297,7 @@ No device found. Network error. Invalid QR code. + Sorry, you have too many devices registered already, try removing some... Enter passphrase @@ -532,6 +547,9 @@ Loading countries... Search + + No devices paired... + Could not grab logs from your device. You can still use ADB to get debug logs instead. Thanks for your help! @@ -715,6 +733,7 @@ All images All images with %1$s Message Details + Manage paired devices Import / export @@ -959,6 +978,7 @@ Transport icon + diff --git a/res/values/themes.xml b/res/values/themes.xml index 62171cc14c..6ac92c8023 100644 --- a/res/values/themes.xml +++ b/res/values/themes.xml @@ -173,6 +173,7 @@ @drawable/ic_app_protection_black @drawable/ic_brightness_6_black @drawable/ic_delete_black + @drawable/ic_devices_black_48dp @drawable/ic_advanced_black @style/BetterPickersDialogFragment.Light @@ -297,6 +298,7 @@ @drawable/ic_app_protection_gray @drawable/ic_brightness_6_gray @drawable/ic_delete_gray + @drawable/ic_devices_grey600_48dp @drawable/ic_advanced_gray @style/BetterPickersDialogFragment diff --git a/res/xml/preferences.xml b/res/xml/preferences.xml index ff44032dcb..bea5750b3d 100644 --- a/res/xml/preferences.xml +++ b/res/xml/preferences.xml @@ -21,6 +21,12 @@ android:title="@string/preferences__delete_old_messages" android:icon="?pref_ic_storage"/> + + + + diff --git a/src/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java b/src/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java index cef095caec..733ee79da8 100644 --- a/src/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java +++ b/src/org/thoughtcrime/securesms/ApplicationPreferencesActivity.java @@ -55,6 +55,7 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActionBarA private static final String PREFERENCE_CATEGORY_APP_PROTECTION = "preference_category_app_protection"; private static final String PREFERENCE_CATEGORY_APPEARANCE = "preference_category_appearance"; private static final String PREFERENCE_CATEGORY_STORAGE = "preference_category_storage"; + private static final String PREFERENCE_CATEGORY_DEVICES = "preference_category_devices"; private static final String PREFERENCE_CATEGORY_ADVANCED = "preference_category_advanced"; private final DynamicTheme dynamicTheme = new DynamicTheme(); @@ -131,6 +132,8 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActionBarA .setOnPreferenceClickListener(new CategoryClickListener(masterSecret, PREFERENCE_CATEGORY_APPEARANCE)); this.findPreference(PREFERENCE_CATEGORY_STORAGE) .setOnPreferenceClickListener(new CategoryClickListener(masterSecret, PREFERENCE_CATEGORY_STORAGE)); + this.findPreference(PREFERENCE_CATEGORY_DEVICES) + .setOnPreferenceClickListener(new CategoryClickListener(masterSecret, PREFERENCE_CATEGORY_DEVICES)); this.findPreference(PREFERENCE_CATEGORY_ADVANCED) .setOnPreferenceClickListener(new CategoryClickListener(masterSecret, PREFERENCE_CATEGORY_ADVANCED)); } @@ -166,7 +169,7 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActionBarA @Override public boolean onPreferenceClick(Preference preference) { - Fragment fragment; + Fragment fragment = null; switch (category) { case PREFERENCE_CATEGORY_SMS_MMS: @@ -184,6 +187,10 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActionBarA case PREFERENCE_CATEGORY_STORAGE: fragment = new StoragePreferenceFragment(); break; + case PREFERENCE_CATEGORY_DEVICES: + Intent intent = new Intent(getActivity(), DeviceListActivity.class); + startActivity(intent); + break; case PREFERENCE_CATEGORY_ADVANCED: fragment = new AdvancedPreferenceFragment(); break; @@ -191,15 +198,17 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActionBarA throw new AssertionError(); } - Bundle args = new Bundle(); - args.putParcelable("master_secret", masterSecret); - fragment.setArguments(args); + if (fragment != null) { + Bundle args = new Bundle(); + args.putParcelable("master_secret", masterSecret); + fragment.setArguments(args); - FragmentManager fragmentManager = getActivity().getSupportFragmentManager(); - FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); - fragmentTransaction.replace(android.R.id.content, fragment); - fragmentTransaction.addToBackStack(null); - fragmentTransaction.commit(); + FragmentManager fragmentManager = getActivity().getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + fragmentTransaction.replace(android.R.id.content, fragment); + fragmentTransaction.addToBackStack(null); + fragmentTransaction.commit(); + } return true; } diff --git a/src/org/thoughtcrime/securesms/DeviceListActivity.java b/src/org/thoughtcrime/securesms/DeviceListActivity.java new file mode 100644 index 0000000000..895a4b0c53 --- /dev/null +++ b/src/org/thoughtcrime/securesms/DeviceListActivity.java @@ -0,0 +1,211 @@ +package org.thoughtcrime.securesms; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.ListFragment; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.Loader; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.Toast; + +import com.afollestad.materialdialogs.AlertDialogWrapper; + +import org.thoughtcrime.securesms.crypto.MasterSecret; +import org.thoughtcrime.securesms.database.loaders.DeviceListLoader; +import org.thoughtcrime.securesms.dependencies.InjectableType; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.ProgressDialogAsyncTask; +import org.whispersystems.textsecure.api.TextSecureAccountManager; +import org.whispersystems.textsecure.api.messages.multidevice.DeviceInfo; + +import java.io.IOException; +import java.util.List; + +import javax.inject.Inject; + +public class DeviceListActivity extends PassphraseRequiredActionBarActivity { + + + private final DynamicTheme dynamicTheme = new DynamicTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + @Override + public void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + + @Override + public void onCreate(Bundle bundle, @NonNull MasterSecret masterSecret) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + initFragment(android.R.id.content, new DeviceListFragment(), masterSecret); + } + + @Override + public void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + dynamicLanguage.onResume(this); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: finish(); return true; + } + + return false; + } + + public static class DeviceListFragment extends ListFragment + implements LoaderManager.LoaderCallbacks>, ListView.OnItemClickListener, InjectableType + { + + private static final String TAG = DeviceListFragment.class.getSimpleName(); + + @Inject TextSecureAccountManager accountManager; + + private View empty; + private View progressContainer; + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + ApplicationContext.getInstance(activity).injectDependencies(this); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { + View view = inflater.inflate(R.layout.device_list_fragment, container, false); + + this.empty = view.findViewById(R.id.empty); + this.progressContainer = view.findViewById(R.id.progress_container); + + return view; + } + + @Override + public void onActivityCreated(Bundle bundle) { + super.onActivityCreated(bundle); + getLoaderManager().initLoader(0, null, this).forceLoad(); + getListView().setOnItemClickListener(this); + } + + @Override + public Loader> onCreateLoader(int id, Bundle args) { + empty.setVisibility(View.GONE); + progressContainer.setVisibility(View.VISIBLE); + + return new DeviceListLoader(getActivity(), accountManager); + } + + @Override + public void onLoadFinished(Loader> loader, List data) { + progressContainer.setVisibility(View.GONE); + + if (data == null) { + handleLoaderFailed(); + return; + } + + setListAdapter(new DeviceListAdapter(getActivity(), R.layout.device_list_item_view, data)); + + if (data.isEmpty()) empty.setVisibility(View.VISIBLE); + else empty.setVisibility(View.GONE); + } + + @Override + public void onLoaderReset(Loader> loader) { + setListAdapter(null); + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + final String deviceName = ((DeviceListItem)view).getDeviceName(); + final long deviceId = ((DeviceListItem)view).getDeviceId(); + + AlertDialogWrapper.Builder builder = new AlertDialogWrapper.Builder(getActivity()); + builder.setTitle(getActivity().getString(R.string.DeviceListActivity_disconnect_s, deviceName)); + builder.setMessage(R.string.DeviceListActivity_by_disconnecting_this_device_it_will_no_longer_be_able_to_send_or_receive); + builder.setNegativeButton(android.R.string.cancel, null); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + handleDisconnectDevice(deviceId); + } + }); + builder.show(); + } + + private void handleLoaderFailed() { + AlertDialogWrapper.Builder builder = new AlertDialogWrapper.Builder(getActivity()); + builder.setMessage(R.string.DeviceListActivity_network_connection_failed); + builder.setPositiveButton(R.string.DeviceListActivity_try_again, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + getLoaderManager().initLoader(0, null, DeviceListFragment.this); + } + }); + builder.show(); + } + + private void handleDisconnectDevice(final long deviceId) { + new ProgressDialogAsyncTask(getActivity(), + R.string.DeviceListActivity_disconnecting_device, + R.string.DeviceListActivity_disconnecting_device_no_ellipse) + { + @Override + protected Void doInBackground(Void... params) { + try { + accountManager.removeDevice(deviceId); + } catch (IOException e) { + Log.w(TAG, e); + Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show(); + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + getLoaderManager().restartLoader(0, null, DeviceListFragment.this); + } + }.execute(); + } + + private static class DeviceListAdapter extends ArrayAdapter { + + private final int resource; + + public DeviceListAdapter(Context context, int resource, List objects) { + super(context, resource, objects); + this.resource = resource; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = ((Activity)getContext()).getLayoutInflater().inflate(resource, parent, false); + } + + ((DeviceListItem)convertView).set(getItem(position)); + + return convertView; + } + } + } + +} diff --git a/src/org/thoughtcrime/securesms/DeviceListItem.java b/src/org/thoughtcrime/securesms/DeviceListItem.java new file mode 100644 index 0000000000..39b32f5bac --- /dev/null +++ b/src/org/thoughtcrime/securesms/DeviceListItem.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.thoughtcrime.securesms.util.DateUtils; +import org.whispersystems.textsecure.api.messages.multidevice.DeviceInfo; + +import java.util.Locale; + +public class DeviceListItem extends LinearLayout { + + private long deviceId; + private TextView name; + private TextView created; + private TextView lastActive; + + public DeviceListItem(Context context) { + super(context); + } + + public DeviceListItem(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + this.name = (TextView) findViewById(R.id.name); + this.created = (TextView) findViewById(R.id.created); + this.lastActive = (TextView) findViewById(R.id.active); + } + + public void set(DeviceInfo deviceInfo) { + if (TextUtils.isEmpty(deviceInfo.getName())) this.name.setText(R.string.DeviceListItem_unnamed_device); + else this.name.setText(deviceInfo.getName()); + + this.created.setText(getContext().getString(R.string.DeviceListItem_created_s, + DateUtils.getExtendedRelativeTimeSpanString(getContext(), + Locale.getDefault(), + deviceInfo.getCreated()))); + + this.lastActive.setText(getContext().getString(R.string.DeviceListItem_last_active_s, + DateUtils.getExtendedRelativeTimeSpanString(getContext(), + Locale.getDefault(), + deviceInfo.getLastSeen()))); + + this.deviceId = deviceInfo.getId(); + } + + public long getDeviceId() { + return deviceId; + } + + public String getDeviceName() { + return name.getText().toString(); + } + +} diff --git a/src/org/thoughtcrime/securesms/DeviceProvisioningActivity.java b/src/org/thoughtcrime/securesms/DeviceProvisioningActivity.java index 2749ad5421..4f6149a3f5 100644 --- a/src/org/thoughtcrime/securesms/DeviceProvisioningActivity.java +++ b/src/org/thoughtcrime/securesms/DeviceProvisioningActivity.java @@ -6,13 +6,11 @@ import android.content.DialogInterface.OnDismissListener; import android.net.Uri; import android.os.Bundle; import android.support.annotation.NonNull; -import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.util.Log; import android.view.Window; import android.widget.Toast; -import com.afollestad.materialdialogs.DialogAction; import com.afollestad.materialdialogs.MaterialDialog; import com.afollestad.materialdialogs.MaterialDialog.Builder; import com.afollestad.materialdialogs.MaterialDialog.ButtonCallback; @@ -28,6 +26,7 @@ import org.whispersystems.libaxolotl.ecc.Curve; import org.whispersystems.libaxolotl.ecc.ECPublicKey; import org.whispersystems.textsecure.api.TextSecureAccountManager; import org.whispersystems.textsecure.api.push.exceptions.NotFoundException; +import org.whispersystems.textsecure.internal.push.DeviceLimitExceededException; import java.io.IOException; @@ -94,10 +93,11 @@ public class DeviceProvisioningActivity extends PassphraseRequiredActionBarActiv R.string.DeviceProvisioningActivity_content_progress_title, R.string.DeviceProvisioningActivity_content_progress_content) { - private static final int SUCCESS = 0; - private static final int NO_DEVICE = 1; - private static final int NETWORK_ERROR = 2; - private static final int KEY_ERROR = 3; + private static final int SUCCESS = 0; + private static final int NO_DEVICE = 1; + private static final int NETWORK_ERROR = 2; + private static final int KEY_ERROR = 3; + private static final int LIMIT_EXCEEDED = 4; @Override protected Integer doInBackground(Void... params) { @@ -113,9 +113,13 @@ public class DeviceProvisioningActivity extends PassphraseRequiredActionBarActiv accountManager.addDevice(ephemeralId, publicKey, identityKeyPair, verificationCode); return SUCCESS; + } catch (NotFoundException e) { Log.w(TAG, e); return NO_DEVICE; + } catch (DeviceLimitExceededException e) { + Log.w(TAG, e); + return LIMIT_EXCEEDED; } catch (IOException e) { Log.w(TAG, e); return NETWORK_ERROR; @@ -144,6 +148,9 @@ public class DeviceProvisioningActivity extends PassphraseRequiredActionBarActiv case KEY_ERROR: Toast.makeText(context, R.string.DeviceProvisioningActivity_content_progress_key_error, Toast.LENGTH_LONG).show(); break; + case LIMIT_EXCEEDED: + Toast.makeText(context, R.string.DeviceProvisioningActivity_sorry_you_have_too_many_devices_registered_already, Toast.LENGTH_LONG).show(); + break; } dialog.dismiss(); } diff --git a/src/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java b/src/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java new file mode 100644 index 0000000000..dfdf07ce39 --- /dev/null +++ b/src/org/thoughtcrime/securesms/database/loaders/DeviceListLoader.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.database.loaders; + +import android.content.Context; +import android.support.v4.content.AsyncTaskLoader; +import android.util.Log; + +import org.whispersystems.textsecure.api.TextSecureAccountManager; +import org.whispersystems.textsecure.api.messages.multidevice.DeviceInfo; +import org.whispersystems.textsecure.api.push.TextSecureAddress; + +import java.io.IOException; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; + +public class DeviceListLoader extends AsyncTaskLoader> { + + private static final String TAG = DeviceListLoader.class.getSimpleName(); + + private final TextSecureAccountManager accountManager; + + public DeviceListLoader(Context context, TextSecureAccountManager accountManager) { + super(context); + this.accountManager = accountManager; + } + + @Override + public List loadInBackground() { + try { + List devices = accountManager.getDevices(); + Iterator iterator = devices.iterator(); + + while (iterator.hasNext()) { + if ((iterator.next().getId() == TextSecureAddress.DEFAULT_DEVICE_ID)) { + iterator.remove(); + } + } + + Collections.sort(devices, new DeviceInfoComparator()); + + return devices; + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + private static class DeviceInfoComparator implements Comparator { + + @Override + public int compare(DeviceInfo lhs, DeviceInfo rhs) { + if (lhs.getCreated() < rhs.getCreated()) return -1; + else if (lhs.getCreated() != rhs.getCreated()) return 1; + else return 0; + } + } +} diff --git a/src/org/thoughtcrime/securesms/dependencies/TextSecureCommunicationModule.java b/src/org/thoughtcrime/securesms/dependencies/TextSecureCommunicationModule.java index c9a4e77d7a..14e014a27a 100644 --- a/src/org/thoughtcrime/securesms/dependencies/TextSecureCommunicationModule.java +++ b/src/org/thoughtcrime/securesms/dependencies/TextSecureCommunicationModule.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.dependencies; import android.content.Context; import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.DeviceListActivity; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.crypto.storage.TextSecureAxolotlStore; import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob; @@ -12,7 +13,6 @@ import org.thoughtcrime.securesms.jobs.DeliveryReceiptJob; import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.PushGroupSendJob; import org.thoughtcrime.securesms.jobs.PushMediaSendJob; -import org.thoughtcrime.securesms.jobs.PushContentReceiveJob; import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; import org.thoughtcrime.securesms.jobs.PushTextSendJob; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; @@ -39,7 +39,8 @@ import dagger.Provides; RefreshPreKeysJob.class, MessageRetrievalService.class, PushNotificationReceiveJob.class, - MultiDeviceContactUpdateJob.class}) + MultiDeviceContactUpdateJob.class, + DeviceListActivity.DeviceListFragment.class}) public class TextSecureCommunicationModule { private final Context context; diff --git a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java index a3f1c88ac9..2d2d2dd25f 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java @@ -50,6 +50,7 @@ import org.whispersystems.textsecure.api.messages.TextSecureContent; import org.whispersystems.textsecure.api.messages.TextSecureEnvelope; import org.whispersystems.textsecure.api.messages.TextSecureGroup; import org.whispersystems.textsecure.api.messages.TextSecureDataMessage; +import org.whispersystems.textsecure.api.messages.multidevice.RequestMessage; import org.whispersystems.textsecure.api.messages.multidevice.SentTranscriptMessage; import org.whispersystems.textsecure.api.messages.multidevice.TextSecureSyncMessage; import org.whispersystems.textsecure.api.push.TextSecureAddress; @@ -126,7 +127,8 @@ public class PushDecryptJob extends MasterSecretJob { } else if (content.getSyncMessage().isPresent()) { TextSecureSyncMessage syncMessage = content.getSyncMessage().get(); - if (syncMessage.getSent().isPresent()) handleSynchronizeSentMessage(masterSecret, syncMessage.getSent().get(), smsMessageId); + if (syncMessage.getSent().isPresent()) handleSynchronizeSentMessage(masterSecret, syncMessage.getSent().get(), smsMessageId); + else if (syncMessage.getRequest().isPresent()) handleSynchronizeRequestMessage(masterSecret, syncMessage.getRequest().get()); } if (envelope.isPreKeyWhisperMessage()) { @@ -198,6 +200,14 @@ public class PushDecryptJob extends MasterSecretJob { } } + private void handleSynchronizeRequestMessage(MasterSecret masterSecret, RequestMessage message) { + if (message.isContactsRequest()) { + ApplicationContext.getInstance(context) + .getJobManager() + .add(new MultiDeviceContactUpdateJob(getContext())); + } + } + private void handleMediaMessage(MasterSecret masterSecret, TextSecureEnvelope envelope, TextSecureDataMessage message, Optional smsMessageId) throws MmsException