mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-26 03:40:56 +01:00
Move all files to natural position.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
/*
|
||||
* Copyright (C) 2006 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import android.database.AbstractCursor;
|
||||
import android.database.CursorWindow;
|
||||
|
||||
import java.lang.System;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* A convenience class that presents a two-dimensional ArrayList
|
||||
* as a Cursor.
|
||||
*/
|
||||
public class ArrayListCursor extends AbstractCursor {
|
||||
private String[] mColumnNames;
|
||||
private ArrayList<Object>[] mRows;
|
||||
|
||||
@SuppressWarnings({"unchecked"})
|
||||
public ArrayListCursor(String[] columnNames, ArrayList<ArrayList> rows) {
|
||||
int colCount = columnNames.length;
|
||||
boolean foundID = false;
|
||||
// Add an _id column if not in columnNames
|
||||
for (int i = 0; i < colCount; ++i) {
|
||||
if (columnNames[i].compareToIgnoreCase("_id") == 0) {
|
||||
mColumnNames = columnNames;
|
||||
foundID = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundID) {
|
||||
mColumnNames = new String[colCount + 1];
|
||||
System.arraycopy(columnNames, 0, mColumnNames, 0, columnNames.length);
|
||||
mColumnNames[colCount] = "_id";
|
||||
}
|
||||
|
||||
int rowCount = rows.size();
|
||||
mRows = new ArrayList[rowCount];
|
||||
|
||||
for (int i = 0; i < rowCount; ++i) {
|
||||
mRows[i] = rows.get(i);
|
||||
if (!foundID) {
|
||||
mRows[i].add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fillWindow(int position, CursorWindow window) {
|
||||
if (position < 0 || position > getCount()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.acquireReference();
|
||||
try {
|
||||
int oldpos = mPos;
|
||||
mPos = position - 1;
|
||||
window.clear();
|
||||
window.setStartPosition(position);
|
||||
int columnNum = getColumnCount();
|
||||
window.setNumColumns(columnNum);
|
||||
while (moveToNext() && window.allocRow()) {
|
||||
for (int i = 0; i < columnNum; i++) {
|
||||
final Object data = mRows[mPos].get(i);
|
||||
if (data != null) {
|
||||
if (data instanceof byte[]) {
|
||||
byte[] field = (byte[]) data;
|
||||
if (!window.putBlob(field, mPos, i)) {
|
||||
window.freeLastRow();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
String field = data.toString();
|
||||
if (!window.putString(field, mPos, i)) {
|
||||
window.freeLastRow();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!window.putNull(mPos, i)) {
|
||||
window.freeLastRow();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mPos = oldpos;
|
||||
} catch (IllegalStateException e){
|
||||
// simply ignore it
|
||||
} finally {
|
||||
window.releaseReference();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return mRows.length;
|
||||
}
|
||||
|
||||
public boolean deleteRow() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getColumnNames() {
|
||||
return mColumnNames;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getBlob(int columnIndex) {
|
||||
return (byte[]) mRows[mPos].get(columnIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(int columnIndex) {
|
||||
Object cell = mRows[mPos].get(columnIndex);
|
||||
return (cell == null) ? null : cell.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public short getShort(int columnIndex) {
|
||||
Number num = (Number) mRows[mPos].get(columnIndex);
|
||||
return num.shortValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(int columnIndex) {
|
||||
Number num = (Number) mRows[mPos].get(columnIndex);
|
||||
return num.intValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(int columnIndex) {
|
||||
Number num = (Number) mRows[mPos].get(columnIndex);
|
||||
return num.longValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(int columnIndex) {
|
||||
Number num = (Number) mRows[mPos].get(columnIndex);
|
||||
return num.floatValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getDouble(int columnIndex) {
|
||||
Number num = (Number) mRows[mPos].get(columnIndex);
|
||||
return num.doubleValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isNull(int columnIndex) {
|
||||
return mRows[mPos].get(columnIndex) == null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Copyright (C) 2011 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MergeCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import android.provider.ContactsContract.CommonDataKinds.Phone;
|
||||
import android.provider.ContactsContract.Contacts;
|
||||
import android.provider.ContactsContract.PhoneLookup;
|
||||
import android.telephony.PhoneNumberUtils;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
||||
|
||||
/**
|
||||
* This class was originally a layer of indirection between
|
||||
* ContactAccessorNewApi and ContactAccessorOldApi, which corresponded
|
||||
* to the API changes between 1.x and 2.x.
|
||||
*
|
||||
* Now that we no longer support 1.x, this class mostly serves as a place
|
||||
* to encapsulate Contact-related logic. It's still a singleton, mostly
|
||||
* just because that's how it's currently called from everywhere.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
|
||||
public class ContactAccessor {
|
||||
|
||||
public static final String PUSH_COLUMN = "push";
|
||||
|
||||
private static final ContactAccessor instance = new ContactAccessor();
|
||||
|
||||
public static synchronized ContactAccessor getInstance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
public Set<String> getAllContactsWithNumbers(Context context) {
|
||||
Set<String> results = new HashSet<>();
|
||||
|
||||
try (Cursor cursor = context.getContentResolver().query(Phone.CONTENT_URI, new String[] {Phone.NUMBER}, null ,null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
if (!TextUtils.isEmpty(cursor.getString(0))) {
|
||||
results.add(PhoneNumberFormatter.get(context).format(cursor.getString(0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public Cursor getAllSystemContacts(Context context) {
|
||||
return context.getContentResolver().query(Phone.CONTENT_URI, new String[] {Phone.NUMBER, Phone.DISPLAY_NAME, Phone.LABEL, Phone.PHOTO_URI, Phone._ID, Phone.LOOKUP_KEY, Phone.TYPE}, null, null, null);
|
||||
}
|
||||
|
||||
public boolean isSystemContact(Context context, String number) {
|
||||
Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number));
|
||||
String[] projection = new String[]{PhoneLookup.DISPLAY_NAME, PhoneLookup.LOOKUP_KEY,
|
||||
PhoneLookup._ID, PhoneLookup.NUMBER};
|
||||
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
|
||||
|
||||
try {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return true;
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Collection<ContactData> getContactsWithPush(Context context) {
|
||||
final ContentResolver resolver = context.getContentResolver();
|
||||
final String[] inProjection = new String[]{PhoneLookup._ID, PhoneLookup.DISPLAY_NAME};
|
||||
|
||||
final List<String> registeredAddresses = Stream.of(DatabaseFactory.getRecipientDatabase(context).getRegistered())
|
||||
.map(Recipient::resolved)
|
||||
.filter(r -> r.getE164().isPresent())
|
||||
.map(Recipient::requireE164)
|
||||
.toList();
|
||||
final Collection<ContactData> lookupData = new ArrayList<>(registeredAddresses.size());
|
||||
|
||||
for (String registeredAddress : registeredAddresses) {
|
||||
Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(registeredAddress));
|
||||
Cursor lookupCursor = resolver.query(uri, inProjection, null, null, null);
|
||||
|
||||
try {
|
||||
if (lookupCursor != null && lookupCursor.moveToFirst()) {
|
||||
final ContactData contactData = new ContactData(lookupCursor.getLong(0), lookupCursor.getString(1));
|
||||
contactData.numbers.add(new NumberData("TextSecure", registeredAddress));
|
||||
lookupData.add(contactData);
|
||||
}
|
||||
} finally {
|
||||
if (lookupCursor != null)
|
||||
lookupCursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
return lookupData;
|
||||
}
|
||||
|
||||
public String getNameFromContact(Context context, Uri uri) {
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = context.getContentResolver().query(uri, new String[] {Contacts.DISPLAY_NAME},
|
||||
null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst())
|
||||
return cursor.getString(0);
|
||||
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public ContactData getContactData(Context context, Uri uri) {
|
||||
return getContactData(context, getNameFromContact(context, uri), Long.parseLong(uri.getLastPathSegment()));
|
||||
}
|
||||
|
||||
private ContactData getContactData(Context context, String displayName, long id) {
|
||||
ContactData contactData = new ContactData(id, displayName);
|
||||
Cursor numberCursor = null;
|
||||
|
||||
try {
|
||||
numberCursor = context.getContentResolver().query(Phone.CONTENT_URI, null,
|
||||
Phone.CONTACT_ID + " = ?",
|
||||
new String[] {contactData.id + ""}, null);
|
||||
|
||||
while (numberCursor != null && numberCursor.moveToNext()) {
|
||||
int type = numberCursor.getInt(numberCursor.getColumnIndexOrThrow(Phone.TYPE));
|
||||
String label = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.LABEL));
|
||||
String number = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.NUMBER));
|
||||
String typeLabel = Phone.getTypeLabel(context.getResources(), type, label).toString();
|
||||
|
||||
contactData.numbers.add(new NumberData(typeLabel, number));
|
||||
}
|
||||
} finally {
|
||||
if (numberCursor != null)
|
||||
numberCursor.close();
|
||||
}
|
||||
|
||||
return contactData;
|
||||
}
|
||||
|
||||
public List<String> getNumbersForThreadSearchFilter(Context context, String constraint) {
|
||||
LinkedList<String> numberList = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = DatabaseFactory.getRecipientDatabase(context).queryAllContacts(constraint)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String phone = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.PHONE));
|
||||
String email = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.EMAIL));
|
||||
|
||||
numberList.add(Util.getFirstNonEmpty(phone, email));
|
||||
}
|
||||
}
|
||||
|
||||
GroupDatabase.Reader reader = null;
|
||||
GroupRecord record;
|
||||
|
||||
try {
|
||||
reader = DatabaseFactory.getGroupDatabase(context).getGroupsFilteredByTitle(constraint, true);
|
||||
|
||||
while ((record = reader.getNext()) != null) {
|
||||
numberList.add(record.getEncodedId());
|
||||
}
|
||||
} finally {
|
||||
if (reader != null)
|
||||
reader.close();
|
||||
}
|
||||
|
||||
if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) &&
|
||||
!numberList.contains(TextSecurePreferences.getLocalNumber(context)))
|
||||
{
|
||||
numberList.add(TextSecurePreferences.getLocalNumber(context));
|
||||
}
|
||||
|
||||
return numberList;
|
||||
}
|
||||
|
||||
public CharSequence phoneTypeToString(Context mContext, int type, CharSequence label) {
|
||||
return Phone.getTypeLabel(mContext.getResources(), type, label);
|
||||
}
|
||||
|
||||
public static class NumberData implements Parcelable {
|
||||
|
||||
public static final Parcelable.Creator<NumberData> CREATOR = new Parcelable.Creator<NumberData>() {
|
||||
public NumberData createFromParcel(Parcel in) {
|
||||
return new NumberData(in);
|
||||
}
|
||||
|
||||
public NumberData[] newArray(int size) {
|
||||
return new NumberData[size];
|
||||
}
|
||||
};
|
||||
|
||||
public final String number;
|
||||
public final String type;
|
||||
|
||||
public NumberData(String type, String number) {
|
||||
this.type = type;
|
||||
this.number = number;
|
||||
}
|
||||
|
||||
public NumberData(Parcel in) {
|
||||
number = in.readString();
|
||||
type = in.readString();
|
||||
}
|
||||
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(number);
|
||||
dest.writeString(type);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ContactData implements Parcelable {
|
||||
|
||||
public static final Parcelable.Creator<ContactData> CREATOR = new Parcelable.Creator<ContactData>() {
|
||||
public ContactData createFromParcel(Parcel in) {
|
||||
return new ContactData(in);
|
||||
}
|
||||
|
||||
public ContactData[] newArray(int size) {
|
||||
return new ContactData[size];
|
||||
}
|
||||
};
|
||||
|
||||
public final long id;
|
||||
public final String name;
|
||||
public final List<NumberData> numbers;
|
||||
|
||||
public ContactData(long id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.numbers = new LinkedList<NumberData>();
|
||||
}
|
||||
|
||||
public ContactData(Parcel in) {
|
||||
id = in.readLong();
|
||||
name = in.readString();
|
||||
numbers = new LinkedList<NumberData>();
|
||||
in.readTypedList(numbers, NumberData.CREATOR);
|
||||
}
|
||||
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeLong(id);
|
||||
dest.writeString(name);
|
||||
dest.writeTypedList(numbers);
|
||||
}
|
||||
}
|
||||
|
||||
/***
|
||||
* If the code below looks shitty to you, that's because it was taken
|
||||
* directly from the Android source, where shitty code is all you get.
|
||||
*/
|
||||
|
||||
public Cursor getCursorForRecipientFilter(CharSequence constraint,
|
||||
ContentResolver mContentResolver)
|
||||
{
|
||||
final String SORT_ORDER = Contacts.TIMES_CONTACTED + " DESC," +
|
||||
Contacts.DISPLAY_NAME + "," +
|
||||
Contacts.Data.IS_SUPER_PRIMARY + " DESC," +
|
||||
Phone.TYPE;
|
||||
|
||||
final String[] PROJECTION_PHONE = {
|
||||
Phone._ID, // 0
|
||||
Phone.CONTACT_ID, // 1
|
||||
Phone.TYPE, // 2
|
||||
Phone.NUMBER, // 3
|
||||
Phone.LABEL, // 4
|
||||
Phone.DISPLAY_NAME, // 5
|
||||
};
|
||||
|
||||
String phone = "";
|
||||
String cons = null;
|
||||
|
||||
if (constraint != null) {
|
||||
cons = constraint.toString();
|
||||
|
||||
if (RecipientsAdapter.usefulAsDigits(cons)) {
|
||||
phone = PhoneNumberUtils.convertKeypadLettersToDigits(cons);
|
||||
if (phone.equals(cons) && !PhoneNumberUtils.isWellFormedSmsAddress(phone)) {
|
||||
phone = "";
|
||||
} else {
|
||||
phone = phone.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
Uri uri = Uri.withAppendedPath(Phone.CONTENT_FILTER_URI, Uri.encode(cons));
|
||||
String selection = String.format("%s=%s OR %s=%s OR %s=%s",
|
||||
Phone.TYPE,
|
||||
Phone.TYPE_MOBILE,
|
||||
Phone.TYPE,
|
||||
Phone.TYPE_WORK_MOBILE,
|
||||
Phone.TYPE,
|
||||
Phone.TYPE_MMS);
|
||||
|
||||
Cursor phoneCursor = mContentResolver.query(uri,
|
||||
PROJECTION_PHONE,
|
||||
null,
|
||||
null,
|
||||
SORT_ORDER);
|
||||
|
||||
if (phone.length() > 0) {
|
||||
ArrayList result = new ArrayList();
|
||||
result.add(Integer.valueOf(-1)); // ID
|
||||
result.add(Long.valueOf(-1)); // CONTACT_ID
|
||||
result.add(Integer.valueOf(Phone.TYPE_CUSTOM)); // TYPE
|
||||
result.add(phone); // NUMBER
|
||||
|
||||
/*
|
||||
* The "\u00A0" keeps Phone.getDisplayLabel() from deciding
|
||||
* to display the default label ("Home") next to the transformation
|
||||
* of the letters into numbers.
|
||||
*/
|
||||
result.add("\u00A0"); // LABEL
|
||||
result.add(cons); // NAME
|
||||
|
||||
ArrayList<ArrayList> wrap = new ArrayList<ArrayList>();
|
||||
wrap.add(result);
|
||||
|
||||
ArrayListCursor translated = new ArrayListCursor(PROJECTION_PHONE, wrap);
|
||||
|
||||
return new MergeCursor(new Cursor[] { translated, phoneCursor });
|
||||
} else {
|
||||
return phoneCursor;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public abstract class ContactIdentityManager {
|
||||
|
||||
public static ContactIdentityManager getInstance(Context context) {
|
||||
return new ContactIdentityManagerICS(context);
|
||||
}
|
||||
|
||||
protected final Context context;
|
||||
|
||||
public ContactIdentityManager(Context context) {
|
||||
this.context = context.getApplicationContext();
|
||||
}
|
||||
|
||||
public abstract Uri getSelfIdentityUri();
|
||||
public abstract boolean isSelfIdentityAutoDetected();
|
||||
public abstract List<Long> getSelfIdentityRawContactIds();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.ContactsContract;
|
||||
import android.provider.ContactsContract.Contacts;
|
||||
import android.provider.ContactsContract.PhoneLookup;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
class ContactIdentityManagerICS extends ContactIdentityManager {
|
||||
|
||||
public ContactIdentityManagerICS(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getSelfIdentityUri() {
|
||||
String[] PROJECTION = new String[] {
|
||||
PhoneLookup.DISPLAY_NAME,
|
||||
PhoneLookup.LOOKUP_KEY,
|
||||
PhoneLookup._ID,
|
||||
};
|
||||
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = context.getContentResolver().query(ContactsContract.Profile.CONTENT_URI,
|
||||
PROJECTION, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return Contacts.getLookupUri(cursor.getLong(2), cursor.getString(1));
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSelfIdentityAutoDetected() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> getSelfIdentityRawContactIds() {
|
||||
List<Long> results = new LinkedList<Long>();
|
||||
|
||||
String[] PROJECTION = new String[] {
|
||||
ContactsContract.Profile._ID
|
||||
};
|
||||
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = context.getContentResolver().query(ContactsContract.Profile.CONTENT_RAW_CONTACTS_URI,
|
||||
PROJECTION, null, null, null);
|
||||
|
||||
if (cursor == null || cursor.getCount() == 0)
|
||||
return null;
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
results.add(cursor.getLong(0));
|
||||
}
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.CursorWrapper;
|
||||
import android.database.MatrixCursor;
|
||||
import android.database.MergeCursor;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
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.
|
||||
*/
|
||||
public class ContactRepository {
|
||||
|
||||
private final RecipientDatabase recipientDatabase;
|
||||
private final String noteToSelfTitle;
|
||||
private final Context context;
|
||||
|
||||
public static final String ID_COLUMN = "id";
|
||||
static final String NAME_COLUMN = "name";
|
||||
static final String NUMBER_COLUMN = "number";
|
||||
static final String NUMBER_TYPE_COLUMN = "number_type";
|
||||
static final String LABEL_COLUMN = "label";
|
||||
static final String CONTACT_TYPE_COLUMN = "contact_type";
|
||||
|
||||
static final int NORMAL_TYPE = 0;
|
||||
static final int PUSH_TYPE = 1;
|
||||
static final int NEW_PHONE_TYPE = 2;
|
||||
static final int NEW_USERNAME_TYPE = 3;
|
||||
static final int RECENT_TYPE = 4;
|
||||
static final int DIVIDER_TYPE = 5;
|
||||
|
||||
/** Maps the recipient results to the legacy contact column names */
|
||||
private static final List<Pair<String, ValueMapper>> SEARCH_CURSOR_MAPPERS = new ArrayList<Pair<String, ValueMapper>>() {{
|
||||
add(new Pair<>(ID_COLUMN, cursor -> cursor.getLong(cursor.getColumnIndexOrThrow(RecipientDatabase.ID))));
|
||||
|
||||
add(new Pair<>(NAME_COLUMN, cursor -> {
|
||||
String system = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.SYSTEM_DISPLAY_NAME));
|
||||
String profile = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.SIGNAL_PROFILE_NAME));
|
||||
|
||||
return Util.getFirstNonEmpty(system, profile);
|
||||
}));
|
||||
|
||||
add(new Pair<>(NUMBER_COLUMN, cursor -> {
|
||||
String phone = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.PHONE));
|
||||
String email = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.EMAIL));
|
||||
|
||||
return Util.getFirstNonEmpty(phone, email);
|
||||
}));
|
||||
|
||||
add(new Pair<>(NUMBER_TYPE_COLUMN, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(RecipientDatabase.SYSTEM_PHONE_TYPE))));
|
||||
|
||||
add(new Pair<>(LABEL_COLUMN, cursor -> cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.SYSTEM_PHONE_LABEL))));
|
||||
|
||||
add(new Pair<>(CONTACT_TYPE_COLUMN, cursor -> {
|
||||
int registered = cursor.getInt(cursor.getColumnIndexOrThrow(RecipientDatabase.REGISTERED));
|
||||
return registered == RecipientDatabase.RegisteredState.REGISTERED.getId() ? PUSH_TYPE : NORMAL_TYPE;
|
||||
}));
|
||||
}};
|
||||
|
||||
public ContactRepository(@NonNull Context context) {
|
||||
this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
this.noteToSelfTitle = context.getString(R.string.note_to_self);
|
||||
this.context = context.getApplicationContext();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public Cursor querySignalContacts(@NonNull String query) {
|
||||
Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getSignalContacts()
|
||||
: recipientDatabase.querySignalContacts(query);
|
||||
|
||||
|
||||
if (noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) {
|
||||
Recipient self = Recipient.self();
|
||||
boolean nameMatch = self.getDisplayName(context).toLowerCase().contains(query.toLowerCase());
|
||||
boolean numberMatch = self.getE164().isPresent() && self.requireE164().contains(query);
|
||||
boolean shouldAdd = !nameMatch && !numberMatch;
|
||||
|
||||
if (shouldAdd) {
|
||||
MatrixCursor selfCursor = new MatrixCursor(RecipientDatabase.SEARCH_PROJECTION);
|
||||
selfCursor.addRow(new Object[]{ self.getId().serialize(), noteToSelfTitle, null, self.getE164().or(""), self.getEmail().orNull(), null, -1, RecipientDatabase.RegisteredState.REGISTERED.getId(), noteToSelfTitle });
|
||||
|
||||
cursor = cursor == null ? selfCursor : new MergeCursor(new Cursor[]{ cursor, selfCursor });
|
||||
}
|
||||
}
|
||||
|
||||
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public Cursor queryNonSignalContacts(@NonNull String query) {
|
||||
Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getNonSignalContacts()
|
||||
: recipientDatabase.queryNonSignalContacts(query);
|
||||
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This lets us mock the legacy cursor interface while using the new cursor, even though the data
|
||||
* doesn't quite match up exactly.
|
||||
*/
|
||||
private static class SearchCursorWrapper extends CursorWrapper {
|
||||
|
||||
private final Cursor wrapped;
|
||||
private final String[] columnNames;
|
||||
private final List<Pair<String, ValueMapper>> mappers;
|
||||
private final Map<String, Integer> positions;
|
||||
|
||||
SearchCursorWrapper(Cursor cursor, @NonNull List<Pair<String, ValueMapper>> mappers) {
|
||||
super(cursor);
|
||||
|
||||
this.wrapped = cursor;
|
||||
this.mappers = mappers;
|
||||
this.positions = new HashMap<>();
|
||||
this.columnNames = new String[mappers.size()];
|
||||
|
||||
for (int i = 0; i < mappers.size(); i++) {
|
||||
Pair<String, ValueMapper> pair = mappers.get(i);
|
||||
|
||||
positions.put(pair.first(), i);
|
||||
columnNames[i] = pair.first();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColumnCount() {
|
||||
return mappers.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getColumnNames() {
|
||||
return columnNames;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColumnIndexOrThrow(String columnName) throws IllegalArgumentException {
|
||||
Integer index = positions.get(columnName);
|
||||
|
||||
if (index != null) {
|
||||
return index;
|
||||
} else {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(int columnIndex) {
|
||||
return String.valueOf(mappers.get(columnIndex).second().get(wrapped));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(int columnIndex) {
|
||||
return (int) mappers.get(columnIndex).second().get(wrapped);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(int columnIndex) {
|
||||
return (long) mappers.get(columnIndex).second().get(wrapped);
|
||||
}
|
||||
}
|
||||
|
||||
private interface ValueMapper<T> {
|
||||
T get(@NonNull Cursor cursor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Copyright (C) 2014 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.database.Cursor;
|
||||
import android.provider.ContactsContract;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller.FastScrollAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.HeaderViewHolder;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.ViewHolder;
|
||||
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration.StickyHeaderAdapter;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* List adapter to display all contacts and their related information
|
||||
*
|
||||
* @author Jake McGinty
|
||||
*/
|
||||
public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewHolder>
|
||||
implements FastScrollAdapter,
|
||||
StickyHeaderAdapter<HeaderViewHolder>
|
||||
{
|
||||
@SuppressWarnings("unused")
|
||||
private final static String TAG = Log.tag(ContactSelectionListAdapter.class);
|
||||
|
||||
private static final int VIEW_TYPE_CONTACT = 0;
|
||||
private static final int VIEW_TYPE_DIVIDER = 1;
|
||||
|
||||
private final static int STYLE_ATTRIBUTES[] = new int[]{R.attr.contact_selection_push_user,
|
||||
R.attr.contact_selection_lay_user};
|
||||
|
||||
private final boolean multiSelect;
|
||||
private final LayoutInflater li;
|
||||
private final TypedArray drawables;
|
||||
private final ItemClickListener clickListener;
|
||||
private final GlideRequests glideRequests;
|
||||
|
||||
private final Set<SelectedContact> selectedContacts = new HashSet<>();
|
||||
|
||||
public abstract static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
public ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
public abstract void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, int color, boolean multiSelect);
|
||||
public abstract void unbind(@NonNull GlideRequests glideRequests);
|
||||
public abstract void setChecked(boolean checked);
|
||||
}
|
||||
|
||||
public static class ContactViewHolder extends ViewHolder {
|
||||
ContactViewHolder(@NonNull final View itemView,
|
||||
@Nullable final ItemClickListener clickListener)
|
||||
{
|
||||
super(itemView);
|
||||
itemView.setOnClickListener(v -> {
|
||||
if (clickListener != null) clickListener.onItemClick(getView());
|
||||
});
|
||||
}
|
||||
|
||||
public ContactSelectionListItem getView() {
|
||||
return (ContactSelectionListItem) itemView;
|
||||
}
|
||||
|
||||
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, int color, boolean multiSelect) {
|
||||
getView().set(glideRequests, recipientId, type, name, number, label, color, multiSelect);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unbind(@NonNull GlideRequests glideRequests) {
|
||||
getView().unbind(glideRequests);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setChecked(boolean checked) {
|
||||
getView().setChecked(checked);
|
||||
}
|
||||
}
|
||||
|
||||
public static class DividerViewHolder extends ViewHolder {
|
||||
|
||||
private final TextView label;
|
||||
|
||||
DividerViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
this.label = itemView.findViewById(R.id.label);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, int color, boolean multiSelect) {
|
||||
this.label.setText(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unbind(@NonNull GlideRequests glideRequests) {}
|
||||
|
||||
@Override
|
||||
public void setChecked(boolean checked) {}
|
||||
}
|
||||
|
||||
static class HeaderViewHolder extends RecyclerView.ViewHolder {
|
||||
HeaderViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
}
|
||||
|
||||
public ContactSelectionListAdapter(@NonNull Context context,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@Nullable Cursor cursor,
|
||||
@Nullable ItemClickListener clickListener,
|
||||
boolean multiSelect)
|
||||
{
|
||||
super(context, cursor);
|
||||
this.li = LayoutInflater.from(context);
|
||||
this.glideRequests = glideRequests;
|
||||
this.drawables = context.obtainStyledAttributes(STYLE_ATTRIBUTES);
|
||||
this.multiSelect = multiSelect;
|
||||
this.clickListener = clickListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getHeaderId(int i) {
|
||||
if (!isActiveCursor()) return -1;
|
||||
|
||||
int contactType = getContactType(i);
|
||||
|
||||
if (contactType == ContactRepository.DIVIDER_TYPE) return -1;
|
||||
return Util.hashCode(getHeaderString(i), getContactType(i));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
|
||||
if (viewType == VIEW_TYPE_CONTACT) {
|
||||
return new ContactViewHolder(li.inflate(R.layout.contact_selection_list_item, parent, false), clickListener);
|
||||
} else {
|
||||
return new DividerViewHolder(li.inflate(R.layout.contact_selection_list_divider, parent, false));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) {
|
||||
String rawId = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN));
|
||||
RecipientId id = rawId != null ? RecipientId.from(rawId) : null;
|
||||
int contactType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactRepository.CONTACT_TYPE_COLUMN));
|
||||
String name = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NAME_COLUMN ));
|
||||
String number = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NUMBER_COLUMN));
|
||||
int numberType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactRepository.NUMBER_TYPE_COLUMN ));
|
||||
String label = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.LABEL_COLUMN ));
|
||||
String labelText = ContactsContract.CommonDataKinds.Phone.getTypeLabel(getContext().getResources(),
|
||||
numberType, label).toString();
|
||||
|
||||
int color = (contactType == ContactRepository.PUSH_TYPE) ? drawables.getColor(0, 0xa0000000) :
|
||||
drawables.getColor(1, 0xff000000);
|
||||
|
||||
viewHolder.unbind(glideRequests);
|
||||
viewHolder.bind(glideRequests, id, contactType, name, number, labelText, color, multiSelect);
|
||||
|
||||
if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
|
||||
} else {
|
||||
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(@NonNull Cursor cursor) {
|
||||
if (cursor.getInt(cursor.getColumnIndexOrThrow(ContactRepository.CONTACT_TYPE_COLUMN)) == ContactRepository.DIVIDER_TYPE) {
|
||||
return VIEW_TYPE_DIVIDER;
|
||||
} else {
|
||||
return VIEW_TYPE_CONTACT;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position) {
|
||||
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.contact_selection_recyclerview_header, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) {
|
||||
((TextView)viewHolder.itemView).setText(getSpannedHeaderString(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemViewRecycled(ViewHolder holder) {
|
||||
holder.unbind(glideRequests);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CharSequence getBubbleText(int position) {
|
||||
return getHeaderString(position);
|
||||
}
|
||||
|
||||
public Set<SelectedContact> getSelectedContacts() {
|
||||
return selectedContacts;
|
||||
}
|
||||
|
||||
private CharSequence getSpannedHeaderString(int position) {
|
||||
final String headerString = getHeaderString(position);
|
||||
if (isPush(position)) {
|
||||
SpannableString spannable = new SpannableString(headerString);
|
||||
spannable.setSpan(new ForegroundColorSpan(getContext().getResources().getColor(R.color.signal_primary)), 0, headerString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
} else {
|
||||
return headerString;
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull String getHeaderString(int position) {
|
||||
int contactType = getContactType(position);
|
||||
|
||||
if (contactType == ContactRepository.RECENT_TYPE || contactType == ContactRepository.DIVIDER_TYPE) {
|
||||
return " ";
|
||||
}
|
||||
|
||||
Cursor cursor = getCursorAtPositionOrThrow(position);
|
||||
String letter = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NAME_COLUMN));
|
||||
|
||||
if (letter != null) {
|
||||
letter = letter.trim();
|
||||
if (letter.length() > 0) {
|
||||
char firstChar = letter.charAt(0);
|
||||
if (Character.isLetterOrDigit(firstChar)) {
|
||||
return String.valueOf(Character.toUpperCase(firstChar));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "#";
|
||||
}
|
||||
|
||||
private int getContactType(int position) {
|
||||
final Cursor cursor = getCursorAtPositionOrThrow(position);
|
||||
return cursor.getInt(cursor.getColumnIndexOrThrow(ContactRepository.CONTACT_TYPE_COLUMN));
|
||||
}
|
||||
|
||||
private boolean isPush(int position) {
|
||||
return getContactType(position) == ContactRepository.PUSH_TYPE;
|
||||
}
|
||||
|
||||
public interface ItemClickListener {
|
||||
void onItemClick(ContactSelectionListItem item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.components.FromTextView;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
public class ContactSelectionListItem extends LinearLayout implements RecipientForeverObserver {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = ContactSelectionListItem.class.getSimpleName();
|
||||
|
||||
private AvatarImageView contactPhotoImage;
|
||||
private TextView numberView;
|
||||
private FromTextView nameView;
|
||||
private TextView labelView;
|
||||
private CheckBox checkBox;
|
||||
|
||||
private String number;
|
||||
private int contactType;
|
||||
private LiveRecipient recipient;
|
||||
private GlideRequests glideRequests;
|
||||
|
||||
public ContactSelectionListItem(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public ContactSelectionListItem(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
this.contactPhotoImage = findViewById(R.id.contact_photo_image);
|
||||
this.numberView = findViewById(R.id.number);
|
||||
this.labelView = findViewById(R.id.label);
|
||||
this.nameView = findViewById(R.id.name);
|
||||
this.checkBox = findViewById(R.id.check_box);
|
||||
|
||||
ViewUtil.setTextViewGravityStart(this.nameView, getContext());
|
||||
}
|
||||
|
||||
public void set(@NonNull GlideRequests glideRequests,
|
||||
@Nullable RecipientId recipientId,
|
||||
int type,
|
||||
String name,
|
||||
String number,
|
||||
String label,
|
||||
int color,
|
||||
boolean multiSelect)
|
||||
{
|
||||
this.glideRequests = glideRequests;
|
||||
this.number = number;
|
||||
this.contactType = type;
|
||||
|
||||
if (type == ContactRepository.NEW_PHONE_TYPE || type == ContactRepository.NEW_USERNAME_TYPE) {
|
||||
this.recipient = null;
|
||||
this.contactPhotoImage.setAvatar(glideRequests, null, false);
|
||||
} else if (recipientId != null) {
|
||||
this.recipient = Recipient.live(recipientId);
|
||||
this.recipient.observeForever(this);
|
||||
name = this.recipient.get().getDisplayName(getContext());
|
||||
}
|
||||
|
||||
Recipient recipientSnapshot = recipient != null ? recipient.get() : null;
|
||||
|
||||
this.nameView.setTextColor(color);
|
||||
this.numberView.setTextColor(color);
|
||||
this.contactPhotoImage.setAvatar(glideRequests, recipientSnapshot, false);
|
||||
|
||||
setText(recipientSnapshot, type, name, number, label);
|
||||
|
||||
if (multiSelect) this.checkBox.setVisibility(View.VISIBLE);
|
||||
else this.checkBox.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setChecked(boolean selected) {
|
||||
this.checkBox.setChecked(selected);
|
||||
}
|
||||
|
||||
public void unbind(GlideRequests glideRequests) {
|
||||
if (recipient != null) {
|
||||
recipient.removeForeverObserver(this);
|
||||
recipient = null;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private void setText(@Nullable Recipient recipient, int type, String name, String number, String label) {
|
||||
if (number == null || number.isEmpty() || GroupUtil.isEncodedGroup(number)) {
|
||||
this.nameView.setEnabled(false);
|
||||
this.numberView.setText("");
|
||||
this.labelView.setVisibility(View.GONE);
|
||||
} else if (type == ContactRepository.PUSH_TYPE) {
|
||||
this.numberView.setText(number);
|
||||
this.nameView.setEnabled(true);
|
||||
this.labelView.setVisibility(View.GONE);
|
||||
} else if (type == ContactRepository.NEW_USERNAME_TYPE) {
|
||||
this.numberView.setText("@" + number);
|
||||
this.nameView.setEnabled(true);
|
||||
this.labelView.setText(label);
|
||||
this.labelView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
this.numberView.setText(number);
|
||||
this.nameView.setEnabled(true);
|
||||
this.labelView.setText(label != null && !label.equals("null") ? label : "");
|
||||
this.labelView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
if (recipient != null) {
|
||||
this.nameView.setText(recipient);
|
||||
} else {
|
||||
this.nameView.setText(name);
|
||||
}
|
||||
}
|
||||
|
||||
public String getNumber() {
|
||||
return number;
|
||||
}
|
||||
|
||||
public boolean isUsernameType() {
|
||||
return contactType == ContactRepository.NEW_USERNAME_TYPE;
|
||||
}
|
||||
|
||||
public Optional<RecipientId> getRecipientId() {
|
||||
return recipient != null ? Optional.of(recipient.getId()) : Optional.absent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
contactPhotoImage.setAvatar(glideRequests, recipient, false);
|
||||
nameView.setText(recipient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* Copyright (C) 2013-2017 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.database.MergeCursor;
|
||||
import android.provider.ContactsContract;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.loader.content.CursorLoader;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* CursorLoader that initializes a ContactsDatabase instance
|
||||
*
|
||||
* @author Jake McGinty
|
||||
*/
|
||||
public class ContactsCursorLoader extends CursorLoader {
|
||||
|
||||
private static final String TAG = ContactsCursorLoader.class.getSimpleName();
|
||||
|
||||
public static final class DisplayMode {
|
||||
public static final int FLAG_PUSH = 1;
|
||||
public static final int FLAG_SMS = 1 << 1;
|
||||
public static final int FLAG_ACTIVE_GROUPS = 1 << 2;
|
||||
public static final int FLAG_INACTIVE_GROUPS = 1 << 3;
|
||||
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS;
|
||||
}
|
||||
|
||||
private static final String[] CONTACT_PROJECTION = new String[]{ContactRepository.ID_COLUMN,
|
||||
ContactRepository.NAME_COLUMN,
|
||||
ContactRepository.NUMBER_COLUMN,
|
||||
ContactRepository.NUMBER_TYPE_COLUMN,
|
||||
ContactRepository.LABEL_COLUMN,
|
||||
ContactRepository.CONTACT_TYPE_COLUMN};
|
||||
|
||||
private static final int RECENT_CONVERSATION_MAX = 25;
|
||||
|
||||
private final String filter;
|
||||
private final int mode;
|
||||
private final boolean recents;
|
||||
|
||||
private final ContactRepository contactRepository;
|
||||
|
||||
public ContactsCursorLoader(@NonNull Context context, int mode, String filter, boolean recents)
|
||||
{
|
||||
super(context);
|
||||
|
||||
if (flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS) && !flagSet(mode, DisplayMode.FLAG_ACTIVE_GROUPS)) {
|
||||
throw new AssertionError("Inactive group flag set, but the active group flag isn't!");
|
||||
}
|
||||
|
||||
this.filter = sanitizeFilter(filter);
|
||||
this.mode = mode;
|
||||
this.recents = recents;
|
||||
this.contactRepository = new ContactRepository(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor loadInBackground() {
|
||||
List<Cursor> cursorList = TextUtils.isEmpty(filter) ? getUnfilteredResults()
|
||||
: getFilteredResults();
|
||||
if (cursorList.size() > 0) {
|
||||
return new MergeCursor(cursorList.toArray(new Cursor[0]));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static @NonNull String sanitizeFilter(@Nullable String filter) {
|
||||
if (filter == null) {
|
||||
return "";
|
||||
} else if (filter.startsWith("@")) {
|
||||
return filter.substring(1);
|
||||
} else {
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Cursor> getUnfilteredResults() {
|
||||
ArrayList<Cursor> cursorList = new ArrayList<>();
|
||||
|
||||
if (recents) {
|
||||
Cursor recentConversations = getRecentConversationsCursor();
|
||||
if (recentConversations.getCount() > 0) {
|
||||
cursorList.add(getRecentsHeaderCursor());
|
||||
cursorList.add(recentConversations);
|
||||
cursorList.add(getContactsHeaderCursor());
|
||||
}
|
||||
}
|
||||
cursorList.addAll(getContactsCursors());
|
||||
return cursorList;
|
||||
}
|
||||
|
||||
private List<Cursor> getFilteredResults() {
|
||||
ArrayList<Cursor> cursorList = new ArrayList<>();
|
||||
|
||||
if (groupsEnabled(mode)) {
|
||||
Cursor groups = getGroupsCursor();
|
||||
if (groups.getCount() > 0) {
|
||||
List<Cursor> contacts = getContactsCursors();
|
||||
if (!isCursorListEmpty(contacts)) {
|
||||
cursorList.add(getContactsHeaderCursor());
|
||||
cursorList.addAll(contacts);
|
||||
cursorList.add(getGroupsHeaderCursor());
|
||||
}
|
||||
cursorList.add(groups);
|
||||
} else {
|
||||
cursorList.addAll(getContactsCursors());
|
||||
}
|
||||
} else {
|
||||
cursorList.addAll(getContactsCursors());
|
||||
}
|
||||
|
||||
if (FeatureFlags.USERNAMES && NumberUtil.isVisuallyValidNumberOrEmail(filter)) {
|
||||
cursorList.add(getPhoneNumberSearchHeaderCursor());
|
||||
cursorList.add(getNewNumberCursor());
|
||||
} else if (!FeatureFlags.USERNAMES && NumberUtil.isValidSmsOrEmail(filter)){
|
||||
cursorList.add(getContactsHeaderCursor());
|
||||
cursorList.add(getNewNumberCursor());
|
||||
}
|
||||
|
||||
if (FeatureFlags.USERNAMES && UsernameUtil.isValidUsernameForSearch(filter)) {
|
||||
cursorList.add(getUsernameSearchHeaderCursor());
|
||||
cursorList.add(getUsernameSearchCursor());
|
||||
}
|
||||
|
||||
return cursorList;
|
||||
}
|
||||
|
||||
private Cursor getRecentsHeaderCursor() {
|
||||
MatrixCursor recentsHeader = new MatrixCursor(CONTACT_PROJECTION);
|
||||
recentsHeader.addRow(new Object[]{ null,
|
||||
getContext().getString(R.string.ContactsCursorLoader_recent_chats),
|
||||
"",
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE,
|
||||
"",
|
||||
ContactRepository.DIVIDER_TYPE });
|
||||
return recentsHeader;
|
||||
}
|
||||
|
||||
private Cursor getContactsHeaderCursor() {
|
||||
MatrixCursor contactsHeader = new MatrixCursor(CONTACT_PROJECTION, 1);
|
||||
contactsHeader.addRow(new Object[] { null,
|
||||
getContext().getString(R.string.ContactsCursorLoader_contacts),
|
||||
"",
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE,
|
||||
"",
|
||||
ContactRepository.DIVIDER_TYPE });
|
||||
return contactsHeader;
|
||||
}
|
||||
|
||||
private Cursor getGroupsHeaderCursor() {
|
||||
MatrixCursor groupHeader = new MatrixCursor(CONTACT_PROJECTION, 1);
|
||||
groupHeader.addRow(new Object[]{ null,
|
||||
getContext().getString(R.string.ContactsCursorLoader_groups),
|
||||
"",
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE,
|
||||
"",
|
||||
ContactRepository.DIVIDER_TYPE });
|
||||
return groupHeader;
|
||||
}
|
||||
|
||||
private Cursor getPhoneNumberSearchHeaderCursor() {
|
||||
MatrixCursor contactsHeader = new MatrixCursor(CONTACT_PROJECTION, 1);
|
||||
contactsHeader.addRow(new Object[] { null,
|
||||
getContext().getString(R.string.ContactsCursorLoader_phone_number_search),
|
||||
"",
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE,
|
||||
"",
|
||||
ContactRepository.DIVIDER_TYPE });
|
||||
return contactsHeader;
|
||||
}
|
||||
|
||||
private Cursor getUsernameSearchHeaderCursor() {
|
||||
MatrixCursor contactsHeader = new MatrixCursor(CONTACT_PROJECTION, 1);
|
||||
contactsHeader.addRow(new Object[] { null,
|
||||
getContext().getString(R.string.ContactsCursorLoader_username_search),
|
||||
"",
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE,
|
||||
"",
|
||||
ContactRepository.DIVIDER_TYPE });
|
||||
return contactsHeader;
|
||||
}
|
||||
|
||||
|
||||
private Cursor getRecentConversationsCursor() {
|
||||
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(getContext());
|
||||
|
||||
MatrixCursor recentConversations = new MatrixCursor(CONTACT_PROJECTION, RECENT_CONVERSATION_MAX);
|
||||
try (Cursor rawConversations = threadDatabase.getRecentConversationList(RECENT_CONVERSATION_MAX, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS))) {
|
||||
ThreadDatabase.Reader reader = threadDatabase.readerFor(rawConversations);
|
||||
ThreadRecord threadRecord;
|
||||
while ((threadRecord = reader.getNext()) != null) {
|
||||
Recipient recipient = threadRecord.getRecipient();
|
||||
String stringId = recipient.isGroup() ? recipient.requireGroupId() : recipient.getE164().or(recipient.getEmail()).or("");
|
||||
|
||||
recentConversations.addRow(new Object[] { recipient.getId().serialize(),
|
||||
recipient.toShortString(getContext()),
|
||||
stringId,
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE,
|
||||
"",
|
||||
ContactRepository.RECENT_TYPE });
|
||||
}
|
||||
}
|
||||
return recentConversations;
|
||||
}
|
||||
|
||||
private List<Cursor> getContactsCursors() {
|
||||
List<Cursor> cursorList = new ArrayList<>(2);
|
||||
|
||||
if (!Permissions.hasAny(getContext(), Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) {
|
||||
return cursorList;
|
||||
}
|
||||
|
||||
if (pushEnabled(mode)) {
|
||||
cursorList.add(contactRepository.querySignalContacts(filter));
|
||||
}
|
||||
|
||||
if (pushEnabled(mode) && smsEnabled(mode)) {
|
||||
cursorList.add(contactRepository.queryNonSignalContacts(filter));
|
||||
} else if (smsEnabled(mode)) {
|
||||
cursorList.add(filterNonPushContacts(contactRepository.queryNonSignalContacts(filter)));
|
||||
}
|
||||
return cursorList;
|
||||
}
|
||||
|
||||
private Cursor getGroupsCursor() {
|
||||
MatrixCursor groupContacts = new MatrixCursor(CONTACT_PROJECTION);
|
||||
try (GroupDatabase.Reader reader = DatabaseFactory.getGroupDatabase(getContext()).getGroupsFilteredByTitle(filter, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS))) {
|
||||
GroupDatabase.GroupRecord groupRecord;
|
||||
while ((groupRecord = reader.getNext()) != null) {
|
||||
groupContacts.addRow(new Object[] { groupRecord.getRecipientId().serialize(),
|
||||
groupRecord.getTitle(),
|
||||
groupRecord.getEncodedId(),
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
|
||||
"",
|
||||
ContactRepository.NORMAL_TYPE });
|
||||
}
|
||||
}
|
||||
return groupContacts;
|
||||
}
|
||||
|
||||
private Cursor getNewNumberCursor() {
|
||||
MatrixCursor newNumberCursor = new MatrixCursor(CONTACT_PROJECTION, 1);
|
||||
newNumberCursor.addRow(new Object[] { null,
|
||||
getContext().getString(R.string.contact_selection_list__unknown_contact),
|
||||
filter,
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
|
||||
"\u21e2",
|
||||
ContactRepository.NEW_PHONE_TYPE});
|
||||
return newNumberCursor;
|
||||
}
|
||||
|
||||
private Cursor getUsernameSearchCursor() {
|
||||
MatrixCursor cursor = new MatrixCursor(CONTACT_PROJECTION, 1);
|
||||
cursor.addRow(new Object[] { null,
|
||||
getContext().getString(R.string.contact_selection_list__unknown_contact),
|
||||
filter,
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
|
||||
"\u21e2",
|
||||
ContactRepository.NEW_USERNAME_TYPE});
|
||||
return cursor;
|
||||
}
|
||||
|
||||
private @NonNull Cursor filterNonPushContacts(@NonNull Cursor cursor) {
|
||||
try {
|
||||
final long startMillis = System.currentTimeMillis();
|
||||
final MatrixCursor matrix = new MatrixCursor(CONTACT_PROJECTION);
|
||||
while (cursor.moveToNext()) {
|
||||
final RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN)));
|
||||
final Recipient recipient = Recipient.resolved(id);
|
||||
|
||||
if (recipient.resolve().getRegistered() != RecipientDatabase.RegisteredState.REGISTERED) {
|
||||
matrix.addRow(new Object[]{cursor.getLong(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NAME_COLUMN)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NUMBER_COLUMN)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NUMBER_TYPE_COLUMN)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.LABEL_COLUMN)),
|
||||
ContactRepository.NORMAL_TYPE});
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "filterNonPushContacts() -> " + (System.currentTimeMillis() - startMillis) + "ms");
|
||||
return matrix;
|
||||
} finally {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isCursorListEmpty(List<Cursor> list) {
|
||||
int sum = 0;
|
||||
for (Cursor cursor : list) {
|
||||
sum += cursor.getCount();
|
||||
}
|
||||
return sum == 0;
|
||||
}
|
||||
|
||||
private static boolean pushEnabled(int mode) {
|
||||
return flagSet(mode, DisplayMode.FLAG_PUSH);
|
||||
}
|
||||
|
||||
private static boolean smsEnabled(int mode) {
|
||||
return flagSet(mode, DisplayMode.FLAG_SMS);
|
||||
}
|
||||
|
||||
private static boolean groupsEnabled(int mode) {
|
||||
return flagSet(mode, DisplayMode.FLAG_ACTIVE_GROUPS);
|
||||
}
|
||||
|
||||
private static boolean flagSet(int mode, int flag) {
|
||||
return (mode & flag) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.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();
|
||||
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<String> registeredAddressList,
|
||||
boolean remove)
|
||||
throws RemoteException, OperationApplicationException
|
||||
{
|
||||
Set<String> registeredAddressSet = new HashSet<>(registeredAddressList);
|
||||
ArrayList<ContentProviderOperation> operations = new ArrayList<>();
|
||||
Map<String, SignalContact> currentContacts = getSignalRawContacts(account);
|
||||
List<List<String>> registeredChunks = Util.chunk(registeredAddressList, 50);
|
||||
|
||||
for (List<String> registeredChunk : registeredChunks) {
|
||||
for (String registeredAddress : registeredChunk) {
|
||||
if (!currentContacts.containsKey(registeredAddress)) {
|
||||
Optional<SystemContactInfo> 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<String, SignalContact> 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<ContentProviderOperation> 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<ContentProviderOperation> 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<ContentProviderOperation> 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<ContentProviderOperation> 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<String, SignalContact> getSignalRawContacts(@NonNull Account account) {
|
||||
Uri currentContactsUri = RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type).build();
|
||||
|
||||
Map<String, SignalContact> 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<SystemContactInfo> 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.absent();
|
||||
}
|
||||
|
||||
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<ContentProviderOperation> operations,
|
||||
int batchSize)
|
||||
throws OperationApplicationException, RemoteException
|
||||
{
|
||||
List<List<ContentProviderOperation>> batches = Util.chunk(operations, batchSize);
|
||||
for (List<ContentProviderOperation> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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 org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
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)
|
||||
{
|
||||
Log.i(TAG, "onPerformSync(" + authority +")");
|
||||
|
||||
if (TextSecurePreferences.isPushRegistered(getContext())) {
|
||||
try {
|
||||
DirectoryHelper.refreshDirectory(getContext(), true);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSyncCanceled() {
|
||||
Log.w(TAG, "onSyncCanceled()");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSyncCanceled(Thread thread) {
|
||||
Log.w(TAG, "onSyncCanceled(" + thread + ")");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (C) 2011 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
/**
|
||||
* Name and number tuple.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
public class NameAndNumber {
|
||||
public String name;
|
||||
public String number;
|
||||
|
||||
public NameAndNumber(String name, String number) {
|
||||
this.name = name;
|
||||
this.number = number;
|
||||
}
|
||||
|
||||
public NameAndNumber() {}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (C) 2008 Esmertec AG.
|
||||
* Copyright (C) 2008 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientsFormatter;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.text.Annotation;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.ResourceCursorAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
/**
|
||||
* This adapter is used to filter contacts on both name and number.
|
||||
*/
|
||||
public class RecipientsAdapter extends ResourceCursorAdapter {
|
||||
|
||||
public static final int CONTACT_ID_INDEX = 1;
|
||||
public static final int TYPE_INDEX = 2;
|
||||
public static final int NUMBER_INDEX = 3;
|
||||
public static final int LABEL_INDEX = 4;
|
||||
public static final int NAME_INDEX = 5;
|
||||
|
||||
private final Context mContext;
|
||||
private final ContentResolver mContentResolver;
|
||||
private ContactAccessor mContactAccessor;
|
||||
|
||||
public RecipientsAdapter(Context context) {
|
||||
super(context, R.layout.recipient_filter_item, null);
|
||||
mContext = context;
|
||||
mContentResolver = context.getContentResolver();
|
||||
mContactAccessor = ContactAccessor.getInstance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public final CharSequence convertToString(Cursor cursor) {
|
||||
String name = cursor.getString(RecipientsAdapter.NAME_INDEX);
|
||||
int type = cursor.getInt(RecipientsAdapter.TYPE_INDEX);
|
||||
String number = cursor.getString(RecipientsAdapter.NUMBER_INDEX).trim();
|
||||
|
||||
String label = cursor.getString(RecipientsAdapter.LABEL_INDEX);
|
||||
CharSequence displayLabel = mContactAccessor.phoneTypeToString(mContext, type, label);
|
||||
|
||||
if (number.length() == 0) {
|
||||
return number;
|
||||
}
|
||||
|
||||
if (name == null) {
|
||||
name = "";
|
||||
} else {
|
||||
// Names with commas are the bane of the recipient editor's existence.
|
||||
// We've worked around them by using spans, but there are edge cases
|
||||
// where the spans get deleted. Furthermore, having commas in names
|
||||
// can be confusing to the user since commas are used as separators
|
||||
// between recipients. The best solution is to simply remove commas
|
||||
// from names.
|
||||
name = name.replace(", ", " ")
|
||||
.replace(",", " "); // Make sure we leave a space between parts of names.
|
||||
}
|
||||
|
||||
String nameAndNumber = RecipientsFormatter.formatNameAndNumber(name, number);
|
||||
|
||||
SpannableString out = new SpannableString(nameAndNumber);
|
||||
int len = out.length();
|
||||
|
||||
if (!TextUtils.isEmpty(name)) {
|
||||
out.setSpan(new Annotation("name", name), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
} else {
|
||||
out.setSpan(new Annotation("name", number), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
String person_id = cursor.getString(RecipientsAdapter.CONTACT_ID_INDEX);
|
||||
out.setSpan(new Annotation("person_id", person_id), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
out.setSpan(new Annotation("label", displayLabel.toString()), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
out.setSpan(new Annotation("number", number), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void bindView(View view, Context context, Cursor cursor) {
|
||||
TextView name = (TextView) view.findViewById(R.id.name);
|
||||
name.setText(cursor.getString(NAME_INDEX));
|
||||
|
||||
TextView label = (TextView) view.findViewById(R.id.label);
|
||||
int type = cursor.getInt(TYPE_INDEX);
|
||||
label.setText(mContactAccessor.phoneTypeToString(mContext, type, cursor.getString(LABEL_INDEX)));
|
||||
|
||||
TextView number = (TextView) view.findViewById(R.id.number);
|
||||
number.setText("(" + cursor.getString(NUMBER_INDEX) + ")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor runQueryOnBackgroundThread(CharSequence constraint) {
|
||||
return mContactAccessor.getCursorForRecipientFilter( constraint, mContentResolver );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if all the characters are meaningful as digits
|
||||
* in a phone number -- letters, digits, and a few punctuation marks.
|
||||
*/
|
||||
public static boolean usefulAsDigits(CharSequence cons) {
|
||||
int len = cons.length();
|
||||
|
||||
for (int i = 0; i < len; i++) {
|
||||
char c = cons.charAt(i);
|
||||
|
||||
if ((c >= '0') && (c <= '9')) {
|
||||
continue;
|
||||
}
|
||||
if ((c == ' ') || (c == '-') || (c == '(') || (c == ')') || (c == '.') || (c == '+')
|
||||
|| (c == '#') || (c == '*')) {
|
||||
continue;
|
||||
}
|
||||
if ((c >= 'A') && (c <= 'Z')) {
|
||||
continue;
|
||||
}
|
||||
if ((c >= 'a') && (c <= 'z')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/*
|
||||
* Copyright (C) 2008 Esmertec AG.
|
||||
* Copyright (C) 2008 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView;
|
||||
import android.telephony.PhoneNumberUtils;
|
||||
import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.Layout;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ContextMenu.ContextMenuInfo;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.MultiAutoCompleteTextView;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientsFormatter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Provide UI for editing the recipients of multi-media messages.
|
||||
*/
|
||||
public class RecipientsEditor extends AppCompatMultiAutoCompleteTextView {
|
||||
private int mLongPressedPosition = -1;
|
||||
private final RecipientsEditorTokenizer mTokenizer;
|
||||
private char mLastSeparator = ',';
|
||||
private Context mContext;
|
||||
|
||||
public RecipientsEditor(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
mContext = context;
|
||||
mTokenizer = new RecipientsEditorTokenizer(context, this);
|
||||
setTokenizer(mTokenizer);
|
||||
// For the focus to move to the message body when soft Next is pressed
|
||||
setImeOptions(EditorInfo.IME_ACTION_NEXT);
|
||||
|
||||
/*
|
||||
* The point of this TextWatcher is that when the user chooses
|
||||
* an address completion from the AutoCompleteTextView menu, it
|
||||
* is marked up with Annotation objects to tie it back to the
|
||||
* address book entry that it came from. If the user then goes
|
||||
* back and edits that part of the text, it no longer corresponds
|
||||
* to that address book entry and needs to have the Annotations
|
||||
* claiming that it does removed.
|
||||
*/
|
||||
addTextChangedListener(new TextWatcher() {
|
||||
private Annotation[] mAffected;
|
||||
|
||||
public void beforeTextChanged(CharSequence s, int start,
|
||||
int count, int after) {
|
||||
mAffected = ((Spanned) s).getSpans(start, start + count,
|
||||
Annotation.class);
|
||||
}
|
||||
|
||||
public void onTextChanged(CharSequence s, int start,
|
||||
int before, int after) {
|
||||
if (before == 0 && after == 1) { // inserting a character
|
||||
char c = s.charAt(start);
|
||||
if (c == ',' || c == ';') {
|
||||
// Remember the delimiter the user typed to end this recipient. We'll
|
||||
// need it shortly in terminateToken().
|
||||
mLastSeparator = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void afterTextChanged(Editable s) {
|
||||
if (mAffected != null) {
|
||||
for (Annotation a : mAffected) {
|
||||
s.removeSpan(a);
|
||||
}
|
||||
}
|
||||
|
||||
mAffected = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean enoughToFilter() {
|
||||
if (!super.enoughToFilter()) {
|
||||
return false;
|
||||
}
|
||||
// If the user is in the middle of editing an existing recipient, don't offer the
|
||||
// auto-complete menu. Without this, when the user selects an auto-complete menu item,
|
||||
// it will get added to the list of recipients so we end up with the old before-editing
|
||||
// recipient and the new post-editing recipient. As a precedent, gmail does not show
|
||||
// the auto-complete menu when editing an existing recipient.
|
||||
int end = getSelectionEnd();
|
||||
int len = getText().length();
|
||||
|
||||
return end == len;
|
||||
}
|
||||
|
||||
public int getRecipientCount() {
|
||||
return mTokenizer.getNumbers().size();
|
||||
}
|
||||
|
||||
public List<String> getNumbers() {
|
||||
return mTokenizer.getNumbers();
|
||||
}
|
||||
|
||||
// public Recipients constructContactsFromInput() {
|
||||
// return RecipientFactory.getRecipientsFromString(mContext, mTokenizer.getRawString(), false);
|
||||
// }
|
||||
|
||||
private boolean isValidAddress(String number, boolean isMms) {
|
||||
/*if (isMms) {
|
||||
return MessageUtils.isValidMmsAddress(number);
|
||||
} else {*/
|
||||
// TODO: PhoneNumberUtils.isWellFormedSmsAddress() only check if the number is a valid
|
||||
// GSM SMS address. If the address contains a dialable char, it considers it a well
|
||||
// formed SMS addr. CDMA doesn't work that way and has a different parser for SMS
|
||||
// address (see CdmaSmsAddress.parse(String address)). We should definitely fix this!!!
|
||||
return PhoneNumberUtils.isWellFormedSmsAddress(number);
|
||||
}
|
||||
|
||||
public boolean hasValidRecipient(boolean isMms) {
|
||||
for (String number : mTokenizer.getNumbers()) {
|
||||
if (isValidAddress(number, isMms))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/*public boolean hasInvalidRecipient(boolean isMms) {
|
||||
for (String number : mTokenizer.getNumbers()) {
|
||||
if (!isValidAddress(number, isMms)) {
|
||||
/* TODO if (MmsConfig.getEmailGateway() == null) {
|
||||
return true;
|
||||
} else if (!MessageUtils.isAlias(number)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}*/
|
||||
|
||||
public String formatInvalidNumbers(boolean isMms) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String number : mTokenizer.getNumbers()) {
|
||||
if (!isValidAddress(number, isMms)) {
|
||||
if (sb.length() != 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
sb.append(number);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/*public boolean containsEmail() {
|
||||
if (TextUtils.indexOf(getText(), '@') == -1)
|
||||
return false;
|
||||
|
||||
List<String> numbers = mTokenizer.getNumbers();
|
||||
for (String number : numbers) {
|
||||
if (Mms.isEmailAddress(number))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}*/
|
||||
|
||||
public static CharSequence contactToToken(@NonNull Context context, @NonNull Recipient c) {
|
||||
String name = c.getDisplayName(context);
|
||||
String number = c.getE164().or(c.getEmail()).or("");
|
||||
SpannableString s = new SpannableString(RecipientsFormatter.formatNameAndNumber(name, number));
|
||||
int len = s.length();
|
||||
|
||||
if (len == 0) {
|
||||
return s;
|
||||
}
|
||||
|
||||
s.setSpan(new Annotation("number", number), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
public void populate(List<Recipient> list) {
|
||||
SpannableStringBuilder sb = new SpannableStringBuilder();
|
||||
|
||||
for (Recipient c : list) {
|
||||
if (sb.length() != 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
|
||||
sb.append(contactToToken(mContext, c));
|
||||
}
|
||||
|
||||
setText(sb);
|
||||
}
|
||||
|
||||
private int pointToPosition(int x, int y) {
|
||||
x -= getCompoundPaddingLeft();
|
||||
y -= getExtendedPaddingTop();
|
||||
|
||||
x += getScrollX();
|
||||
y += getScrollY();
|
||||
|
||||
Layout layout = getLayout();
|
||||
if (layout == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int line = layout.getLineForVertical(y);
|
||||
int off = layout.getOffsetForHorizontal(line, x);
|
||||
|
||||
return off;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent ev) {
|
||||
final int action = ev.getAction();
|
||||
final int x = (int) ev.getX();
|
||||
final int y = (int) ev.getY();
|
||||
|
||||
if (action == MotionEvent.ACTION_DOWN) {
|
||||
mLongPressedPosition = pointToPosition(x, y);
|
||||
}
|
||||
|
||||
return super.onTouchEvent(ev);
|
||||
}
|
||||
|
||||
private static String getNumberAt(Spanned sp, int start, int end, Context context) {
|
||||
return getFieldAt("number", sp, start, end, context);
|
||||
}
|
||||
|
||||
private static int getSpanLength(Spanned sp, int start, int end, Context context) {
|
||||
// TODO: there's a situation where the span can lose its annotations:
|
||||
// - add an auto-complete contact
|
||||
// - add another auto-complete contact
|
||||
// - delete that second contact and keep deleting into the first
|
||||
// - we lose the annotation and can no longer get the span.
|
||||
// Need to fix this case because it breaks auto-complete contacts with commas in the name.
|
||||
Annotation[] a = sp.getSpans(start, end, Annotation.class);
|
||||
if (a.length > 0) {
|
||||
return sp.getSpanEnd(a[0]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static String getFieldAt(String field, Spanned sp, int start, int end,
|
||||
Context context) {
|
||||
Annotation[] a = sp.getSpans(start, end, Annotation.class);
|
||||
String fieldValue = getAnnotation(a, field);
|
||||
if (TextUtils.isEmpty(fieldValue)) {
|
||||
fieldValue = TextUtils.substring(sp, start, end);
|
||||
}
|
||||
return fieldValue;
|
||||
|
||||
}
|
||||
|
||||
private static String getAnnotation(Annotation[] a, String key) {
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i].getKey().equals(key)) {
|
||||
return a[i].getValue();
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private class RecipientsEditorTokenizer
|
||||
implements MultiAutoCompleteTextView.Tokenizer {
|
||||
private final MultiAutoCompleteTextView mList;
|
||||
private final Context mContext;
|
||||
|
||||
RecipientsEditorTokenizer(Context context, MultiAutoCompleteTextView list) {
|
||||
mList = list;
|
||||
mContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the start of the token that ends at offset
|
||||
* <code>cursor</code> within <code>text</code>.
|
||||
* It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
|
||||
*/
|
||||
public int findTokenStart(CharSequence text, int cursor) {
|
||||
int i = cursor;
|
||||
char c;
|
||||
|
||||
while (i > 0 && (c = text.charAt(i - 1)) != ',' && c != ';') {
|
||||
i--;
|
||||
}
|
||||
while (i < cursor && text.charAt(i) == ' ') {
|
||||
i++;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the end of the token (minus trailing punctuation)
|
||||
* that begins at offset <code>cursor</code> within <code>text</code>.
|
||||
* It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
|
||||
*/
|
||||
public int findTokenEnd(CharSequence text, int cursor) {
|
||||
int i = cursor;
|
||||
int len = text.length();
|
||||
char c;
|
||||
|
||||
while (i < len) {
|
||||
if ((c = text.charAt(i)) == ',' || c == ';') {
|
||||
return i;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns <code>text</code>, modified, if necessary, to ensure that
|
||||
* it ends with a token terminator (for example a space or comma).
|
||||
* It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
|
||||
*/
|
||||
public CharSequence terminateToken(CharSequence text) {
|
||||
int i = text.length();
|
||||
|
||||
while (i > 0 && text.charAt(i - 1) == ' ') {
|
||||
i--;
|
||||
}
|
||||
|
||||
char c;
|
||||
if (i > 0 && ((c = text.charAt(i - 1)) == ',' || c == ';')) {
|
||||
return text;
|
||||
} else {
|
||||
// Use the same delimiter the user just typed.
|
||||
// This lets them have a mixture of commas and semicolons in their list.
|
||||
String separator = mLastSeparator + " ";
|
||||
if (text instanceof Spanned) {
|
||||
SpannableString sp = new SpannableString(text + separator);
|
||||
TextUtils.copySpansFrom((Spanned) text, 0, text.length(),
|
||||
Object.class, sp, 0);
|
||||
return sp;
|
||||
} else {
|
||||
return text + separator;
|
||||
}
|
||||
}
|
||||
}
|
||||
public String getRawString() {
|
||||
return mList.getText().toString();
|
||||
}
|
||||
public List<String> getNumbers() {
|
||||
Spanned sp = mList.getText();
|
||||
int len = sp.length();
|
||||
List<String> list = new ArrayList<String>();
|
||||
|
||||
int start = 0;
|
||||
int i = 0;
|
||||
while (i < len + 1) {
|
||||
char c;
|
||||
if ((i == len) || ((c = sp.charAt(i)) == ',') || (c == ';')) {
|
||||
if (i > start) {
|
||||
list.add(getNumberAt(sp, start, i, mContext));
|
||||
|
||||
// calculate the recipients total length. This is so if the name contains
|
||||
// commas or semis, we'll skip over the whole name to the next
|
||||
// recipient, rather than parsing this single name into multiple
|
||||
// recipients.
|
||||
int spanLen = getSpanLength(sp, start, i, mContext);
|
||||
if (spanLen > i) {
|
||||
i = spanLen;
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
|
||||
while ((i < len) && (sp.charAt(i) == ' ')) {
|
||||
i++;
|
||||
}
|
||||
|
||||
start = i;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
static class RecipientContextMenuInfo implements ContextMenuInfo {
|
||||
final Recipient recipient;
|
||||
|
||||
RecipientContextMenuInfo(Recipient r) {
|
||||
recipient = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Model for a contact and the various ways it could be represented. Used in situations where we
|
||||
* don't want to create Recipients for the wrapped data (like a custom-entered phone number for
|
||||
* someone you don't yet have a conversation with).
|
||||
*
|
||||
* Designed so that two instances will be equal if *any* of its properties match.
|
||||
*/
|
||||
public class SelectedContact {
|
||||
private final RecipientId recipientId;
|
||||
private final String number;
|
||||
private final String username;
|
||||
|
||||
public static @NonNull SelectedContact forPhone(@Nullable RecipientId recipientId, @NonNull String number) {
|
||||
return new SelectedContact(recipientId, number, null);
|
||||
}
|
||||
|
||||
public static @NonNull SelectedContact forUsername(@Nullable RecipientId recipientId, @NonNull String username) {
|
||||
return new SelectedContact(recipientId, null, username);
|
||||
}
|
||||
|
||||
private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username) {
|
||||
this.recipientId = recipientId;
|
||||
this.number = number;
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public @NonNull RecipientId getOrCreateRecipientId(@NonNull Context context) {
|
||||
if (recipientId != null) {
|
||||
return recipientId;
|
||||
} else if (number != null) {
|
||||
return Recipient.external(context, number).getId();
|
||||
} else {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
SelectedContact that = (SelectedContact) o;
|
||||
|
||||
return Objects.equals(recipientId, that.recipientId) ||
|
||||
Objects.equals(number, that.number) ||
|
||||
Objects.equals(username, that.username);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(recipientId, number, username);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class ContactColors {
|
||||
|
||||
public static final MaterialColor UNKNOWN_COLOR = MaterialColor.STEEL;
|
||||
|
||||
private static final List<MaterialColor> CONVERSATION_PALETTE = new ArrayList<>(Arrays.asList(
|
||||
MaterialColor.PLUM,
|
||||
MaterialColor.CRIMSON,
|
||||
MaterialColor.VERMILLION,
|
||||
MaterialColor.VIOLET,
|
||||
MaterialColor.BLUE,
|
||||
MaterialColor.INDIGO,
|
||||
MaterialColor.FOREST,
|
||||
MaterialColor.WINTERGREEN,
|
||||
MaterialColor.TEAL,
|
||||
MaterialColor.BURLAP,
|
||||
MaterialColor.TAUPE
|
||||
));
|
||||
|
||||
public static MaterialColor generateFor(@NonNull String name) {
|
||||
return CONVERSATION_PALETTE.get(Math.abs(name.hashCode()) % CONVERSATION_PALETTE.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
|
||||
/**
|
||||
* Used for migrating legacy colors to modern colors. For normal color generation, use
|
||||
* {@link ContactColors}.
|
||||
*/
|
||||
public class ContactColorsLegacy {
|
||||
|
||||
private static final String[] LEGACY_PALETTE = new String[] {
|
||||
"red",
|
||||
"pink",
|
||||
"purple",
|
||||
"deep_purple",
|
||||
"indigo",
|
||||
"blue",
|
||||
"light_blue",
|
||||
"cyan",
|
||||
"teal",
|
||||
"green",
|
||||
"light_green",
|
||||
"orange",
|
||||
"deep_orange",
|
||||
"amber",
|
||||
"blue_grey"
|
||||
};
|
||||
|
||||
public static MaterialColor generateFor(@NonNull String name) {
|
||||
String serialized = LEGACY_PALETTE[Math.abs(name.hashCode()) % LEGACY_PALETTE.length];
|
||||
try {
|
||||
return MaterialColor.fromSerialized(serialized);
|
||||
} catch (MaterialColor.UnknownColorException e) {
|
||||
return ContactColors.generateFor(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
|
||||
import com.bumptech.glide.load.Key;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
public interface ContactPhoto extends Key {
|
||||
|
||||
InputStream openInputStream(Context context) throws IOException;
|
||||
|
||||
@Nullable Uri getUri(@NonNull Context context);
|
||||
|
||||
boolean isProfilePhoto();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
public interface FallbackContactPhoto {
|
||||
|
||||
public Drawable asDrawable(Context context, int color);
|
||||
public Drawable asDrawable(Context context, int color, boolean inverted);
|
||||
public Drawable asSmallDrawable(Context context, int color, boolean inverted);
|
||||
public Drawable asCallCard(Context context);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.LayerDrawable;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.amulyakhare.textdrawable.TextDrawable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class GeneratedContactPhoto implements FallbackContactPhoto {
|
||||
|
||||
private static final Pattern PATTERN = Pattern.compile("[^\\p{L}\\p{Nd}\\p{S}]+");
|
||||
private static final Typeface TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL);
|
||||
|
||||
private final String name;
|
||||
private final int fallbackResId;
|
||||
|
||||
public GeneratedContactPhoto(@NonNull String name, @DrawableRes int fallbackResId) {
|
||||
this.name = name;
|
||||
this.fallbackResId = fallbackResId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asDrawable(Context context, int color) {
|
||||
return asDrawable(context, color,false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asDrawable(Context context, int color, boolean inverted) {
|
||||
int targetSize = context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size);
|
||||
String character = getAbbreviation(name);
|
||||
|
||||
if (!TextUtils.isEmpty(character)) {
|
||||
Drawable base = TextDrawable.builder()
|
||||
.beginConfig()
|
||||
.width(targetSize)
|
||||
.height(targetSize)
|
||||
.useFont(TYPEFACE)
|
||||
.fontSize(ViewUtil.dpToPx(context, 24))
|
||||
.textColor(inverted ? color : Color.WHITE)
|
||||
.endConfig()
|
||||
.buildRound(character, inverted ? Color.WHITE : color);
|
||||
|
||||
Drawable gradient = context.getResources().getDrawable(ThemeUtil.isDarkTheme(context) ? R.drawable.avatar_gradient_dark
|
||||
: R.drawable.avatar_gradient_light);
|
||||
return new LayerDrawable(new Drawable[] { base, gradient });
|
||||
}
|
||||
|
||||
return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
|
||||
return asDrawable(context, color, inverted);
|
||||
}
|
||||
|
||||
private @Nullable String getAbbreviation(String name) {
|
||||
String[] parts = name.split(" ");
|
||||
StringBuilder builder = new StringBuilder();
|
||||
int count = 0;
|
||||
|
||||
for (int i = 0; i < parts.length && count < 2; i++) {
|
||||
String cleaned = PATTERN.matcher(parts[i]).replaceFirst("");
|
||||
if (!TextUtils.isEmpty(cleaned)) {
|
||||
builder.appendCodePoint(cleaned.codePointAt(0));
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (builder.length() == 0) {
|
||||
return null;
|
||||
} else {
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asCallCard(Context context) {
|
||||
return AppCompatResources.getDrawable(context, R.drawable.ic_person_large);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.util.Conversions;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
public class GroupRecordContactPhoto implements ContactPhoto {
|
||||
|
||||
private final String groupId;
|
||||
private final long avatarId;
|
||||
|
||||
public GroupRecordContactPhoto(@NonNull String groupId, long avatarId) {
|
||||
this.groupId = groupId;
|
||||
this.avatarId = avatarId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream openInputStream(Context context) throws IOException {
|
||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
Optional<GroupDatabase.GroupRecord> groupRecord = groupDatabase.getGroup(groupId);
|
||||
|
||||
if (groupRecord.isPresent() && groupRecord.get().getAvatar() != null) {
|
||||
return new ByteArrayInputStream(groupRecord.get().getAvatar());
|
||||
}
|
||||
|
||||
throw new IOException("Couldn't load avatar for group: " + groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Uri getUri(@NonNull Context context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isProfilePhoto() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) {
|
||||
messageDigest.update(groupId.getBytes());
|
||||
messageDigest.update(Conversions.longToByteArray(avatarId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other == null || !(other instanceof GroupRecordContactPhoto)) return false;
|
||||
|
||||
GroupRecordContactPhoto that = (GroupRecordContactPhoto)other;
|
||||
return this.groupId.equals(that.groupId) && this.avatarId == that.avatarId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return this.groupId.hashCode() ^ (int) avatarId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
public class ProfileContactPhoto implements ContactPhoto {
|
||||
|
||||
private final @NonNull RecipientId recipient;
|
||||
private final @NonNull String avatarObject;
|
||||
|
||||
public ProfileContactPhoto(@NonNull RecipientId recipient, @NonNull String avatarObject) {
|
||||
this.recipient = recipient;
|
||||
this.avatarObject = avatarObject;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull InputStream openInputStream(Context context) throws IOException {
|
||||
return AvatarHelper.getInputStreamFor(context, recipient);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Uri getUri(@NonNull Context context) {
|
||||
File avatarFile = AvatarHelper.getAvatarFile(context, recipient);
|
||||
return avatarFile.exists() ? Uri.fromFile(avatarFile) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isProfilePhoto() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) {
|
||||
messageDigest.update(recipient.serialize().getBytes());
|
||||
messageDigest.update(avatarObject.getBytes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other == null || !(other instanceof ProfileContactPhoto)) return false;
|
||||
|
||||
ProfileContactPhoto that = (ProfileContactPhoto)other;
|
||||
|
||||
return this.recipient.equals(that.recipient) && this.avatarObject.equals(that.avatarObject);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return recipient.hashCode() ^ avatarObject.hashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.LayerDrawable;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
|
||||
import com.amulyakhare.textdrawable.TextDrawable;
|
||||
import com.makeramen.roundedimageview.RoundedDrawable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
public class ResourceContactPhoto implements FallbackContactPhoto {
|
||||
|
||||
private final int resourceId;
|
||||
private final int smallResourceId;
|
||||
private final int callCardResourceId;
|
||||
|
||||
public ResourceContactPhoto(@DrawableRes int resourceId) {
|
||||
this(resourceId, resourceId, resourceId);
|
||||
}
|
||||
|
||||
public ResourceContactPhoto(@DrawableRes int resourceId, @DrawableRes int smallResourceId) {
|
||||
this(resourceId, smallResourceId, resourceId);
|
||||
}
|
||||
|
||||
public ResourceContactPhoto(@DrawableRes int resourceId, @DrawableRes int smallResourceId, @DrawableRes int callCardResourceId) {
|
||||
this.resourceId = resourceId;
|
||||
this.callCardResourceId = callCardResourceId;
|
||||
this.smallResourceId = smallResourceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asDrawable(Context context, int color) {
|
||||
return asDrawable(context, color, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asDrawable(Context context, int color, boolean inverted) {
|
||||
return buildDrawable(context, resourceId, color, inverted);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
|
||||
return buildDrawable(context, smallResourceId, color, inverted);
|
||||
}
|
||||
|
||||
private Drawable buildDrawable(Context context, int resourceId, int color, boolean inverted) {
|
||||
Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color);
|
||||
RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(AppCompatResources.getDrawable(context, resourceId));
|
||||
|
||||
foreground.setScaleType(ImageView.ScaleType.CENTER);
|
||||
|
||||
if (inverted) {
|
||||
foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP);
|
||||
}
|
||||
|
||||
Drawable gradient = context.getResources().getDrawable(ThemeUtil.isDarkTheme(context) ? R.drawable.avatar_gradient_dark
|
||||
: R.drawable.avatar_gradient_light);
|
||||
|
||||
return new ExpandingLayerDrawable(new Drawable[] {background, foreground, gradient});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asCallCard(Context context) {
|
||||
return AppCompatResources.getDrawable(context, callCardResourceId);
|
||||
}
|
||||
|
||||
private static class ExpandingLayerDrawable extends LayerDrawable {
|
||||
public ExpandingLayerDrawable(Drawable[] layers) {
|
||||
super(layers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getIntrinsicWidth() {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getIntrinsicHeight() {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.Conversions;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
public class SystemContactPhoto implements ContactPhoto {
|
||||
|
||||
private final RecipientId recipientId;
|
||||
private final Uri contactPhotoUri;
|
||||
private final long lastModifiedTime;
|
||||
|
||||
public SystemContactPhoto(@NonNull RecipientId recipientId, @NonNull Uri contactPhotoUri, long lastModifiedTime) {
|
||||
this.recipientId = recipientId;
|
||||
this.contactPhotoUri = contactPhotoUri;
|
||||
this.lastModifiedTime = lastModifiedTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream openInputStream(Context context) throws FileNotFoundException {
|
||||
return context.getContentResolver().openInputStream(contactPhotoUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Uri getUri(@NonNull Context context) {
|
||||
return contactPhotoUri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isProfilePhoto() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) {
|
||||
messageDigest.update(recipientId.serialize().getBytes());
|
||||
messageDigest.update(contactPhotoUri.toString().getBytes());
|
||||
messageDigest.update(Conversions.longToByteArray(lastModifiedTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other == null || !(other instanceof SystemContactPhoto)) return false;
|
||||
|
||||
SystemContactPhoto that = (SystemContactPhoto)other;
|
||||
|
||||
return this.recipientId.equals(that.recipientId) && this.contactPhotoUri.equals(that.contactPhotoUri) && this.lastModifiedTime == that.lastModifiedTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return recipientId.hashCode() ^ contactPhotoUri.hashCode() ^ (int)lastModifiedTime;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.contacts.avatars;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.makeramen.roundedimageview.RoundedDrawable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public class TransparentContactPhoto implements FallbackContactPhoto {
|
||||
|
||||
public TransparentContactPhoto() {}
|
||||
|
||||
@Override
|
||||
public Drawable asDrawable(Context context, int color) {
|
||||
return asDrawable(context, color, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asDrawable(Context context, int color, boolean inverted) {
|
||||
return RoundedDrawable.fromDrawable(context.getResources().getDrawable(android.R.color.transparent));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
|
||||
return asDrawable(context, color, inverted);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Drawable asCallCard(Context context) {
|
||||
return ContextCompat.getDrawable(context, R.drawable.ic_contact_picture_large);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.thoughtcrime.securesms.contacts.sync;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class DirectoryHelper {
|
||||
|
||||
@WorkerThread
|
||||
public static void refreshDirectory(@NonNull Context context, boolean notifyOfNewUsers) throws IOException {
|
||||
if (FeatureFlags.UUIDS) {
|
||||
// TODO [greyson] Create a DirectoryHelperV2 when appropriate.
|
||||
DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers);
|
||||
} else {
|
||||
DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers);
|
||||
}
|
||||
|
||||
if (FeatureFlags.STORAGE_SERVICE) {
|
||||
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static RegisteredState refreshDirectoryFor(@NonNull Context context, @NonNull Recipient recipient, boolean notifyOfNewUsers) throws IOException {
|
||||
RegisteredState originalRegisteredState = recipient.resolve().getRegistered();
|
||||
RegisteredState newRegisteredState = null;
|
||||
|
||||
if (FeatureFlags.UUIDS) {
|
||||
// TODO [greyson] Create a DirectoryHelperV2 when appropriate.
|
||||
newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers);
|
||||
} else {
|
||||
newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers);
|
||||
}
|
||||
|
||||
if (FeatureFlags.STORAGE_SERVICE && newRegisteredState != originalRegisteredState) {
|
||||
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
|
||||
}
|
||||
|
||||
return newRegisteredState;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package org.thoughtcrime.securesms.contacts.sync;
|
||||
|
||||
import android.Manifest;
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountManager;
|
||||
import android.annotation.SuppressLint;
|
||||
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.ContactsContract;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.crypto.SessionUtil;
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
|
||||
import org.thoughtcrime.securesms.sms.IncomingJoinedMessage;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
|
||||
import org.whispersystems.signalservice.api.push.ContactTokenDetails;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
class DirectoryHelperV1 {
|
||||
|
||||
private static final String TAG = DirectoryHelperV1.class.getSimpleName();
|
||||
|
||||
@WorkerThread
|
||||
static void refreshDirectory(@NonNull Context context, boolean notifyOfNewUsers) throws IOException {
|
||||
if (TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) return;
|
||||
if (!Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) return;
|
||||
|
||||
List<RecipientId> newlyActiveUsers = refreshDirectory(context, ApplicationDependencies.getSignalServiceAccountManager());
|
||||
|
||||
if (TextSecurePreferences.isMultiDevice(context)) {
|
||||
ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob());
|
||||
}
|
||||
|
||||
if (notifyOfNewUsers) notifyNewUsers(context, newlyActiveUsers);
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private static @NonNull List<RecipientId> refreshDirectory(@NonNull Context context, @NonNull SignalServiceAccountManager accountManager) throws IOException {
|
||||
if (TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (!Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
Stream<String> eligibleRecipientDatabaseContactNumbers = Stream.of(recipientDatabase.getAllPhoneNumbers());
|
||||
Stream<String> eligibleSystemDatabaseContactNumbers = Stream.of(ContactAccessor.getInstance().getAllContactsWithNumbers(context));
|
||||
Set<String> eligibleContactNumbers = Stream.concat(eligibleRecipientDatabaseContactNumbers, eligibleSystemDatabaseContactNumbers).collect(Collectors.toSet());
|
||||
|
||||
try {
|
||||
Future<DirectoryResult> legacyRequest = getLegacyDirectoryResult(context, accountManager, recipientDatabase, eligibleContactNumbers);
|
||||
DirectoryResult legacyResult = legacyRequest.get();
|
||||
|
||||
return legacyResult.getNewlyActiveRecipients();
|
||||
} catch (InterruptedException e) {
|
||||
throw new IOException("[Batch] Operation was interrupted.", e);
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof IOException) {
|
||||
throw (IOException) e.getCause();
|
||||
} else {
|
||||
Log.e(TAG, "[Batch] Experienced an unexpected exception.", e);
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
static RegisteredState refreshDirectoryFor(@NonNull Context context, @NonNull Recipient recipient, boolean notifyOfNewUsers) throws IOException {
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
|
||||
if (recipient.getUuid().isPresent() && !recipient.getE164().isPresent()) {
|
||||
boolean isRegistered = isUuidRegistered(context, recipient);
|
||||
if (isRegistered) {
|
||||
recipientDatabase.markRegistered(recipient.getId(), recipient.getUuid().get());
|
||||
} else {
|
||||
recipientDatabase.markUnregistered(recipient.getId());
|
||||
}
|
||||
|
||||
return isRegistered ? RegisteredState.REGISTERED : RegisteredState.NOT_REGISTERED;
|
||||
}
|
||||
|
||||
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
|
||||
Future<RegisteredState> legacyRequest = getLegacyRegisteredState(context, accountManager, recipientDatabase, recipient);
|
||||
|
||||
try {
|
||||
return legacyRequest.get();
|
||||
} catch (InterruptedException e) {
|
||||
throw new IOException("[Singular] Operation was interrupted.", e);
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof IOException) {
|
||||
throw (IOException) e.getCause();
|
||||
} else {
|
||||
Log.e(TAG, "[Singular] Experienced an unexpected exception.", e);
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateContactsDatabase(@NonNull Context context, @NonNull List<RecipientId> activeIds, boolean removeMissing) {
|
||||
Optional<AccountHolder> account = getOrCreateAccount(context);
|
||||
|
||||
if (account.isPresent()) {
|
||||
try {
|
||||
List<String> activeAddresses = Stream.of(activeIds).map(Recipient::resolved).filter(Recipient::hasE164).map(Recipient::requireE164).toList();
|
||||
|
||||
DatabaseFactory.getContactsDatabase(context).removeDeletedRawContacts(account.get().getAccount());
|
||||
DatabaseFactory.getContactsDatabase(context).setRegisteredUsers(account.get().getAccount(), activeAddresses, removeMissing);
|
||||
|
||||
Cursor cursor = ContactAccessor.getInstance().getAllSystemContacts(context);
|
||||
RecipientDatabase.BulkOperationsHandle handle = DatabaseFactory.getRecipientDatabase(context).beginBulkSystemContactUpdate();
|
||||
|
||||
try {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String number = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER));
|
||||
|
||||
if (isValidContactNumber(number)) {
|
||||
RecipientId recipientId = Recipient.externalContact(context, number).getId();
|
||||
String displayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
|
||||
String contactPhotoUri = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.PHOTO_URI));
|
||||
String contactLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LABEL));
|
||||
int phoneType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE));
|
||||
Uri contactUri = ContactsContract.Contacts.getLookupUri(cursor.getLong(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone._ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY)));
|
||||
|
||||
|
||||
handle.setSystemContactInfo(recipientId, displayName, contactPhotoUri, contactLabel, phoneType, contactUri.toString());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
handle.finish();
|
||||
}
|
||||
|
||||
if (NotificationChannels.supported()) {
|
||||
try (RecipientDatabase.RecipientReader recipients = DatabaseFactory.getRecipientDatabase(context).getRecipientsWithNotificationChannels()) {
|
||||
Recipient recipient;
|
||||
while ((recipient = recipients.getNext()) != null) {
|
||||
NotificationChannels.updateContactChannelName(context, recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RemoteException | OperationApplicationException e) {
|
||||
Log.w(TAG, "Failed to update contacts.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void notifyNewUsers(@NonNull Context context,
|
||||
@NonNull List<RecipientId> newUsers)
|
||||
{
|
||||
if (!TextSecurePreferences.isNewContactsNotificationEnabled(context)) return;
|
||||
|
||||
for (RecipientId newUser: newUsers) {
|
||||
Recipient recipient = Recipient.resolved(newUser);
|
||||
if (!SessionUtil.hasSession(context, recipient.getId()) && !recipient.isLocalNumber()) {
|
||||
IncomingJoinedMessage message = new IncomingJoinedMessage(newUser);
|
||||
Optional<InsertResult> insertResult = DatabaseFactory.getSmsDatabase(context).insertMessageInbox(message);
|
||||
|
||||
if (insertResult.isPresent()) {
|
||||
int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
|
||||
if (hour >= 9 && hour < 23) {
|
||||
MessageNotifier.updateNotification(context, insertResult.get().getThreadId(), true);
|
||||
} else {
|
||||
MessageNotifier.updateNotification(context, insertResult.get().getThreadId(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<AccountHolder> getOrCreateAccount(Context context) {
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
Account[] accounts = accountManager.getAccountsByType("org.thoughtcrime.securesms");
|
||||
|
||||
Optional<AccountHolder> account;
|
||||
|
||||
if (accounts.length == 0) account = createAccount(context);
|
||||
else account = Optional.of(new AccountHolder(accounts[0], false));
|
||||
|
||||
if (account.isPresent() && !ContentResolver.getSyncAutomatically(account.get().getAccount(), ContactsContract.AUTHORITY)) {
|
||||
ContentResolver.setSyncAutomatically(account.get().getAccount(), ContactsContract.AUTHORITY, true);
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
private static Optional<AccountHolder> createAccount(Context context) {
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
Account account = new Account(context.getString(R.string.app_name), "org.thoughtcrime.securesms");
|
||||
|
||||
if (accountManager.addAccountExplicitly(account, null, null)) {
|
||||
Log.i(TAG, "Created new account...");
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1);
|
||||
return Optional.of(new AccountHolder(account, true));
|
||||
} else {
|
||||
Log.w(TAG, "Failed to create account!");
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
private static Future<DirectoryResult> getLegacyDirectoryResult(@NonNull Context context,
|
||||
@NonNull SignalServiceAccountManager accountManager,
|
||||
@NonNull RecipientDatabase recipientDatabase,
|
||||
@NonNull Set<String> eligibleContactNumbers)
|
||||
{
|
||||
return SignalExecutors.UNBOUNDED.submit(() -> {
|
||||
List<ContactTokenDetails> activeTokens = accountManager.getContacts(eligibleContactNumbers);
|
||||
|
||||
if (activeTokens != null) {
|
||||
List<RecipientId> activeIds = new LinkedList<>();
|
||||
List<RecipientId> inactiveIds = new LinkedList<>();
|
||||
|
||||
Set<String> inactiveContactNumbers = new HashSet<>(eligibleContactNumbers);
|
||||
|
||||
for (ContactTokenDetails activeToken : activeTokens) {
|
||||
activeIds.add(recipientDatabase.getOrInsertFromE164(activeToken.getNumber()));
|
||||
inactiveContactNumbers.remove(activeToken.getNumber());
|
||||
}
|
||||
|
||||
for (String inactiveContactNumber : inactiveContactNumbers) {
|
||||
inactiveIds.add(recipientDatabase.getOrInsertFromE164(inactiveContactNumber));
|
||||
}
|
||||
|
||||
Set<RecipientId> currentActiveIds = new HashSet<>(recipientDatabase.getRegistered());
|
||||
Set<RecipientId> contactIds = new HashSet<>(recipientDatabase.getSystemContacts());
|
||||
List<RecipientId> newlyActiveIds = Stream.of(activeIds)
|
||||
.filter(id -> !currentActiveIds.contains(id))
|
||||
.filter(contactIds::contains)
|
||||
.toList();
|
||||
|
||||
recipientDatabase.setRegistered(activeIds, inactiveIds);
|
||||
updateContactsDatabase(context, activeIds, true);
|
||||
|
||||
Set<String> activeContactNumbers = Stream.of(activeIds).map(Recipient::resolved).filter(Recipient::hasSmsAddress).map(Recipient::requireSmsAddress).collect(Collectors.toSet());
|
||||
|
||||
if (TextSecurePreferences.hasSuccessfullyRetrievedDirectory(context)) {
|
||||
return new DirectoryResult(activeContactNumbers, newlyActiveIds);
|
||||
} else {
|
||||
TextSecurePreferences.setHasSuccessfullyRetrievedDirectory(context, true);
|
||||
return new DirectoryResult(activeContactNumbers);
|
||||
}
|
||||
}
|
||||
return new DirectoryResult(Collections.emptySet(), Collections.emptyList());
|
||||
});
|
||||
}
|
||||
|
||||
private static Future<RegisteredState> getLegacyRegisteredState(@NonNull Context context,
|
||||
@NonNull SignalServiceAccountManager accountManager,
|
||||
@NonNull RecipientDatabase recipientDatabase,
|
||||
@NonNull Recipient recipient)
|
||||
{
|
||||
return SignalExecutors.UNBOUNDED.submit(() -> {
|
||||
boolean activeUser = recipient.resolve().getRegistered() == RegisteredState.REGISTERED;
|
||||
boolean systemContact = recipient.isSystemContact();
|
||||
Optional<ContactTokenDetails> details = recipient.hasE164() ? accountManager.getContact(recipient.requireE164()) : Optional.absent();
|
||||
|
||||
if (details.isPresent()) {
|
||||
recipientDatabase.setRegistered(recipient.getId(), RegisteredState.REGISTERED);
|
||||
|
||||
if (Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) {
|
||||
updateContactsDatabase(context, Util.asList(recipient.getId()), false);
|
||||
}
|
||||
|
||||
if (!activeUser && TextSecurePreferences.isMultiDevice(context)) {
|
||||
ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob());
|
||||
}
|
||||
|
||||
if (!activeUser && systemContact && !TextSecurePreferences.getNeedsSqlCipherMigration(context)) {
|
||||
notifyNewUsers(context, Collections.singletonList(recipient.getId()));
|
||||
}
|
||||
|
||||
return RegisteredState.REGISTERED;
|
||||
} else {
|
||||
recipientDatabase.setRegistered(recipient.getId(), RegisteredState.NOT_REGISTERED);
|
||||
return RegisteredState.NOT_REGISTERED;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean isValidContactNumber(@Nullable String number) {
|
||||
return !TextUtils.isEmpty(number) && !UuidUtil.isUuid(number);
|
||||
}
|
||||
|
||||
private static boolean isUuidRegistered(@NonNull Context context, @NonNull Recipient recipient) throws IOException {
|
||||
Optional<UnidentifiedAccessPair> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient);
|
||||
SignalServiceMessagePipe authPipe = IncomingMessageObserver.getPipe();
|
||||
SignalServiceMessagePipe unidentifiedPipe = IncomingMessageObserver.getUnidentifiedPipe();
|
||||
SignalServiceMessagePipe pipe = unidentifiedPipe != null && unidentifiedAccess.isPresent() ? unidentifiedPipe : authPipe;
|
||||
SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, recipient);
|
||||
|
||||
if (pipe != null) {
|
||||
try {
|
||||
pipe.getProfile(address, unidentifiedAccess.get().getTargetUnidentifiedAccess());
|
||||
return true;
|
||||
} catch (NotFoundException e) {
|
||||
return false;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Websocket request failed. Falling back to REST.");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ApplicationDependencies.getSignalServiceMessageReceiver().retrieveProfile(address, unidentifiedAccess.get().getTargetUnidentifiedAccess());
|
||||
return true;
|
||||
} catch (NotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static class DirectoryResult {
|
||||
|
||||
private final Set<String> numbers;
|
||||
private final List<RecipientId> newlyActiveRecipients;
|
||||
|
||||
DirectoryResult(@NonNull Set<String> numbers) {
|
||||
this(numbers, Collections.emptyList());
|
||||
}
|
||||
|
||||
DirectoryResult(@NonNull Set<String> numbers, @NonNull List<RecipientId> newlyActiveRecipients) {
|
||||
this.numbers = numbers;
|
||||
this.newlyActiveRecipients = newlyActiveRecipients;
|
||||
}
|
||||
|
||||
Set<String> getNumbers() {
|
||||
return numbers;
|
||||
}
|
||||
|
||||
List<RecipientId> getNewlyActiveRecipients() {
|
||||
return newlyActiveRecipients;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package org.thoughtcrime.securesms.contacts.sync;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
|
||||
import org.thoughtcrime.securesms.database.StorageKeyDatabase;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord.IdentityState;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class StorageSyncHelper {
|
||||
|
||||
private static final String TAG = Log.tag(StorageSyncHelper.class);
|
||||
|
||||
private static final KeyGenerator KEY_GENERATOR = () -> Util.getSecretBytes(16);
|
||||
|
||||
private static KeyGenerator testKeyGenerator = null;
|
||||
|
||||
/**
|
||||
* Given the local state of pending storage mutatations, this will generate a result that will
|
||||
* include that data that needs to be written to the storage service, as well as any changes you
|
||||
* need to write back to local storage (like storage keys that might have changed for updated
|
||||
* contacts).
|
||||
*
|
||||
* @param currentManifestVersion What you think the version is locally.
|
||||
* @param currentLocalKeys All local keys you have. This assumes that 'inserts' were given keys
|
||||
* already, and that deletes still have keys.
|
||||
* @param updates Contacts that have been altered.
|
||||
* @param inserts Contacts that have been inserted (or newly marked as registered).
|
||||
* @param deletes Contacts that are no longer registered.
|
||||
*
|
||||
* @return If changes need to be written, then it will return those changes. If no changes need
|
||||
* to be written, this will return {@link Optional#absent()}.
|
||||
*/
|
||||
public static @NonNull Optional<LocalWriteResult> buildStorageUpdatesForLocal(long currentManifestVersion,
|
||||
@NonNull List<byte[]> currentLocalKeys,
|
||||
@NonNull List<RecipientSettings> updates,
|
||||
@NonNull List<RecipientSettings> inserts,
|
||||
@NonNull List<RecipientSettings> deletes)
|
||||
{
|
||||
Set<ByteBuffer> completeKeys = new LinkedHashSet<>(Stream.of(currentLocalKeys).map(ByteBuffer::wrap).toList());
|
||||
Set<SignalContactRecord> contactInserts = new LinkedHashSet<>();
|
||||
Set<ByteBuffer> contactDeletes = new LinkedHashSet<>();
|
||||
Map<RecipientId, byte[]> storageKeyUpdates = new HashMap<>();
|
||||
|
||||
for (RecipientSettings insert : inserts) {
|
||||
contactInserts.add(localToRemoteContact(insert));
|
||||
}
|
||||
|
||||
for (RecipientSettings delete : deletes) {
|
||||
byte[] key = Objects.requireNonNull(delete.getStorageKey());
|
||||
contactDeletes.add(ByteBuffer.wrap(key));
|
||||
completeKeys.remove(ByteBuffer.wrap(key));
|
||||
}
|
||||
|
||||
for (RecipientSettings update : updates) {
|
||||
byte[] oldKey = Objects.requireNonNull(update.getStorageKey());
|
||||
byte[] newKey = generateKey();
|
||||
|
||||
contactInserts.add(localToRemoteContact(update, newKey));
|
||||
contactDeletes.add(ByteBuffer.wrap(oldKey));
|
||||
completeKeys.remove(ByteBuffer.wrap(oldKey));
|
||||
completeKeys.add(ByteBuffer.wrap(newKey));
|
||||
storageKeyUpdates.put(update.getId(), newKey);
|
||||
}
|
||||
|
||||
if (contactInserts.isEmpty() && contactDeletes.isEmpty()) {
|
||||
return Optional.absent();
|
||||
} else {
|
||||
List<SignalStorageRecord> storageInserts = Stream.of(contactInserts).map(c -> SignalStorageRecord.forContact(c.getKey(), c)).toList();
|
||||
List<byte[]> contactDeleteBytes = Stream.of(contactDeletes).map(ByteBuffer::array).toList();
|
||||
List<byte[]> completeKeysBytes = Stream.of(completeKeys).map(ByteBuffer::array).toList();
|
||||
SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, completeKeysBytes);
|
||||
WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, storageInserts, contactDeleteBytes);
|
||||
|
||||
return Optional.of(new LocalWriteResult(writeOperationResult, storageKeyUpdates));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of all the local and remote keys you know about, this will return a result telling
|
||||
* you which keys are exclusively remote and which are exclusively local.
|
||||
*
|
||||
* @param remoteKeys All remote keys available.
|
||||
* @param localKeys All local keys available.
|
||||
*
|
||||
* @return An object describing which keys are exclusive to the remote data set and which keys are
|
||||
* exclusive to the local data set.
|
||||
*/
|
||||
public static @NonNull KeyDifferenceResult findKeyDifference(@NonNull List<byte[]> remoteKeys,
|
||||
@NonNull List<byte[]> localKeys)
|
||||
{
|
||||
Set<ByteBuffer> allRemoteKeys = Stream.of(remoteKeys).map(ByteBuffer::wrap).collect(LinkedHashSet::new, HashSet::add);
|
||||
Set<ByteBuffer> allLocalKeys = Stream.of(localKeys).map(ByteBuffer::wrap).collect(LinkedHashSet::new, HashSet::add);
|
||||
|
||||
Set<ByteBuffer> remoteOnlyKeys = SetUtil.difference(allRemoteKeys, allLocalKeys);
|
||||
Set<ByteBuffer> localOnlyKeys = SetUtil.difference(allLocalKeys, allRemoteKeys);
|
||||
|
||||
return new KeyDifferenceResult(Stream.of(remoteOnlyKeys).map(ByteBuffer::array).toList(),
|
||||
Stream.of(localOnlyKeys).map(ByteBuffer::array).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Given two sets of storage records, this will resolve the data into a set of actions that need
|
||||
* to be applied to resolve the differences. This will handle discovering which records between
|
||||
* the two collections refer to the same contacts and are actually updates, which are brand new,
|
||||
* etc.
|
||||
*
|
||||
* @param remoteOnlyRecords Records that are only present remotely.
|
||||
* @param localOnlyRecords Records that are only present locally.
|
||||
*
|
||||
* @return A set of actions that should be applied to resolve the conflict.
|
||||
*/
|
||||
public static @NonNull MergeResult resolveConflict(@NonNull Collection<SignalStorageRecord> remoteOnlyRecords,
|
||||
@NonNull Collection<SignalStorageRecord> localOnlyRecords)
|
||||
{
|
||||
List<SignalContactRecord> remoteOnlyContacts = Stream.of(remoteOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList();
|
||||
List<SignalContactRecord> localOnlyContacts = Stream.of(localOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList();
|
||||
|
||||
List<SignalStorageRecord> remoteOnlyUnknowns = Stream.of(remoteOnlyRecords).filter(SignalStorageRecord::isUnknown).toList();
|
||||
List<SignalStorageRecord> localOnlyUnknowns = Stream.of(localOnlyRecords).filter(SignalStorageRecord::isUnknown).toList();
|
||||
|
||||
ContactRecordMergeResult contactMergeResult = resolveContactConflict(remoteOnlyContacts, localOnlyContacts);
|
||||
|
||||
return new MergeResult(contactMergeResult.localInserts,
|
||||
contactMergeResult.localUpdates,
|
||||
contactMergeResult.remoteInserts,
|
||||
contactMergeResult.remoteUpdates,
|
||||
new LinkedHashSet<>(remoteOnlyUnknowns),
|
||||
new LinkedHashSet<>(localOnlyUnknowns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assumes that the merge result has *not* yet been applied to the local data. That means that
|
||||
* this method will handle generating the correct final key set based on the merge result.
|
||||
*/
|
||||
public static @NonNull WriteOperationResult createWriteOperation(long currentManifestVersion,
|
||||
@NonNull List<byte[]> currentLocalStorageKeys,
|
||||
@NonNull MergeResult mergeResult)
|
||||
{
|
||||
Set<ByteBuffer> completeKeys = new LinkedHashSet<>(Stream.of(currentLocalStorageKeys).map(ByteBuffer::wrap).toList());
|
||||
|
||||
for (SignalContactRecord insert : mergeResult.getLocalContactInserts()) {
|
||||
completeKeys.add(ByteBuffer.wrap(insert.getKey()));
|
||||
}
|
||||
|
||||
for (SignalContactRecord insert : mergeResult.getRemoteContactInserts()) {
|
||||
completeKeys.add(ByteBuffer.wrap(insert.getKey()));
|
||||
}
|
||||
|
||||
for (SignalStorageRecord insert : mergeResult.getLocalUnknownInserts()) {
|
||||
completeKeys.add(ByteBuffer.wrap(insert.getKey()));
|
||||
}
|
||||
|
||||
for (ContactUpdate update : mergeResult.getLocalContactUpdates()) {
|
||||
completeKeys.remove(ByteBuffer.wrap(update.getOldContact().getKey()));
|
||||
completeKeys.add(ByteBuffer.wrap(update.getNewContact().getKey()));
|
||||
}
|
||||
|
||||
for (ContactUpdate update : mergeResult.getRemoteContactUpdates()) {
|
||||
completeKeys.remove(ByteBuffer.wrap(update.getOldContact().getKey()));
|
||||
completeKeys.add(ByteBuffer.wrap(update.getNewContact().getKey()));
|
||||
}
|
||||
|
||||
SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, Stream.of(completeKeys).map(ByteBuffer::array).toList());
|
||||
|
||||
List<SignalContactRecord> contactInserts = new ArrayList<>();
|
||||
contactInserts.addAll(mergeResult.getRemoteContactInserts());
|
||||
contactInserts.addAll(Stream.of(mergeResult.getRemoteContactUpdates()).map(ContactUpdate::getNewContact).toList());
|
||||
|
||||
List<SignalStorageRecord> inserts = Stream.of(contactInserts).map(c -> SignalStorageRecord.forContact(c.getKey(), c)).toList();
|
||||
|
||||
List<byte[]> deletes = Stream.of(mergeResult.getRemoteContactUpdates()).map(ContactUpdate::getOldContact).map(SignalContactRecord::getKey).toList();
|
||||
|
||||
return new WriteOperationResult(manifest, inserts, deletes);
|
||||
}
|
||||
|
||||
public static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient) {
|
||||
if (recipient.getStorageKey() == null) {
|
||||
throw new AssertionError("Must have a storage key!");
|
||||
}
|
||||
|
||||
return localToRemoteContact(recipient, recipient.getStorageKey());
|
||||
}
|
||||
|
||||
private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient, byte[] storageKey) {
|
||||
if (recipient.getUuid() == null && recipient.getE164() == null) {
|
||||
throw new AssertionError("Must have either a UUID or a phone number!");
|
||||
}
|
||||
|
||||
return new SignalContactRecord.Builder(storageKey, new SignalServiceAddress(recipient.getUuid(), recipient.getE164()))
|
||||
.setProfileKey(recipient.getProfileKey())
|
||||
.setProfileName(recipient.getProfileName())
|
||||
.setBlocked(recipient.isBlocked())
|
||||
.setProfileSharingEnabled(recipient.isProfileSharing())
|
||||
.setIdentityKey(recipient.getIdentityKey())
|
||||
.setIdentityState(localToRemoteIdentityState(recipient.getIdentityStatus()))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static @NonNull IdentityDatabase.VerifiedStatus remoteToLocalIdentityStatus(@NonNull IdentityState identityState) {
|
||||
switch (identityState) {
|
||||
case VERIFIED: return IdentityDatabase.VerifiedStatus.VERIFIED;
|
||||
case UNVERIFIED: return IdentityDatabase.VerifiedStatus.UNVERIFIED;
|
||||
default: return IdentityDatabase.VerifiedStatus.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull byte[] generateKey() {
|
||||
if (testKeyGenerator != null) {
|
||||
return testKeyGenerator.generate();
|
||||
} else {
|
||||
return KEY_GENERATOR.generate();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static @NonNull SignalContactRecord mergeContacts(@NonNull SignalContactRecord remote,
|
||||
@NonNull SignalContactRecord local)
|
||||
{
|
||||
UUID uuid = remote.getAddress().getUuid().or(local.getAddress().getUuid()).orNull();
|
||||
String e164 = remote.getAddress().getNumber().or(local.getAddress().getNumber()).orNull();
|
||||
SignalServiceAddress address = new SignalServiceAddress(uuid, e164);
|
||||
String profileName = remote.getProfileName().or(local.getProfileName()).orNull();
|
||||
byte[] profileKey = remote.getProfileKey().or(local.getProfileKey()).orNull();
|
||||
String username = remote.getUsername().or(local.getUsername()).orNull();
|
||||
IdentityState identityState = remote.getIdentityState();
|
||||
byte[] identityKey = remote.getIdentityKey().or(local.getIdentityKey()).orNull();
|
||||
String nickname = local.getNickname().orNull(); // TODO [greyson] Update this when we add real nickname support
|
||||
boolean blocked = remote.isBlocked();
|
||||
boolean profileSharing = remote.isProfileSharingEnabled() | local.isProfileSharingEnabled();
|
||||
boolean matchesRemote = doParamsMatchContact(remote, address, profileName, profileKey, username, identityState, identityKey, blocked, profileSharing, nickname);
|
||||
boolean matchesLocal = doParamsMatchContact(local, address, profileName, profileKey, username, identityState, identityKey, blocked, profileSharing, nickname);
|
||||
|
||||
if (remote.getProtoVersion() > 0) {
|
||||
Log.w(TAG, "Inbound model has version " + remote.getProtoVersion() + ", but our version is 0.");
|
||||
}
|
||||
|
||||
if (matchesRemote) {
|
||||
return remote;
|
||||
} else if (matchesLocal) {
|
||||
return local;
|
||||
} else {
|
||||
return new SignalContactRecord.Builder(generateKey(), address)
|
||||
.setProfileName(profileName)
|
||||
.setProfileKey(profileKey)
|
||||
.setUsername(username)
|
||||
.setIdentityState(identityState)
|
||||
.setIdentityKey(identityKey)
|
||||
.setBlocked(blocked)
|
||||
.setProfileSharingEnabled(profileSharing)
|
||||
.setNickname(nickname)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static void setTestKeyGenerator(@Nullable KeyGenerator keyGenerator) {
|
||||
testKeyGenerator = keyGenerator;
|
||||
}
|
||||
|
||||
private static IdentityState localToRemoteIdentityState(@NonNull IdentityDatabase.VerifiedStatus local) {
|
||||
switch (local) {
|
||||
case VERIFIED: return IdentityState.VERIFIED;
|
||||
case UNVERIFIED: return IdentityState.UNVERIFIED;
|
||||
default: return IdentityState.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean doParamsMatchContact(@NonNull SignalContactRecord contact,
|
||||
@NonNull SignalServiceAddress address,
|
||||
@Nullable String profileName,
|
||||
@Nullable byte[] profileKey,
|
||||
@Nullable String username,
|
||||
@Nullable IdentityState identityState,
|
||||
@Nullable byte[] identityKey,
|
||||
boolean blocked,
|
||||
boolean profileSharing,
|
||||
@Nullable String nickname)
|
||||
{
|
||||
return Objects.equals(contact.getAddress(), address) &&
|
||||
Objects.equals(contact.getProfileName().orNull(), profileName) &&
|
||||
Arrays.equals(contact.getProfileKey().orNull(), profileKey) &&
|
||||
Objects.equals(contact.getUsername().orNull(), username) &&
|
||||
Objects.equals(contact.getIdentityState(), identityState) &&
|
||||
Arrays.equals(contact.getIdentityKey().orNull(), identityKey) &&
|
||||
contact.isBlocked() == blocked &&
|
||||
contact.isProfileSharingEnabled() == profileSharing &&
|
||||
Objects.equals(contact.getNickname().orNull(), nickname);
|
||||
}
|
||||
|
||||
private static @NonNull ContactRecordMergeResult resolveContactConflict(@NonNull Collection<SignalContactRecord> remoteOnlyRecords,
|
||||
@NonNull Collection<SignalContactRecord> localOnlyRecords)
|
||||
{
|
||||
Map<UUID, SignalContactRecord> localByUuid = new HashMap<>();
|
||||
Map<String, SignalContactRecord> localByE164 = new HashMap<>();
|
||||
|
||||
for (SignalContactRecord contact : localOnlyRecords) {
|
||||
if (contact.getAddress().getUuid().isPresent()) {
|
||||
localByUuid.put(contact.getAddress().getUuid().get(), contact);
|
||||
}
|
||||
if (contact.getAddress().getNumber().isPresent()) {
|
||||
localByE164.put(contact.getAddress().getNumber().get(), contact);
|
||||
}
|
||||
}
|
||||
|
||||
Set<SignalContactRecord> localInserts = new LinkedHashSet<>(remoteOnlyRecords);
|
||||
Set<SignalContactRecord> remoteInserts = new LinkedHashSet<>(localOnlyRecords);
|
||||
Set<ContactUpdate> localUpdates = new LinkedHashSet<>();
|
||||
Set<ContactUpdate> remoteUpdates = new LinkedHashSet<>();
|
||||
|
||||
for (SignalContactRecord remote : remoteOnlyRecords) {
|
||||
SignalContactRecord localUuid = remote.getAddress().getUuid().isPresent() ? localByUuid.get(remote.getAddress().getUuid().get()) : null;
|
||||
SignalContactRecord localE164 = remote.getAddress().getNumber().isPresent() ? localByE164.get(remote.getAddress().getNumber().get()) : null;
|
||||
|
||||
Optional<SignalContactRecord> local = Optional.fromNullable(localUuid).or(Optional.fromNullable(localE164));
|
||||
|
||||
if (local.isPresent()) {
|
||||
SignalContactRecord merged = mergeContacts(remote, local.get());
|
||||
|
||||
if (!merged.equals(remote)) {
|
||||
remoteUpdates.add(new ContactUpdate(remote, merged));
|
||||
}
|
||||
|
||||
if (!merged.equals(local.get())) {
|
||||
localUpdates.add(new ContactUpdate(local.get(), merged));
|
||||
}
|
||||
|
||||
localInserts.remove(remote);
|
||||
remoteInserts.remove(local.get());
|
||||
}
|
||||
}
|
||||
|
||||
return new ContactRecordMergeResult(localInserts, localUpdates, remoteInserts, remoteUpdates);
|
||||
}
|
||||
|
||||
public static final class ContactUpdate {
|
||||
private final SignalContactRecord oldContact;
|
||||
private final SignalContactRecord newContact;
|
||||
|
||||
public ContactUpdate(@NonNull SignalContactRecord oldContact, @NonNull SignalContactRecord newContact) {
|
||||
this.oldContact = oldContact;
|
||||
this.newContact = newContact;
|
||||
}
|
||||
|
||||
public @NonNull
|
||||
SignalContactRecord getOldContact() {
|
||||
return oldContact;
|
||||
}
|
||||
|
||||
public @NonNull
|
||||
SignalContactRecord getNewContact() {
|
||||
return newContact;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ContactUpdate that = (ContactUpdate) o;
|
||||
return oldContact.equals(that.oldContact) &&
|
||||
newContact.equals(that.newContact);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(oldContact, newContact);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class KeyDifferenceResult {
|
||||
private final List<byte[]> remoteOnlyKeys;
|
||||
private final List<byte[]> localOnlyKeys;
|
||||
|
||||
private KeyDifferenceResult(@NonNull List<byte[]> remoteOnlyKeys, @NonNull List<byte[]> localOnlyKeys) {
|
||||
this.remoteOnlyKeys = remoteOnlyKeys;
|
||||
this.localOnlyKeys = localOnlyKeys;
|
||||
}
|
||||
|
||||
public @NonNull List<byte[]> getRemoteOnlyKeys() {
|
||||
return remoteOnlyKeys;
|
||||
}
|
||||
|
||||
public @NonNull List<byte[]> getLocalOnlyKeys() {
|
||||
return localOnlyKeys;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return remoteOnlyKeys.isEmpty() && localOnlyKeys.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public static final class MergeResult {
|
||||
private final Set<SignalContactRecord> localContactInserts;
|
||||
private final Set<ContactUpdate> localContactUpdates;
|
||||
private final Set<SignalContactRecord> remoteContactInserts;
|
||||
private final Set<ContactUpdate> remoteContactUpdates;
|
||||
private final Set<SignalStorageRecord> localUnknownInserts;
|
||||
private final Set<SignalStorageRecord> localUnknownDeletes;
|
||||
|
||||
@VisibleForTesting
|
||||
MergeResult(@NonNull Set<SignalContactRecord> localContactInserts,
|
||||
@NonNull Set<ContactUpdate> localContactUpdates,
|
||||
@NonNull Set<SignalContactRecord> remoteContactInserts,
|
||||
@NonNull Set<ContactUpdate> remoteContactUpdates,
|
||||
@NonNull Set<SignalStorageRecord> localUnknownInserts,
|
||||
@NonNull Set<SignalStorageRecord> localUnknownDeletes)
|
||||
{
|
||||
this.localContactInserts = localContactInserts;
|
||||
this.localContactUpdates = localContactUpdates;
|
||||
this.remoteContactInserts = remoteContactInserts;
|
||||
this.remoteContactUpdates = remoteContactUpdates;
|
||||
this.localUnknownInserts = localUnknownInserts;
|
||||
this.localUnknownDeletes = localUnknownDeletes;
|
||||
}
|
||||
|
||||
public @NonNull Set<SignalContactRecord> getLocalContactInserts() {
|
||||
return localContactInserts;
|
||||
}
|
||||
|
||||
public @NonNull Set<ContactUpdate> getLocalContactUpdates() {
|
||||
return localContactUpdates;
|
||||
}
|
||||
|
||||
public @NonNull Set<SignalContactRecord> getRemoteContactInserts() {
|
||||
return remoteContactInserts;
|
||||
}
|
||||
|
||||
public @NonNull Set<ContactUpdate> getRemoteContactUpdates() {
|
||||
return remoteContactUpdates;
|
||||
}
|
||||
|
||||
public @NonNull Set<SignalStorageRecord> getLocalUnknownInserts() {
|
||||
return localUnknownInserts;
|
||||
}
|
||||
|
||||
public @NonNull Set<SignalStorageRecord> getLocalUnknownDeletes() {
|
||||
return localUnknownDeletes;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class WriteOperationResult {
|
||||
private final SignalStorageManifest manifest;
|
||||
private final List<SignalStorageRecord> inserts;
|
||||
private final List<byte[]> deletes;
|
||||
|
||||
private WriteOperationResult(@NonNull SignalStorageManifest manifest,
|
||||
@NonNull List<SignalStorageRecord> inserts,
|
||||
@NonNull List<byte[]> deletes)
|
||||
{
|
||||
this.manifest = manifest;
|
||||
this.inserts = inserts;
|
||||
this.deletes = deletes;
|
||||
}
|
||||
|
||||
public @NonNull SignalStorageManifest getManifest() {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public @NonNull List<SignalStorageRecord> getInserts() {
|
||||
return inserts;
|
||||
}
|
||||
|
||||
public @NonNull List<byte[]> getDeletes() {
|
||||
return deletes;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LocalWriteResult {
|
||||
private final WriteOperationResult writeResult;
|
||||
private final Map<RecipientId, byte[]> storageKeyUpdates;
|
||||
|
||||
public LocalWriteResult(WriteOperationResult writeResult, Map<RecipientId, byte[]> storageKeyUpdates) {
|
||||
this.writeResult = writeResult;
|
||||
this.storageKeyUpdates = storageKeyUpdates;
|
||||
}
|
||||
|
||||
public @NonNull WriteOperationResult getWriteResult() {
|
||||
return writeResult;
|
||||
}
|
||||
|
||||
public @NonNull Map<RecipientId, byte[]> getStorageKeyUpdates() {
|
||||
return storageKeyUpdates;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ContactRecordMergeResult {
|
||||
final Set<SignalContactRecord> localInserts;
|
||||
final Set<ContactUpdate> localUpdates;
|
||||
final Set<SignalContactRecord> remoteInserts;
|
||||
final Set<ContactUpdate> remoteUpdates;
|
||||
|
||||
ContactRecordMergeResult(@NonNull Set<SignalContactRecord> localInserts,
|
||||
@NonNull Set<ContactUpdate> localUpdates,
|
||||
@NonNull Set<SignalContactRecord> remoteInserts,
|
||||
@NonNull Set<ContactUpdate> remoteUpdates)
|
||||
{
|
||||
this.localInserts = localInserts;
|
||||
this.localUpdates = localUpdates;
|
||||
this.remoteInserts = remoteInserts;
|
||||
this.remoteUpdates = remoteUpdates;
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyGenerator {
|
||||
@NonNull byte[] generate();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user