mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-23 03:05:26 +00:00
Move system contact interactions into their own module.
This commit is contained in:
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* 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.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.RelativeLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.RecipientsAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.RecipientsEditor;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* Panel component combining both an editable field with a button for
|
||||
* a list-based contact selector.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class PushRecipientsPanel extends RelativeLayout implements RecipientForeverObserver {
|
||||
private final String TAG = Log.tag(PushRecipientsPanel.class);
|
||||
private RecipientsPanelChangedListener panelChangeListener;
|
||||
|
||||
private RecipientsEditor recipientsText;
|
||||
private View panel;
|
||||
|
||||
private static final int RECIPIENTS_MAX_LENGTH = 312;
|
||||
|
||||
public PushRecipientsPanel(Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public PushRecipientsPanel(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public PushRecipientsPanel(Context context, AttributeSet attrs, int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
initialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
Stream.of(getRecipients()).map(Recipient::live).forEach(r -> r.removeForeverObserver(this));
|
||||
}
|
||||
|
||||
public List<Recipient> getRecipients() {
|
||||
String rawText = recipientsText.getText().toString();
|
||||
return getRecipientsFromString(getContext(), rawText);
|
||||
}
|
||||
|
||||
public void disable() {
|
||||
recipientsText.setText("");
|
||||
panel.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setPanelChangeListener(RecipientsPanelChangedListener panelChangeListener) {
|
||||
this.panelChangeListener = panelChangeListener;
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||
inflater.inflate(R.layout.push_recipients_panel, this, true);
|
||||
|
||||
View imageButton = findViewById(R.id.contacts_button);
|
||||
((MarginLayoutParams) imageButton.getLayoutParams()).topMargin = 0;
|
||||
|
||||
panel = findViewById(R.id.recipients_panel);
|
||||
initRecipientsEditor();
|
||||
}
|
||||
|
||||
private void initRecipientsEditor() {
|
||||
|
||||
this.recipientsText = (RecipientsEditor)findViewById(R.id.recipients_text);
|
||||
|
||||
List<Recipient> recipients = getRecipients();
|
||||
|
||||
Stream.of(recipients).map(Recipient::live).forEach(r -> r.observeForever(this));
|
||||
|
||||
recipientsText.setAdapter(new RecipientsAdapter(this.getContext()));
|
||||
recipientsText.populate(recipients);
|
||||
|
||||
recipientsText.setOnFocusChangeListener(new FocusChangedListener());
|
||||
recipientsText.setOnItemClickListener(new AdapterView.OnItemClickListener() {
|
||||
@Override
|
||||
public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
|
||||
if (panelChangeListener != null) {
|
||||
panelChangeListener.onRecipientsPanelUpdate(getRecipients());
|
||||
}
|
||||
recipientsText.setText("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private @NonNull List<Recipient> getRecipientsFromString(Context context, @NonNull String rawText) {
|
||||
StringTokenizer tokenizer = new StringTokenizer(rawText, ",");
|
||||
List<Recipient> recipients = new LinkedList<>();
|
||||
|
||||
while (tokenizer.hasMoreTokens()) {
|
||||
String token = tokenizer.nextToken().trim();
|
||||
|
||||
if (!TextUtils.isEmpty(token)) {
|
||||
if (hasBracketedNumber(token)) recipients.add(Recipient.external(context, parseBracketedNumber(token)));
|
||||
else recipients.add(Recipient.external(context, token));
|
||||
}
|
||||
}
|
||||
|
||||
return recipients;
|
||||
}
|
||||
|
||||
private boolean hasBracketedNumber(String recipient) {
|
||||
int openBracketIndex = recipient.indexOf('<');
|
||||
|
||||
return (openBracketIndex != -1) &&
|
||||
(recipient.indexOf('>', openBracketIndex) != -1);
|
||||
}
|
||||
|
||||
private String parseBracketedNumber(String recipient) {
|
||||
int begin = recipient.indexOf('<');
|
||||
int end = recipient.indexOf('>', begin);
|
||||
String value = recipient.substring(begin + 1, end);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
recipientsText.populate(getRecipients());
|
||||
}
|
||||
|
||||
private class FocusChangedListener implements View.OnFocusChangeListener {
|
||||
public void onFocusChange(View v, boolean hasFocus) {
|
||||
if (!hasFocus && (panelChangeListener != null)) {
|
||||
panelChangeListener.onRecipientsPanelUpdate(getRecipients());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface RecipientsPanelChangedListener {
|
||||
public void onRecipientsPanelUpdate(List<Recipient> recipients);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,42 +1,31 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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;
|
||||
import android.provider.ContactsContract.CommonDataKinds.Phone;
|
||||
import android.provider.ContactsContract.Contacts;
|
||||
import android.telephony.PhoneNumberUtils;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.signal.core.util.SqlUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* This class was originally a layer of indirection between
|
||||
@@ -52,102 +41,26 @@ import java.util.Set;
|
||||
|
||||
public class ContactAccessor {
|
||||
|
||||
public static final String PUSH_COLUMN = "push";
|
||||
|
||||
private static final String GIVEN_NAME = ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME;
|
||||
private static final String FAMILY_NAME = ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME;
|
||||
|
||||
private static final ContactAccessor instance = new ContactAccessor();
|
||||
|
||||
public static synchronized ContactAccessor getInstance() {
|
||||
public static 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets and returns a cursor of data for all contacts, containing both phone number data and
|
||||
* structured name data.
|
||||
*
|
||||
* Cursor rows are ordered as follows:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Contact Lookup Key</li>
|
||||
* <li>Mimetype</li>
|
||||
* <li>id</li>
|
||||
* </ol>
|
||||
*
|
||||
* The lookup key is a fixed value that allows you to verify two rows in the database actually
|
||||
* belong to the same contact, since the contact uri can be unstable (if a sync fails, say.)
|
||||
*
|
||||
* We order by id explicitly here for the same contact sync failure error, which could result in
|
||||
* multiple structured name rows for the same user. By ordering by id DESC, we ensure we get
|
||||
* whatever the latest input data was.
|
||||
*
|
||||
* What this results in is a cursor that looks like:
|
||||
*
|
||||
* Alice phone 1
|
||||
* Alice phone 2
|
||||
* Alice structured name 2
|
||||
* Alice structured name 1
|
||||
* Bob phone 1
|
||||
* ... etc.
|
||||
*/
|
||||
public Cursor getAllSystemContacts(Context context) {
|
||||
Uri uri = ContactsContract.Data.CONTENT_URI;
|
||||
String[] projection = SqlUtil.buildArgs(ContactsContract.Data.MIMETYPE, Phone.NUMBER, Phone.DISPLAY_NAME, Phone.LABEL, Phone.PHOTO_URI, Phone._ID, Phone.LOOKUP_KEY, Phone.TYPE, GIVEN_NAME, FAMILY_NAME);
|
||||
String where = ContactsContract.Data.MIMETYPE + " IN (?, ?)";
|
||||
String[] args = SqlUtil.buildArgs(Phone.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE);
|
||||
String orderBy = Phone.LOOKUP_KEY + " ASC, " + ContactsContract.Data.MIMETYPE + " DESC, " + ContactsContract.CommonDataKinds.Phone._ID + " DESC";
|
||||
|
||||
return context.getContentResolver().query(uri, projection, where, args, orderBy);
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
String displayName = getNameFromContact(context, uri);
|
||||
long id = Long.parseLong(uri.getLastPathSegment());
|
||||
|
||||
private ContactData getContactData(Context context, String displayName, long id) {
|
||||
ContactData contactData = new ContactData(id, displayName);
|
||||
|
||||
try (Cursor numberCursor = context.getContentResolver().query(Phone.CONTENT_URI,
|
||||
null,
|
||||
Phone.CONTACT_ID + " = ?",
|
||||
new String[] {contactData.id + ""},
|
||||
new String[] { contactData.id + "" },
|
||||
null))
|
||||
{
|
||||
while (numberCursor != null && numberCursor.moveToNext()) {
|
||||
int type = numberCursor.getInt(numberCursor.getColumnIndexOrThrow(Phone.TYPE));
|
||||
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();
|
||||
@@ -159,10 +72,25 @@ public class ContactAccessor {
|
||||
return contactData;
|
||||
}
|
||||
|
||||
public CharSequence phoneTypeToString(Context mContext, int type, CharSequence label) {
|
||||
return Phone.getTypeLabel(mContext.getResources(), type, label);
|
||||
private 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 static class NumberData implements Parcelable {
|
||||
|
||||
public static final Parcelable.Creator<NumberData> CREATOR = new Parcelable.Creator<NumberData>() {
|
||||
@@ -179,7 +107,7 @@ public class ContactAccessor {
|
||||
public final String type;
|
||||
|
||||
public NumberData(String type, String number) {
|
||||
this.type = type;
|
||||
this.type = type;
|
||||
this.number = number;
|
||||
}
|
||||
|
||||
@@ -210,8 +138,8 @@ public class ContactAccessor {
|
||||
}
|
||||
};
|
||||
|
||||
public final long id;
|
||||
public final String name;
|
||||
public final long id;
|
||||
public final String name;
|
||||
public final List<NumberData> numbers;
|
||||
|
||||
public ContactData(long id, String name) {
|
||||
@@ -237,83 +165,4 @@ public class ContactAccessor {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,18 +9,21 @@ import android.os.Bundle;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.contacts.SystemContactsRepository;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
|
||||
|
||||
@@ -51,20 +54,23 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> allSystemNumbers = ContactAccessor.getInstance().getAllContactsWithNumbers(context);
|
||||
Set<String> knownSystemNumbers = SignalDatabase.recipients().getAllPhoneNumbers();
|
||||
Set<String> unknownSystemNumbers = SetUtil.difference(allSystemNumbers, knownSystemNumbers);
|
||||
Set<String> allSystemE164s = SystemContactsRepository.getAllDisplayNumbers(context)
|
||||
.stream()
|
||||
.map(number -> PhoneNumberFormatter.get(context).format(number))
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> knownSystemE164s = SignalDatabase.recipients().getAllE164s();
|
||||
Set<String> unknownSystemE164s = SetUtil.difference(allSystemE164s, knownSystemE164s);
|
||||
|
||||
if (unknownSystemNumbers.size() > FULL_SYNC_THRESHOLD) {
|
||||
Log.i(TAG, "There are " + unknownSystemNumbers.size() + " unknown contacts. Doing a full sync.");
|
||||
if (unknownSystemE164s.size() > FULL_SYNC_THRESHOLD) {
|
||||
Log.i(TAG, "There are " + unknownSystemE164s.size() + " unknown contacts. Doing a full sync.");
|
||||
try {
|
||||
ContactDiscovery.refreshAll(context, true);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
} else if (unknownSystemNumbers.size() > 0) {
|
||||
Log.i(TAG, "There are " + unknownSystemNumbers.size() + " unknown contacts. Doing an individual sync.");
|
||||
List<Recipient> recipients = Stream.of(unknownSystemNumbers)
|
||||
} else if (unknownSystemE164s.size() > 0) {
|
||||
Log.i(TAG, "There are " + unknownSystemE164s.size() + " unknown contacts. Doing an individual sync.");
|
||||
List<Recipient> recipients = Stream.of(unknownSystemE164s)
|
||||
.filter(s -> s.startsWith("+"))
|
||||
.map(s -> Recipient.external(getContext(), s))
|
||||
.toList();
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
* 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.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;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientsFormatter;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
/*
|
||||
* 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 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 androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientsFormatter;
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil;
|
||||
|
||||
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 = OptionalUtil.or(c.getE164(), c.getEmail()).orElse("");
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package org.thoughtcrime.securesms.contacts.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.contacts.ContactLinkConfiguration
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.io.IOException
|
||||
|
||||
@@ -11,6 +15,9 @@ import java.io.IOException
|
||||
*/
|
||||
object ContactDiscovery {
|
||||
|
||||
private const val MESSAGE_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"
|
||||
private const val CALL_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call"
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
@WorkerThread
|
||||
@@ -37,4 +44,17 @@ object ContactDiscovery {
|
||||
fun syncRecipientInfoWithSystemContacts(context: Context) {
|
||||
DirectoryHelper.syncRecipientInfoWithSystemContacts(context)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun buildContactLinkConfiguration(context: Context, account: Account): ContactLinkConfiguration {
|
||||
return ContactLinkConfiguration(
|
||||
account = account,
|
||||
appName = context.getString(R.string.app_name),
|
||||
messagePrompt = { e164 -> context.getString(R.string.ContactsDatabase_message_s, e164) },
|
||||
callPrompt = { e164 -> context.getString(R.string.ContactsDatabase_signal_call_s, e164) },
|
||||
e164Formatter = { number -> PhoneNumberFormatter.get(context).format(number) },
|
||||
messageMimetype = MESSAGE_MIMETYPE,
|
||||
callMimetype = CALL_MIMETYPE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,21 +17,12 @@ final class ContactHolder {
|
||||
|
||||
private static final String TAG = Log.tag(ContactHolder.class);
|
||||
|
||||
private final String lookupKey;
|
||||
private final List<PhoneNumberRecord> phoneNumberRecords = new LinkedList<>();
|
||||
|
||||
private StructuredNameRecord structuredNameRecord;
|
||||
|
||||
ContactHolder(@NonNull String lookupKey) {
|
||||
this.lookupKey = lookupKey;
|
||||
}
|
||||
|
||||
@NonNull String getLookupKey() {
|
||||
return lookupKey;
|
||||
}
|
||||
|
||||
public void addPhoneNumberRecord(@NonNull PhoneNumberRecord phoneNumberRecord) {
|
||||
phoneNumberRecords.add(phoneNumberRecord);
|
||||
public void addPhoneNumberRecords(@NonNull List<PhoneNumberRecord> phoneNumberRecords) {
|
||||
this.phoneNumberRecords.addAll(phoneNumberRecords);
|
||||
}
|
||||
|
||||
public void setStructuredNameRecord(@NonNull StructuredNameRecord structuredNameRecord) {
|
||||
|
||||
@@ -4,20 +4,20 @@ import android.Manifest;
|
||||
import android.accounts.Account;
|
||||
import android.content.Context;
|
||||
import android.content.OperationApplicationException;
|
||||
import android.database.Cursor;
|
||||
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.signal.contacts.SystemContactsRepository;
|
||||
import org.signal.contacts.SystemContactsRepository.ContactDetails;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.BulkOperationsHandle;
|
||||
@@ -36,13 +36,11 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationUtil;
|
||||
import org.thoughtcrime.securesms.sms.IncomingJoinedMessage;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.signal.core.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
||||
@@ -50,7 +48,6 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.ACI;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.services.ProfileService;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -60,7 +57,6 @@ import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -94,10 +90,12 @@ class DirectoryHelper {
|
||||
}
|
||||
|
||||
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
|
||||
Set<String> databaseNumbers = sanitizeNumbers(recipientDatabase.getAllPhoneNumbers());
|
||||
Set<String> systemNumbers = sanitizeNumbers(ContactAccessor.getInstance().getAllContactsWithNumbers(context));
|
||||
Set<String> databaseE164s = sanitizeNumbers(recipientDatabase.getAllE164s());
|
||||
Set<String> systemE164s = sanitizeNumbers(Stream.of(SystemContactsRepository.getAllDisplayNumbers(context))
|
||||
.map(number -> PhoneNumberFormatter.get(context).format(number))
|
||||
.collect(Collectors.toSet()));
|
||||
|
||||
refreshNumbers(context, databaseNumbers, systemNumbers, notifyOfNewUsers, true);
|
||||
refreshNumbers(context, databaseE164s, systemE164s, notifyOfNewUsers, true);
|
||||
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
}
|
||||
@@ -302,7 +300,10 @@ class DirectoryHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
Account account = SystemContactsRepository.getOrCreateSystemAccount(context);
|
||||
Stopwatch stopwatch = new Stopwatch("contacts");
|
||||
|
||||
Account account = SystemContactsRepository.getOrCreateSystemAccount(context, BuildConfig.APPLICATION_ID, context.getString(R.string.app_name));
|
||||
stopwatch.split("account");
|
||||
|
||||
if (account == null) {
|
||||
Log.w(TAG, "Failed to create an account!");
|
||||
@@ -310,16 +311,23 @@ class DirectoryHelper {
|
||||
}
|
||||
|
||||
try {
|
||||
List<String> activeAddresses = Stream.of(activeIds)
|
||||
.map(Recipient::resolved)
|
||||
.filter(Recipient::hasE164)
|
||||
.map(Recipient::requireE164)
|
||||
.toList();
|
||||
Set<String> activeE164s = Stream.of(activeIds)
|
||||
.map(Recipient::resolved)
|
||||
.filter(Recipient::hasE164)
|
||||
.map(Recipient::requireE164)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
SystemContactsRepository.removeDeletedRawContacts(context, account);
|
||||
SystemContactsRepository.setRegisteredUsers(context, account, activeAddresses, removeMissing);
|
||||
SystemContactsRepository.removeDeletedRawContactsForAccount(context, account);
|
||||
stopwatch.split("remove-deleted");
|
||||
SystemContactsRepository.addMessageAndCallLinksToContacts(context,
|
||||
ContactDiscovery.buildContactLinkConfiguration(context, account),
|
||||
activeE164s,
|
||||
removeMissing);
|
||||
stopwatch.split("add-links");
|
||||
|
||||
syncRecipientInfoWithSystemContacts(context, rewrites);
|
||||
stopwatch.split("sync-info");
|
||||
stopwatch.stop(TAG);
|
||||
} catch (RemoteException | OperationApplicationException e) {
|
||||
Log.w(TAG, "Failed to update contacts.", e);
|
||||
}
|
||||
@@ -329,59 +337,25 @@ class DirectoryHelper {
|
||||
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
|
||||
BulkOperationsHandle handle = recipientDatabase.beginBulkSystemContactUpdate();
|
||||
|
||||
try (Cursor cursor = ContactAccessor.getInstance().getAllSystemContacts(context)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String mimeType = getMimeType(cursor);
|
||||
try (SystemContactsRepository.ContactIterator iterator = SystemContactsRepository.getAllSystemContacts(context, rewrites, (number) -> PhoneNumberFormatter.get(context).format(number))) {
|
||||
while (iterator.hasNext()) {
|
||||
ContactDetails contact = iterator.next();
|
||||
ContactHolder holder = new ContactHolder();
|
||||
StructuredNameRecord name = new StructuredNameRecord(contact.getGivenName(), contact.getFamilyName());
|
||||
List<PhoneNumberRecord> phones = Stream.of(contact.getNumbers())
|
||||
.map(number -> {
|
||||
return new PhoneNumberRecord.Builder()
|
||||
.withRecipientId(Recipient.externalContact(context, number.getNumber()).getId())
|
||||
.withContactUri(number.getContactUri())
|
||||
.withDisplayName(number.getDisplayName())
|
||||
.withContactPhotoUri(number.getPhotoUri())
|
||||
.withContactLabel(number.getLabel())
|
||||
.build();
|
||||
}).toList();
|
||||
|
||||
if (!isPhoneMimeType(mimeType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String lookupKey = getLookupKey(cursor);
|
||||
ContactHolder contactHolder = new ContactHolder(lookupKey);
|
||||
|
||||
while (!cursor.isAfterLast() && getLookupKey(cursor).equals(lookupKey) && isPhoneMimeType(getMimeType(cursor))) {
|
||||
String number = CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.NUMBER);
|
||||
|
||||
if (isValidContactNumber(number)) {
|
||||
String formattedNumber = PhoneNumberFormatter.get(context).format(number);
|
||||
String realNumber = Util.getFirstNonEmpty(rewrites.get(formattedNumber), formattedNumber);
|
||||
|
||||
PhoneNumberRecord.Builder builder = new PhoneNumberRecord.Builder();
|
||||
|
||||
builder.withRecipientId(Recipient.externalContact(context, realNumber).getId());
|
||||
builder.withDisplayName(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
|
||||
builder.withContactPhotoUri(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.PHOTO_URI));
|
||||
builder.withContactLabel(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.LABEL));
|
||||
builder.withPhoneType(CursorUtil.requireInt(cursor, ContactsContract.CommonDataKinds.Phone.TYPE));
|
||||
builder.withContactUri(ContactsContract.Contacts.getLookupUri(CursorUtil.requireLong(cursor, ContactsContract.CommonDataKinds.Phone._ID),
|
||||
CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY)));
|
||||
|
||||
contactHolder.addPhoneNumberRecord(builder.build());
|
||||
} else {
|
||||
Log.w(TAG, "Skipping phone entry with invalid number");
|
||||
}
|
||||
|
||||
cursor.moveToNext();
|
||||
}
|
||||
|
||||
if (!cursor.isAfterLast() && getLookupKey(cursor).equals(lookupKey)) {
|
||||
if (isStructuredNameMimeType(getMimeType(cursor))) {
|
||||
StructuredNameRecord.Builder builder = new StructuredNameRecord.Builder();
|
||||
|
||||
builder.withGivenName(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME));
|
||||
builder.withFamilyName(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME));
|
||||
|
||||
contactHolder.setStructuredNameRecord(builder.build());
|
||||
} else {
|
||||
Log.i(TAG, "Skipping invalid mimeType " + mimeType);
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "No structured name for user, rolling back cursor.");
|
||||
cursor.moveToPrevious();
|
||||
}
|
||||
|
||||
contactHolder.commit(handle);
|
||||
holder.setStructuredNameRecord(name);
|
||||
holder.addPhoneNumberRecords(phones);
|
||||
holder.commit(handle);
|
||||
}
|
||||
} catch (IllegalStateException e) {
|
||||
Log.w(TAG, "Hit an issue with the cursor while reading!", e);
|
||||
@@ -399,26 +373,6 @@ class DirectoryHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isPhoneMimeType(@NonNull String mimeType) {
|
||||
return ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mimeType);
|
||||
}
|
||||
|
||||
private static boolean isStructuredNameMimeType(@NonNull String mimeType) {
|
||||
return ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE.equals(mimeType);
|
||||
}
|
||||
|
||||
private static boolean isValidContactNumber(@Nullable String number) {
|
||||
return !TextUtils.isEmpty(number) && !UuidUtil.isUuid(number);
|
||||
}
|
||||
|
||||
private static @NonNull String getLookupKey(@NonNull Cursor cursor) {
|
||||
return Objects.requireNonNull(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY));
|
||||
}
|
||||
|
||||
private static @NonNull String getMimeType(@NonNull Cursor cursor) {
|
||||
return CursorUtil.requireString(cursor, ContactsContract.Data.MIMETYPE);
|
||||
}
|
||||
|
||||
private static void notifyNewUsers(@NonNull Context context,
|
||||
@NonNull Collection<RecipientId> newUsers)
|
||||
{
|
||||
|
||||
@@ -77,12 +77,12 @@ final class PhoneNumberRecord {
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withContactLabel(@NonNull String contactLabel) {
|
||||
@NonNull Builder withContactLabel(@Nullable String contactLabel) {
|
||||
this.contactLabel = contactLabel;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withContactPhotoUri(@NonNull String contactPhotoUri) {
|
||||
@NonNull Builder withContactPhotoUri(@Nullable String contactPhotoUri) {
|
||||
this.contactPhotoUri = contactPhotoUri;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ final class StructuredNameRecord {
|
||||
private final String givenName;
|
||||
private final String familyName;
|
||||
|
||||
StructuredNameRecord(@NonNull StructuredNameRecord.Builder builder) {
|
||||
this.givenName = builder.givenName;
|
||||
this.familyName = builder.familyName;
|
||||
public StructuredNameRecord(@Nullable String givenName, @Nullable String familyName) {
|
||||
this.givenName = givenName;
|
||||
this.familyName = familyName;
|
||||
}
|
||||
|
||||
public boolean hasGivenName() {
|
||||
@@ -24,23 +24,4 @@ final class StructuredNameRecord {
|
||||
public @NonNull ProfileName asProfileName() {
|
||||
return ProfileName.fromParts(givenName, familyName);
|
||||
}
|
||||
|
||||
final static class Builder {
|
||||
private String givenName;
|
||||
private String familyName;
|
||||
|
||||
@NonNull Builder withGivenName(@Nullable String givenName) {
|
||||
this.givenName = givenName;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withFamilyName(@Nullable String familyName) {
|
||||
this.familyName = familyName;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull StructuredNameRecord build() {
|
||||
return new StructuredNameRecord(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
package org.thoughtcrime.securesms.contacts.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.OperationApplicationException
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import android.provider.BaseColumns
|
||||
import android.provider.ContactsContract
|
||||
import org.signal.core.util.ListUtil
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireString
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import java.util.ArrayList
|
||||
import java.util.HashMap
|
||||
|
||||
/**
|
||||
* A way to retrieve and update data in the Android system contacts.
|
||||
*/
|
||||
object SystemContactsRepository {
|
||||
|
||||
private val TAG = Log.tag(SystemContactsRepository::class.java)
|
||||
private const val CONTACT_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"
|
||||
private const val CALL_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call"
|
||||
private const val SYNC = "__TS"
|
||||
|
||||
@JvmStatic
|
||||
fun getOrCreateSystemAccount(context: Context): Account? {
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
val accounts: Array<Account> = accountManager.getAccountsByType(BuildConfig.APPLICATION_ID)
|
||||
var account: Account? = if (accounts.isNotEmpty()) accounts[0] else null
|
||||
|
||||
if (account == null) {
|
||||
Log.i(TAG, "Attempting to create a new account...")
|
||||
val newAccount = Account(context.getString(R.string.app_name), BuildConfig.APPLICATION_ID)
|
||||
|
||||
if (accountManager.addAccountExplicitly(newAccount, null, null)) {
|
||||
Log.i(TAG, "Successfully created a new account.")
|
||||
ContentResolver.setIsSyncable(newAccount, ContactsContract.AUTHORITY, 1)
|
||||
account = newAccount
|
||||
} else {
|
||||
Log.w(TAG, "Failed to create a new account!")
|
||||
}
|
||||
}
|
||||
|
||||
if (account != null && !ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) {
|
||||
Log.i(TAG, "Updated account to sync automatically.")
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
fun removeDeletedRawContacts(context: Context, account: Account) {
|
||||
val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.build()
|
||||
|
||||
val projection = arrayOf(BaseColumns._ID, ContactsContract.RawContacts.SYNC1)
|
||||
|
||||
context.contentResolver.query(currentContactsUri, projection, "${ContactsContract.RawContacts.DELETED} = ?", SqlUtil.buildArgs(1), null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val rawContactId = cursor.getLong(0)
|
||||
|
||||
Log.i(TAG, """Deleting raw contact: ${cursor.getString(1)}, $rawContactId""")
|
||||
context.contentResolver.delete(currentContactsUri, "${ContactsContract.RawContacts._ID} = ?", arrayOf(rawContactId.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
@Throws(RemoteException::class, OperationApplicationException::class)
|
||||
fun setRegisteredUsers(
|
||||
context: Context,
|
||||
account: Account,
|
||||
registeredAddressList: List<String>,
|
||||
remove: Boolean
|
||||
) {
|
||||
val registeredAddressSet: Set<String> = registeredAddressList.toSet()
|
||||
val operations: ArrayList<ContentProviderOperation> = ArrayList()
|
||||
val currentContacts: Map<String, SignalContact> = getSignalRawContacts(context, account)
|
||||
|
||||
val registeredChunks: List<List<String>> = ListUtil.chunk(registeredAddressList, 50)
|
||||
for (registeredChunk in registeredChunks) {
|
||||
for (registeredAddress in registeredChunk) {
|
||||
if (!currentContacts.containsKey(registeredAddress)) {
|
||||
val systemContactInfo: SystemContactInfo? = getSystemContactInfo(context, registeredAddress)
|
||||
if (systemContactInfo != null) {
|
||||
Log.i(TAG, "Adding number: $registeredAddress")
|
||||
addTextSecureRawContact(
|
||||
context = context,
|
||||
operations = operations,
|
||||
account = account,
|
||||
e164number = systemContactInfo.number,
|
||||
displayName = systemContactInfo.name,
|
||||
aggregateId = systemContactInfo.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operations.isNotEmpty()) {
|
||||
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, operations)
|
||||
operations.clear()
|
||||
}
|
||||
}
|
||||
|
||||
for ((key, value) in currentContacts) {
|
||||
if (!registeredAddressSet.contains(key)) {
|
||||
if (remove) {
|
||||
Log.i(TAG, "Removing number: $key")
|
||||
removeTextSecureRawContact(operations, account, value.id)
|
||||
}
|
||||
} else if (!value.isVoiceSupported()) {
|
||||
Log.i(TAG, "Adding voice support: $key")
|
||||
addContactVoiceSupport(context, operations, key, value.id)
|
||||
} else if (!Util.isStringEquals(value.rawDisplayName, value.aggregateDisplayName)) {
|
||||
Log.i(TAG, "Updating display name: $key")
|
||||
updateDisplayName(operations, value.aggregateDisplayName, value.id, value.displayNameSource)
|
||||
}
|
||||
}
|
||||
|
||||
if (operations.isNotEmpty()) {
|
||||
applyOperationsInBatches(context.contentResolver, ContactsContract.AUTHORITY, operations, 50)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getNameDetails(context: Context, contactId: Long): NameDetails? {
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.PREFIX,
|
||||
ContactsContract.CommonDataKinds.StructuredName.SUFFIX,
|
||||
ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME
|
||||
)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
|
||||
return context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
NameDetails(
|
||||
displayName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME),
|
||||
givenName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME),
|
||||
familyName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME),
|
||||
prefix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.PREFIX),
|
||||
suffix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.SUFFIX),
|
||||
middleName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getOrganizationName(context: Context, contactId: Long): String? {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
|
||||
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getPhoneDetails(context: Context, contactId: Long): List<PhoneDetails> {
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE,
|
||||
ContactsContract.CommonDataKinds.Phone.LABEL
|
||||
)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
|
||||
|
||||
val phoneDetails: MutableList<PhoneDetails> = mutableListOf()
|
||||
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
phoneDetails += PhoneDetails(
|
||||
number = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER),
|
||||
type = cursor.requireInt(ContactsContract.CommonDataKinds.Phone.TYPE),
|
||||
label = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LABEL)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return phoneDetails
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getEmailDetails(context: Context, contactId: Long): List<EmailDetails> {
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Email.ADDRESS,
|
||||
ContactsContract.CommonDataKinds.Email.TYPE,
|
||||
ContactsContract.CommonDataKinds.Email.LABEL
|
||||
)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
|
||||
|
||||
val emailDetails: MutableList<EmailDetails> = mutableListOf()
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
emailDetails += EmailDetails(
|
||||
address = cursor.requireString(ContactsContract.CommonDataKinds.Email.ADDRESS),
|
||||
type = cursor.requireInt(ContactsContract.CommonDataKinds.Email.TYPE),
|
||||
label = cursor.requireString(ContactsContract.CommonDataKinds.Email.LABEL)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return emailDetails
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getPostalAddressDetails(context: Context, contactId: Long): List<PostalAddressDetails> {
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.TYPE,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.LABEL,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.STREET,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.POBOX,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.CITY,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.REGION,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY
|
||||
)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE)
|
||||
|
||||
val postalDetails: MutableList<PostalAddressDetails> = mutableListOf()
|
||||
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
postalDetails += PostalAddressDetails(
|
||||
type = cursor.requireInt(ContactsContract.CommonDataKinds.StructuredPostal.TYPE),
|
||||
label = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.LABEL),
|
||||
street = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.STREET),
|
||||
poBox = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POBOX),
|
||||
neighborhood = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD),
|
||||
city = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.CITY),
|
||||
region = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.REGION),
|
||||
postal = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE),
|
||||
country = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return postalDetails
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getAvatarUri(context: Context, contactId: Long): Uri? {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_URI)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
|
||||
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val uri = cursor.getString(0)
|
||||
if (uri != null) {
|
||||
return Uri.parse(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun addContactVoiceSupport(context: Context, operations: MutableList<ContentProviderOperation>, address: String, rawContactId: Long) {
|
||||
operations.add(
|
||||
ContentProviderOperation.newUpdate(ContactsContract.RawContacts.CONTENT_URI)
|
||||
.withSelection("${ContactsContract.RawContacts._ID} = ?", arrayOf(rawContactId.toString()))
|
||||
.withValue(ContactsContract.RawContacts.SYNC4, "true")
|
||||
.build()
|
||||
)
|
||||
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build())
|
||||
.withValue(ContactsContract.Data.RAW_CONTACT_ID, rawContactId)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE)
|
||||
.withValue(ContactsContract.Data.DATA1, address)
|
||||
.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
|
||||
.withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, address))
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateDisplayName(operations: MutableList<ContentProviderOperation>, displayName: String?, rawContactId: Long, displayNameSource: Int) {
|
||||
val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.build()
|
||||
|
||||
if (displayNameSource != ContactsContract.DisplayNameSources.STRUCTURED_NAME) {
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(dataUri)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, rawContactId)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
operations.add(
|
||||
ContentProviderOperation.newUpdate(dataUri)
|
||||
.withSelection("${ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?", SqlUtil.buildArgs(rawContactId.toString(), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE))
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addTextSecureRawContact(
|
||||
context: Context,
|
||||
operations: MutableList<ContentProviderOperation>,
|
||||
account: Account,
|
||||
e164number: String,
|
||||
displayName: String,
|
||||
aggregateId: Long
|
||||
) {
|
||||
val index = operations.size
|
||||
val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.build()
|
||||
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.withValue(ContactsContract.RawContacts.SYNC1, e164number)
|
||||
.withValue(ContactsContract.RawContacts.SYNC4, true.toString())
|
||||
.build()
|
||||
)
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(dataUri)
|
||||
.withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, index)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
.build()
|
||||
)
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(dataUri)
|
||||
.withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, index)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, e164number)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_OTHER)
|
||||
.withValue(ContactsContract.Data.SYNC2, SYNC)
|
||||
.build()
|
||||
)
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(dataUri)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, CONTACT_MIMETYPE)
|
||||
.withValue(ContactsContract.Data.DATA1, e164number)
|
||||
.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
|
||||
.withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_message_s, e164number))
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
)
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(dataUri)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE)
|
||||
.withValue(ContactsContract.Data.DATA1, e164number)
|
||||
.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
|
||||
.withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, e164number))
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
)
|
||||
operations.add(
|
||||
ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI)
|
||||
.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, aggregateId)
|
||||
.withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, index)
|
||||
.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeTextSecureRawContact(operations: MutableList<ContentProviderOperation>, account: Account, rowId: Long) {
|
||||
operations.add(
|
||||
ContentProviderOperation.newDelete(
|
||||
ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()
|
||||
)
|
||||
.withYieldAllowed(true)
|
||||
.withSelection("${BaseColumns._ID} = ?", SqlUtil.buildArgs(rowId))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun getSignalRawContacts(context: Context, account: Account): Map<String, SignalContact> {
|
||||
val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build()
|
||||
val projection = arrayOf(BaseColumns._ID, ContactsContract.RawContacts.SYNC1, ContactsContract.RawContacts.SYNC4, ContactsContract.RawContacts.CONTACT_ID, ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY, ContactsContract.RawContacts.DISPLAY_NAME_SOURCE)
|
||||
|
||||
val signalContacts: MutableMap<String, SignalContact> = HashMap()
|
||||
|
||||
context.contentResolver.query(currentContactsUri, projection, null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val currentAddress = PhoneNumberFormatter.get(context).format(cursor.getString(1))
|
||||
|
||||
signalContacts[currentAddress] = SignalContact(
|
||||
id = cursor.getLong(0),
|
||||
supportsVoice = cursor.getString(2),
|
||||
rawDisplayName = cursor.getString(4),
|
||||
aggregateDisplayName = getDisplayName(context, cursor.getLong(3)),
|
||||
displayNameSource = cursor.getInt(5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return signalContacts
|
||||
}
|
||||
|
||||
private fun getSystemContactInfo(context: Context, address: String): SystemContactInfo? {
|
||||
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address))
|
||||
val projection = arrayOf(
|
||||
ContactsContract.PhoneLookup.NUMBER,
|
||||
ContactsContract.PhoneLookup._ID,
|
||||
ContactsContract.PhoneLookup.DISPLAY_NAME
|
||||
)
|
||||
|
||||
context.contentResolver.query(uri, projection, null, null, null)?.use { numberCursor ->
|
||||
while (numberCursor.moveToNext()) {
|
||||
val systemNumber = numberCursor.getString(0)
|
||||
val systemAddress = PhoneNumberFormatter.get(context).format(systemNumber)
|
||||
if (systemAddress == address) {
|
||||
context.contentResolver.query(ContactsContract.RawContacts.CONTENT_URI, arrayOf(ContactsContract.RawContacts._ID), "${ContactsContract.RawContacts.CONTACT_ID} = ? ", SqlUtil.buildArgs(numberCursor.getLong(1)), null)?.use { idCursor ->
|
||||
if (idCursor.moveToNext()) {
|
||||
return SystemContactInfo(
|
||||
name = numberCursor.getString(2),
|
||||
number = numberCursor.getString(0),
|
||||
id = idCursor.getLong(0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getDisplayName(context: Context, contactId: Long): String? {
|
||||
val projection = arrayOf(ContactsContract.Contacts.DISPLAY_NAME)
|
||||
val selection = "${ContactsContract.Contacts._ID} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId)
|
||||
|
||||
context.contentResolver.query(ContactsContract.Contacts.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@Throws(OperationApplicationException::class, RemoteException::class)
|
||||
private fun applyOperationsInBatches(
|
||||
contentResolver: ContentResolver,
|
||||
authority: String,
|
||||
operations: List<ContentProviderOperation>,
|
||||
batchSize: Int
|
||||
) {
|
||||
val batches = ListUtil.chunk(operations, batchSize)
|
||||
for (batch in batches) {
|
||||
contentResolver.applyBatch(authority, ArrayList(batch))
|
||||
}
|
||||
}
|
||||
|
||||
private data class SystemContactInfo(val name: String, val number: String, val id: Long)
|
||||
|
||||
private data class SignalContact(
|
||||
val id: Long,
|
||||
val supportsVoice: String?,
|
||||
val rawDisplayName: String?,
|
||||
val aggregateDisplayName: String?,
|
||||
val displayNameSource: Int
|
||||
) {
|
||||
fun isVoiceSupported(): Boolean {
|
||||
return "true" == supportsVoice
|
||||
}
|
||||
}
|
||||
|
||||
data class NameDetails(
|
||||
val displayName: String?,
|
||||
val givenName: String?,
|
||||
val familyName: String?,
|
||||
val prefix: String?,
|
||||
val suffix: String?,
|
||||
val middleName: String?
|
||||
)
|
||||
|
||||
data class PhoneDetails(
|
||||
val number: String?,
|
||||
val type: Int,
|
||||
val label: String?
|
||||
)
|
||||
|
||||
data class EmailDetails(
|
||||
val address: String?,
|
||||
val type: Int,
|
||||
val label: String?
|
||||
)
|
||||
|
||||
data class PostalAddressDetails(
|
||||
val type: Int,
|
||||
val label: String?,
|
||||
val street: String?,
|
||||
val poBox: String?,
|
||||
val neighborhood: String?,
|
||||
val city: String?,
|
||||
val region: String?,
|
||||
val postal: String?,
|
||||
val country: String?
|
||||
)
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.sync.SystemContactsRepository;
|
||||
import org.thoughtcrime.securesms.contacts.sync.SystemContactsRepository.NameDetails;
|
||||
import org.thoughtcrime.securesms.contacts.sync.SystemContactsRepository.PhoneDetails;
|
||||
import org.signal.contacts.SystemContactsRepository;
|
||||
import org.signal.contacts.SystemContactsRepository.NameDetails;
|
||||
import org.signal.contacts.SystemContactsRepository.PhoneDetails;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact.Email;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact.Name;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact.Phone;
|
||||
|
||||
@@ -1901,7 +1901,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllPhoneNumbers(): Set<String> {
|
||||
fun getAllE164s(): Set<String> {
|
||||
val results: MutableSet<String> = HashSet()
|
||||
readableDatabase.query(TABLE_NAME, arrayOf(PHONE), null, null, null, null, null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
|
||||
@@ -106,7 +106,7 @@ public class PhoneNumberFormatter {
|
||||
}
|
||||
|
||||
|
||||
public String format(@Nullable String number) {
|
||||
public @NonNull String format(@Nullable String number) {
|
||||
if (number == null) return "Unknown";
|
||||
if (GroupId.isEncodedGroup(number)) return number;
|
||||
if (ALPHA_PATTERN.matcher(number).find()) return number.trim();
|
||||
|
||||
@@ -472,11 +472,6 @@ public class Util {
|
||||
return (int)value;
|
||||
}
|
||||
|
||||
public static boolean isStringEquals(String first, String second) {
|
||||
if (first == null) return second == null;
|
||||
return first.equals(second);
|
||||
}
|
||||
|
||||
public static boolean isEquals(@Nullable Long first, long second) {
|
||||
return first != null && first == second;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user