/** * 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.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; import android.provider.BaseColumns; import android.provider.ContactsContract; import android.provider.ContactsContract.RawContacts; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.Pair; import org.thoughtcrime.securesms.R; import org.whispersystems.libaxolotl.util.guava.Optional; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; 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 * * @author Jake McGinty */ public class ContactsDatabase { private static final String TAG = ContactsDatabase.class.getSimpleName(); public static final String ID_COLUMN = "_id"; public static final String NAME_COLUMN = "name"; public static final String NUMBER_COLUMN = "number"; public static final String NUMBER_TYPE_COLUMN = "number_type"; public static final String LABEL_COLUMN = "label"; public static final String CONTACT_TYPE_COLUMN = "contact_type"; public static final int NORMAL_TYPE = 0; public static final int PUSH_TYPE = 1; public static final int NEW_TYPE = 2; private final Context context; public ContactsDatabase(Context context) { this.context = context; } public synchronized boolean 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)) { Optional> systemContactInfo = getSystemContactInfo(number); if (systemContactInfo.isPresent()) { addTextSecureRawContact(operations, account, systemContactInfo.get().first, systemContactInfo.get().second); } } } 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); return true; } else { return false; } } 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, "__TS") .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, 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; if (!TextUtils.isEmpty(filter)) { uri = Uri.withAppendedPath(ContactsContract.CommonDataKinds.Phone.CONTENT_FILTER_URI, Uri.encode(filter)); } else { uri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI; } if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { uri = uri.buildUpon().appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true").build(); } String[] projection = new String[]{ContactsContract.CommonDataKinds.Phone._ID, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.LABEL}; String sort = ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " COLLATE NOCASE ASC"; Map projectionMap = new HashMap() {{ put(ID_COLUMN, ContactsContract.CommonDataKinds.Phone._ID); put(NAME_COLUMN, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME); put(NUMBER_COLUMN, ContactsContract.CommonDataKinds.Phone.NUMBER); put(NUMBER_TYPE_COLUMN, ContactsContract.CommonDataKinds.Phone.TYPE); put(LABEL_COLUMN, ContactsContract.CommonDataKinds.Phone.LABEL); }}; Cursor cursor = context.getContentResolver().query(uri, projection, ContactsContract.Data.SYNC2 + " IS NULL OR " + ContactsContract.Data.SYNC2 + " != ?", new String[] {"__TS"}, sort); return new ProjectionMappingCursor(cursor, projectionMap, new Pair(CONTACT_TYPE_COLUMN, NORMAL_TYPE)); } public @NonNull Cursor queryTextSecureContacts(String filter) { String[] projection = new String[] {ContactsContract.Data._ID, ContactsContract.Contacts.DISPLAY_NAME, ContactsContract.Data.DATA1}; String sort = ContactsContract.Contacts.DISPLAY_NAME + " COLLATE NOCASE ASC"; Map projectionMap = new HashMap(){{ put(ID_COLUMN, ContactsContract.Data._ID); put(NAME_COLUMN, ContactsContract.Contacts.DISPLAY_NAME); put(NUMBER_COLUMN, ContactsContract.Data.DATA1); }}; Cursor cursor; if (TextUtils.isEmpty(filter)) { cursor = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI, projection, ContactsContract.Data.MIMETYPE + " = ?", new String[] {"vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"}, 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[] {"vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact", "%" + filter + "%", "%" + filter + "%"}, sort); } return new ProjectionMappingCursor(cursor, projectionMap, new Pair(LABEL_COLUMN, "TextSecure"), new Pair(NUMBER_TYPE_COLUMN, 0), new Pair(CONTACT_TYPE_COLUMN, PUSH_TYPE)); } 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}); return newNumberCursor; } private Optional> getSystemContactInfo(String e164number) { Uri uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(e164number)); String[] projection = {ContactsContract.PhoneLookup.NUMBER, ContactsContract.PhoneLookup._ID}; Cursor numberCursor = null; Cursor idCursor = null; try { numberCursor = context.getContentResolver().query(uri, projection, null, null, null); if (numberCursor != null && numberCursor.moveToNext()) { 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 Pair<>(numberCursor.getString(0), idCursor.getLong(0))); } } } finally { if (numberCursor != null) numberCursor.close(); if (idCursor != null) idCursor.close(); } return Optional.absent(); } private static class ProjectionMappingCursor extends CursorWrapper { private final Map projectionMap; private final Pair[] extras; @SafeVarargs public ProjectionMappingCursor(Cursor cursor, Map projectionMap, Pair... extras) { super(cursor); this.projectionMap = projectionMap; this.extras = extras; } @Override public int getColumnCount() { return super.getColumnCount() + extras.length; } @Override public int getColumnIndex(String columnName) { for (int i=0;i= baseColumnCount) { int offset = columnIndex - baseColumnCount; return extras[offset].first; } return getReverseProjection(super.getColumnName(columnIndex)); } @Override public String[] getColumnNames() { String[] names = super.getColumnNames(); String[] allNames = new String[names.length + extras.length]; for (int i=0;i= super.getColumnCount()) { int offset = columnIndex - super.getColumnCount(); return (Integer)extras[offset].second; } return super.getInt(columnIndex); } @Override public String getString(int columnIndex) { if (columnIndex >= super.getColumnCount()) { int offset = columnIndex - super.getColumnCount(); return (String)extras[offset].second; } return super.getString(columnIndex); } private @Nullable String getReverseProjection(String columnName) { for (Map.Entry entry : projectionMap.entrySet()) { if (entry.getValue().equals(columnName)) { return entry.getKey(); } } return null; } } }