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: - * - *

    - *
  1. Contact Lookup Key
  2. - *
  3. Mimetype
  4. - *
  5. id
  6. - *
- * - * 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