From 7bec5efe1a4ac3fd30882a14d573c9e3558c45ed Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Mon, 9 Nov 2015 12:30:36 -0800 Subject: [PATCH] Add 'Signal Call' option to contact card Fixes #4392 Closes #4465 // FREEBIE --- AndroidManifest.xml | 13 + res/values/strings.xml | 1 + res/xml/contactsformat.xml | 5 + .../thoughtcrime/redphone/RedPhoneShare.java | 46 +++ .../contacts/ContactsCursorLoader.java | 15 +- .../securesms/contacts/ContactsDatabase.java | 280 +++++++++++------- .../securesms/util/DirectoryHelper.java | 8 +- 7 files changed, 256 insertions(+), 112 deletions(-) create mode 100644 src/org/thoughtcrime/redphone/RedPhoneShare.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index e08c6cdc67..96b04318ba 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -317,6 +317,19 @@ + + + + + + + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml index 9174a2376e..c806d57e61 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -70,6 +70,7 @@ Message %s + Signal Call %s Message size: %d KB diff --git a/res/xml/contactsformat.xml b/res/xml/contactsformat.xml index 3857e2a44a..7aaceef0be 100644 --- a/res/xml/contactsformat.xml +++ b/res/xml/contactsformat.xml @@ -6,4 +6,9 @@ android:summaryColumn="data2" android:detailColumn="data3" android:detailSocialSummary="true"/> + diff --git a/src/org/thoughtcrime/redphone/RedPhoneShare.java b/src/org/thoughtcrime/redphone/RedPhoneShare.java new file mode 100644 index 0000000000..54938775d6 --- /dev/null +++ b/src/org/thoughtcrime/redphone/RedPhoneShare.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.redphone; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.text.TextUtils; + +public class RedPhoneShare extends Activity { + + private static final String TAG = RedPhone.class.getSimpleName(); + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + if (getIntent().getData() != null && "content".equals(getIntent().getData().getScheme())) { + Cursor cursor = null; + + try { + cursor = getContentResolver().query(getIntent().getData(), null, null, null, null); + + if (cursor != null && cursor.moveToNext()) { + String destination = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)); + + if (!TextUtils.isEmpty(destination)) { + Intent serviceIntent = new Intent(this, RedPhoneService.class); + serviceIntent.setAction(RedPhoneService.ACTION_OUTGOING_CALL); + serviceIntent.putExtra(RedPhoneService.EXTRA_REMOTE_NUMBER, destination); + startService(serviceIntent); + + Intent activityIntent = new Intent(this, RedPhone.class); + activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(activityIntent); + } + } + } finally { + if (cursor != null) cursor.close(); + } + } + + finish(); + } + +} diff --git a/src/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/src/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java index 5339e28e6c..391d27fff5 100644 --- a/src/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java +++ b/src/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java @@ -20,11 +20,13 @@ import android.content.Context; import android.database.Cursor; import android.database.MatrixCursor; import android.database.MergeCursor; +import android.provider.ContactsContract; import android.support.annotation.NonNull; import android.support.v4.content.CursorLoader; import android.text.TextUtils; import android.util.Log; +import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.recipients.RecipientFactory; import org.thoughtcrime.securesms.recipients.Recipients; @@ -73,7 +75,18 @@ public class ContactsCursorLoader extends CursorLoader { } if (!TextUtils.isEmpty(filter) && NumberUtil.isValidSmsOrEmail(filter)) { - cursorList.add(contactsDatabase.getNewNumberCursor(filter)); + MatrixCursor newNumberCursor = new MatrixCursor(new String[] {ContactsDatabase.ID_COLUMN, + ContactsDatabase.NAME_COLUMN, + ContactsDatabase.NUMBER_COLUMN, + ContactsDatabase.NUMBER_TYPE_COLUMN, + ContactsDatabase.LABEL_COLUMN, + ContactsDatabase.CONTACT_TYPE_COLUMN}, 1); + + newNumberCursor.addRow(new Object[] {-1L, getContext().getString(R.string.contact_selection_list__unknown_contact), + filter, ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM, + "\u21e2", ContactsDatabase.NEW_TYPE}); + + cursorList.add(newNumberCursor); } return new MergeCursor(cursorList.toArray(new Cursor[0])); diff --git a/src/org/thoughtcrime/securesms/contacts/ContactsDatabase.java b/src/org/thoughtcrime/securesms/contacts/ContactsDatabase.java index 47d7f490bd..a8f3204d37 100644 --- a/src/org/thoughtcrime/securesms/contacts/ContactsDatabase.java +++ b/src/org/thoughtcrime/securesms/contacts/ContactsDatabase.java @@ -22,7 +22,6 @@ import android.content.Context; import android.content.OperationApplicationException; import android.database.Cursor; import android.database.CursorWrapper; -import android.database.MatrixCursor; import android.net.Uri; import android.os.Build; import android.os.RemoteException; @@ -37,16 +36,15 @@ import android.util.Pair; import org.thoughtcrime.securesms.R; import org.whispersystems.libaxolotl.util.guava.Optional; +import org.whispersystems.textsecure.api.push.ContactTokenDetails; import org.whispersystems.textsecure.api.util.InvalidNumberException; import org.whispersystems.textsecure.api.util.PhoneNumberFormatter; import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Set; /** * Database to supply all types of contacts that TextSecure needs to know about @@ -55,9 +53,10 @@ import java.util.Set; */ public class ContactsDatabase { - private static final String TAG = ContactsDatabase.class.getSimpleName(); - private static final String MIME = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"; - private static final String SYNC = "__TS"; + private static final String TAG = ContactsDatabase.class.getSimpleName(); + private static final String CONTACT_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"; + private static final String CALL_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call"; + private static final String SYNC = "__TS"; public static final String ID_COLUMN = "_id"; public static final String NAME_COLUMN = "name"; @@ -78,54 +77,44 @@ public class ContactsDatabase { public synchronized @NonNull List setRegisteredUsers(@NonNull Account account, @NonNull String localNumber, - @NonNull List e164numbers) + @NonNull List registeredContacts) throws RemoteException, OperationApplicationException { - Map currentContacts = new HashMap<>(); - Set registeredNumbers = new HashSet<>(e164numbers); - List addedNumbers = new LinkedList<>(); - ArrayList operations = new ArrayList<>(); - Uri currentContactsUri = RawContacts.CONTENT_URI.buildUpon() - .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name) - .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type).build(); - Cursor cursor = null; + Map registeredNumbers = new HashMap<>(); + List addedNumbers = new LinkedList<>(); + ArrayList operations = new ArrayList<>(); + Map currentContacts = getSignalRawContacts(account, localNumber); - try { - cursor = context.getContentResolver().query(currentContactsUri, new String[] {BaseColumns._ID, RawContacts.SYNC1}, null, null, null); + for (ContactTokenDetails registeredContact : registeredContacts) { + String registeredNumber = registeredContact.getNumber(); - while (cursor != null && cursor.moveToNext()) { - String currentNumber; + registeredNumbers.put(registeredNumber, registeredContact); - try { - currentNumber = PhoneNumberFormatter.formatNumber(cursor.getString(1), localNumber); - } catch (InvalidNumberException e) { - Log.w(TAG, e); - currentNumber = cursor.getString(1); - } - - currentContacts.put(currentNumber, cursor.getLong(0)); - } - } finally { - if (cursor != null) - cursor.close(); - } - - for (String number : e164numbers) { - if (!currentContacts.containsKey(number)) { - Optional systemContactInfo = getSystemContactInfo(number, localNumber); + if (!currentContacts.containsKey(registeredNumber)) { + Optional systemContactInfo = getSystemContactInfo(registeredNumber, localNumber); if (systemContactInfo.isPresent()) { - Log.w(TAG, "Adding number: " + number); - addedNumbers.add(number); - addTextSecureRawContact(operations, account, systemContactInfo.get().number, systemContactInfo.get().id); + Log.w(TAG, "Adding number: " + registeredNumber); + addedNumbers.add(registeredNumber); + addTextSecureRawContact(operations, account, systemContactInfo.get().number, + systemContactInfo.get().id, registeredContact.isVoice()); } } } - for (Map.Entry currentContactEntry : currentContacts.entrySet()) { - if (!registeredNumbers.contains(currentContactEntry.getKey())) { - removeTextSecureRawContact(operations, account, currentContactEntry.getValue()); + for (Map.Entry currentContactEntry : currentContacts.entrySet()) { + ContactTokenDetails tokenDetails = registeredNumbers.get(currentContactEntry.getKey()); + + if (tokenDetails == null) { + Log.w(TAG, "Removing number: " + currentContactEntry.getKey()); + removeTextSecureRawContact(operations, account, currentContactEntry.getValue().getId()); + } else if (tokenDetails.isVoice() && !currentContactEntry.getValue().isVoiceSupported()) { + Log.w(TAG, "Adding voice support: " + currentContactEntry.getKey()); + addContactVoiceSupport(operations, currentContactEntry.getKey(), currentContactEntry.getValue().getId()); + } else if (!tokenDetails.isVoice() && currentContactEntry.getValue().isVoiceSupported()) { + Log.w(TAG, "Removing voice support: " + currentContactEntry.getKey()); + removeContactVoiceSupport(operations, currentContactEntry.getValue().getId()); } } @@ -136,60 +125,6 @@ public class ContactsDatabase { return addedNumbers; } - private void addTextSecureRawContact(List operations, - Account account, - String e164number, - long aggregateId) - { - int index = operations.size(); - Uri dataUri = ContactsContract.Data.CONTENT_URI.buildUpon() - .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") - .build(); - - operations.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI) - .withValue(RawContacts.ACCOUNT_NAME, account.name) - .withValue(RawContacts.ACCOUNT_TYPE, account.type) - .withValue(RawContacts.SYNC1, e164number) - .build()); - - operations.add(ContentProviderOperation.newInsert(dataUri) - .withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, index) - .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) - .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, e164number) - .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_OTHER) - .withValue(ContactsContract.Data.SYNC2, SYNC) - .build()); - - operations.add(ContentProviderOperation.newInsert(dataUri) - .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index) - .withValue(ContactsContract.Data.MIMETYPE, MIME) - .withValue(ContactsContract.Data.DATA1, e164number) - .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name)) - .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_message_s, e164number)) - .withYieldAllowed(true) - .build()); - - if (Build.VERSION.SDK_INT >= 11) { - operations.add(ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI) - .withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, aggregateId) - .withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, index) - .withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER) - .build()); - } - } - - private void removeTextSecureRawContact(List operations, - Account account, long rowId) - { - operations.add(ContentProviderOperation.newDelete(RawContacts.CONTENT_URI.buildUpon() - .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name) - .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type) - .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()) - .withYieldAllowed(true) - .withSelection(BaseColumns._ID + " = ?", new String[] {String.valueOf(rowId)}) - .build()); - } - public @NonNull Cursor querySystemContacts(String filter) { Uri uri; @@ -248,13 +183,13 @@ public class ContactsDatabase { cursor = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, projection, ContactsContract.Data.MIMETYPE + " = ?", - new String[] {MIME}, + new String[] {CONTACT_MIMETYPE}, sort); } else { cursor = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, projection, ContactsContract.Data.MIMETYPE + " = ? AND (" + ContactsContract.Contacts.DISPLAY_NAME + " LIKE ? OR " + ContactsContract.Data.DATA1 + " LIKE ?)", - new String[] {MIME, + new String[] {CONTACT_MIMETYPE, "%" + filter + "%", "%" + filter + "%"}, sort); } @@ -266,13 +201,132 @@ public class ContactsDatabase { } - public Cursor getNewNumberCursor(String filter) { - MatrixCursor newNumberCursor = new MatrixCursor(new String[] {ID_COLUMN, NAME_COLUMN, NUMBER_COLUMN, NUMBER_TYPE_COLUMN, LABEL_COLUMN, CONTACT_TYPE_COLUMN}, 1); - newNumberCursor.addRow(new Object[]{-1L, context.getString(R.string.contact_selection_list__unknown_contact), - filter, ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM, - "\u21e2", NEW_TYPE}); + private void addContactVoiceSupport(List operations, + @NonNull String e164number, long rawContactId) + { + operations.add(ContentProviderOperation.newUpdate(RawContacts.CONTENT_URI) + .withSelection(RawContacts._ID + " = ?", new String[] {String.valueOf(rawContactId)}) + .withValue(RawContacts.SYNC4, "true") + .build()); - return newNumberCursor; + operations.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()) + .withValue(ContactsContract.Data.RAW_CONTACT_ID, rawContactId) + .withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE) + .withValue(ContactsContract.Data.DATA1, e164number) + .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name)) + .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, e164number)) + .withYieldAllowed(true) + .build()); + } + + private void removeContactVoiceSupport(List operations, long rawContactId) { + operations.add(ContentProviderOperation.newUpdate(RawContacts.CONTENT_URI) + .withSelection(RawContacts._ID + " = ?", new String[] {String.valueOf(rawContactId)}) + .withValue(RawContacts.SYNC4, "false") + .build()); + + operations.add(ContentProviderOperation.newDelete(ContactsContract.Data.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()) + .withSelection(ContactsContract.Data.RAW_CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?", + new String[] {String.valueOf(rawContactId), CALL_MIMETYPE}) + .withYieldAllowed(true) + .build()); + } + + private void addTextSecureRawContact(List operations, + Account account, String e164number, + long aggregateId, boolean supportsVoice) + { + int index = operations.size(); + Uri dataUri = ContactsContract.Data.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build(); + + operations.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI) + .withValue(RawContacts.ACCOUNT_NAME, account.name) + .withValue(RawContacts.ACCOUNT_TYPE, account.type) + .withValue(RawContacts.SYNC1, e164number) + .withValue(RawContacts.SYNC4, String.valueOf(supportsVoice)) + .build()); + + operations.add(ContentProviderOperation.newInsert(dataUri) + .withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, index) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, e164number) + .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_OTHER) + .withValue(ContactsContract.Data.SYNC2, SYNC) + .build()); + + operations.add(ContentProviderOperation.newInsert(dataUri) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index) + .withValue(ContactsContract.Data.MIMETYPE, CONTACT_MIMETYPE) + .withValue(ContactsContract.Data.DATA1, e164number) + .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name)) + .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_message_s, e164number)) + .withYieldAllowed(true) + .build()); + + if (supportsVoice) { + operations.add(ContentProviderOperation.newInsert(dataUri) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index) + .withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE) + .withValue(ContactsContract.Data.DATA1, e164number) + .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name)) + .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, e164number)) + .withYieldAllowed(true) + .build()); + } + + + if (Build.VERSION.SDK_INT >= 11) { + operations.add(ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI) + .withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, aggregateId) + .withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, index) + .withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER) + .build()); + } + } + + private void removeTextSecureRawContact(List operations, + Account account, long rowId) + { + operations.add(ContentProviderOperation.newDelete(RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type) + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()) + .withYieldAllowed(true) + .withSelection(BaseColumns._ID + " = ?", new String[] {String.valueOf(rowId)}) + .build()); + } + + private @NonNull Map getSignalRawContacts(Account account, String localNumber) { + Uri currentContactsUri = RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type).build(); + + Map signalContacts = new HashMap<>(); + Cursor cursor = null; + + try { + cursor = context.getContentResolver().query(currentContactsUri, new String[] {BaseColumns._ID, RawContacts.SYNC1, RawContacts.SYNC4}, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + String currentNumber; + + try { + currentNumber = PhoneNumberFormatter.formatNumber(cursor.getString(1), localNumber); + } catch (InvalidNumberException e) { + Log.w(TAG, e); + currentNumber = cursor.getString(1); + } + + signalContacts.put(currentNumber, new SignalContact(cursor.getLong(0), cursor.getString(2))); + } + } finally { + if (cursor != null) + cursor.close(); + } + + return signalContacts; } private Optional getSystemContactInfo(@NonNull String e164number, @@ -428,4 +482,22 @@ public class ContactsDatabase { this.id = id; } } + + private static class SignalContact { + private final long id; + @Nullable private final String supportsVoice; + + public SignalContact(long id, @Nullable String supportsVoice) { + this.id = id; + this.supportsVoice = supportsVoice; + } + + public long getId() { + return id; + } + + public boolean isVoiceSupported() { + return "true".equals(supportsVoice); + } + } } diff --git a/src/org/thoughtcrime/securesms/util/DirectoryHelper.java b/src/org/thoughtcrime/securesms/util/DirectoryHelper.java index 3db59d242d..cd484b35cd 100644 --- a/src/org/thoughtcrime/securesms/util/DirectoryHelper.java +++ b/src/org/thoughtcrime/securesms/util/DirectoryHelper.java @@ -106,15 +106,9 @@ public class DirectoryHelper { directory.setNumbers(activeTokens, eligibleContactNumbers); if (account.isPresent()) { - List e164numbers = new LinkedList<>(); - - for (ContactTokenDetails contactTokenDetails : activeTokens) { - e164numbers.add(contactTokenDetails.getNumber()); - } - try { return DatabaseFactory.getContactsDatabase(context) - .setRegisteredUsers(account.get(), localNumber, e164numbers); + .setRegisteredUsers(account.get(), localNumber, activeTokens); } catch (RemoteException | OperationApplicationException e) { Log.w(TAG, e); }