diff --git a/app/build.gradle b/app/build.gradle
index 3224a53bb..d6904c895 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -455,6 +455,7 @@ dependencies {
implementation project(':device-transfer')
implementation project(':image-editor')
implementation project(':donations')
+ implementation project(':contacts')
implementation libs.signal.client.android
implementation libs.google.protobuf.javalite
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/PushRecipientsPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/PushRecipientsPanel.java
deleted file mode 100644
index 7a320ce83..000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/PushRecipientsPanel.java
+++ /dev/null
@@ -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 .
- */
-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 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 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 getRecipientsFromString(Context context, @NonNull String rawText) {
- StringTokenizer tokenizer = new StringTokenizer(rawText, ",");
- List 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 recipients);
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java
index 11ca83f77..c425eb8c2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java
@@ -1,42 +1,31 @@
/**
* 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 .
*/
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 getAllContactsWithNumbers(Context context) {
- Set 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:
- *
- *
- *
Contact Lookup Key
- *
Mimetype
- *
id
- *
- *
- * 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 CREATOR = new Parcelable.Creator() {
@@ -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 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 wrap = new ArrayList();
- wrap.add(result);
-
- ArrayListCursor translated = new ArrayListCursor(PROJECTION_PHONE, wrap);
-
- return new MergeCursor(new Cursor[] { translated, phoneCursor });
- } else {
- return phoneCursor;
- }
- }
-
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java
index 5d79b0cd8..973335d03 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsSyncAdapter.java
@@ -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 allSystemNumbers = ContactAccessor.getInstance().getAllContactsWithNumbers(context);
- Set knownSystemNumbers = SignalDatabase.recipients().getAllPhoneNumbers();
- Set unknownSystemNumbers = SetUtil.difference(allSystemNumbers, knownSystemNumbers);
+ Set allSystemE164s = SystemContactsRepository.getAllDisplayNumbers(context)
+ .stream()
+ .map(number -> PhoneNumberFormatter.get(context).format(number))
+ .collect(Collectors.toSet());
+ Set knownSystemE164s = SignalDatabase.recipients().getAllE164s();
+ Set 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 recipients = Stream.of(unknownSystemNumbers)
+ } else if (unknownSystemE164s.size() > 0) {
+ Log.i(TAG, "There are " + unknownSystemE164s.size() + " unknown contacts. Doing an individual sync.");
+ List recipients = Stream.of(unknownSystemE164s)
.filter(s -> s.startsWith("+"))
.map(s -> Recipient.external(getContext(), s))
.toList();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsAdapter.java
deleted file mode 100644
index 2470b3829..000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsAdapter.java
+++ /dev/null
@@ -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;
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsEditor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsEditor.java
deleted file mode 100644
index 9aa3c5103..000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/RecipientsEditor.java
+++ /dev/null
@@ -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 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 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 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
- * cursor within text.
- * 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 cursor within text.
- * 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 text, 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 getNumbers() {
- Spanned sp = mList.getText();
- int len = sp.length();
- List list = new ArrayList();
-
- 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;
- }
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt
index 4e425371a..f963e7e9c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt
@@ -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
+ )
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactHolder.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactHolder.java
index 9af2cb316..d869932ca 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactHolder.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactHolder.java
@@ -17,21 +17,12 @@ final class ContactHolder {
private static final String TAG = Log.tag(ContactHolder.class);
- private final String lookupKey;
private final List 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 phoneNumberRecords) {
+ this.phoneNumberRecords.addAll(phoneNumberRecords);
}
public void setStructuredNameRecord(@NonNull StructuredNameRecord structuredNameRecord) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java
index baa7a6b91..107e39961 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java
@@ -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 databaseNumbers = sanitizeNumbers(recipientDatabase.getAllPhoneNumbers());
- Set systemNumbers = sanitizeNumbers(ContactAccessor.getInstance().getAllContactsWithNumbers(context));
+ Set databaseE164s = sanitizeNumbers(recipientDatabase.getAllE164s());
+ Set 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 activeAddresses = Stream.of(activeIds)
- .map(Recipient::resolved)
- .filter(Recipient::hasE164)
- .map(Recipient::requireE164)
- .toList();
+ Set 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 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 newUsers)
{
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/PhoneNumberRecord.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/PhoneNumberRecord.java
index 0393f8941..3edb01bad 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/PhoneNumberRecord.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/PhoneNumberRecord.java
@@ -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;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/StructuredNameRecord.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/StructuredNameRecord.java
index bfcacd2a7..a7e51b94f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/StructuredNameRecord.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/StructuredNameRecord.java
@@ -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);
- }
- }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/SystemContactsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/SystemContactsRepository.kt
deleted file mode 100644
index 75b441ace..000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/SystemContactsRepository.kt
+++ /dev/null
@@ -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 = accountManager.getAccountsByType(BuildConfig.APPLICATION_ID)
- var account: Account? = if (accounts.isNotEmpty()) accounts[0] else null
-
- if (account == null) {
- Log.i(TAG, "Attempting to create a new account...")
- val newAccount = Account(context.getString(R.string.app_name), BuildConfig.APPLICATION_ID)
-
- if (accountManager.addAccountExplicitly(newAccount, null, null)) {
- Log.i(TAG, "Successfully created a new account.")
- ContentResolver.setIsSyncable(newAccount, ContactsContract.AUTHORITY, 1)
- account = newAccount
- } else {
- Log.w(TAG, "Failed to create a new account!")
- }
- }
-
- if (account != null && !ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) {
- Log.i(TAG, "Updated account to sync automatically.")
- ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
- }
-
- return account
- }
-
- @JvmStatic
- @Synchronized
- fun removeDeletedRawContacts(context: Context, account: Account) {
- val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
- .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
- .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
- .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
- .build()
-
- val projection = arrayOf(BaseColumns._ID, ContactsContract.RawContacts.SYNC1)
-
- context.contentResolver.query(currentContactsUri, projection, "${ContactsContract.RawContacts.DELETED} = ?", SqlUtil.buildArgs(1), null)?.use { cursor ->
- while (cursor.moveToNext()) {
- val rawContactId = cursor.getLong(0)
-
- Log.i(TAG, """Deleting raw contact: ${cursor.getString(1)}, $rawContactId""")
- context.contentResolver.delete(currentContactsUri, "${ContactsContract.RawContacts._ID} = ?", arrayOf(rawContactId.toString()))
- }
- }
- }
-
- @JvmStatic
- @Synchronized
- @Throws(RemoteException::class, OperationApplicationException::class)
- fun setRegisteredUsers(
- context: Context,
- account: Account,
- registeredAddressList: List,
- remove: Boolean
- ) {
- val registeredAddressSet: Set = registeredAddressList.toSet()
- val operations: ArrayList = ArrayList()
- val currentContacts: Map = getSignalRawContacts(context, account)
-
- val registeredChunks: List> = 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 {
- val projection = arrayOf(
- ContactsContract.CommonDataKinds.Phone.NUMBER,
- ContactsContract.CommonDataKinds.Phone.TYPE,
- ContactsContract.CommonDataKinds.Phone.LABEL
- )
- val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
- val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
-
- val phoneDetails: MutableList = mutableListOf()
-
- context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
- while (cursor.moveToNext()) {
- phoneDetails += PhoneDetails(
- number = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER),
- type = cursor.requireInt(ContactsContract.CommonDataKinds.Phone.TYPE),
- label = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LABEL)
- )
- }
- }
-
- return phoneDetails
- }
-
- @JvmStatic
- fun getEmailDetails(context: Context, contactId: Long): List {
- val projection = arrayOf(
- ContactsContract.CommonDataKinds.Email.ADDRESS,
- ContactsContract.CommonDataKinds.Email.TYPE,
- ContactsContract.CommonDataKinds.Email.LABEL
- )
- val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
- val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
-
- val emailDetails: MutableList = mutableListOf()
- context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
- while (cursor.moveToNext()) {
- emailDetails += EmailDetails(
- address = cursor.requireString(ContactsContract.CommonDataKinds.Email.ADDRESS),
- type = cursor.requireInt(ContactsContract.CommonDataKinds.Email.TYPE),
- label = cursor.requireString(ContactsContract.CommonDataKinds.Email.LABEL)
- )
- }
- }
-
- return emailDetails
- }
-
- @JvmStatic
- fun getPostalAddressDetails(context: Context, contactId: Long): List {
- val projection = arrayOf(
- ContactsContract.CommonDataKinds.StructuredPostal.TYPE,
- ContactsContract.CommonDataKinds.StructuredPostal.LABEL,
- ContactsContract.CommonDataKinds.StructuredPostal.STREET,
- ContactsContract.CommonDataKinds.StructuredPostal.POBOX,
- ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD,
- ContactsContract.CommonDataKinds.StructuredPostal.CITY,
- ContactsContract.CommonDataKinds.StructuredPostal.REGION,
- ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE,
- ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY
- )
- val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
- val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE)
-
- val postalDetails: MutableList = mutableListOf()
-
- context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
- while (cursor.moveToNext()) {
- postalDetails += PostalAddressDetails(
- type = cursor.requireInt(ContactsContract.CommonDataKinds.StructuredPostal.TYPE),
- label = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.LABEL),
- street = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.STREET),
- poBox = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POBOX),
- neighborhood = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD),
- city = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.CITY),
- region = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.REGION),
- postal = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE),
- country = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY)
- )
- }
- }
-
- return postalDetails
- }
-
- @JvmStatic
- fun getAvatarUri(context: Context, contactId: Long): Uri? {
- val projection = arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_URI)
- val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
- val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
-
- context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
- if (cursor.moveToFirst()) {
- val uri = cursor.getString(0)
- if (uri != null) {
- return Uri.parse(uri)
- }
- }
- }
-
- return null
- }
-
- private fun addContactVoiceSupport(context: Context, operations: MutableList, address: String, rawContactId: Long) {
- operations.add(
- ContentProviderOperation.newUpdate(ContactsContract.RawContacts.CONTENT_URI)
- .withSelection("${ContactsContract.RawContacts._ID} = ?", arrayOf(rawContactId.toString()))
- .withValue(ContactsContract.RawContacts.SYNC4, "true")
- .build()
- )
-
- operations.add(
- ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build())
- .withValue(ContactsContract.Data.RAW_CONTACT_ID, rawContactId)
- .withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE)
- .withValue(ContactsContract.Data.DATA1, address)
- .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
- .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, address))
- .withYieldAllowed(true)
- .build()
- )
- }
-
- private fun updateDisplayName(operations: MutableList, displayName: String?, rawContactId: Long, displayNameSource: Int) {
- val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
- .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
- .build()
-
- if (displayNameSource != ContactsContract.DisplayNameSources.STRUCTURED_NAME) {
- operations.add(
- ContentProviderOperation.newInsert(dataUri)
- .withValue(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, rawContactId)
- .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
- .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
- .build()
- )
- } else {
- operations.add(
- ContentProviderOperation.newUpdate(dataUri)
- .withSelection("${ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?", SqlUtil.buildArgs(rawContactId.toString(), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE))
- .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
- .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
- .build()
- )
- }
- }
-
- private fun addTextSecureRawContact(
- context: Context,
- operations: MutableList,
- account: Account,
- e164number: String,
- displayName: String,
- aggregateId: Long
- ) {
- val index = operations.size
- val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
- .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
- .build()
-
- operations.add(
- ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
- .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
- .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
- .withValue(ContactsContract.RawContacts.SYNC1, e164number)
- .withValue(ContactsContract.RawContacts.SYNC4, true.toString())
- .build()
- )
- operations.add(
- ContentProviderOperation.newInsert(dataUri)
- .withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, index)
- .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
- .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
- .build()
- )
- operations.add(
- ContentProviderOperation.newInsert(dataUri)
- .withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, index)
- .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
- .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, e164number)
- .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_OTHER)
- .withValue(ContactsContract.Data.SYNC2, SYNC)
- .build()
- )
- operations.add(
- ContentProviderOperation.newInsert(dataUri)
- .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index)
- .withValue(ContactsContract.Data.MIMETYPE, CONTACT_MIMETYPE)
- .withValue(ContactsContract.Data.DATA1, e164number)
- .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
- .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_message_s, e164number))
- .withYieldAllowed(true)
- .build()
- )
- operations.add(
- ContentProviderOperation.newInsert(dataUri)
- .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index)
- .withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE)
- .withValue(ContactsContract.Data.DATA1, e164number)
- .withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
- .withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, e164number))
- .withYieldAllowed(true)
- .build()
- )
- operations.add(
- ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI)
- .withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, aggregateId)
- .withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, index)
- .withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER)
- .build()
- )
- }
-
- private fun removeTextSecureRawContact(operations: MutableList, account: Account, rowId: Long) {
- operations.add(
- ContentProviderOperation.newDelete(
- ContactsContract.RawContacts.CONTENT_URI.buildUpon()
- .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
- .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
- .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()
- )
- .withYieldAllowed(true)
- .withSelection("${BaseColumns._ID} = ?", SqlUtil.buildArgs(rowId))
- .build()
- )
- }
-
- private fun getSignalRawContacts(context: Context, account: Account): Map {
- val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
- .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
- .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build()
- val projection = arrayOf(BaseColumns._ID, ContactsContract.RawContacts.SYNC1, ContactsContract.RawContacts.SYNC4, ContactsContract.RawContacts.CONTACT_ID, ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY, ContactsContract.RawContacts.DISPLAY_NAME_SOURCE)
-
- val signalContacts: MutableMap = HashMap()
-
- context.contentResolver.query(currentContactsUri, projection, null, null, null)?.use { cursor ->
- while (cursor.moveToNext()) {
- val currentAddress = PhoneNumberFormatter.get(context).format(cursor.getString(1))
-
- signalContacts[currentAddress] = SignalContact(
- id = cursor.getLong(0),
- supportsVoice = cursor.getString(2),
- rawDisplayName = cursor.getString(4),
- aggregateDisplayName = getDisplayName(context, cursor.getLong(3)),
- displayNameSource = cursor.getInt(5)
- )
- }
- }
-
- return signalContacts
- }
-
- private fun getSystemContactInfo(context: Context, address: String): SystemContactInfo? {
- val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address))
- val projection = arrayOf(
- ContactsContract.PhoneLookup.NUMBER,
- ContactsContract.PhoneLookup._ID,
- ContactsContract.PhoneLookup.DISPLAY_NAME
- )
-
- context.contentResolver.query(uri, projection, null, null, null)?.use { numberCursor ->
- while (numberCursor.moveToNext()) {
- val systemNumber = numberCursor.getString(0)
- val systemAddress = PhoneNumberFormatter.get(context).format(systemNumber)
- if (systemAddress == address) {
- context.contentResolver.query(ContactsContract.RawContacts.CONTENT_URI, arrayOf(ContactsContract.RawContacts._ID), "${ContactsContract.RawContacts.CONTACT_ID} = ? ", SqlUtil.buildArgs(numberCursor.getLong(1)), null)?.use { idCursor ->
- if (idCursor.moveToNext()) {
- return SystemContactInfo(
- name = numberCursor.getString(2),
- number = numberCursor.getString(0),
- id = idCursor.getLong(0)
- )
- }
- }
- }
- }
- }
-
- return null
- }
-
- private fun getDisplayName(context: Context, contactId: Long): String? {
- val projection = arrayOf(ContactsContract.Contacts.DISPLAY_NAME)
- val selection = "${ContactsContract.Contacts._ID} = ?"
- val args = SqlUtil.buildArgs(contactId)
-
- context.contentResolver.query(ContactsContract.Contacts.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
- if (cursor.moveToFirst()) {
- return cursor.getString(0)
- }
- }
-
- return null
- }
-
- @Throws(OperationApplicationException::class, RemoteException::class)
- private fun applyOperationsInBatches(
- contentResolver: ContentResolver,
- authority: String,
- operations: List,
- batchSize: Int
- ) {
- val batches = 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?
- )
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java
index 6d8adf382..9c0a78f8d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SharedContactRepository.java
@@ -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;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt
index 0a511a2fc..aaa893c8c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt
@@ -1901,7 +1901,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
}
}
- fun getAllPhoneNumbers(): Set {
+ fun getAllE164s(): Set {
val results: MutableSet = HashSet()
readableDatabase.query(TABLE_NAME, arrayOf(PHONE), null, null, null, null, null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java b/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java
index 93b0d99bb..0c5897918 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java
@@ -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();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java
index 172ab8476..c01f9f6c7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java
@@ -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;
}
diff --git a/app/src/main/res/layout/push_recipients_panel.xml b/app/src/main/res/layout/push_recipients_panel.xml
deleted file mode 100644
index 76b6e9245..000000000
--- a/app/src/main/res/layout/push_recipients_panel.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/contacts/app/build.gradle b/contacts/app/build.gradle
new file mode 100644
index 000000000..3fa1875fe
--- /dev/null
+++ b/contacts/app/build.gradle
@@ -0,0 +1,50 @@
+plugins {
+ id 'com.android.application'
+ id 'kotlin-android'
+ id 'org.jlleitschuh.gradle.ktlint'
+}
+
+android {
+ buildToolsVersion BUILD_TOOL_VERSION
+ compileSdkVersion COMPILE_SDK
+
+ defaultConfig {
+ applicationId "org.signal.contactstest"
+ versionCode 1
+ versionName "1.0"
+
+ minSdkVersion 19
+ targetSdkVersion TARGET_SDK
+ multiDexEnabled true
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+
+ compileOptions {
+ coreLibraryDesugaringEnabled true
+ sourceCompatibility JAVA_VERSION
+ targetCompatibility JAVA_VERSION
+ }
+}
+
+ktlint {
+ // Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507
+ version = "0.43.2"
+}
+
+dependencies {
+ coreLibraryDesugaring libs.android.tools.desugar
+
+ implementation "androidx.activity:activity-ktx:1.2.2"
+
+ implementation libs.androidx.appcompat
+ implementation libs.material.material
+ implementation libs.androidx.constraintlayout
+
+ testImplementation testLibs.junit.junit
+
+ implementation project(':contacts')
+ implementation project(':core-util')
+}
\ No newline at end of file
diff --git a/contacts/app/src/main/AndroidManifest.xml b/contacts/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..cbc7ad496
--- /dev/null
+++ b/contacts/app/src/main/AndroidManifest.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/contacts/app/src/main/java/org/signal/contactstest/AccountAuthenticatorService.kt b/contacts/app/src/main/java/org/signal/contactstest/AccountAuthenticatorService.kt
new file mode 100644
index 000000000..d40d623dd
--- /dev/null
+++ b/contacts/app/src/main/java/org/signal/contactstest/AccountAuthenticatorService.kt
@@ -0,0 +1,63 @@
+package org.signal.contactstest
+
+import android.accounts.AbstractAccountAuthenticator
+import android.accounts.Account
+import android.accounts.AccountAuthenticatorResponse
+import android.accounts.AccountManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.os.IBinder
+
+class AccountAuthenticatorService : Service() {
+ companion object {
+ private var accountAuthenticator: AccountAuthenticatorImpl? = null
+ }
+
+ override fun onBind(intent: Intent): IBinder? {
+ return if (intent.action == AccountManager.ACTION_AUTHENTICATOR_INTENT) {
+ getOrCreateAuthenticator().iBinder
+ } else {
+ null
+ }
+ }
+
+ @Synchronized
+ private fun getOrCreateAuthenticator(): AccountAuthenticatorImpl {
+ if (accountAuthenticator == null) {
+ accountAuthenticator = AccountAuthenticatorImpl(this)
+ }
+ return accountAuthenticator as AccountAuthenticatorImpl
+ }
+
+ private class AccountAuthenticatorImpl(context: Context) : AbstractAccountAuthenticator(context) {
+ override fun addAccount(response: AccountAuthenticatorResponse, accountType: String, authTokenType: String, requiredFeatures: Array, options: Bundle): Bundle? {
+ return null
+ }
+
+ override fun confirmCredentials(response: AccountAuthenticatorResponse, account: Account, options: Bundle): Bundle? {
+ return null
+ }
+
+ override fun editProperties(response: AccountAuthenticatorResponse, accountType: String): Bundle? {
+ return null
+ }
+
+ override fun getAuthToken(response: AccountAuthenticatorResponse, account: Account, authTokenType: String, options: Bundle): Bundle? {
+ return null
+ }
+
+ override fun getAuthTokenLabel(authTokenType: String): String? {
+ return null
+ }
+
+ override fun hasFeatures(response: AccountAuthenticatorResponse, account: Account, features: Array): Bundle? {
+ return null
+ }
+
+ override fun updateCredentials(response: AccountAuthenticatorResponse, account: Account, authTokenType: String, options: Bundle): Bundle? {
+ return null
+ }
+ }
+}
diff --git a/contacts/app/src/main/java/org/signal/contactstest/ContactsActivity.kt b/contacts/app/src/main/java/org/signal/contactstest/ContactsActivity.kt
new file mode 100644
index 000000000..3dec95968
--- /dev/null
+++ b/contacts/app/src/main/java/org/signal/contactstest/ContactsActivity.kt
@@ -0,0 +1,117 @@
+package org.signal.contactstest
+
+import android.content.Intent
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.os.Bundle
+import android.provider.ContactsContract
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import org.signal.contacts.SystemContactsRepository.ContactDetails
+import org.signal.contacts.SystemContactsRepository.ContactPhoneDetails
+
+class ContactsActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(R.layout.activity_contacts)
+
+ val list: RecyclerView = findViewById(R.id.list)
+ val adapter = ContactsAdapter()
+
+ list.layoutManager = LinearLayoutManager(this)
+ list.adapter = adapter
+
+ val viewModel: ContactsViewModel by viewModels()
+ viewModel.contacts.observe(this) { adapter.submitList(it) }
+ }
+
+ private inner class ContactsAdapter : ListAdapter(object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: ContactDetails, newItem: ContactDetails): Boolean {
+ return oldItem == newItem
+ }
+
+ override fun areContentsTheSame(oldItem: ContactDetails, newItem: ContactDetails): Boolean {
+ return oldItem == newItem
+ }
+ }) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactViewHolder {
+ return ContactViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.parent_item, parent, false))
+ }
+
+ override fun onBindViewHolder(holder: ContactViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+ }
+
+ private inner class ContactViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val givenName: TextView = itemView.findViewById(R.id.given_name)
+ val familyName: TextView = itemView.findViewById(R.id.family_name)
+ val phoneAdapter: PhoneAdapter = PhoneAdapter()
+ val phoneList: RecyclerView = itemView.findViewById(R.id.phone_list).apply {
+ layoutManager = LinearLayoutManager(itemView.context)
+ adapter = phoneAdapter
+ }
+
+ fun bind(contact: ContactDetails) {
+ givenName.text = "Given Name: ${contact.givenName}"
+ familyName.text = "Family Name: ${contact.familyName}"
+ phoneAdapter.submitList(contact.numbers)
+ }
+ }
+
+ private inner class PhoneAdapter : ListAdapter(object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: ContactPhoneDetails, newItem: ContactPhoneDetails): Boolean {
+ return oldItem == newItem
+ }
+
+ override fun areContentsTheSame(oldItem: ContactPhoneDetails, newItem: ContactPhoneDetails): Boolean {
+ return oldItem == newItem
+ }
+ }) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhoneViewHolder {
+ return PhoneViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.child_item, parent, false))
+ }
+
+ override fun onBindViewHolder(holder: PhoneViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+ }
+
+ private inner class PhoneViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val photo: ImageView = itemView.findViewById(R.id.contact_photo)
+ val displayName: TextView = itemView.findViewById(R.id.display_name)
+ val number: TextView = itemView.findViewById(R.id.number)
+ val type: TextView = itemView.findViewById(R.id.type)
+ val goButton: View = itemView.findViewById(R.id.go_button)
+
+ fun bind(details: ContactPhoneDetails) {
+ if (details.photoUri != null) {
+ photo.setImageBitmap(BitmapFactory.decodeStream(itemView.context.contentResolver.openInputStream(Uri.parse(details.photoUri))))
+ } else {
+ photo.setImageBitmap(null)
+ }
+ displayName.text = details.displayName
+ number.text = details.number
+ type.text = ContactsContract.CommonDataKinds.Phone.getTypeLabel(itemView.resources, details.type, details.label)
+ goButton.setOnClickListener {
+ startActivity(
+ Intent(Intent.ACTION_VIEW).apply {
+ data = details.contactUri
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/contacts/app/src/main/java/org/signal/contactstest/ContactsViewModel.kt b/contacts/app/src/main/java/org/signal/contactstest/ContactsViewModel.kt
new file mode 100644
index 000000000..b46018a0e
--- /dev/null
+++ b/contacts/app/src/main/java/org/signal/contactstest/ContactsViewModel.kt
@@ -0,0 +1,52 @@
+package org.signal.contactstest
+
+import android.accounts.Account
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import org.signal.contacts.SystemContactsRepository
+import org.signal.contacts.SystemContactsRepository.ContactDetails
+import org.signal.contacts.SystemContactsRepository.ContactIterator
+import org.signal.core.util.concurrent.SignalExecutors
+import org.signal.core.util.logging.Log
+
+class ContactsViewModel(application: Application) : AndroidViewModel(application) {
+
+ companion object {
+ private val TAG = Log.tag(ContactsViewModel::class.java)
+ }
+
+ private val _contacts: MutableLiveData> = MutableLiveData()
+
+ val contacts: LiveData>
+ get() = _contacts
+
+ init {
+ SignalExecutors.BOUNDED.execute {
+ val account: Account? = SystemContactsRepository.getOrCreateSystemAccount(
+ context = application,
+ applicationId = BuildConfig.APPLICATION_ID,
+ accountDisplayName = "Test"
+ )
+
+ if (account != null) {
+ val contactList: List = SystemContactsRepository.getAllSystemContacts(
+ context = application,
+ rewrites = emptyMap(),
+ e164Formatter = { number -> number }
+ ).use { it.toList() }
+
+ _contacts.postValue(contactList)
+ } else {
+ Log.w(TAG, "Failed to create an account!")
+ }
+ }
+ }
+
+ private fun ContactIterator.toList(): List {
+ val list: MutableList = mutableListOf()
+ forEach { list += it }
+ return list
+ }
+}
diff --git a/contacts/app/src/main/java/org/signal/contactstest/MainActivity.kt b/contacts/app/src/main/java/org/signal/contactstest/MainActivity.kt
new file mode 100644
index 000000000..d14fe6535
--- /dev/null
+++ b/contacts/app/src/main/java/org/signal/contactstest/MainActivity.kt
@@ -0,0 +1,51 @@
+package org.signal.contactstest
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.widget.Button
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import org.signal.core.util.logging.Log
+
+class MainActivity : AppCompatActivity() {
+
+ companion object {
+ private val TAG = Log.tag(MainActivity::class.java)
+ private const val PERMISSION_CODE = 7
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(R.layout.activity_main)
+
+ if (hasPermission(Manifest.permission.READ_CONTACTS) && hasPermission(Manifest.permission.WRITE_CONTACTS)) {
+ Log.i(TAG, "Already have permission.")
+ startActivity(Intent(this, ContactsActivity::class.java))
+ finish()
+ return
+ }
+
+ findViewById