From d1940fe0f94d166d38ba475402b9defdff2f6998 Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Tue, 14 Jul 2015 14:31:03 -0700 Subject: [PATCH] Support for populating contacts DB with TS account type. // FREEBIE --- AndroidManifest.xml | 21 +++++ res/xml/authenticator.xml | 6 ++ res/xml/contactsformat.xml | 9 ++ res/xml/syncadapter.xml | 8 ++ .../securesms/SmsSendtoActivity.java | 74 ++++++++++++--- .../securesms/contacts/ContactsDatabase.java | 93 +++++++++++++++++++ .../contacts/ContactsSyncAdapter.java | 34 +++++++ .../database/TextSecureDirectory.java | 29 +----- .../service/AccountAuthenticatorService.java | 80 ++++++++++++++++ .../service/ContactsSyncAdapterService.java | 26 ++++++ .../securesms/util/DirectoryHelper.java | 55 ++++++++--- 11 files changed, 380 insertions(+), 55 deletions(-) create mode 100644 res/xml/authenticator.xml create mode 100644 res/xml/contactsformat.xml create mode 100644 res/xml/syncadapter.xml create mode 100644 src/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java create mode 100644 src/org/thoughtcrime/securesms/service/AccountAuthenticatorService.java create mode 100644 src/org/thoughtcrime/securesms/service/ContactsSyncAdapterService.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 5e06408666..b259eee64f 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -148,6 +148,8 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/res/xml/authenticator.xml b/res/xml/authenticator.xml new file mode 100644 index 0000000000..ac313c3007 --- /dev/null +++ b/res/xml/authenticator.xml @@ -0,0 +1,6 @@ + + diff --git a/res/xml/contactsformat.xml b/res/xml/contactsformat.xml new file mode 100644 index 0000000000..3857e2a44a --- /dev/null +++ b/res/xml/contactsformat.xml @@ -0,0 +1,9 @@ + + + + diff --git a/res/xml/syncadapter.xml b/res/xml/syncadapter.xml new file mode 100644 index 0000000000..67a058349f --- /dev/null +++ b/res/xml/syncadapter.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/SmsSendtoActivity.java b/src/org/thoughtcrime/securesms/SmsSendtoActivity.java index 798748e132..5b63b2def8 100644 --- a/src/org/thoughtcrime/securesms/SmsSendtoActivity.java +++ b/src/org/thoughtcrime/securesms/SmsSendtoActivity.java @@ -2,7 +2,10 @@ package org.thoughtcrime.securesms; import android.app.Activity; import android.content.Intent; +import android.database.Cursor; import android.os.Bundle; +import android.provider.ContactsContract; +import android.support.annotation.NonNull; import android.util.Log; import android.widget.Toast; @@ -25,36 +28,79 @@ public class SmsSendtoActivity extends Activity { } private Intent getNextIntent(Intent original) { - String body = ""; - String data = ""; + DestinationAndBody destination; if (original.getAction().equals(Intent.ACTION_SENDTO)) { - body = original.getStringExtra("sms_body"); - data = original.getData().getSchemeSpecificPart(); + destination = getDestinationForSendTo(original); + } else if (original.getData() != null && "content".equals(original.getData().getScheme())) { + destination = getDestinationForSyncAdapter(original); } else { - try { - Rfc5724Uri smsUri = new Rfc5724Uri(original.getData().toString()); - body = smsUri.getQueryParams().get("body"); - data = smsUri.getPath(); - } catch (URISyntaxException e) { - Log.w(TAG, "unable to parse RFC5724 URI from intent", e); - } + destination = getDestinationForView(original); } - Recipients recipients = RecipientFactory.getRecipientsFromString(this, data, false); + Recipients recipients = RecipientFactory.getRecipientsFromString(this, destination.getDestination(), false); long threadId = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipients); final Intent nextIntent; if (recipients == null || recipients.isEmpty()) { nextIntent = new Intent(this, NewConversationActivity.class); - nextIntent.putExtra(ConversationActivity.DRAFT_TEXT_EXTRA, body); + nextIntent.putExtra(ConversationActivity.DRAFT_TEXT_EXTRA, destination.getBody()); Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show(); } else { nextIntent = new Intent(this, ConversationActivity.class); - nextIntent.putExtra(ConversationActivity.DRAFT_TEXT_EXTRA, body); + nextIntent.putExtra(ConversationActivity.DRAFT_TEXT_EXTRA, destination.getBody()); nextIntent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); nextIntent.putExtra(ConversationActivity.RECIPIENTS_EXTRA, recipients.getIds()); } return nextIntent; } + + private @NonNull DestinationAndBody getDestinationForSendTo(Intent intent) { + return new DestinationAndBody(intent.getData().getSchemeSpecificPart(), + intent.getStringExtra("sms_body")); + } + + private @NonNull DestinationAndBody getDestinationForView(Intent intent) { + try { + Rfc5724Uri smsUri = new Rfc5724Uri(intent.getData().toString()); + return new DestinationAndBody(smsUri.getPath(), smsUri.getQueryParams().get("body")); + } catch (URISyntaxException e) { + Log.w(TAG, "unable to parse RFC5724 URI from intent", e); + return new DestinationAndBody("", ""); + } + } + + private @NonNull DestinationAndBody getDestinationForSyncAdapter(Intent intent) { + Cursor cursor = null; + + try { + cursor = getContentResolver().query(intent.getData(), null, null, null, null); + + if (cursor != null && cursor.moveToNext()) { + return new DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), ""); + } + + return new DestinationAndBody("", ""); + } finally { + if (cursor != null) cursor.close(); + } + } + + private static class DestinationAndBody { + private final String destination; + private final String body; + + private DestinationAndBody(String destination, String body) { + this.destination = destination; + this.body = body; + } + + public String getDestination() { + return destination; + } + + public String getBody() { + return body; + } + } } diff --git a/src/org/thoughtcrime/securesms/contacts/ContactsDatabase.java b/src/org/thoughtcrime/securesms/contacts/ContactsDatabase.java index a999a907d2..bf21a45845 100644 --- a/src/org/thoughtcrime/securesms/contacts/ContactsDatabase.java +++ b/src/org/thoughtcrime/securesms/contacts/ContactsDatabase.java @@ -16,8 +16,11 @@ */ package org.thoughtcrime.securesms.contacts; +import android.accounts.Account; +import android.content.ContentProviderOperation; import android.content.ContentValues; import android.content.Context; +import android.content.OperationApplicationException; import android.database.Cursor; import android.database.CursorWrapper; import android.database.MatrixCursor; @@ -25,7 +28,10 @@ import android.database.MergeCursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.net.Uri; +import android.os.RemoteException; +import android.provider.BaseColumns; import android.provider.ContactsContract; +import android.provider.ContactsContract.RawContacts; import android.text.TextUtils; import android.util.Log; @@ -36,7 +42,12 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; +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 @@ -84,6 +95,88 @@ public class ContactsDatabase { dbHelper.close(); } + public synchronized void setRegisteredUsers(Account account, List e164numbers) + throws RemoteException, OperationApplicationException + { + Map currentContacts = new HashMap<>(); + Set registeredNumbers = new HashSet<>(e164numbers); + 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; + + try { + cursor = context.getContentResolver().query(currentContactsUri, new String[] {BaseColumns._ID, RawContacts.SYNC1}, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + currentContacts.put(cursor.getString(1), cursor.getLong(0)); + } + } finally { + if (cursor != null) + cursor.close(); + } + + for (String number : e164numbers) { + if (!currentContacts.containsKey(number)) { + addTextSecureRawContact(operations, account, number); + } + } + + for (Map.Entry currentContactEntry : currentContacts.entrySet()) { + if (!registeredNumbers.contains(currentContactEntry.getKey())) { + removeTextSecureRawContact(operations, account, currentContactEntry.getValue()); + } + } + + if (!operations.isEmpty()) { + context.getContentResolver().applyBatch(ContactsContract.AUTHORITY, operations); + } + } + + private void addTextSecureRawContact(List operations, + Account account, String e164number) + { + 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) + .build()); + + operations.add(ContentProviderOperation.newInsert(dataUri) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index) + .withValue(ContactsContract.Data.MIMETYPE, "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact") + .withValue(ContactsContract.Data.DATA1, e164number) + .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name)) + .withValue(ContactsContract.Data.DATA3, String.format("Message %s", e164number)) + .withYieldAllowed(true) + .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 Cursor query(String filter, boolean pushOnly) { // FIXME: This doesn't make sense to me. You pass in pushOnly, but then // conditionally check to see whether other contacts should be included diff --git a/src/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java b/src/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java new file mode 100644 index 0000000000..996c8fc86d --- /dev/null +++ b/src/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.contacts; + +import android.accounts.Account; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.Context; +import android.content.SyncResult; +import android.os.Bundle; +import android.util.Log; + +import org.thoughtcrime.securesms.util.DirectoryHelper; + +import java.io.IOException; + +public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter { + + private static final String TAG = ContactsSyncAdapter.class.getSimpleName(); + + public ContactsSyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + } + + @Override + public void onPerformSync(Account account, Bundle extras, String authority, + ContentProviderClient provider, SyncResult syncResult) + { + try { + DirectoryHelper.refreshDirectory(getContext()); + } catch (IOException e) { + Log.w(TAG, e); + } + } + +} diff --git a/src/org/thoughtcrime/securesms/database/TextSecureDirectory.java b/src/org/thoughtcrime/securesms/database/TextSecureDirectory.java index e51aab9af6..bd30e51395 100644 --- a/src/org/thoughtcrime/securesms/database/TextSecureDirectory.java +++ b/src/org/thoughtcrime/securesms/database/TextSecureDirectory.java @@ -24,20 +24,18 @@ public class TextSecureDirectory { private static final int INTRODUCED_CHANGE_FROM_TOKEN_TO_E164_NUMBER = 2; private static final String DATABASE_NAME = "whisper_directory.db"; - private static final int DATABASE_VERSION = 2; + private static final int DATABASE_VERSION = 3; private static final String TABLE_NAME = "directory"; private static final String ID = "_id"; private static final String NUMBER = "number"; private static final String REGISTERED = "registered"; private static final String RELAY = "relay"; - private static final String SUPPORTS_SMS = "supports_sms"; private static final String TIMESTAMP = "timestamp"; private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY, " + NUMBER + " TEXT UNIQUE, " + REGISTERED + " INTEGER, " + RELAY + " TEXT, " + - SUPPORTS_SMS + " INTEGER, " + TIMESTAMP + " INTEGER);"; private static final Object instanceLock = new Object(); @@ -63,25 +61,6 @@ public class TextSecureDirectory { this.databaseHelper = new DatabaseHelper(context, DATABASE_NAME, null, DATABASE_VERSION); } - public boolean isSmsFallbackSupported(String e164number) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - - try { - cursor = db.query(TABLE_NAME, new String[] {SUPPORTS_SMS}, NUMBER + " = ?", - new String[]{e164number}, null, null, null); - - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0) == 1; - } else { - return false; - } - } finally { - if (cursor != null) - cursor.close(); - } - } - public boolean isActiveNumber(String e164number) throws NotInDirectoryException { if (e164number == null || e164number.length() == 0) { return false; @@ -131,7 +110,6 @@ public class TextSecureDirectory { values.put(NUMBER, token.getNumber()); values.put(RELAY, token.getRelay()); values.put(REGISTERED, active ? 1 : 0); - values.put(SUPPORTS_SMS, token.isSupportsSms() ? 1 : 0); values.put(TIMESTAMP, System.currentTimeMillis()); db.replace(TABLE_NAME, null, values); } @@ -149,7 +127,6 @@ public class TextSecureDirectory { values.put(REGISTERED, 1); values.put(TIMESTAMP, timestamp); values.put(RELAY, token.getRelay()); - values.put(SUPPORTS_SMS, token.isSupportsSms() ? 1 : 0); db.replace(TABLE_NAME, null, values); } @@ -169,7 +146,7 @@ public class TextSecureDirectory { public Set getPushEligibleContactNumbers(String localNumber) { final Uri uri = Phone.CONTENT_URI; - final Set results = new HashSet(); + final Set results = new HashSet<>(); Cursor cursor = null; try { @@ -208,7 +185,7 @@ public class TextSecureDirectory { } public List getActiveNumbers() { - final List results = new ArrayList(); + final List results = new ArrayList<>(); Cursor cursor = null; try { cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{NUMBER}, diff --git a/src/org/thoughtcrime/securesms/service/AccountAuthenticatorService.java b/src/org/thoughtcrime/securesms/service/AccountAuthenticatorService.java new file mode 100644 index 0000000000..0106818277 --- /dev/null +++ b/src/org/thoughtcrime/securesms/service/AccountAuthenticatorService.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.service; + +import android.accounts.AbstractAccountAuthenticator; +import android.accounts.Account; +import android.accounts.AccountAuthenticatorResponse; +import android.accounts.NetworkErrorException; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.IBinder; + +public class AccountAuthenticatorService extends Service { + + private static AccountAuthenticatorImpl accountAuthenticator = null; + + @Override + public IBinder onBind(Intent intent) { + if (intent.getAction().equals(android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT)) { + return getAuthenticator().getIBinder(); + } else { + return null; + } + } + + private synchronized AccountAuthenticatorImpl getAuthenticator() { + if (accountAuthenticator == null) { + accountAuthenticator = new AccountAuthenticatorImpl(this); + } + + return accountAuthenticator; + } + + private static class AccountAuthenticatorImpl extends AbstractAccountAuthenticator { + + public AccountAuthenticatorImpl(Context context) { + super(context); + } + + @Override + public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, + String[] requiredFeatures, Bundle options) + throws NetworkErrorException + { + return null; + } + + public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) { + return null; + } + + @Override + public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { + return null; + } + + @Override + public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, + Bundle options) throws NetworkErrorException { + return null; + } + + @Override + public String getAuthTokenLabel(String authTokenType) { + return null; + } + + @Override + public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) + throws NetworkErrorException { + return null; + } + + @Override + public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, + Bundle options) { + return null; + } + } +} diff --git a/src/org/thoughtcrime/securesms/service/ContactsSyncAdapterService.java b/src/org/thoughtcrime/securesms/service/ContactsSyncAdapterService.java new file mode 100644 index 0000000000..c162d9104d --- /dev/null +++ b/src/org/thoughtcrime/securesms/service/ContactsSyncAdapterService.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.service; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; +import android.support.annotation.Nullable; + +import org.thoughtcrime.securesms.contacts.ContactsSyncAdapter; + +public class ContactsSyncAdapterService extends Service { + + private static ContactsSyncAdapter syncAdapter; + + @Override + public synchronized void onCreate() { + if (syncAdapter == null) { + syncAdapter = new ContactsSyncAdapter(this, true); + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return syncAdapter.getSyncAdapterBinder(); + } +} diff --git a/src/org/thoughtcrime/securesms/util/DirectoryHelper.java b/src/org/thoughtcrime/securesms/util/DirectoryHelper.java index be3f66d966..578a1dfe9d 100644 --- a/src/org/thoughtcrime/securesms/util/DirectoryHelper.java +++ b/src/org/thoughtcrime/securesms/util/DirectoryHelper.java @@ -1,19 +1,28 @@ package org.thoughtcrime.securesms.util; +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.ContentResolver; import android.content.Context; +import android.content.OperationApplicationException; +import android.os.RemoteException; +import android.provider.ContactsContract; import android.util.Log; import android.widget.Toast; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.ContactsDatabase; import org.thoughtcrime.securesms.database.NotInDirectoryException; import org.thoughtcrime.securesms.database.TextSecureDirectory; import org.thoughtcrime.securesms.push.TextSecureCommunicationFactory; import org.thoughtcrime.securesms.recipients.Recipients; +import org.whispersystems.libaxolotl.util.guava.Optional; import org.whispersystems.textsecure.api.TextSecureAccountManager; import org.whispersystems.textsecure.api.push.ContactTokenDetails; import org.whispersystems.textsecure.api.util.InvalidNumberException; import java.io.IOException; +import java.util.LinkedList; import java.util.List; import java.util.Set; @@ -65,6 +74,7 @@ public class DirectoryHelper { throws IOException { TextSecureDirectory directory = TextSecureDirectory.getInstance(context); + Optional account = getOrCreateAccount(context); Set eligibleContactNumbers = directory.getPushEligibleContactNumbers(localNumber); List activeTokens = accountManager.getContacts(eligibleContactNumbers); @@ -75,6 +85,20 @@ public class DirectoryHelper { } directory.setNumbers(activeTokens, eligibleContactNumbers); + + if (account.isPresent()) { + List e164numbers = new LinkedList<>(); + + for (ContactTokenDetails contactTokenDetails : activeTokens) { + e164numbers.add(contactTokenDetails.getNumber()); + } + + try { + new ContactsDatabase(context).setRegisteredUsers(account.get(), e164numbers); + } catch (RemoteException | OperationApplicationException e) { + Log.w(TAG, e); + } + } } } @@ -113,24 +137,25 @@ public class DirectoryHelper { } } - public static boolean isSmsFallbackAllowed(Context context, Recipients recipients) { - try { - if (recipients == null || !recipients.isSingleRecipient() || recipients.isGroupRecipient()) { - return false; - } + private static Optional getOrCreateAccount(Context context) { + AccountManager accountManager = AccountManager.get(context); + Account[] accounts = accountManager.getAccountsByType("org.thoughtcrime.securesms"); - final String number = recipients.getPrimaryRecipient().getNumber(); + if (accounts.length == 0) return createAccount(context); + else return Optional.of(accounts[0]); + } - if (number == null) { - return false; - } + private static Optional createAccount(Context context) { + AccountManager accountManager = AccountManager.get(context); + Account account = new Account(context.getString(R.string.app_name), "org.thoughtcrime.securesms"); - final String e164number = Util.canonicalizeNumber(context, number); - - return TextSecureDirectory.getInstance(context).isSmsFallbackSupported(e164number); - } catch (InvalidNumberException e) { - Log.w(TAG, e); - return false; + if (accountManager.addAccountExplicitly(account, null, null)) { + Log.w(TAG, "Created new account..."); + ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1); + return Optional.of(account); + } else { + Log.w(TAG, "Failed to create account!"); + return Optional.absent(); } }