diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java index 840518925..27c8de587 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java @@ -27,8 +27,8 @@ import java.util.Map; * Repository for all contacts. Allows you to filter them via queries. * * Currently this is implemented to return cursors. This is to ease the migration between this class - * and the previous way we'd query contacts: {@link ContactsDatabase}. It's much easier in the - * short-term to mock the cursor interface rather than try to switch everything over to models. + * and the previous way we'd query contacts. It's much easier in the short-term to mock the cursor + * interface rather than try to switch everything over to models. */ public class ContactRepository { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsDatabase.java deleted file mode 100644 index 0a294fc76..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsDatabase.java +++ /dev/null @@ -1,506 +0,0 @@ -/* - * Copyright (C) 2013 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.contacts; - -import android.accounts.Account; -import android.content.ContentProviderOperation; -import android.content.ContentResolver; -import android.content.Context; -import android.content.OperationApplicationException; -import android.database.Cursor; -import android.net.Uri; -import android.os.RemoteException; -import android.provider.BaseColumns; -import android.provider.ContactsContract; -import android.provider.ContactsContract.RawContacts; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; -import org.thoughtcrime.securesms.util.Util; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -/** - * Database to supply all types of contacts that TextSecure needs to know about - * - * @author Jake McGinty - */ -public class ContactsDatabase { - - private static final String TAG = Log.tag(ContactsDatabase.class); - 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"; - - private final Context context; - - public ContactsDatabase(Context context) { - this.context = context; - } - - public synchronized void removeDeletedRawContacts(@NonNull Account account) { - Uri currentContactsUri = RawContacts.CONTENT_URI.buildUpon() - .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name) - .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type) - .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") - .build(); - - String[] projection = new String[] {BaseColumns._ID, RawContacts.SYNC1}; - - try (Cursor cursor = context.getContentResolver().query(currentContactsUri, projection, RawContacts.DELETED + " = ?", new String[] {"1"}, null)) { - while (cursor != null && cursor.moveToNext()) { - long rawContactId = cursor.getLong(0); - Log.i(TAG, "Deleting raw contact: " + cursor.getString(1) + ", " + rawContactId); - - context.getContentResolver().delete(currentContactsUri, RawContacts._ID + " = ?", new String[] {String.valueOf(rawContactId)}); - } - } - } - - public synchronized void setRegisteredUsers(@NonNull Account account, - @NonNull List registeredAddressList, - boolean remove) - throws RemoteException, OperationApplicationException - { - Set registeredAddressSet = new HashSet<>(registeredAddressList); - ArrayList operations = new ArrayList<>(); - Map currentContacts = getSignalRawContacts(account); - List> registeredChunks = Util.chunk(registeredAddressList, 50); - - for (List registeredChunk : registeredChunks) { - for (String registeredAddress : registeredChunk) { - if (!currentContacts.containsKey(registeredAddress)) { - Optional systemContactInfo = getSystemContactInfo(registeredAddress); - - if (systemContactInfo.isPresent()) { - Log.i(TAG, "Adding number: " + registeredAddress); - addTextSecureRawContact(operations, account, systemContactInfo.get().number, - systemContactInfo.get().name, systemContactInfo.get().id); - } - } - } - if (!operations.isEmpty()) { - context.getContentResolver().applyBatch(ContactsContract.AUTHORITY, operations); - operations.clear(); - } - } - - for (Map.Entry currentContactEntry : currentContacts.entrySet()) { - if (!registeredAddressSet.contains(currentContactEntry.getKey())) { - if (remove) { - Log.i(TAG, "Removing number: " + currentContactEntry.getKey()); - removeTextSecureRawContact(operations, account, currentContactEntry.getValue().getId()); - } - } else if (!currentContactEntry.getValue().isVoiceSupported()) { - Log.i(TAG, "Adding voice support: " + currentContactEntry.getKey()); - addContactVoiceSupport(operations, currentContactEntry.getKey(), currentContactEntry.getValue().getId()); - } else if (!Util.isStringEquals(currentContactEntry.getValue().getRawDisplayName(), - currentContactEntry.getValue().getAggregateDisplayName())) - { - Log.i(TAG, "Updating display name: " + currentContactEntry.getKey()); - updateDisplayName(operations, currentContactEntry.getValue().getAggregateDisplayName(), currentContactEntry.getValue().getId(), currentContactEntry.getValue().getDisplayNameSource()); - } - } - - if (!operations.isEmpty()) { - applyOperationsInBatches(context.getContentResolver(), ContactsContract.AUTHORITY, operations, 50); - } - } - - public @Nullable Cursor getNameDetails(long contactId) { - String[] projection = new String[] { ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, - ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, - ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, - ContactsContract.CommonDataKinds.StructuredName.PREFIX, - ContactsContract.CommonDataKinds.StructuredName.SUFFIX, - ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME }; - String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; - String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE }; - - return context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, - projection, - selection, - args, - null); - } - - public @Nullable String getOrganizationName(long contactId) { - String[] projection = new String[] { ContactsContract.CommonDataKinds.Organization.COMPANY }; - String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; - String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE }; - - try (Cursor cursor = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, - projection, - selection, - args, - null)) - { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getString(0); - } - } - - return null; - } - - public @Nullable Cursor getPhoneDetails(long contactId) { - String[] projection = new String[] { ContactsContract.CommonDataKinds.Phone.NUMBER, - ContactsContract.CommonDataKinds.Phone.TYPE, - ContactsContract.CommonDataKinds.Phone.LABEL }; - String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; - String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE }; - - return context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, - projection, - selection, - args, - null); - } - - public @Nullable Cursor getEmailDetails(long contactId) { - String[] projection = new String[] { ContactsContract.CommonDataKinds.Email.ADDRESS, - ContactsContract.CommonDataKinds.Email.TYPE, - ContactsContract.CommonDataKinds.Email.LABEL }; - String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; - String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE }; - - return context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, - projection, - selection, - args, - null); - } - - public @Nullable Cursor getPostalAddressDetails(long contactId) { - String[] projection = new String[] { ContactsContract.CommonDataKinds.StructuredPostal.TYPE, - ContactsContract.CommonDataKinds.StructuredPostal.LABEL, - ContactsContract.CommonDataKinds.StructuredPostal.STREET, - ContactsContract.CommonDataKinds.StructuredPostal.POBOX, - ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD, - ContactsContract.CommonDataKinds.StructuredPostal.CITY, - ContactsContract.CommonDataKinds.StructuredPostal.REGION, - ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE, - ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY }; - String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; - String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE }; - - return context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, - projection, - selection, - args, - null); - } - - public @Nullable Uri getAvatarUri(long contactId) { - String[] projection = new String[] { ContactsContract.CommonDataKinds.Photo.PHOTO_URI }; - String selection = ContactsContract.Data.CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?"; - String[] args = new String[] { String.valueOf(contactId), ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE }; - - try (Cursor cursor = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, - projection, - selection, - args, - null)) - { - if (cursor != null && cursor.moveToFirst()) { - String uri = cursor.getString(0); - if (uri != null) { - return Uri.parse(uri); - } - } - } - - return null; - } - - - - private void addContactVoiceSupport(List operations, - @NonNull String address, long rawContactId) - { - operations.add(ContentProviderOperation.newUpdate(RawContacts.CONTENT_URI) - .withSelection(RawContacts._ID + " = ?", new String[] {String.valueOf(rawContactId)}) - .withValue(RawContacts.SYNC4, "true") - .build()); - - 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, address) - .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name)) - .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, address)) - .withYieldAllowed(true) - .build()); - } - - private void updateDisplayName(List operations, - @Nullable String displayName, - long rawContactId, int displayNameSource) - { - Uri dataUri = ContactsContract.Data.CONTENT_URI.buildUpon() - .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") - .build(); - - if (displayNameSource != ContactsContract.DisplayNameSources.STRUCTURED_NAME) { - operations.add(ContentProviderOperation.newInsert(dataUri) - .withValue(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, rawContactId) - .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName) - .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) - .build()); - } else { - operations.add(ContentProviderOperation.newUpdate(dataUri) - .withSelection(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID + " = ? AND " + ContactsContract.Data.MIMETYPE + " = ?", - new String[] {String.valueOf(rawContactId), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE}) - .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName) - .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) - .build()); - } - } - - private void addTextSecureRawContact(List operations, - Account account, String e164number, String displayName, - 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) - .withValue(RawContacts.SYNC4, String.valueOf(true)) - .build()); - - operations.add(ContentProviderOperation.newInsert(dataUri) - .withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, index) - .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName) - .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) - .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()); - - 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()); - - 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(@NonNull Account account) { - 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 { - String[] projection = new String[] {BaseColumns._ID, RawContacts.SYNC1, RawContacts.SYNC4, RawContacts.CONTACT_ID, RawContacts.DISPLAY_NAME_PRIMARY, RawContacts.DISPLAY_NAME_SOURCE}; - - cursor = context.getContentResolver().query(currentContactsUri, projection, null, null, null); - - while (cursor != null && cursor.moveToNext()) { - String currentAddress = PhoneNumberFormatter.get(context).format(cursor.getString(1)); - long rawContactId = cursor.getLong(0); - long contactId = cursor.getLong(3); - String supportsVoice = cursor.getString(2); - String rawContactDisplayName = cursor.getString(4); - String aggregateDisplayName = getDisplayName(contactId); - int rawContactDisplayNameSource = cursor.getInt(5); - - signalContacts.put(currentAddress, new SignalContact(rawContactId, supportsVoice, rawContactDisplayName, aggregateDisplayName, rawContactDisplayNameSource)); - } - } finally { - if (cursor != null) - cursor.close(); - } - - return signalContacts; - } - - private Optional getSystemContactInfo(@NonNull String address) - { - Uri uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address)); - String[] projection = {ContactsContract.PhoneLookup.NUMBER, - ContactsContract.PhoneLookup._ID, - ContactsContract.PhoneLookup.DISPLAY_NAME}; - Cursor numberCursor = null; - Cursor idCursor = null; - - try { - numberCursor = context.getContentResolver().query(uri, projection, null, null, null); - - while (numberCursor != null && numberCursor.moveToNext()) { - String systemNumber = numberCursor.getString(0); - String systemAddress = PhoneNumberFormatter.get(context).format(systemNumber); - - if (systemAddress.equals(address)) { - idCursor = context.getContentResolver().query(RawContacts.CONTENT_URI, - new String[] {RawContacts._ID}, - RawContacts.CONTACT_ID + " = ? ", - new String[] {String.valueOf(numberCursor.getLong(1))}, - null); - - if (idCursor != null && idCursor.moveToNext()) { - return Optional.of(new SystemContactInfo(numberCursor.getString(2), - numberCursor.getString(0), - idCursor.getLong(0))); - } - } - } - } finally { - if (numberCursor != null) numberCursor.close(); - if (idCursor != null) idCursor.close(); - } - - return Optional.empty(); - } - - private @Nullable String getDisplayName(long contactId) { - Cursor cursor = context.getContentResolver().query(ContactsContract.Contacts.CONTENT_URI, - new String[]{ContactsContract.Contacts.DISPLAY_NAME}, - ContactsContract.Contacts._ID + " = ?", - new String[] {String.valueOf(contactId)}, - null); - - try { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getString(0); - } else { - return null; - } - } finally { - if (cursor != null) cursor.close(); - } - } - - private void applyOperationsInBatches(@NonNull ContentResolver contentResolver, - @NonNull String authority, - @NonNull List operations, - int batchSize) - throws OperationApplicationException, RemoteException - { - List> batches = Util.chunk(operations, batchSize); - for (List batch : batches) { - contentResolver.applyBatch(authority, new ArrayList<>(batch)); - } - } - - private static class SystemContactInfo { - private final String name; - private final String number; - private final long id; - - private SystemContactInfo(String name, String number, long id) { - this.name = name; - this.number = number; - this.id = id; - } - } - - private static class SignalContact { - - private final long id; - @Nullable private final String supportsVoice; - @Nullable private final String rawDisplayName; - @Nullable private final String aggregateDisplayName; - private final int displayNameSource; - - SignalContact(long id, - @Nullable String supportsVoice, - @Nullable String rawDisplayName, - @Nullable String aggregateDisplayName, - int displayNameSource) - { - this.id = id; - this.supportsVoice = supportsVoice; - this.rawDisplayName = rawDisplayName; - this.aggregateDisplayName = aggregateDisplayName; - this.displayNameSource = displayNameSource; - } - - public long getId() { - return id; - } - - boolean isVoiceSupported() { - return "true".equals(supportsVoice); - } - - @Nullable - String getRawDisplayName() { - return rawDisplayName; - } - - @Nullable - String getAggregateDisplayName() { - return aggregateDisplayName; - } - - int getDisplayNameSource() { - return displayNameSource; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java index 38d5d6d25..75c59eb74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java @@ -19,10 +19,7 @@ import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.BuildConfig; -import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.contacts.ContactAccessor; -import org.thoughtcrime.securesms.contacts.ContactsDatabase; import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.BulkOperationsHandle; @@ -307,7 +304,7 @@ class DirectoryHelper { return; } - AccountHolder account = getOrCreateSystemAccount(context); + Account account = SystemContactsRepository.getOrCreateSystemAccount(context); if (account == null) { Log.w(TAG, "Failed to create an account!"); @@ -315,15 +312,14 @@ class DirectoryHelper { } try { - ContactsDatabase contactsDatabase = SignalDatabase.contacts(); - List activeAddresses = Stream.of(activeIds) - .map(Recipient::resolved) - .filter(Recipient::hasE164) - .map(Recipient::requireE164) - .toList(); + List activeAddresses = Stream.of(activeIds) + .map(Recipient::resolved) + .filter(Recipient::hasE164) + .map(Recipient::requireE164) + .toList(); - contactsDatabase.removeDeletedRawContacts(account.getAccount()); - contactsDatabase.setRegisteredUsers(account.getAccount(), activeAddresses, removeMissing); + SystemContactsRepository.removeDeletedRawContacts(context, account); + SystemContactsRepository.setRegisteredUsers(context, account, activeAddresses, removeMissing); syncRecipientInfoWithSystemContacts(context, rewrites); } catch (RemoteException | OperationApplicationException e) { @@ -425,39 +421,6 @@ class DirectoryHelper { return CursorUtil.requireString(cursor, ContactsContract.Data.MIMETYPE); } - private static @Nullable AccountHolder getOrCreateSystemAccount(Context context) { - AccountManager accountManager = AccountManager.get(context); - Account[] accounts = accountManager.getAccountsByType(BuildConfig.APPLICATION_ID); - - AccountHolder account; - - if (accounts.length == 0) { - account = createAccount(context); - } else { - account = new AccountHolder(accounts[0], false); - } - - if (account != null && !ContentResolver.getSyncAutomatically(account.getAccount(), ContactsContract.AUTHORITY)) { - ContentResolver.setSyncAutomatically(account.getAccount(), ContactsContract.AUTHORITY, true); - } - - return account; - } - - private static @Nullable AccountHolder createAccount(Context context) { - AccountManager accountManager = AccountManager.get(context); - Account account = new Account(context.getString(R.string.app_name), BuildConfig.APPLICATION_ID); - - if (accountManager.addAccountExplicitly(account, null, null)) { - Log.i(TAG, "Created new account..."); - ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1); - return new AccountHolder(account, true); - } else { - Log.w(TAG, "Failed to create account!"); - return null; - } - } - private static void notifyNewUsers(@NonNull Context context, @NonNull Collection newUsers) { @@ -611,23 +574,4 @@ class DirectoryHelper { } } } - - private static class AccountHolder { - private final boolean fresh; - private final Account account; - - private AccountHolder(Account account, boolean fresh) { - this.fresh = fresh; - this.account = account; - } - - @SuppressWarnings("unused") - public boolean isFresh() { - return fresh; - } - - public Account getAccount() { - return account; - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/SystemContactsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/SystemContactsRepository.kt new file mode 100644 index 000000000..d25a721e0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/SystemContactsRepository.kt @@ -0,0 +1,539 @@ +package org.thoughtcrime.securesms.contacts.sync + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderOperation +import android.content.ContentResolver +import android.content.Context +import android.content.OperationApplicationException +import android.net.Uri +import android.os.RemoteException +import android.provider.BaseColumns +import android.provider.ContactsContract +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.BuildConfig +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.requireInt +import org.thoughtcrime.securesms.database.requireString +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter +import org.thoughtcrime.securesms.util.SqlUtil +import org.thoughtcrime.securesms.util.Util +import java.util.ArrayList +import java.util.HashMap + +/** + * A way to retrieve and update data in the Android system contacts. + */ +object SystemContactsRepository { + + private val TAG = Log.tag(SystemContactsRepository::class.java) + private const val CONTACT_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact" + private const val CALL_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call" + private const val SYNC = "__TS" + + @JvmStatic + fun getOrCreateSystemAccount(context: Context): Account? { + val accountManager: AccountManager = AccountManager.get(context) + val accounts: Array = accountManager.getAccountsByType(BuildConfig.APPLICATION_ID) + var account: Account? = if (accounts.isNotEmpty()) accounts[0] else null + + if (account == null) { + Log.i(TAG, "Attempting to create a new account...") + val newAccount = Account(context.getString(R.string.app_name), BuildConfig.APPLICATION_ID) + + if (accountManager.addAccountExplicitly(newAccount, null, null)) { + Log.i(TAG, "Successfully created a new account.") + ContentResolver.setIsSyncable(newAccount, ContactsContract.AUTHORITY, 1) + account = newAccount + } else { + Log.w(TAG, "Failed to create a new account!") + } + } + + if (account != null && !ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) { + Log.i(TAG, "Updated account to sync automatically.") + ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) + } + + return account + } + + @JvmStatic + @Synchronized + fun removeDeletedRawContacts(context: Context, account: Account) { + val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type) + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build() + + val projection = arrayOf(BaseColumns._ID, ContactsContract.RawContacts.SYNC1) + + context.contentResolver.query(currentContactsUri, projection, "${ContactsContract.RawContacts.DELETED} = ?", SqlUtil.buildArgs(1), null)?.use { cursor -> + while (cursor.moveToNext()) { + val rawContactId = cursor.getLong(0) + + Log.i(TAG, """Deleting raw contact: ${cursor.getString(1)}, $rawContactId""") + context.contentResolver.delete(currentContactsUri, "${ContactsContract.RawContacts._ID} = ?", arrayOf(rawContactId.toString())) + } + } + } + + @JvmStatic + @Synchronized + @Throws(RemoteException::class, OperationApplicationException::class) + fun setRegisteredUsers( + context: Context, + account: Account, + registeredAddressList: List, + remove: Boolean + ) { + val registeredAddressSet: Set = registeredAddressList.toSet() + val operations: ArrayList = ArrayList() + val currentContacts: Map = getSignalRawContacts(context, account) + + val registeredChunks: List> = Util.chunk(registeredAddressList, 50) + for (registeredChunk in registeredChunks) { + for (registeredAddress in registeredChunk) { + if (!currentContacts.containsKey(registeredAddress)) { + val systemContactInfo: SystemContactInfo? = getSystemContactInfo(context, registeredAddress) + if (systemContactInfo != null) { + Log.i(TAG, "Adding number: $registeredAddress") + addTextSecureRawContact( + context = context, + operations = operations, + account = account, + e164number = systemContactInfo.number, + displayName = systemContactInfo.name, + aggregateId = systemContactInfo.id + ) + } + } + } + + if (operations.isNotEmpty()) { + context.contentResolver.applyBatch(ContactsContract.AUTHORITY, operations) + operations.clear() + } + } + + for ((key, value) in currentContacts) { + if (!registeredAddressSet.contains(key)) { + if (remove) { + Log.i(TAG, "Removing number: $key") + removeTextSecureRawContact(operations, account, value.id) + } + } else if (!value.isVoiceSupported()) { + Log.i(TAG, "Adding voice support: $key") + addContactVoiceSupport(context, operations, key, value.id) + } else if (!Util.isStringEquals(value.rawDisplayName, value.aggregateDisplayName)) { + Log.i(TAG, "Updating display name: $key") + updateDisplayName(operations, value.aggregateDisplayName, value.id, value.displayNameSource) + } + } + + if (operations.isNotEmpty()) { + applyOperationsInBatches(context.contentResolver, ContactsContract.AUTHORITY, operations, 50) + } + } + + @JvmStatic + fun getNameDetails(context: Context, contactId: Long): NameDetails? { + val projection = arrayOf( + ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, + ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, + ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, + ContactsContract.CommonDataKinds.StructuredName.PREFIX, + ContactsContract.CommonDataKinds.StructuredName.SUFFIX, + ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME + ) + val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?" + val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + + return context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor -> + if (cursor.moveToFirst()) { + NameDetails( + displayName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME), + givenName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME), + familyName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME), + prefix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.PREFIX), + suffix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.SUFFIX), + middleName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME) + ) + } else { + null + } + } + } + + @JvmStatic + fun getOrganizationName(context: Context, contactId: Long): String? { + val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY) + val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?" + val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE) + + context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor -> + if (cursor.moveToFirst()) { + return cursor.getString(0) + } + } + + return null + } + + @JvmStatic + fun getPhoneDetails(context: Context, contactId: Long): List { + val projection = arrayOf( + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.TYPE, + ContactsContract.CommonDataKinds.Phone.LABEL + ) + val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?" + val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) + + val phoneDetails: MutableList = mutableListOf() + + context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor -> + while (cursor.moveToNext()) { + phoneDetails += PhoneDetails( + number = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER), + type = cursor.requireInt(ContactsContract.CommonDataKinds.Phone.TYPE), + label = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LABEL) + ) + } + } + + return phoneDetails + } + + @JvmStatic + fun getEmailDetails(context: Context, contactId: Long): List { + val projection = arrayOf( + ContactsContract.CommonDataKinds.Email.ADDRESS, + ContactsContract.CommonDataKinds.Email.TYPE, + ContactsContract.CommonDataKinds.Email.LABEL + ) + val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?" + val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE) + + val emailDetails: MutableList = mutableListOf() + context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor -> + while (cursor.moveToNext()) { + emailDetails += EmailDetails( + address = cursor.requireString(ContactsContract.CommonDataKinds.Email.ADDRESS), + type = cursor.requireInt(ContactsContract.CommonDataKinds.Email.TYPE), + label = cursor.requireString(ContactsContract.CommonDataKinds.Email.LABEL) + ) + } + } + + return emailDetails + } + + @JvmStatic + fun getPostalAddressDetails(context: Context, contactId: Long): List { + val projection = arrayOf( + ContactsContract.CommonDataKinds.StructuredPostal.TYPE, + ContactsContract.CommonDataKinds.StructuredPostal.LABEL, + ContactsContract.CommonDataKinds.StructuredPostal.STREET, + ContactsContract.CommonDataKinds.StructuredPostal.POBOX, + ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD, + ContactsContract.CommonDataKinds.StructuredPostal.CITY, + ContactsContract.CommonDataKinds.StructuredPostal.REGION, + ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE, + ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY + ) + val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?" + val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE) + + val postalDetails: MutableList = mutableListOf() + + context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor -> + while (cursor.moveToNext()) { + postalDetails += PostalAddressDetails( + type = cursor.requireInt(ContactsContract.CommonDataKinds.StructuredPostal.TYPE), + label = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.LABEL), + street = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.STREET), + poBox = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POBOX), + neighborhood = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD), + city = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.CITY), + region = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.REGION), + postal = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE), + country = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY) + ) + } + } + + return postalDetails + } + + @JvmStatic + fun getAvatarUri(context: Context, contactId: Long): Uri? { + val projection = arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_URI) + val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?" + val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE) + + context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val uri = cursor.getString(0) + if (uri != null) { + return Uri.parse(uri) + } + } + } + + return null + } + + private fun addContactVoiceSupport(context: Context, operations: MutableList, address: String, rawContactId: Long) { + operations.add( + ContentProviderOperation.newUpdate(ContactsContract.RawContacts.CONTENT_URI) + .withSelection("${ContactsContract.RawContacts._ID} = ?", arrayOf(rawContactId.toString())) + .withValue(ContactsContract.RawContacts.SYNC4, "true") + .build() + ) + + 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, address) + .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name)) + .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, address)) + .withYieldAllowed(true) + .build() + ) + } + + private fun updateDisplayName(operations: MutableList, displayName: String?, rawContactId: Long, displayNameSource: Int) { + val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build() + + if (displayNameSource != ContactsContract.DisplayNameSources.STRUCTURED_NAME) { + operations.add( + ContentProviderOperation.newInsert(dataUri) + .withValue(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, rawContactId) + .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .build() + ) + } else { + operations.add( + ContentProviderOperation.newUpdate(dataUri) + .withSelection("${ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?", SqlUtil.buildArgs(rawContactId.toString(), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)) + .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .build() + ) + } + } + + private fun addTextSecureRawContact( + context: Context, + operations: MutableList, + account: Account, + e164number: String, + displayName: String, + aggregateId: Long + ) { + val index = operations.size + val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build() + + operations.add( + ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) + .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name) + .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type) + .withValue(ContactsContract.RawContacts.SYNC1, e164number) + .withValue(ContactsContract.RawContacts.SYNC4, true.toString()) + .build() + ) + operations.add( + ContentProviderOperation.newInsert(dataUri) + .withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, index) + .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .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() + ) + 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() + ) + 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 fun removeTextSecureRawContact(operations: MutableList, account: Account, rowId: Long) { + operations.add( + ContentProviderOperation.newDelete( + ContactsContract.RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type) + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build() + ) + .withYieldAllowed(true) + .withSelection("${BaseColumns._ID} = ?", SqlUtil.buildArgs(rowId)) + .build() + ) + } + + private fun getSignalRawContacts(context: Context, account: Account): Map { + val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build() + val projection = arrayOf(BaseColumns._ID, ContactsContract.RawContacts.SYNC1, ContactsContract.RawContacts.SYNC4, ContactsContract.RawContacts.CONTACT_ID, ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY, ContactsContract.RawContacts.DISPLAY_NAME_SOURCE) + + val signalContacts: MutableMap = HashMap() + + context.contentResolver.query(currentContactsUri, projection, null, null, null)?.use { cursor -> + while (cursor.moveToNext()) { + val currentAddress = PhoneNumberFormatter.get(context).format(cursor.getString(1)) + + signalContacts[currentAddress] = SignalContact( + id = cursor.getLong(0), + supportsVoice = cursor.getString(2), + rawDisplayName = cursor.getString(4), + aggregateDisplayName = getDisplayName(context, cursor.getLong(3)), + displayNameSource = cursor.getInt(5) + ) + } + } + + return signalContacts + } + + private fun getSystemContactInfo(context: Context, address: String): SystemContactInfo? { + val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address)) + val projection = arrayOf( + ContactsContract.PhoneLookup.NUMBER, + ContactsContract.PhoneLookup._ID, + ContactsContract.PhoneLookup.DISPLAY_NAME + ) + + context.contentResolver.query(uri, projection, null, null, null)?.use { numberCursor -> + while (numberCursor.moveToNext()) { + val systemNumber = numberCursor.getString(0) + val systemAddress = PhoneNumberFormatter.get(context).format(systemNumber) + if (systemAddress == address) { + context.contentResolver.query(ContactsContract.RawContacts.CONTENT_URI, arrayOf(ContactsContract.RawContacts._ID), "${ContactsContract.RawContacts.CONTACT_ID} = ? ", SqlUtil.buildArgs(numberCursor.getLong(1)), null)?.use { idCursor -> + if (idCursor.moveToNext()) { + return SystemContactInfo( + name = numberCursor.getString(2), + number = numberCursor.getString(0), + id = idCursor.getLong(0) + ) + } + } + } + } + } + + return null + } + + private fun getDisplayName(context: Context, contactId: Long): String? { + val projection = arrayOf(ContactsContract.Contacts.DISPLAY_NAME) + val selection = "${ContactsContract.Contacts._ID} = ?" + val args = SqlUtil.buildArgs(contactId) + + context.contentResolver.query(ContactsContract.Contacts.CONTENT_URI, projection, selection, args, null)?.use { cursor -> + if (cursor.moveToFirst()) { + return cursor.getString(0) + } + } + + return null + } + + @Throws(OperationApplicationException::class, RemoteException::class) + private fun applyOperationsInBatches( + contentResolver: ContentResolver, + authority: String, + operations: List, + batchSize: Int + ) { + val batches = Util.chunk(operations, batchSize) + for (batch in batches) { + contentResolver.applyBatch(authority, ArrayList(batch)) + } + } + + private data class SystemContactInfo(val name: String, val number: String, val id: Long) + + private data class SignalContact( + val id: Long, + val supportsVoice: String?, + val rawDisplayName: String?, + val aggregateDisplayName: String?, + val displayNameSource: Int + ) { + fun isVoiceSupported(): Boolean { + return "true" == supportsVoice + } + } + + data class NameDetails( + val displayName: String?, + val givenName: String?, + val familyName: String?, + val prefix: String?, + val suffix: String?, + val middleName: String? + ) + + data class PhoneDetails( + val number: String?, + val type: Int, + val label: String? + ) + + data class EmailDetails( + val address: String?, + val type: Int, + val label: String? + ) + + data class PostalAddressDetails( + val type: Int, + val label: String?, + val street: String?, + val poBox: String?, + val neighborhood: String?, + val city: String?, + val region: String?, + val postal: String?, + val country: String? + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java index 6731aa789..5c64c9297 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java @@ -77,9 +77,7 @@ public class ContactShareEditActivity extends PassphraseRequiredActivity impleme ContactShareEditAdapter contactAdapter = new ContactShareEditAdapter(GlideApp.with(this), dynamicLanguage.getCurrentLocale(), this); contactList.setAdapter(contactAdapter); - SharedContactRepository contactRepository = new SharedContactRepository(this, - AsyncTask.THREAD_POOL_EXECUTOR, - SignalDatabase.contacts()); + SharedContactRepository contactRepository = new SharedContactRepository(this, AsyncTask.THREAD_POOL_EXECUTOR); viewModel = ViewModelProviders.of(this, new Factory(contactUris, contactRepository)).get(ContactShareEditViewModel.class); viewModel.getContacts().observe(this, contacts -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactUtil.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactUtil.java index 0ec507f85..5a47013f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactUtil.java @@ -113,7 +113,7 @@ public final class ContactUtil { } } - public static @NonNull String getNormalizedPhoneNumber(@NonNull Context context, @NonNull String number) { + public static @NonNull String getNormalizedPhoneNumber(@NonNull Context context, @Nullable String number) { return PhoneNumberFormatter.get(context).format(number); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java index 2be7c642e..6d8adf382 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.contactshare; import android.content.Context; -import android.database.Cursor; import android.net.Uri; import android.provider.ContactsContract; import android.text.TextUtils; @@ -11,8 +10,10 @@ import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.contacts.ContactsDatabase; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.sync.SystemContactsRepository; +import org.thoughtcrime.securesms.contacts.sync.SystemContactsRepository.NameDetails; +import org.thoughtcrime.securesms.contacts.sync.SystemContactsRepository.PhoneDetails; import org.thoughtcrime.securesms.contactshare.Contact.Email; import org.thoughtcrime.securesms.contactshare.Contact.Name; import org.thoughtcrime.securesms.contactshare.Contact.Phone; @@ -26,10 +27,11 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.Executor; +import java.util.stream.Collectors; import ezvcard.Ezvcard; import ezvcard.VCard; @@ -40,17 +42,12 @@ public class SharedContactRepository { private static final String TAG = Log.tag(SharedContactRepository.class); - private final Context context; - private final Executor executor; - private final ContactsDatabase contactsDatabase; + private final Context context; + private final Executor executor; - SharedContactRepository(@NonNull Context context, - @NonNull Executor executor, - @NonNull ContactsDatabase contactsDatabase) - { - this.context = context.getApplicationContext(); - this.executor = executor; - this.contactsDatabase = contactsDatabase; + SharedContactRepository(@NonNull Context context, @NonNull Executor executor) { + this.context = context.getApplicationContext(); + this.executor = executor; } void getContacts(@NonNull List contactUris, @NonNull ValueCallback> callback) { @@ -108,23 +105,16 @@ public class SharedContactRepository { @WorkerThread private @Nullable Name getName(long contactId) { - try (Cursor cursor = contactsDatabase.getNameDetails(contactId)) { - if (cursor != null && cursor.moveToFirst()) { - String cursorDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME)); - String cursorGivenName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME)); - String cursorFamilyName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME)); - String cursorPrefix = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.PREFIX)); - String cursorSuffix = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.SUFFIX)); - String cursorMiddleName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME)); + NameDetails nameDetails = SystemContactsRepository.getNameDetails(context, contactId); - Name name = new Name(cursorDisplayName, cursorGivenName, cursorFamilyName, cursorPrefix, cursorSuffix, cursorMiddleName); - if (!name.isEmpty()) { - return name; - } + if (nameDetails != null) { + Name name = new Name(nameDetails.getDisplayName(), nameDetails.getGivenName(), nameDetails.getFamilyName(), nameDetails.getPrefix(), nameDetails.getSuffix(), nameDetails.getMiddleName()); + if (!name.isEmpty()) { + return name; } } - String org = contactsDatabase.getOrganizationName(contactId); + String org = SystemContactsRepository.getOrganizationName(context, contactId); if (!TextUtils.isEmpty(org)) { return new Name(org, org, null, null, null, null); } @@ -134,20 +124,16 @@ public class SharedContactRepository { @WorkerThread private @NonNull List getPhoneNumbers(long contactId) { - Map numberMap = new HashMap<>(); - try (Cursor cursor = contactsDatabase.getPhoneDetails(contactId)) { - while (cursor != null && cursor.moveToNext()) { - String cursorNumber = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)); - int cursorType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE)); - String cursorLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LABEL)); + Map numberMap = new HashMap<>(); + List phoneDetails = SystemContactsRepository.getPhoneDetails(context, contactId); - String number = ContactUtil.getNormalizedPhoneNumber(context, cursorNumber); - Phone existing = numberMap.get(number); - Phone candidate = new Phone(number, VCardUtil.phoneTypeFromContactType(cursorType), cursorLabel); + for (PhoneDetails phone : phoneDetails) { + String number = ContactUtil.getNormalizedPhoneNumber(context, phone.getNumber()); + Phone existing = numberMap.get(number); + Phone candidate = new Phone(number, VCardUtil.phoneTypeFromContactType(phone.getType()), phone.getLabel()); - if (existing == null || (existing.getType() == Phone.Type.CUSTOM && existing.getLabel() == null)) { - numberMap.put(number, candidate); - } + if (existing == null || (existing.getType() == Phone.Type.CUSTOM && existing.getLabel() == null)) { + numberMap.put(number, candidate); } } @@ -158,50 +144,31 @@ public class SharedContactRepository { @WorkerThread private @NonNull List getEmails(long contactId) { - List emails = new LinkedList<>(); - - try (Cursor cursor = contactsDatabase.getEmailDetails(contactId)) { - while (cursor != null && cursor.moveToNext()) { - String cursorEmail = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.ADDRESS)); - int cursorType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.TYPE)); - String cursorLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.LABEL)); - - emails.add(new Email(cursorEmail, VCardUtil.emailTypeFromContactType(cursorType), cursorLabel)); - } - } - - return emails; + return SystemContactsRepository.getEmailDetails(context, contactId) + .stream() + .filter(Objects::nonNull) + .map(email -> new Email(Objects.requireNonNull(email.getAddress()), + VCardUtil.emailTypeFromContactType(email.getType()), + email.getLabel())) + .collect(Collectors.toList()); } @WorkerThread private @NonNull List getPostalAddresses(long contactId) { - List postalAddresses = new LinkedList<>(); - - try (Cursor cursor = contactsDatabase.getPostalAddressDetails(contactId)) { - while (cursor != null && cursor.moveToNext()) { - int cursorType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.TYPE)); - String cursorLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.LABEL)); - String cursorStreet = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.STREET)); - String cursorPoBox = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.POBOX)); - String cursorNeighborhood = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD)); - String cursorCity = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.CITY)); - String cursorRegion = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.REGION)); - String cursorPostal = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE)); - String cursorCountry = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY)); - - postalAddresses.add(new PostalAddress(VCardUtil.postalAddressTypeFromContactType(cursorType), - cursorLabel, - cursorStreet, - cursorPoBox, - cursorNeighborhood, - cursorCity, - cursorRegion, - cursorPostal, - cursorCountry)); - } - } - - return postalAddresses; + return SystemContactsRepository.getPostalAddressDetails(context, contactId) + .stream() + .map(address -> { + return new PostalAddress(VCardUtil.postalAddressTypeFromContactType(address.getType()), + address.getLabel(), + address.getStreet(), + address.getPoBox(), + address.getNeighborhood(), + address.getCity(), + address.getRegion(), + address.getPostal(), + address.getCountry()); + }) + .collect(Collectors.toList()); } @WorkerThread @@ -223,7 +190,7 @@ public class SharedContactRepository { @WorkerThread private @Nullable AvatarInfo getSystemAvatarInfo(long contactId) { - Uri uri = contactsDatabase.getAvatarUri(contactId); + Uri uri = SystemContactsRepository.getAvatarUri(context, contactId); if (uri != null) { return new AvatarInfo(uri, false); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index 62440864b..74c6a121b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -4,7 +4,6 @@ import android.app.Application import android.content.Context import net.zetetic.database.sqlcipher.SQLiteOpenHelper import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.contacts.ContactsDatabase import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.DatabaseSecret import org.thoughtcrime.securesms.crypto.MasterSecret @@ -49,7 +48,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data val pushDatabase: PushDatabase = PushDatabase(context, this) val groupDatabase: GroupDatabase = GroupDatabase(context, this) val recipientDatabase: RecipientDatabase = RecipientDatabase(context, this) - val contactsDatabase: ContactsDatabase = ContactsDatabase(context) val groupReceiptDatabase: GroupReceiptDatabase = GroupReceiptDatabase(context, this) val preKeyDatabase: OneTimePreKeyDatabase = OneTimePreKeyDatabase(context, this) val signedPreKeyDatabase: SignedPreKeyDatabase = SignedPreKeyDatabase(context, this) @@ -332,11 +330,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data val chatColors: ChatColorsDatabase get() = instance!!.chatColorsDatabase - @get:JvmStatic - @get:JvmName("contacts") - val contacts: ContactsDatabase - get() = instance!!.contactsDatabase - @get:JvmStatic @get:JvmName("distributionLists") val distributionLists: DistributionListDatabase