Move system contact interactions into their own module.

fork-5.53.8
Greyson Parrelli 2022-03-24 12:47:27 -04:00
rodzic fd930d0b1d
commit dddf830e47
52 zmienionych plików z 1924 dodań i 1640 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -1,174 +0,0 @@
/**
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.AdapterView;
import android.widget.RelativeLayout;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.RecipientsAdapter;
import org.thoughtcrime.securesms.contacts.RecipientsEditor;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import java.util.LinkedList;
import java.util.List;
import java.util.StringTokenizer;
/**
* Panel component combining both an editable field with a button for
* a list-based contact selector.
*
* @author Moxie Marlinspike
*/
public class PushRecipientsPanel extends RelativeLayout implements RecipientForeverObserver {
private final String TAG = Log.tag(PushRecipientsPanel.class);
private RecipientsPanelChangedListener panelChangeListener;
private RecipientsEditor recipientsText;
private View panel;
private static final int RECIPIENTS_MAX_LENGTH = 312;
public PushRecipientsPanel(Context context) {
super(context);
initialize();
}
public PushRecipientsPanel(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public PushRecipientsPanel(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initialize();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
Stream.of(getRecipients()).map(Recipient::live).forEach(r -> r.removeForeverObserver(this));
}
public List<Recipient> getRecipients() {
String rawText = recipientsText.getText().toString();
return getRecipientsFromString(getContext(), rawText);
}
public void disable() {
recipientsText.setText("");
panel.setVisibility(View.GONE);
}
public void setPanelChangeListener(RecipientsPanelChangedListener panelChangeListener) {
this.panelChangeListener = panelChangeListener;
}
private void initialize() {
LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
inflater.inflate(R.layout.push_recipients_panel, this, true);
View imageButton = findViewById(R.id.contacts_button);
((MarginLayoutParams) imageButton.getLayoutParams()).topMargin = 0;
panel = findViewById(R.id.recipients_panel);
initRecipientsEditor();
}
private void initRecipientsEditor() {
this.recipientsText = (RecipientsEditor)findViewById(R.id.recipients_text);
List<Recipient> recipients = getRecipients();
Stream.of(recipients).map(Recipient::live).forEach(r -> r.observeForever(this));
recipientsText.setAdapter(new RecipientsAdapter(this.getContext()));
recipientsText.populate(recipients);
recipientsText.setOnFocusChangeListener(new FocusChangedListener());
recipientsText.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
if (panelChangeListener != null) {
panelChangeListener.onRecipientsPanelUpdate(getRecipients());
}
recipientsText.setText("");
}
});
}
private @NonNull List<Recipient> getRecipientsFromString(Context context, @NonNull String rawText) {
StringTokenizer tokenizer = new StringTokenizer(rawText, ",");
List<Recipient> recipients = new LinkedList<>();
while (tokenizer.hasMoreTokens()) {
String token = tokenizer.nextToken().trim();
if (!TextUtils.isEmpty(token)) {
if (hasBracketedNumber(token)) recipients.add(Recipient.external(context, parseBracketedNumber(token)));
else recipients.add(Recipient.external(context, token));
}
}
return recipients;
}
private boolean hasBracketedNumber(String recipient) {
int openBracketIndex = recipient.indexOf('<');
return (openBracketIndex != -1) &&
(recipient.indexOf('>', openBracketIndex) != -1);
}
private String parseBracketedNumber(String recipient) {
int begin = recipient.indexOf('<');
int end = recipient.indexOf('>', begin);
String value = recipient.substring(begin + 1, end);
return value;
}
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
recipientsText.populate(getRecipients());
}
private class FocusChangedListener implements View.OnFocusChangeListener {
public void onFocusChange(View v, boolean hasFocus) {
if (!hasFocus && (panelChangeListener != null)) {
panelChangeListener.onRecipientsPanelUpdate(getRecipients());
}
}
}
public interface RecipientsPanelChangedListener {
public void onRecipientsPanelUpdate(List<Recipient> recipients);
}
}

Wyświetl plik

@ -1,42 +1,31 @@
/**
* Copyright (C) 2011 Whisper Systems
*
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.contacts;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.database.MergeCursor;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.Contacts;
import android.telephony.PhoneNumberUtils;
import android.text.TextUtils;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.signal.core.util.SqlUtil;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
/**
* This class was originally a layer of indirection between
@ -52,102 +41,26 @@ import java.util.Set;
public class ContactAccessor {
public static final String PUSH_COLUMN = "push";
private static final String GIVEN_NAME = ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME;
private static final String FAMILY_NAME = ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME;
private static final ContactAccessor instance = new ContactAccessor();
public static synchronized ContactAccessor getInstance() {
public static ContactAccessor getInstance() {
return instance;
}
public Set<String> getAllContactsWithNumbers(Context context) {
Set<String> results = new HashSet<>();
try (Cursor cursor = context.getContentResolver().query(Phone.CONTENT_URI, new String[] {Phone.NUMBER}, null ,null, null)) {
while (cursor != null && cursor.moveToNext()) {
if (!TextUtils.isEmpty(cursor.getString(0))) {
results.add(PhoneNumberFormatter.get(context).format(cursor.getString(0)));
}
}
}
return results;
}
/**
* Gets and returns a cursor of data for all contacts, containing both phone number data and
* structured name data.
*
* Cursor rows are ordered as follows:
*
* <ol>
* <li>Contact Lookup Key</li>
* <li>Mimetype</li>
* <li>id</li>
* </ol>
*
* The lookup key is a fixed value that allows you to verify two rows in the database actually
* belong to the same contact, since the contact uri can be unstable (if a sync fails, say.)
*
* We order by id explicitly here for the same contact sync failure error, which could result in
* multiple structured name rows for the same user. By ordering by id DESC, we ensure we get
* whatever the latest input data was.
*
* What this results in is a cursor that looks like:
*
* Alice phone 1
* Alice phone 2
* Alice structured name 2
* Alice structured name 1
* Bob phone 1
* ... etc.
*/
public Cursor getAllSystemContacts(Context context) {
Uri uri = ContactsContract.Data.CONTENT_URI;
String[] projection = SqlUtil.buildArgs(ContactsContract.Data.MIMETYPE, Phone.NUMBER, Phone.DISPLAY_NAME, Phone.LABEL, Phone.PHOTO_URI, Phone._ID, Phone.LOOKUP_KEY, Phone.TYPE, GIVEN_NAME, FAMILY_NAME);
String where = ContactsContract.Data.MIMETYPE + " IN (?, ?)";
String[] args = SqlUtil.buildArgs(Phone.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE);
String orderBy = Phone.LOOKUP_KEY + " ASC, " + ContactsContract.Data.MIMETYPE + " DESC, " + ContactsContract.CommonDataKinds.Phone._ID + " DESC";
return context.getContentResolver().query(uri, projection, where, args, orderBy);
}
public String getNameFromContact(Context context, Uri uri) {
Cursor cursor = null;
try {
cursor = context.getContentResolver().query(uri, new String[] {Contacts.DISPLAY_NAME},
null, null, null);
if (cursor != null && cursor.moveToFirst())
return cursor.getString(0);
} finally {
if (cursor != null)
cursor.close();
}
return null;
}
public ContactData getContactData(Context context, Uri uri) {
return getContactData(context, getNameFromContact(context, uri), Long.parseLong(uri.getLastPathSegment()));
}
String displayName = getNameFromContact(context, uri);
long id = Long.parseLong(uri.getLastPathSegment());
private ContactData getContactData(Context context, String displayName, long id) {
ContactData contactData = new ContactData(id, displayName);
try (Cursor numberCursor = context.getContentResolver().query(Phone.CONTENT_URI,
null,
Phone.CONTACT_ID + " = ?",
new String[] {contactData.id + ""},
new String[] { contactData.id + "" },
null))
{
while (numberCursor != null && numberCursor.moveToNext()) {
int type = numberCursor.getInt(numberCursor.getColumnIndexOrThrow(Phone.TYPE));
int type = numberCursor.getInt(numberCursor.getColumnIndexOrThrow(Phone.TYPE));
String label = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.LABEL));
String number = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.NUMBER));
String typeLabel = Phone.getTypeLabel(context.getResources(), type, label).toString();
@ -159,10 +72,25 @@ public class ContactAccessor {
return contactData;
}
public CharSequence phoneTypeToString(Context mContext, int type, CharSequence label) {
return Phone.getTypeLabel(mContext.getResources(), type, label);
private String getNameFromContact(Context context, Uri uri) {
Cursor cursor = null;
try {
cursor = context.getContentResolver().query(uri, new String[] { Contacts.DISPLAY_NAME }, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
return cursor.getString(0);
}
} finally {
if (cursor != null)
cursor.close();
}
return null;
}
public static class NumberData implements Parcelable {
public static final Parcelable.Creator<NumberData> CREATOR = new Parcelable.Creator<NumberData>() {
@ -179,7 +107,7 @@ public class ContactAccessor {
public final String type;
public NumberData(String type, String number) {
this.type = type;
this.type = type;
this.number = number;
}
@ -210,8 +138,8 @@ public class ContactAccessor {
}
};
public final long id;
public final String name;
public final long id;
public final String name;
public final List<NumberData> numbers;
public ContactData(long id, String name) {
@ -237,83 +165,4 @@ public class ContactAccessor {
dest.writeTypedList(numbers);
}
}
/***
* If the code below looks shitty to you, that's because it was taken
* directly from the Android source, where shitty code is all you get.
*/
public Cursor getCursorForRecipientFilter(CharSequence constraint,
ContentResolver mContentResolver)
{
final String SORT_ORDER = Contacts.TIMES_CONTACTED + " DESC," +
Contacts.DISPLAY_NAME + "," +
Contacts.Data.IS_SUPER_PRIMARY + " DESC," +
Phone.TYPE;
final String[] PROJECTION_PHONE = {
Phone._ID, // 0
Phone.CONTACT_ID, // 1
Phone.TYPE, // 2
Phone.NUMBER, // 3
Phone.LABEL, // 4
Phone.DISPLAY_NAME, // 5
};
String phone = "";
String cons = null;
if (constraint != null) {
cons = constraint.toString();
if (RecipientsAdapter.usefulAsDigits(cons)) {
phone = PhoneNumberUtils.convertKeypadLettersToDigits(cons);
if (phone.equals(cons) && !PhoneNumberUtils.isWellFormedSmsAddress(phone)) {
phone = "";
} else {
phone = phone.trim();
}
}
}
Uri uri = Uri.withAppendedPath(Phone.CONTENT_FILTER_URI, Uri.encode(cons));
String selection = String.format("%s=%s OR %s=%s OR %s=%s",
Phone.TYPE,
Phone.TYPE_MOBILE,
Phone.TYPE,
Phone.TYPE_WORK_MOBILE,
Phone.TYPE,
Phone.TYPE_MMS);
Cursor phoneCursor = mContentResolver.query(uri,
PROJECTION_PHONE,
null,
null,
SORT_ORDER);
if (phone.length() > 0) {
ArrayList result = new ArrayList();
result.add(Integer.valueOf(-1)); // ID
result.add(Long.valueOf(-1)); // CONTACT_ID
result.add(Integer.valueOf(Phone.TYPE_CUSTOM)); // TYPE
result.add(phone); // NUMBER
/*
* The "\u00A0" keeps Phone.getDisplayLabel() from deciding
* to display the default label ("Home") next to the transformation
* of the letters into numbers.
*/
result.add("\u00A0"); // LABEL
result.add(cons); // NAME
ArrayList<ArrayList> wrap = new ArrayList<ArrayList>();
wrap.add(result);
ArrayListCursor translated = new ArrayListCursor(PROJECTION_PHONE, wrap);
return new MergeCursor(new Cursor[] { translated, phoneCursor });
} else {
return phoneCursor;
}
}
}

Wyświetl plik

@ -9,18 +9,21 @@ import android.os.Bundle;
import com.annimon.stream.Stream;
import org.signal.contacts.SystemContactsRepository;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.SetUtil;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
@ -51,20 +54,23 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
return;
}
Set<String> allSystemNumbers = ContactAccessor.getInstance().getAllContactsWithNumbers(context);
Set<String> knownSystemNumbers = SignalDatabase.recipients().getAllPhoneNumbers();
Set<String> unknownSystemNumbers = SetUtil.difference(allSystemNumbers, knownSystemNumbers);
Set<String> allSystemE164s = SystemContactsRepository.getAllDisplayNumbers(context)
.stream()
.map(number -> PhoneNumberFormatter.get(context).format(number))
.collect(Collectors.toSet());
Set<String> knownSystemE164s = SignalDatabase.recipients().getAllE164s();
Set<String> unknownSystemE164s = SetUtil.difference(allSystemE164s, knownSystemE164s);
if (unknownSystemNumbers.size() > FULL_SYNC_THRESHOLD) {
Log.i(TAG, "There are " + unknownSystemNumbers.size() + " unknown contacts. Doing a full sync.");
if (unknownSystemE164s.size() > FULL_SYNC_THRESHOLD) {
Log.i(TAG, "There are " + unknownSystemE164s.size() + " unknown contacts. Doing a full sync.");
try {
ContactDiscovery.refreshAll(context, true);
} catch (IOException e) {
Log.w(TAG, e);
}
} else if (unknownSystemNumbers.size() > 0) {
Log.i(TAG, "There are " + unknownSystemNumbers.size() + " unknown contacts. Doing an individual sync.");
List<Recipient> recipients = Stream.of(unknownSystemNumbers)
} else if (unknownSystemE164s.size() > 0) {
Log.i(TAG, "There are " + unknownSystemE164s.size() + " unknown contacts. Doing an individual sync.");
List<Recipient> recipients = Stream.of(unknownSystemE164s)
.filter(s -> s.startsWith("+"))
.map(s -> Recipient.external(getContext(), s))
.toList();

Wyświetl plik

@ -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;
}
}

Wyświetl plik

@ -1,419 +0,0 @@
/*
* Copyright (C) 2008 Esmertec AG.
* Copyright (C) 2008 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thoughtcrime.securesms.contacts;
import android.content.Context;
import android.telephony.PhoneNumberUtils;
import android.text.Annotation;
import android.text.Editable;
import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.AttributeSet;
import android.view.ContextMenu.ContextMenuInfo;
import android.view.MotionEvent;
import android.view.inputmethod.EditorInfo;
import android.widget.MultiAutoCompleteTextView;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientsFormatter;
import org.whispersystems.signalservice.api.util.OptionalUtil;
import java.util.ArrayList;
import java.util.List;
/**
* Provide UI for editing the recipients of multi-media messages.
*/
public class RecipientsEditor extends AppCompatMultiAutoCompleteTextView {
private int mLongPressedPosition = -1;
private final RecipientsEditorTokenizer mTokenizer;
private char mLastSeparator = ',';
private Context mContext;
public RecipientsEditor(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
mTokenizer = new RecipientsEditorTokenizer(context, this);
setTokenizer(mTokenizer);
// For the focus to move to the message body when soft Next is pressed
setImeOptions(EditorInfo.IME_ACTION_NEXT);
/*
* The point of this TextWatcher is that when the user chooses
* an address completion from the AutoCompleteTextView menu, it
* is marked up with Annotation objects to tie it back to the
* address book entry that it came from. If the user then goes
* back and edits that part of the text, it no longer corresponds
* to that address book entry and needs to have the Annotations
* claiming that it does removed.
*/
addTextChangedListener(new TextWatcher() {
private Annotation[] mAffected;
public void beforeTextChanged(CharSequence s, int start,
int count, int after) {
mAffected = ((Spanned) s).getSpans(start, start + count,
Annotation.class);
}
public void onTextChanged(CharSequence s, int start,
int before, int after) {
if (before == 0 && after == 1) { // inserting a character
char c = s.charAt(start);
if (c == ',' || c == ';') {
// Remember the delimiter the user typed to end this recipient. We'll
// need it shortly in terminateToken().
mLastSeparator = c;
}
}
}
public void afterTextChanged(Editable s) {
if (mAffected != null) {
for (Annotation a : mAffected) {
s.removeSpan(a);
}
}
mAffected = null;
}
});
}
@Override
public boolean enoughToFilter() {
if (!super.enoughToFilter()) {
return false;
}
// If the user is in the middle of editing an existing recipient, don't offer the
// auto-complete menu. Without this, when the user selects an auto-complete menu item,
// it will get added to the list of recipients so we end up with the old before-editing
// recipient and the new post-editing recipient. As a precedent, gmail does not show
// the auto-complete menu when editing an existing recipient.
int end = getSelectionEnd();
int len = getText().length();
return end == len;
}
public int getRecipientCount() {
return mTokenizer.getNumbers().size();
}
public List<String> getNumbers() {
return mTokenizer.getNumbers();
}
// public Recipients constructContactsFromInput() {
// return RecipientFactory.getRecipientsFromString(mContext, mTokenizer.getRawString(), false);
// }
private boolean isValidAddress(String number, boolean isMms) {
/*if (isMms) {
return MessageUtils.isValidMmsAddress(number);
} else {*/
// TODO: PhoneNumberUtils.isWellFormedSmsAddress() only check if the number is a valid
// GSM SMS address. If the address contains a dialable char, it considers it a well
// formed SMS addr. CDMA doesn't work that way and has a different parser for SMS
// address (see CdmaSmsAddress.parse(String address)). We should definitely fix this!!!
return PhoneNumberUtils.isWellFormedSmsAddress(number);
}
public boolean hasValidRecipient(boolean isMms) {
for (String number : mTokenizer.getNumbers()) {
if (isValidAddress(number, isMms))
return true;
}
return false;
}
/*public boolean hasInvalidRecipient(boolean isMms) {
for (String number : mTokenizer.getNumbers()) {
if (!isValidAddress(number, isMms)) {
/* TODO if (MmsConfig.getEmailGateway() == null) {
return true;
} else if (!MessageUtils.isAlias(number)) {
return true;
}
}
}
return false;
}*/
public String formatInvalidNumbers(boolean isMms) {
StringBuilder sb = new StringBuilder();
for (String number : mTokenizer.getNumbers()) {
if (!isValidAddress(number, isMms)) {
if (sb.length() != 0) {
sb.append(", ");
}
sb.append(number);
}
}
return sb.toString();
}
/*public boolean containsEmail() {
if (TextUtils.indexOf(getText(), '@') == -1)
return false;
List<String> numbers = mTokenizer.getNumbers();
for (String number : numbers) {
if (Mms.isEmailAddress(number))
return true;
}
return false;
}*/
public static CharSequence contactToToken(@NonNull Context context, @NonNull Recipient c) {
String name = c.getDisplayName(context);
String number = OptionalUtil.or(c.getE164(), c.getEmail()).orElse("");
SpannableString s = new SpannableString(RecipientsFormatter.formatNameAndNumber(name, number));
int len = s.length();
if (len == 0) {
return s;
}
s.setSpan(new Annotation("number", number), 0, len,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return s;
}
public void populate(List<Recipient> list) {
SpannableStringBuilder sb = new SpannableStringBuilder();
for (Recipient c : list) {
if (sb.length() != 0) {
sb.append(", ");
}
sb.append(contactToToken(mContext, c));
}
setText(sb);
}
private int pointToPosition(int x, int y) {
x -= getCompoundPaddingLeft();
y -= getExtendedPaddingTop();
x += getScrollX();
y += getScrollY();
Layout layout = getLayout();
if (layout == null) {
return -1;
}
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
return off;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
final int action = ev.getAction();
final int x = (int) ev.getX();
final int y = (int) ev.getY();
if (action == MotionEvent.ACTION_DOWN) {
mLongPressedPosition = pointToPosition(x, y);
}
return super.onTouchEvent(ev);
}
private static String getNumberAt(Spanned sp, int start, int end, Context context) {
return getFieldAt("number", sp, start, end, context);
}
private static int getSpanLength(Spanned sp, int start, int end, Context context) {
// TODO: there's a situation where the span can lose its annotations:
// - add an auto-complete contact
// - add another auto-complete contact
// - delete that second contact and keep deleting into the first
// - we lose the annotation and can no longer get the span.
// Need to fix this case because it breaks auto-complete contacts with commas in the name.
Annotation[] a = sp.getSpans(start, end, Annotation.class);
if (a.length > 0) {
return sp.getSpanEnd(a[0]);
}
return 0;
}
private static String getFieldAt(String field, Spanned sp, int start, int end,
Context context) {
Annotation[] a = sp.getSpans(start, end, Annotation.class);
String fieldValue = getAnnotation(a, field);
if (TextUtils.isEmpty(fieldValue)) {
fieldValue = TextUtils.substring(sp, start, end);
}
return fieldValue;
}
private static String getAnnotation(Annotation[] a, String key) {
for (int i = 0; i < a.length; i++) {
if (a[i].getKey().equals(key)) {
return a[i].getValue();
}
}
return "";
}
private class RecipientsEditorTokenizer
implements MultiAutoCompleteTextView.Tokenizer {
private final MultiAutoCompleteTextView mList;
private final Context mContext;
RecipientsEditorTokenizer(Context context, MultiAutoCompleteTextView list) {
mList = list;
mContext = context;
}
/**
* Returns the start of the token that ends at offset
* <code>cursor</code> within <code>text</code>.
* It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
*/
public int findTokenStart(CharSequence text, int cursor) {
int i = cursor;
char c;
while (i > 0 && (c = text.charAt(i - 1)) != ',' && c != ';') {
i--;
}
while (i < cursor && text.charAt(i) == ' ') {
i++;
}
return i;
}
/**
* Returns the end of the token (minus trailing punctuation)
* that begins at offset <code>cursor</code> within <code>text</code>.
* It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
*/
public int findTokenEnd(CharSequence text, int cursor) {
int i = cursor;
int len = text.length();
char c;
while (i < len) {
if ((c = text.charAt(i)) == ',' || c == ';') {
return i;
} else {
i++;
}
}
return len;
}
/**
* Returns <code>text</code>, modified, if necessary, to ensure that
* it ends with a token terminator (for example a space or comma).
* It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
*/
public CharSequence terminateToken(CharSequence text) {
int i = text.length();
while (i > 0 && text.charAt(i - 1) == ' ') {
i--;
}
char c;
if (i > 0 && ((c = text.charAt(i - 1)) == ',' || c == ';')) {
return text;
} else {
// Use the same delimiter the user just typed.
// This lets them have a mixture of commas and semicolons in their list.
String separator = mLastSeparator + " ";
if (text instanceof Spanned) {
SpannableString sp = new SpannableString(text + separator);
TextUtils.copySpansFrom((Spanned) text, 0, text.length(),
Object.class, sp, 0);
return sp;
} else {
return text + separator;
}
}
}
public String getRawString() {
return mList.getText().toString();
}
public List<String> getNumbers() {
Spanned sp = mList.getText();
int len = sp.length();
List<String> list = new ArrayList<String>();
int start = 0;
int i = 0;
while (i < len + 1) {
char c;
if ((i == len) || ((c = sp.charAt(i)) == ',') || (c == ';')) {
if (i > start) {
list.add(getNumberAt(sp, start, i, mContext));
// calculate the recipients total length. This is so if the name contains
// commas or semis, we'll skip over the whole name to the next
// recipient, rather than parsing this single name into multiple
// recipients.
int spanLen = getSpanLength(sp, start, i, mContext);
if (spanLen > i) {
i = spanLen;
}
}
i++;
while ((i < len) && (sp.charAt(i) == ' ')) {
i++;
}
start = i;
} else {
i++;
}
}
return list;
}
}
static class RecipientContextMenuInfo implements ContextMenuInfo {
final Recipient recipient;
RecipientContextMenuInfo(Recipient r) {
recipient = r;
}
}
}

Wyświetl plik

@ -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
)
}
}

Wyświetl plik

@ -17,21 +17,12 @@ final class ContactHolder {
private static final String TAG = Log.tag(ContactHolder.class);
private final String lookupKey;
private final List<PhoneNumberRecord> phoneNumberRecords = new LinkedList<>();
private StructuredNameRecord structuredNameRecord;
ContactHolder(@NonNull String lookupKey) {
this.lookupKey = lookupKey;
}
@NonNull String getLookupKey() {
return lookupKey;
}
public void addPhoneNumberRecord(@NonNull PhoneNumberRecord phoneNumberRecord) {
phoneNumberRecords.add(phoneNumberRecord);
public void addPhoneNumberRecords(@NonNull List<PhoneNumberRecord> phoneNumberRecords) {
this.phoneNumberRecords.addAll(phoneNumberRecords);
}
public void setStructuredNameRecord(@NonNull StructuredNameRecord structuredNameRecord) {

Wyświetl plik

@ -4,20 +4,20 @@ import android.Manifest;
import android.accounts.Account;
import android.content.Context;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.os.RemoteException;
import android.provider.ContactsContract;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.signal.contacts.SystemContactsRepository;
import org.signal.contacts.SystemContactsRepository.ContactDetails;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase.BulkOperationsHandle;
@ -36,13 +36,11 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.registration.RegistrationUtil;
import org.thoughtcrime.securesms.sms.IncomingJoinedMessage;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.signal.core.util.CursorUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ProfileUtil;
import org.thoughtcrime.securesms.util.SetUtil;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.SignalProtocolAddress;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
@ -50,7 +48,6 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.ACI;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
@ -60,7 +57,6 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@ -94,10 +90,12 @@ class DirectoryHelper {
}
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
Set<String> databaseNumbers = sanitizeNumbers(recipientDatabase.getAllPhoneNumbers());
Set<String> systemNumbers = sanitizeNumbers(ContactAccessor.getInstance().getAllContactsWithNumbers(context));
Set<String> databaseE164s = sanitizeNumbers(recipientDatabase.getAllE164s());
Set<String> systemE164s = sanitizeNumbers(Stream.of(SystemContactsRepository.getAllDisplayNumbers(context))
.map(number -> PhoneNumberFormatter.get(context).format(number))
.collect(Collectors.toSet()));
refreshNumbers(context, databaseNumbers, systemNumbers, notifyOfNewUsers, true);
refreshNumbers(context, databaseE164s, systemE164s, notifyOfNewUsers, true);
StorageSyncHelper.scheduleSyncForDataChange();
}
@ -302,7 +300,10 @@ class DirectoryHelper {
return;
}
Account account = SystemContactsRepository.getOrCreateSystemAccount(context);
Stopwatch stopwatch = new Stopwatch("contacts");
Account account = SystemContactsRepository.getOrCreateSystemAccount(context, BuildConfig.APPLICATION_ID, context.getString(R.string.app_name));
stopwatch.split("account");
if (account == null) {
Log.w(TAG, "Failed to create an account!");
@ -310,16 +311,23 @@ class DirectoryHelper {
}
try {
List<String> activeAddresses = Stream.of(activeIds)
.map(Recipient::resolved)
.filter(Recipient::hasE164)
.map(Recipient::requireE164)
.toList();
Set<String> activeE164s = Stream.of(activeIds)
.map(Recipient::resolved)
.filter(Recipient::hasE164)
.map(Recipient::requireE164)
.collect(Collectors.toSet());
SystemContactsRepository.removeDeletedRawContacts(context, account);
SystemContactsRepository.setRegisteredUsers(context, account, activeAddresses, removeMissing);
SystemContactsRepository.removeDeletedRawContactsForAccount(context, account);
stopwatch.split("remove-deleted");
SystemContactsRepository.addMessageAndCallLinksToContacts(context,
ContactDiscovery.buildContactLinkConfiguration(context, account),
activeE164s,
removeMissing);
stopwatch.split("add-links");
syncRecipientInfoWithSystemContacts(context, rewrites);
stopwatch.split("sync-info");
stopwatch.stop(TAG);
} catch (RemoteException | OperationApplicationException e) {
Log.w(TAG, "Failed to update contacts.", e);
}
@ -329,59 +337,25 @@ class DirectoryHelper {
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
BulkOperationsHandle handle = recipientDatabase.beginBulkSystemContactUpdate();
try (Cursor cursor = ContactAccessor.getInstance().getAllSystemContacts(context)) {
while (cursor != null && cursor.moveToNext()) {
String mimeType = getMimeType(cursor);
try (SystemContactsRepository.ContactIterator iterator = SystemContactsRepository.getAllSystemContacts(context, rewrites, (number) -> PhoneNumberFormatter.get(context).format(number))) {
while (iterator.hasNext()) {
ContactDetails contact = iterator.next();
ContactHolder holder = new ContactHolder();
StructuredNameRecord name = new StructuredNameRecord(contact.getGivenName(), contact.getFamilyName());
List<PhoneNumberRecord> phones = Stream.of(contact.getNumbers())
.map(number -> {
return new PhoneNumberRecord.Builder()
.withRecipientId(Recipient.externalContact(context, number.getNumber()).getId())
.withContactUri(number.getContactUri())
.withDisplayName(number.getDisplayName())
.withContactPhotoUri(number.getPhotoUri())
.withContactLabel(number.getLabel())
.build();
}).toList();
if (!isPhoneMimeType(mimeType)) {
continue;
}
String lookupKey = getLookupKey(cursor);
ContactHolder contactHolder = new ContactHolder(lookupKey);
while (!cursor.isAfterLast() && getLookupKey(cursor).equals(lookupKey) && isPhoneMimeType(getMimeType(cursor))) {
String number = CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.NUMBER);
if (isValidContactNumber(number)) {
String formattedNumber = PhoneNumberFormatter.get(context).format(number);
String realNumber = Util.getFirstNonEmpty(rewrites.get(formattedNumber), formattedNumber);
PhoneNumberRecord.Builder builder = new PhoneNumberRecord.Builder();
builder.withRecipientId(Recipient.externalContact(context, realNumber).getId());
builder.withDisplayName(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
builder.withContactPhotoUri(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.PHOTO_URI));
builder.withContactLabel(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.LABEL));
builder.withPhoneType(CursorUtil.requireInt(cursor, ContactsContract.CommonDataKinds.Phone.TYPE));
builder.withContactUri(ContactsContract.Contacts.getLookupUri(CursorUtil.requireLong(cursor, ContactsContract.CommonDataKinds.Phone._ID),
CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY)));
contactHolder.addPhoneNumberRecord(builder.build());
} else {
Log.w(TAG, "Skipping phone entry with invalid number");
}
cursor.moveToNext();
}
if (!cursor.isAfterLast() && getLookupKey(cursor).equals(lookupKey)) {
if (isStructuredNameMimeType(getMimeType(cursor))) {
StructuredNameRecord.Builder builder = new StructuredNameRecord.Builder();
builder.withGivenName(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME));
builder.withFamilyName(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME));
contactHolder.setStructuredNameRecord(builder.build());
} else {
Log.i(TAG, "Skipping invalid mimeType " + mimeType);
}
} else {
Log.i(TAG, "No structured name for user, rolling back cursor.");
cursor.moveToPrevious();
}
contactHolder.commit(handle);
holder.setStructuredNameRecord(name);
holder.addPhoneNumberRecords(phones);
holder.commit(handle);
}
} catch (IllegalStateException e) {
Log.w(TAG, "Hit an issue with the cursor while reading!", e);
@ -399,26 +373,6 @@ class DirectoryHelper {
}
}
private static boolean isPhoneMimeType(@NonNull String mimeType) {
return ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mimeType);
}
private static boolean isStructuredNameMimeType(@NonNull String mimeType) {
return ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE.equals(mimeType);
}
private static boolean isValidContactNumber(@Nullable String number) {
return !TextUtils.isEmpty(number) && !UuidUtil.isUuid(number);
}
private static @NonNull String getLookupKey(@NonNull Cursor cursor) {
return Objects.requireNonNull(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY));
}
private static @NonNull String getMimeType(@NonNull Cursor cursor) {
return CursorUtil.requireString(cursor, ContactsContract.Data.MIMETYPE);
}
private static void notifyNewUsers(@NonNull Context context,
@NonNull Collection<RecipientId> newUsers)
{

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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);
}
}
}

Wyświetl plik

@ -1,540 +0,0 @@
package org.thoughtcrime.securesms.contacts.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.Context
import android.content.OperationApplicationException
import android.net.Uri
import android.os.RemoteException
import android.provider.BaseColumns
import android.provider.ContactsContract
import org.signal.core.util.ListUtil
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.requireInt
import org.signal.core.util.requireString
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.util.Util
import java.util.ArrayList
import java.util.HashMap
/**
* A way to retrieve and update data in the Android system contacts.
*/
object SystemContactsRepository {
private val TAG = Log.tag(SystemContactsRepository::class.java)
private const val CONTACT_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"
private const val CALL_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call"
private const val SYNC = "__TS"
@JvmStatic
fun getOrCreateSystemAccount(context: Context): Account? {
val accountManager: AccountManager = AccountManager.get(context)
val accounts: Array<Account> = accountManager.getAccountsByType(BuildConfig.APPLICATION_ID)
var account: Account? = if (accounts.isNotEmpty()) accounts[0] else null
if (account == null) {
Log.i(TAG, "Attempting to create a new account...")
val newAccount = Account(context.getString(R.string.app_name), BuildConfig.APPLICATION_ID)
if (accountManager.addAccountExplicitly(newAccount, null, null)) {
Log.i(TAG, "Successfully created a new account.")
ContentResolver.setIsSyncable(newAccount, ContactsContract.AUTHORITY, 1)
account = newAccount
} else {
Log.w(TAG, "Failed to create a new account!")
}
}
if (account != null && !ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) {
Log.i(TAG, "Updated account to sync automatically.")
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
}
return account
}
@JvmStatic
@Synchronized
fun removeDeletedRawContacts(context: Context, account: Account) {
val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build()
val projection = arrayOf(BaseColumns._ID, ContactsContract.RawContacts.SYNC1)
context.contentResolver.query(currentContactsUri, projection, "${ContactsContract.RawContacts.DELETED} = ?", SqlUtil.buildArgs(1), null)?.use { cursor ->
while (cursor.moveToNext()) {
val rawContactId = cursor.getLong(0)
Log.i(TAG, """Deleting raw contact: ${cursor.getString(1)}, $rawContactId""")
context.contentResolver.delete(currentContactsUri, "${ContactsContract.RawContacts._ID} = ?", arrayOf(rawContactId.toString()))
}
}
}
@JvmStatic
@Synchronized
@Throws(RemoteException::class, OperationApplicationException::class)
fun setRegisteredUsers(
context: Context,
account: Account,
registeredAddressList: List<String>,
remove: Boolean
) {
val registeredAddressSet: Set<String> = registeredAddressList.toSet()
val operations: ArrayList<ContentProviderOperation> = ArrayList()
val currentContacts: Map<String, SignalContact> = getSignalRawContacts(context, account)
val registeredChunks: List<List<String>> = ListUtil.chunk(registeredAddressList, 50)
for (registeredChunk in registeredChunks) {
for (registeredAddress in registeredChunk) {
if (!currentContacts.containsKey(registeredAddress)) {
val systemContactInfo: SystemContactInfo? = getSystemContactInfo(context, registeredAddress)
if (systemContactInfo != null) {
Log.i(TAG, "Adding number: $registeredAddress")
addTextSecureRawContact(
context = context,
operations = operations,
account = account,
e164number = systemContactInfo.number,
displayName = systemContactInfo.name,
aggregateId = systemContactInfo.id
)
}
}
}
if (operations.isNotEmpty()) {
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, operations)
operations.clear()
}
}
for ((key, value) in currentContacts) {
if (!registeredAddressSet.contains(key)) {
if (remove) {
Log.i(TAG, "Removing number: $key")
removeTextSecureRawContact(operations, account, value.id)
}
} else if (!value.isVoiceSupported()) {
Log.i(TAG, "Adding voice support: $key")
addContactVoiceSupport(context, operations, key, value.id)
} else if (!Util.isStringEquals(value.rawDisplayName, value.aggregateDisplayName)) {
Log.i(TAG, "Updating display name: $key")
updateDisplayName(operations, value.aggregateDisplayName, value.id, value.displayNameSource)
}
}
if (operations.isNotEmpty()) {
applyOperationsInBatches(context.contentResolver, ContactsContract.AUTHORITY, operations, 50)
}
}
@JvmStatic
fun getNameDetails(context: Context, contactId: Long): NameDetails? {
val projection = arrayOf(
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
ContactsContract.CommonDataKinds.StructuredName.PREFIX,
ContactsContract.CommonDataKinds.StructuredName.SUFFIX,
ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
return context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
NameDetails(
displayName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME),
givenName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME),
familyName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME),
prefix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.PREFIX),
suffix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.SUFFIX),
middleName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME)
)
} else {
null
}
}
}
@JvmStatic
fun getOrganizationName(context: Context, contactId: Long): String? {
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getString(0)
}
}
return null
}
@JvmStatic
fun getPhoneDetails(context: Context, contactId: Long): List<PhoneDetails> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.TYPE,
ContactsContract.CommonDataKinds.Phone.LABEL
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
val phoneDetails: MutableList<PhoneDetails> = mutableListOf()
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
while (cursor.moveToNext()) {
phoneDetails += PhoneDetails(
number = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER),
type = cursor.requireInt(ContactsContract.CommonDataKinds.Phone.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LABEL)
)
}
}
return phoneDetails
}
@JvmStatic
fun getEmailDetails(context: Context, contactId: Long): List<EmailDetails> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.Email.ADDRESS,
ContactsContract.CommonDataKinds.Email.TYPE,
ContactsContract.CommonDataKinds.Email.LABEL
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
val emailDetails: MutableList<EmailDetails> = mutableListOf()
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
while (cursor.moveToNext()) {
emailDetails += EmailDetails(
address = cursor.requireString(ContactsContract.CommonDataKinds.Email.ADDRESS),
type = cursor.requireInt(ContactsContract.CommonDataKinds.Email.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.Email.LABEL)
)
}
}
return emailDetails
}
@JvmStatic
fun getPostalAddressDetails(context: Context, contactId: Long): List<PostalAddressDetails> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.StructuredPostal.TYPE,
ContactsContract.CommonDataKinds.StructuredPostal.LABEL,
ContactsContract.CommonDataKinds.StructuredPostal.STREET,
ContactsContract.CommonDataKinds.StructuredPostal.POBOX,
ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD,
ContactsContract.CommonDataKinds.StructuredPostal.CITY,
ContactsContract.CommonDataKinds.StructuredPostal.REGION,
ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE,
ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE)
val postalDetails: MutableList<PostalAddressDetails> = mutableListOf()
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
while (cursor.moveToNext()) {
postalDetails += PostalAddressDetails(
type = cursor.requireInt(ContactsContract.CommonDataKinds.StructuredPostal.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.LABEL),
street = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.STREET),
poBox = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POBOX),
neighborhood = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD),
city = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.CITY),
region = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.REGION),
postal = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE),
country = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY)
)
}
}
return postalDetails
}
@JvmStatic
fun getAvatarUri(context: Context, contactId: Long): Uri? {
val projection = arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_URI)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val uri = cursor.getString(0)
if (uri != null) {
return Uri.parse(uri)
}
}
}
return null
}
private fun addContactVoiceSupport(context: Context, operations: MutableList<ContentProviderOperation>, address: String, rawContactId: Long) {
operations.add(
ContentProviderOperation.newUpdate(ContactsContract.RawContacts.CONTENT_URI)
.withSelection("${ContactsContract.RawContacts._ID} = ?", arrayOf(rawContactId.toString()))
.withValue(ContactsContract.RawContacts.SYNC4, "true")
.build()
)
operations.add(
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build())
.withValue(ContactsContract.Data.RAW_CONTACT_ID, rawContactId)
.withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE)
.withValue(ContactsContract.Data.DATA1, address)
.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
.withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, address))
.withYieldAllowed(true)
.build()
)
}
private fun updateDisplayName(operations: MutableList<ContentProviderOperation>, displayName: String?, rawContactId: Long, displayNameSource: Int) {
val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build()
if (displayNameSource != ContactsContract.DisplayNameSources.STRUCTURED_NAME) {
operations.add(
ContentProviderOperation.newInsert(dataUri)
.withValue(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, rawContactId)
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.build()
)
} else {
operations.add(
ContentProviderOperation.newUpdate(dataUri)
.withSelection("${ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?", SqlUtil.buildArgs(rawContactId.toString(), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE))
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.build()
)
}
}
private fun addTextSecureRawContact(
context: Context,
operations: MutableList<ContentProviderOperation>,
account: Account,
e164number: String,
displayName: String,
aggregateId: Long
) {
val index = operations.size
val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build()
operations.add(
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.withValue(ContactsContract.RawContacts.SYNC1, e164number)
.withValue(ContactsContract.RawContacts.SYNC4, true.toString())
.build()
)
operations.add(
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, index)
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.build()
)
operations.add(
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, index)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, e164number)
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_OTHER)
.withValue(ContactsContract.Data.SYNC2, SYNC)
.build()
)
operations.add(
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index)
.withValue(ContactsContract.Data.MIMETYPE, CONTACT_MIMETYPE)
.withValue(ContactsContract.Data.DATA1, e164number)
.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
.withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_message_s, e164number))
.withYieldAllowed(true)
.build()
)
operations.add(
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index)
.withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE)
.withValue(ContactsContract.Data.DATA1, e164number)
.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
.withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, e164number))
.withYieldAllowed(true)
.build()
)
operations.add(
ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI)
.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, aggregateId)
.withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, index)
.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER)
.build()
)
}
private fun removeTextSecureRawContact(operations: MutableList<ContentProviderOperation>, account: Account, rowId: Long) {
operations.add(
ContentProviderOperation.newDelete(
ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()
)
.withYieldAllowed(true)
.withSelection("${BaseColumns._ID} = ?", SqlUtil.buildArgs(rowId))
.build()
)
}
private fun getSignalRawContacts(context: Context, account: Account): Map<String, SignalContact> {
val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build()
val projection = arrayOf(BaseColumns._ID, ContactsContract.RawContacts.SYNC1, ContactsContract.RawContacts.SYNC4, ContactsContract.RawContacts.CONTACT_ID, ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY, ContactsContract.RawContacts.DISPLAY_NAME_SOURCE)
val signalContacts: MutableMap<String, SignalContact> = HashMap()
context.contentResolver.query(currentContactsUri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val currentAddress = PhoneNumberFormatter.get(context).format(cursor.getString(1))
signalContacts[currentAddress] = SignalContact(
id = cursor.getLong(0),
supportsVoice = cursor.getString(2),
rawDisplayName = cursor.getString(4),
aggregateDisplayName = getDisplayName(context, cursor.getLong(3)),
displayNameSource = cursor.getInt(5)
)
}
}
return signalContacts
}
private fun getSystemContactInfo(context: Context, address: String): SystemContactInfo? {
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address))
val projection = arrayOf(
ContactsContract.PhoneLookup.NUMBER,
ContactsContract.PhoneLookup._ID,
ContactsContract.PhoneLookup.DISPLAY_NAME
)
context.contentResolver.query(uri, projection, null, null, null)?.use { numberCursor ->
while (numberCursor.moveToNext()) {
val systemNumber = numberCursor.getString(0)
val systemAddress = PhoneNumberFormatter.get(context).format(systemNumber)
if (systemAddress == address) {
context.contentResolver.query(ContactsContract.RawContacts.CONTENT_URI, arrayOf(ContactsContract.RawContacts._ID), "${ContactsContract.RawContacts.CONTACT_ID} = ? ", SqlUtil.buildArgs(numberCursor.getLong(1)), null)?.use { idCursor ->
if (idCursor.moveToNext()) {
return SystemContactInfo(
name = numberCursor.getString(2),
number = numberCursor.getString(0),
id = idCursor.getLong(0)
)
}
}
}
}
}
return null
}
private fun getDisplayName(context: Context, contactId: Long): String? {
val projection = arrayOf(ContactsContract.Contacts.DISPLAY_NAME)
val selection = "${ContactsContract.Contacts._ID} = ?"
val args = SqlUtil.buildArgs(contactId)
context.contentResolver.query(ContactsContract.Contacts.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getString(0)
}
}
return null
}
@Throws(OperationApplicationException::class, RemoteException::class)
private fun applyOperationsInBatches(
contentResolver: ContentResolver,
authority: String,
operations: List<ContentProviderOperation>,
batchSize: Int
) {
val batches = ListUtil.chunk(operations, batchSize)
for (batch in batches) {
contentResolver.applyBatch(authority, ArrayList(batch))
}
}
private data class SystemContactInfo(val name: String, val number: String, val id: Long)
private data class SignalContact(
val id: Long,
val supportsVoice: String?,
val rawDisplayName: String?,
val aggregateDisplayName: String?,
val displayNameSource: Int
) {
fun isVoiceSupported(): Boolean {
return "true" == supportsVoice
}
}
data class NameDetails(
val displayName: String?,
val givenName: String?,
val familyName: String?,
val prefix: String?,
val suffix: String?,
val middleName: String?
)
data class PhoneDetails(
val number: String?,
val type: Int,
val label: String?
)
data class EmailDetails(
val address: String?,
val type: Int,
val label: String?
)
data class PostalAddressDetails(
val type: Int,
val label: String?,
val street: String?,
val poBox: String?,
val neighborhood: String?,
val city: String?,
val region: String?,
val postal: String?,
val country: String?
)
}

Wyświetl plik

@ -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;

Wyświetl plik

@ -1901,7 +1901,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
}
}
fun getAllPhoneNumbers(): Set<String> {
fun getAllE164s(): Set<String> {
val results: MutableSet<String> = HashSet()
readableDatabase.query(TABLE_NAME, arrayOf(PHONE), null, null, null, null, null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {

Wyświetl plik

@ -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();

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/recipients_panel"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >
<org.thoughtcrime.securesms.contacts.RecipientsEditor android:id="@+id/recipients_text"
android:layout_height="wrap_content"
android:capitalize="sentences"
android:autoText="true"
android:singleLine="true"
android:hint="@string/recipients_panel__to"
android:paddingEnd="45dp"
android:textColor="@color/signal_text_primary"
android:layout_width="fill_parent"/>
<ImageButton android:id="@+id/contacts_button"
android:background="?actionBarItemBackground"
android:tint="@color/signal_icon_tint_primary"
android:layout_width="40dp"
android:layout_height="35dp"
android:layout_marginEnd="5dp"
android:layout_marginTop="4dp"
android:src="@drawable/ic_menu_add_field_holo_light"
android:layout_alignEnd="@id/recipients_text"
android:maxWidth="32dip"
android:maxHeight="32dip" />
</RelativeLayout>

Wyświetl plik

@ -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')
}

Wyświetl plik

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.signal.contactstest">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ContactsTest">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ContactsActivity"
android:exported="false" />
<service
android:name=".AccountAuthenticatorService"
android:exported="true">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
</application>
</manifest>

Wyświetl plik

@ -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<String>, 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<String>): Bundle? {
return null
}
override fun updateCredentials(response: AccountAuthenticatorResponse, account: Account, authTokenType: String, options: Bundle): Bundle? {
return null
}
}
}

Wyświetl plik

@ -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<ContactDetails, ContactViewHolder>(object : DiffUtil.ItemCallback<ContactDetails>() {
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<RecyclerView?>(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<ContactPhoneDetails, PhoneViewHolder>(object : DiffUtil.ItemCallback<ContactPhoneDetails>() {
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
}
)
}
}
}
}

Wyświetl plik

@ -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<List<ContactDetails>> = MutableLiveData()
val contacts: LiveData<List<ContactDetails>>
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<ContactDetails> = 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<ContactDetails> {
val list: MutableList<ContactDetails> = mutableListOf()
forEach { list += it }
return list
}
}

Wyświetl plik

@ -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<Button>(R.id.permission_button).setOnClickListener { v ->
requestPermissions(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS), PERMISSION_CODE)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (requestCode == PERMISSION_CODE) {
if (grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
startActivity(Intent(this, ContactsActivity::class.java))
finish()
} else {
Toast.makeText(this, "You must provide permissions to continue.", Toast.LENGTH_SHORT).show()
}
}
}
private fun hasPermission(permission: String): Boolean {
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}
}

Wyświetl plik

@ -0,0 +1,31 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

Wyświetl plik

@ -0,0 +1,171 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

Wyświetl plik

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/permission_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Permissions"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:clipChildren="false"
android:clipToPadding="false"
app:cardCornerRadius="5dp"
app:cardElevation="5dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="4dp">
<ImageView
android:id="@+id/contact_photo"
android:layout_width="40dp"
android:layout_height="40dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/display_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginBottom="2dp"
android:fontFamily="monospace"
tools:text="Spider-Man"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/contact_photo"
app:layout_constraintEnd_toEndOf="parent"/>
<TextView
android:id="@+id/number"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="(111) 222-3333"
app:layout_constraintTop_toBottomOf="@id/display_name"
app:layout_constraintStart_toStartOf="@id/display_name"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/type"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="Mobile"
app:layout_constraintTop_toBottomOf="@id/number"
app:layout_constraintStart_toStartOf="@id/number"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/go_button"
android:layout_width="60dp"
android:layout_height="0dp"
android:text="Go"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

Wyświetl plik

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="5dp"
android:clipToPadding="false"
android:clipChildren="false">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardElevation="5dp"
app:cardCornerRadius="5dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="4dp"
android:clipChildren="false"
android:clipToPadding="false">
<TextView
android:id="@+id/given_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
tools:text="Spider-Man"/>
<TextView
android:id="@+id/family_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:fontFamily="monospace"
tools:text="Spider-Man"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/phone_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"/>
</LinearLayout>
</androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Wyświetl plik

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 5.2 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.6 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.3 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.8 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 7.3 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 7.7 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 12 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 10 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 16 KiB

Wyświetl plik

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ContactsTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

Wyświetl plik

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

Wyświetl plik

@ -0,0 +1,3 @@
<resources>
<string name="app_name">ContactsTest</string>
</resources>

Wyświetl plik

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ContactsTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

Wyświetl plik

@ -0,0 +1,6 @@
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="org.signal.contactstest"
android:icon="@mipmap/ic_launcher"
android:smallIcon="@mipmap/ic_launcher"
android:label="@string/app_name"/>

Wyświetl plik

@ -0,0 +1,49 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'org.jlleitschuh.gradle.ktlint'
}
android {
buildToolsVersion BUILD_TOOL_VERSION
compileSdkVersion COMPILE_SDK
defaultConfig {
minSdkVersion MINIMUM_SDK
targetSdkVersion TARGET_SDK
multiDexEnabled true
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
}
kotlinOptions {
jvmTarget = '1.8'
}
lintOptions {
disable 'InvalidVectorPath'
}
}
ktlint {
// Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507
version = "0.43.2"
}
dependencies {
lintChecks project(':lintchecks')
implementation project(':core-util')
coreLibraryDesugaring libs.android.tools.desugar
implementation libs.androidx.core.ktx
implementation libs.androidx.annotation
implementation libs.androidx.appcompat
api libs.rxjava3.rxjava
}

Wyświetl plik

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="org.signal.contacts">
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
</manifest>

Wyświetl plik

@ -0,0 +1,23 @@
package org.signal.contacts
import android.accounts.Account
/**
* Describes how you'd like message and call links added to the system contacts.
*
* [appName] The name of the app
* [messagePrompt] A function that, given a formatted number, will output a string to be used as a label for the message link on a contact
* [callPrompt] A function that, given a formatted number, will output a string to be used as a label for the call link on a contact
* [e164Formatter] A function that, given a formatted number, will output an E164 of that number
* [messageMimetype] The mimetype you'd like to use for the message link
* [callMimetype] The mimetype you'd like to use for the call link
*/
class ContactLinkConfiguration(
val account: Account,
val appName: String,
val messagePrompt: (String) -> String,
val callPrompt: (String) -> String,
val e164Formatter: (String) -> String,
val messageMimetype: String,
val callMimetype: String,
)

Wyświetl plik

@ -0,0 +1,736 @@
package org.signal.contacts
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.database.Cursor
import android.net.Uri
import android.os.RemoteException
import android.provider.BaseColumns
import android.provider.ContactsContract
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import java.io.Closeable
import java.util.Objects
/**
* 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 SYNC_TAG = "__TS"
private const val FIELD_FORMATTED_PHONE = ContactsContract.RawContacts.SYNC1
private const val FIELD_TAG = ContactsContract.Data.SYNC2
private const val FIELD_SUPPORTS_VOICE = ContactsContract.RawContacts.SYNC4
/**
* Gets and returns a cursor of data for all contacts, containing both phone number data and
* structured name data.
*/
@JvmStatic
fun getAllSystemContacts(context: Context, rewrites: Map<String, String>, e164Formatter: (String) -> String): ContactIterator {
val uri = ContactsContract.Data.CONTENT_URI
val projection = SqlUtil.buildArgs(
ContactsContract.Data.MIMETYPE,
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.LABEL,
ContactsContract.CommonDataKinds.Phone.PHOTO_URI,
ContactsContract.CommonDataKinds.Phone._ID,
ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY,
ContactsContract.CommonDataKinds.Phone.TYPE,
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME
)
val where = "${ContactsContract.Data.MIMETYPE} IN (?, ?)"
val args = SqlUtil.buildArgs(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
val orderBy = "${ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY} ASC, ${ContactsContract.Data.MIMETYPE} DESC, ${ContactsContract.CommonDataKinds.Phone._ID} DESC"
val cursor: Cursor = context.contentResolver.query(uri, projection, where, args, orderBy) ?: return EmptyContactIterator()
return CursorContactIterator(cursor, rewrites, e164Formatter)
}
@JvmStatic
fun getAllDisplayNumbers(context: Context): Set<String> {
val results: MutableSet<String> = mutableSetOf()
context.contentResolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER), null, null, null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val formattedPhone: String? = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER)
if (formattedPhone != null && formattedPhone.isNotEmpty()) {
results.add(formattedPhone)
}
}
}
return results
}
/**
* Retrieves a system account for the provided applicationId, creating one if necessary.
*/
@JvmStatic
fun getOrCreateSystemAccount(context: Context, applicationId: String, accountDisplayName: String): Account? {
val accountManager: AccountManager = AccountManager.get(context)
val accounts: Array<Account> = accountManager.getAccountsByType(applicationId)
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(accountDisplayName, applicationId)
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
}
/**
* Deletes all raw contacts the specified account that are flagged as deleted.
*/
@JvmStatic
@Synchronized
fun removeDeletedRawContactsForAccount(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, FIELD_FORMATTED_PHONE)
// TODO Could we write this as a single delete(DELETED = true)?
context.contentResolver.query(currentContactsUri, projection, "${ContactsContract.RawContacts.DELETED} = ?", SqlUtil.buildArgs(1), null)?.use { cursor ->
while (cursor.moveToNext()) {
val rawContactId = cursor.requireLong(BaseColumns._ID)
Log.i(TAG, "Deleting raw contact: ${cursor.requireString(FIELD_FORMATTED_PHONE)}, $rawContactId")
context.contentResolver.delete(currentContactsUri, "${ContactsContract.RawContacts._ID} = ?", SqlUtil.buildArgs(rawContactId))
}
}
}
/**
* Adds links to message and call using your app to the system contacts.
* [config] Your configuration object.
* [targetE164s] A list of E164s whose contact entries you would like to add links to.
* [removeIfMissing] If true, links will be removed from all contacts not in the [targetE164s].
*/
@JvmStatic
@Synchronized
@Throws(RemoteException::class, OperationApplicationException::class)
fun addMessageAndCallLinksToContacts(
context: Context,
config: ContactLinkConfiguration,
targetE164s: Set<String>,
removeIfMissing: Boolean
) {
val operations: ArrayList<ContentProviderOperation> = ArrayList()
val currentLinkedContacts: Map<String, RawContactDetails> = getRawContactsByE164(context, config.account, config.e164Formatter)
val targetChunks: List<List<String>> = targetE164s.chunked(50).toList()
for (targetChunk in targetChunks) {
for (target in targetChunk) {
if (!currentLinkedContacts.containsKey(target)) {
val systemContactInfo: SystemContactInfo? = getSystemContactInfo(context, target, config.e164Formatter)
if (systemContactInfo != null) {
Log.i(TAG, "Adding number: $target")
operations += buildAddRawContactOperations(
operationIndex = operations.size,
account = config.account,
appName = config.appName,
messagePrompt = config.messagePrompt,
callPrompt = config.callPrompt,
formattedPhone = systemContactInfo.formattedPhone,
displayName = systemContactInfo.displayName,
aggregateId = systemContactInfo.rawContactId,
messageMimetype = config.messageMimetype,
callMimetype = config.callMimetype
)
}
}
}
if (operations.isNotEmpty()) {
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, operations)
operations.clear()
}
}
for ((e164, details) in currentLinkedContacts) {
if (!targetE164s.contains(e164)) {
if (removeIfMissing) {
Log.i(TAG, "Removing number: $e164")
removeTextSecureRawContact(operations, config.account, details.id)
}
} else if (!Objects.equals(details.rawDisplayName, details.aggregateDisplayName)) {
Log.i(TAG, "Updating display name: $e164")
operations += buildUpdateDisplayNameOperations(details.aggregateDisplayName, details.id, details.displayNameSource)
}
}
if (operations.isNotEmpty()) {
operations
.chunked(50)
.forEach { batch ->
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ArrayList(batch))
}
}
}
@JvmStatic
fun getNameDetails(context: Context, contactId: Long): NameDetails? {
val projection = arrayOf(
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
ContactsContract.CommonDataKinds.StructuredName.PREFIX,
ContactsContract.CommonDataKinds.StructuredName.SUFFIX,
ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
return context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
NameDetails(
displayName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME),
givenName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME),
familyName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME),
prefix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.PREFIX),
suffix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.SUFFIX),
middleName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME)
)
} else {
null
}
}
}
@JvmStatic
fun getOrganizationName(context: Context, contactId: Long): String? {
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getString(0)
}
}
return null
}
@JvmStatic
fun getPhoneDetails(context: Context, contactId: Long): List<PhoneDetails> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.TYPE,
ContactsContract.CommonDataKinds.Phone.LABEL
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
val phoneDetails: MutableList<PhoneDetails> = mutableListOf()
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
while (cursor.moveToNext()) {
phoneDetails += PhoneDetails(
number = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER),
type = cursor.requireInt(ContactsContract.CommonDataKinds.Phone.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LABEL)
)
}
}
return phoneDetails
}
@JvmStatic
fun getEmailDetails(context: Context, contactId: Long): List<EmailDetails> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.Email.ADDRESS,
ContactsContract.CommonDataKinds.Email.TYPE,
ContactsContract.CommonDataKinds.Email.LABEL
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
val emailDetails: MutableList<EmailDetails> = mutableListOf()
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
while (cursor.moveToNext()) {
emailDetails += EmailDetails(
address = cursor.requireString(ContactsContract.CommonDataKinds.Email.ADDRESS),
type = cursor.requireInt(ContactsContract.CommonDataKinds.Email.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.Email.LABEL)
)
}
}
return emailDetails
}
@JvmStatic
fun getPostalAddressDetails(context: Context, contactId: Long): List<PostalAddressDetails> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.StructuredPostal.TYPE,
ContactsContract.CommonDataKinds.StructuredPostal.LABEL,
ContactsContract.CommonDataKinds.StructuredPostal.STREET,
ContactsContract.CommonDataKinds.StructuredPostal.POBOX,
ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD,
ContactsContract.CommonDataKinds.StructuredPostal.CITY,
ContactsContract.CommonDataKinds.StructuredPostal.REGION,
ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE,
ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY
)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE)
val postalDetails: MutableList<PostalAddressDetails> = mutableListOf()
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
while (cursor.moveToNext()) {
postalDetails += PostalAddressDetails(
type = cursor.requireInt(ContactsContract.CommonDataKinds.StructuredPostal.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.LABEL),
street = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.STREET),
poBox = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POBOX),
neighborhood = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD),
city = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.CITY),
region = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.REGION),
postal = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE),
country = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY)
)
}
}
return postalDetails
}
@JvmStatic
fun getAvatarUri(context: Context, contactId: Long): Uri? {
val projection = arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_URI)
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val uri = cursor.getString(0)
if (uri != null) {
return Uri.parse(uri)
}
}
}
return null
}
private fun buildUpdateDisplayNameOperations(
displayName: String?,
rawContactId: Long,
displayNameSource: Int
): ContentProviderOperation {
val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build()
return if (displayNameSource != ContactsContract.DisplayNameSources.STRUCTURED_NAME) {
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 {
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 buildAddRawContactOperations(
operationIndex: Int,
account: Account,
appName: String,
messagePrompt: (String) -> String,
callPrompt: (String) -> String,
formattedPhone: String,
displayName: String?,
aggregateId: Long,
messageMimetype: String,
callMimetype: String
): List<ContentProviderOperation> {
val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build()
return listOf(
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.withValue(FIELD_FORMATTED_PHONE, formattedPhone)
.withValue(FIELD_SUPPORTS_VOICE, true.toString())
.build(),
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, operationIndex)
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.build(),
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, operationIndex)
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, formattedPhone)
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_OTHER)
.withValue(FIELD_TAG, SYNC_TAG)
.build(),
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, operationIndex)
.withValue(ContactsContract.Data.MIMETYPE, messageMimetype)
.withValue(ContactsContract.Data.DATA1, formattedPhone)
.withValue(ContactsContract.Data.DATA2, appName)
.withValue(ContactsContract.Data.DATA3, messagePrompt(formattedPhone))
.withYieldAllowed(true)
.build(),
ContentProviderOperation.newInsert(dataUri)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, operationIndex)
.withValue(ContactsContract.Data.MIMETYPE, callMimetype)
.withValue(ContactsContract.Data.DATA1, formattedPhone)
.withValue(ContactsContract.Data.DATA2, appName)
.withValue(ContactsContract.Data.DATA3, callPrompt(formattedPhone))
.withYieldAllowed(true)
.build(),
ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI)
.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, aggregateId)
.withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, operationIndex)
.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER)
.build()
)
}
private fun removeTextSecureRawContact(operations: MutableList<ContentProviderOperation>, account: Account, rowId: Long) {
operations.add(
ContentProviderOperation.newDelete(
ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()
)
.withYieldAllowed(true)
.withSelection("${BaseColumns._ID} = ?", SqlUtil.buildArgs(rowId))
.build()
)
}
private fun getRawContactsByE164(context: Context, account: Account, e164Formatter: (String) -> String): Map<String, RawContactDetails> {
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,
FIELD_FORMATTED_PHONE,
FIELD_SUPPORTS_VOICE,
ContactsContract.RawContacts.CONTACT_ID,
ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY,
ContactsContract.RawContacts.DISPLAY_NAME_SOURCE
)
val contactsDetails: MutableMap<String, RawContactDetails> = HashMap()
context.contentResolver.query(currentContactsUri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val formattedPhone = cursor.requireString(FIELD_FORMATTED_PHONE)
if (formattedPhone != null) {
val e164 = e164Formatter(formattedPhone)
contactsDetails[e164] = RawContactDetails(
id = cursor.requireLong(BaseColumns._ID),
supportsVoice = cursor.requireString(FIELD_SUPPORTS_VOICE),
rawDisplayName = cursor.requireString(ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY),
aggregateDisplayName = getDisplayName(context, cursor.requireLong(ContactsContract.RawContacts.CONTACT_ID)),
displayNameSource = cursor.requireInt(ContactsContract.RawContacts.DISPLAY_NAME_SOURCE)
)
}
}
}
return contactsDetails
}
private fun getSystemContactInfo(context: Context, e164: String, e164Formatter: (String) -> String): SystemContactInfo? {
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(e164))
val projection = arrayOf(
ContactsContract.PhoneLookup.NUMBER,
ContactsContract.PhoneLookup._ID,
ContactsContract.PhoneLookup.DISPLAY_NAME
)
context.contentResolver.query(uri, projection, null, null, null)?.use { contactCursor ->
while (contactCursor.moveToNext()) {
val systemNumber: String? = contactCursor.requireString(ContactsContract.PhoneLookup.NUMBER)
if (systemNumber != null && e164Formatter(systemNumber) == e164) {
val phoneLookupId = contactCursor.requireLong(ContactsContract.PhoneLookup._ID)
context.contentResolver.query(ContactsContract.RawContacts.CONTENT_URI, arrayOf(ContactsContract.RawContacts._ID), "${ContactsContract.RawContacts.CONTACT_ID} = ? ", SqlUtil.buildArgs(phoneLookupId), null)?.use { idCursor ->
if (idCursor.moveToNext()) {
return SystemContactInfo(
displayName = contactCursor.requireString(ContactsContract.PhoneLookup.DISPLAY_NAME),
formattedPhone = systemNumber,
rawContactId = idCursor.requireLong(ContactsContract.RawContacts._ID)
)
}
}
}
}
}
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
}
interface ContactIterator : Iterator<ContactDetails>, Closeable {
@Throws
override fun close() {}
}
private class EmptyContactIterator : ContactIterator {
override fun close() {}
override fun hasNext(): Boolean = false
override fun next(): ContactDetails = throw NoSuchElementException()
}
/**
* Remember cursor rows are ordered by the following params:
* 1. Contact Lookup Key ASC
* 1. Mimetype ASC
* 1. id DESC
*
* 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 that the
* latest name is first in the cursor.
*
* What this results in is a cursor that looks like:
*
* Alice phone 2
* Alice phone 1
* Alice structured name 2
* Alice structured name 1
* Bob phone 1
* ... etc.
*
* The general idea of how this is implemented:
* - Assume you're already on the correct row at the start of [next].
* - Store the lookup key from the first row.
* - Read all phone entries for that lookup key and store them.
* - Read the first name entry for that lookup key and store it.
* - Skip all other rows for that lookup key. This will ensure that you're on the correct row for the next call to [next]
*/
private class CursorContactIterator(
private val cursor: Cursor,
private val e164Rewrites: Map<String, String>,
private val e164Formatter: (String) -> String
) : ContactIterator {
init {
cursor.moveToFirst()
}
override fun hasNext(): Boolean {
return !cursor.isAfterLast
}
override fun next(): ContactDetails {
if (cursor.isAfterLast) {
throw NoSuchElementException()
}
val lookupKey: String = cursor.getLookupKey()
val phoneDetails: List<ContactPhoneDetails> = readAllPhones(cursor, lookupKey)
val structuredName: StructuredName? = readStructuredName(cursor, lookupKey)
while (!cursor.isAfterLast && cursor.getLookupKey() == lookupKey) {
cursor.moveToNext()
}
return ContactDetails(
givenName = structuredName?.givenName,
familyName = structuredName?.familyName,
numbers = phoneDetails
)
}
override fun close() {
cursor.close()
}
fun readAllPhones(cursor: Cursor, lookupKey: String): List<ContactPhoneDetails> {
val phoneDetails: MutableList<ContactPhoneDetails> = mutableListOf()
while (!cursor.isAfterLast && lookupKey == cursor.getLookupKey() && cursor.isPhoneMimeType()) {
val formattedNumber: String? = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER)
if (formattedNumber != null && formattedNumber.isNotEmpty()) {
val e164: String = e164Formatter(formattedNumber)
val realE164: String = firstNonEmpty(e164Rewrites[e164], e164)
phoneDetails += ContactPhoneDetails(
contactUri = ContactsContract.Contacts.getLookupUri(cursor.requireLong(ContactsContract.CommonDataKinds.Phone._ID), lookupKey),
displayName = cursor.requireString(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME),
photoUri = cursor.requireString(ContactsContract.CommonDataKinds.Phone.PHOTO_URI),
number = realE164,
type = cursor.requireInt(ContactsContract.CommonDataKinds.Phone.TYPE),
label = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LABEL),
)
} else {
Log.w(TAG, "Skipping phone entry with invalid number!")
}
cursor.moveToNext()
}
// You may get duplicates of the same phone number with different types.
// This dedupes by taking the entry with the lowest phone type.
return phoneDetails
.groupBy { it.number }
.mapValues { entry ->
entry.value.minByOrNull { it.type }!!
}
.values
.toList()
}
fun readStructuredName(cursor: Cursor, lookupKey: String): StructuredName? {
return if (!cursor.isAfterLast && cursor.getLookupKey() == lookupKey && cursor.isNameMimeType()) {
StructuredName(
givenName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME),
familyName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME)
)
} else {
null
}
}
fun Cursor.getLookupKey(): String {
return requireNonNullString(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY)
}
fun Cursor.isPhoneMimeType(): Boolean {
return requireString(ContactsContract.Data.MIMETYPE) == ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE
}
fun Cursor.isNameMimeType(): Boolean {
return requireString(ContactsContract.Data.MIMETYPE) == ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE
}
fun firstNonEmpty(s1: String?, s2: String): String {
return if (s1 != null && s1.isNotEmpty()) s1 else s2
}
}
data class ContactDetails(
val givenName: String?,
val familyName: String?,
val numbers: List<ContactPhoneDetails>
)
data class ContactPhoneDetails(
val contactUri: Uri,
val displayName: String?,
val photoUri: String?,
val number: String,
val type: Int,
val label: String?
)
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?
)
private data class RawContactDetails(
val id: Long,
val supportsVoice: String?,
val rawDisplayName: String?,
val aggregateDisplayName: String?,
val displayNameSource: Int
)
private data class SystemContactInfo(
val displayName: String?,
val formattedPhone: String,
val rawContactId: Long
)
private data class StructuredName(val givenName: String?, val familyName: String?)
}

Wyświetl plik

@ -33,6 +33,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="b7730754793e2fa510ddb10b7514e65f8706e4ec4b100acf7e4215f0bd5519b4" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.activity" name="activity" version="1.3.0">
<artifact name="activity-1.3.0.aar">
<sha256 value="db584d89011a078829209d24157b3256ca8985b1c6c000204eebe9973a7d09da" origin="Generated by Gradle"/>
</artifact>
<artifact name="activity-1.3.0.module">
<sha256 value="500fbd07c683cc8e2266db77264eb17d52f3bee947ba0a90fbe4faaee07185bd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.activity" name="activity" version="1.4.0">
<artifact name="activity-1.4.0.aar">
<sha256 value="89dc38e0cdbd11f328c7d0b3b021ddb387ca9da0d49f14b18c91e300c45ed79c" origin="Generated by Gradle"/>
</artifact>
<artifact name="activity-1.4.0.module">
<sha256 value="b38ce719cf1862701ab54b48405fc832a8ca8d4aacb2ce0d37456d0aff329147" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.activity" name="activity-ktx" version="1.2.2">
<artifact name="activity-ktx-1.2.2.aar">
<sha256 value="9829e13d6a6b045b03b21a330512e091dc76eb5b3ded0d88d1ab0509cf84a50e" origin="Generated by Gradle"/>
@ -41,6 +57,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="92f4431091650b5a67cc4f654bd9b822c585cf4262180912f075779f07a04ba6" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.activity" name="activity-ktx" version="1.3.0">
<artifact name="activity-ktx-1.3.0.aar">
<sha256 value="675df62188fa9dc2f470ace72a00ccd154472e26efb6137bff4adc34ce670f18" origin="Generated by Gradle"/>
</artifact>
<artifact name="activity-ktx-1.3.0.module">
<sha256 value="332265dd581e12a704957e4b0a5bc2142cd8362195c74722646064cb1e68280a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.activity" name="activity-ktx" version="1.4.0">
<artifact name="activity-ktx-1.4.0.aar">
<sha256 value="3f301941f37a90b4bc553dbbe84e7464a97c0d21df6cf2d6c0cb1b2c07349f33" origin="Generated by Gradle"/>
</artifact>
<artifact name="activity-ktx-1.4.0.module">
<sha256 value="44950669cc9951b30ca8f9dd426fff3d660672262e74afac785bded4aacc5a03" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.annotation" name="annotation" version="1.0.0">
<artifact name="annotation-1.0.0.jar">
<sha256 value="0baae9755f7caf52aa80cd04324b91ba93af55d4d1d17dcc9a7b53d99ef7c016" origin="Generated by Gradle"/>
@ -64,6 +96,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="b219d2b568e7e4ba534e09f8c2fd242343df6ccbdfbbe938846f5d740e6b0b11" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.annotation" name="annotation-experimental" version="1.1.0">
<artifact name="annotation-experimental-1.1.0.aar">
<sha256 value="0157de61a2064047896a058080f3fd67ba57ad9a94857b3f7a363660243e3f90" origin="Generated by Gradle"/>
</artifact>
<artifact name="annotation-experimental-1.1.0.module">
<sha256 value="0361d1526a4d7501255e19779e09e93cdbd07fee0e2f5c50b7a137432d510119" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.appcompat" name="appcompat" version="1.2.0">
<artifact name="appcompat-1.2.0.aar">
<sha256 value="3d2131a55a61a777322e2126e0018011efa6339e53b44153eb651b16020cca70" origin="Generated by Gradle"/>
@ -223,6 +263,19 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="e3877fa529fe29177f34a26e0790ed35544848b0c7503bfed30b2539f1686d65" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core" version="1.7.0">
<artifact name="core-1.7.0.aar">
<sha256 value="aaf6734226fff923784f92f65d78a2984dbf17534138855c5ce2038f18656e0b" origin="Generated by Gradle"/>
</artifact>
<artifact name="core-1.7.0.module">
<sha256 value="988f820899d5a4982e5c878ca1cd417970ace332ea2ff72f5be19b233fa0e788" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core-ktx" version="1.1.0">
<artifact name="core-ktx-1.1.0.aar">
<sha256 value="070cc5f8864f449128a2f4b25ca5b67aa3adca3ee1bd611e2eaf1a18fad83178" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core-ktx" version="1.5.0">
<artifact name="core-ktx-1.5.0.aar">
<sha256 value="5964cfe7a4882da2a00fb6ca3d3a072d04139208186f7bc4b3cb66022764fc42" origin="Generated by Gradle"/>
@ -1719,6 +1772,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="e6dd072f9d3fe02a4600688380bd422bdac184caf6fe2418cfdd0934f09432aa" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.guava" name="listenablefuture" version="1.0">
<artifact name="listenablefuture-1.0.jar">
<sha256 value="e4ad7607e5c0477c6f890ef26a49cb8d1bb4dffb650bab4502afee64644e3069" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.guava" name="listenablefuture" version="9999.0-empty-to-avoid-conflict-with-guava">
<artifact name="listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar">
<sha256 value="b372a037d4230aa57fbeffdef30fd6123f9c0c2db85d0aced00c91b974f33f99" origin="Generated by Gradle"/>
@ -1868,6 +1926,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="68025a14e3e7673d6ad2f95e4b46d78d7d068343aa99256b686fe59de1b3163a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest" name="ktlint" version="0.42.1">
<artifact name="ktlint-0.42.1.jar">
<sha256 value="aafdc2c1e66746a3c383cd6fb94343f0b7a856c2cfbfd40ff4464c726618a9a7" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-0.42.1.module">
<sha256 value="f06ba76eb422ad7b7da5ccf048d06d54dc5261ef953393a9043abd4f958c6e29" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest" name="ktlint" version="0.43.2">
<artifact name="ktlint-0.43.2.jar">
<sha256 value="99ec69ef0628695c24dbbc2cc4b8d7c61a754697d624f5233fc65f43faf2d235" origin="Generated by Gradle"/>
@ -1876,6 +1942,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="8bbdf6bc56cb12aa8ddea097e9ae862cde9a7c11bc32332dedda73241fb220dc" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-core" version="0.42.1">
<artifact name="ktlint-core-0.42.1.jar">
<sha256 value="a7bd968f4f408521e44a781594a2237df0199aab1ad2942c52bf8ad21e15dea4" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-core-0.42.1.module">
<sha256 value="6b1efb95887d9172d109df25afc2ef89fa0f09e4b230a47f56c57ad53bfb17ba" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-core" version="0.43.2">
<artifact name="ktlint-core-0.43.2.jar">
<sha256 value="401515a76b780a32ef9dfeaf69f77316934c4bb90f339488638311789eca7a1a" origin="Generated by Gradle"/>
@ -1884,6 +1958,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="aa276dfa9dcfab2f0459c81e7f903712058230d0908d545cc4bc8674273a51d7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-baseline" version="0.42.1">
<artifact name="ktlint-reporter-baseline-0.42.1.jar">
<sha256 value="6a6de6072e3a8b7b96ef9b8486985889977500761ff37f0467689af9fcbc2843" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-reporter-baseline-0.42.1.module">
<sha256 value="7476d04c105bfec627889c9f2807f524d26ab316dd57d42f7748db7ffbe8ad4f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-baseline" version="0.43.2">
<artifact name="ktlint-reporter-baseline-0.43.2.jar">
<sha256 value="733ee7e2cadb321d6597b3501c70c7da73117adaa0c6bc084dfc16c455d68806" origin="Generated by Gradle"/>
@ -1892,6 +1974,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="3b6466c5813d2deb31a534ae694c41c36b93aec787eb2a8aff162a1288c63533" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-checkstyle" version="0.42.1">
<artifact name="ktlint-reporter-checkstyle-0.42.1.jar">
<sha256 value="dad0e9626f6cbfec9df70eb8100ba5ea62d421e5c179b9b0e1f69586b0ba1fa6" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-reporter-checkstyle-0.42.1.module">
<sha256 value="017768838d4276018aaebe07a271f0022b5f3e66952bc2f0ceae202da4cb66be" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-checkstyle" version="0.43.2">
<artifact name="ktlint-reporter-checkstyle-0.43.2.jar">
<sha256 value="becafb4006b9f2e82c99749864a1a8de340ee84ac7271631a68981a44f51e808" origin="Generated by Gradle"/>
@ -1900,6 +1990,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="3937057372b1cab189647a1e2fa25aa19cb5f72168ca663421b9e250b4e77d05" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-html" version="0.42.1">
<artifact name="ktlint-reporter-html-0.42.1.jar">
<sha256 value="ca2c35bf0f436434a6fd8a95a8e47321b62d02cb242a4989c17a5d5b27ecea74" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-reporter-html-0.42.1.module">
<sha256 value="61fdc1ded68e730b76f269c94d1024484d565df629bfcd5eb45fd4ce05353def" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-html" version="0.43.2">
<artifact name="ktlint-reporter-html-0.43.2.jar">
<sha256 value="800392e150d3266e72ca53c6ccca3136d4e26445dd9216c6ac6cfc1ba3afafe5" origin="Generated by Gradle"/>
@ -1908,6 +2006,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="432a6fbb008f1373d3e8bde4ab9d905620ff87fd9f3b50a5654b7717f0a3eaab" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-json" version="0.42.1">
<artifact name="ktlint-reporter-json-0.42.1.jar">
<sha256 value="d173003331b292dec16bcd5f898546cfcaf4c61c2214136808e21f222a1afd1c" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-reporter-json-0.42.1.module">
<sha256 value="3cd549d0c0bf07182cfe69bf6f1a7643473ec1669d1fca12194b2586f25525ed" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-json" version="0.43.2">
<artifact name="ktlint-reporter-json-0.43.2.jar">
<sha256 value="9d4a94190d96d671000a06a50c9d1ce111d0dcf629bef8b4f0221a9e3f3699a0" origin="Generated by Gradle"/>
@ -1916,6 +2022,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="7e7be45882eb7abc67a62d12980018f2bb067d88d9947395a84ad678099b5179" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-plain" version="0.42.1">
<artifact name="ktlint-reporter-plain-0.42.1.jar">
<sha256 value="df673cd3e88e330e45dc37d58c2789b37b3ed8c3d2edcc4bd52cf719f2a7ee4c" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-reporter-plain-0.42.1.module">
<sha256 value="2afb405369eee884f7dcc1e17a2c5f37b1836d7de7ac506196c5b325584febe0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-plain" version="0.43.2">
<artifact name="ktlint-reporter-plain-0.43.2.jar">
<sha256 value="1cab63f431ec4e9463df7a767f131ccfa8d76259c01fecc63a4c000063e8ee43" origin="Generated by Gradle"/>
@ -1924,6 +2038,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="ea97899a3d8b6f8e18c7ae1a5d2f7147f976844f1bd2a51c27b7d8285d90a5ec" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-sarif" version="0.42.1">
<artifact name="ktlint-reporter-sarif-0.42.1.jar">
<sha256 value="13723186b353287cbdfd60ede056f25dbfb21a7a398be782ab64c9b4ef0ab593" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-reporter-sarif-0.42.1.module">
<sha256 value="d480e84b60a747582cfe4e4b1608806511bc4cebe7c5c394920e842160c5cf7a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-reporter-sarif" version="0.43.2">
<artifact name="ktlint-reporter-sarif-0.43.2.jar">
<sha256 value="ed0046aaa4a2e4544197bfdccf88d472ef413a55ad05b6dc8aae41338e9d3748" origin="Generated by Gradle"/>
@ -1932,6 +2054,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="7ff665bb3f0f36af38b80087c9a0067a9dff3c89b6a2c1c78a1f6e1455eb1d09" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-ruleset-experimental" version="0.42.1">
<artifact name="ktlint-ruleset-experimental-0.42.1.jar">
<sha256 value="9cdc257cba3d0568c553da9ebc90d0d8eda0743f150e2f0f9d3c60626165840d" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-ruleset-experimental-0.42.1.module">
<sha256 value="a3f839fb54c9443f60bde4518c69c65b3f5fa807deb4104f472c7ee22d6e2ae5" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-ruleset-experimental" version="0.43.2">
<artifact name="ktlint-ruleset-experimental-0.43.2.jar">
<sha256 value="d89e0edcdca0ae375c090565e323520ab5d424d82fd6ac6290ea986d360f0b11" origin="Generated by Gradle"/>
@ -1940,6 +2070,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="2d85cd883fe88c4b5429f266de027afca9f9c53a4f49bf14822a4fdf4abeb67a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-ruleset-standard" version="0.42.1">
<artifact name="ktlint-ruleset-standard-0.42.1.jar">
<sha256 value="cd3a1f034a554a2e1877aead61a252f1eadc9adfed345edec0ce863dcff4e61c" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-ruleset-standard-0.42.1.module">
<sha256 value="cfb11e428ae3564249b96ebc08e5170596b3b3790250a9133782681f6b56a036" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-ruleset-standard" version="0.43.2">
<artifact name="ktlint-ruleset-standard-0.43.2.jar">
<sha256 value="6774dc9d42aa7c7fdd4a7f3732b56fdab99ba78ce0c4eb5159036525657d0014" origin="Generated by Gradle"/>
@ -1948,6 +2086,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="7ce4e3721b8a6a2e0dd9607e8e5e5b337f5be4f9ed3f6a5dde9ff6d189355303" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-ruleset-test" version="0.42.1">
<artifact name="ktlint-ruleset-test-0.42.1.jar">
<sha256 value="0e9001347428a5be6b6b3a8bb322204259805e04b0d4bb6ed427d8a451db5097" origin="Generated by Gradle"/>
</artifact>
<artifact name="ktlint-ruleset-test-0.42.1.module">
<sha256 value="c0c9319daa040e6e3c0f4b8503138f9764dfdfc81672f4d2f7f9824cc4d7db39" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.pinterest.ktlint" name="ktlint-ruleset-test" version="0.43.2">
<artifact name="ktlint-ruleset-test-0.43.2.jar">
<sha256 value="7270c4d98b2cda268c25397a02b7dea0ab8cb923958cb3853121e0d9366ce797" origin="Generated by Gradle"/>
@ -2796,6 +2942,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="083d80ea6262faac293d248c32bf89e062a4e44d657ea6a095c8066e31791e5e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-compiler-embeddable" version="1.5.20">
<artifact name="kotlin-compiler-embeddable-1.5.20.jar">
<sha256 value="11d51087eb70b5abbad6fbf459a4349a0335916588000b5ecd990f01482e38ff" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-compiler-embeddable" version="1.5.31">
<artifact name="kotlin-compiler-embeddable-1.5.31.jar">
<sha256 value="e39811a9e4c102e779c659eefe90b041c66ce87578c1bfdac07cf504d1551745" origin="Generated by Gradle"/>
@ -2816,6 +2967,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="0c52722dfb15d6c79f77e1c1c55caf93d0a480f9e1ee76da751cf0cc1e4b6d19" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-daemon-embeddable" version="1.5.20">
<artifact name="kotlin-daemon-embeddable-1.5.20.jar">
<sha256 value="5a2e1e6869d130d937b39c668ea6bca758ef8960d168847f6e13aa2a2add424a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-daemon-embeddable" version="1.5.31">
<artifact name="kotlin-daemon-embeddable-1.5.31.jar">
<sha256 value="f61eaf89e5e3848631650b25cdfb66fe8cae0281a054d9d986716000a15ba8d6" origin="Generated by Gradle"/>
@ -2866,6 +3022,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="d6a6a36120ebcc8b291c4b6508d123b01347a4ee73dffdc744e88a3dd630d474" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-reflect" version="1.5.20">
<artifact name="kotlin-reflect-1.5.20.jar">
<sha256 value="fd6782d18bcc17ffa98221a1c34e4a42a7e3e6b4a4b72b474b5c82e14c8bab5a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-reflect" version="1.5.31">
<artifact name="kotlin-reflect-1.5.31.jar">
<sha256 value="6e0f5490e6b9649ddd2670534e4d3a03bd283c3358b8eef5d1304fd5f8a5a4fb" origin="Generated by Gradle"/>
@ -2876,6 +3037,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="4496e90565b6cc312213acd65fe8ad6d149264ff12d2f1f6b6ba4122afffbbfe" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-script-runtime" version="1.5.20">
<artifact name="kotlin-script-runtime-1.5.20.jar">
<sha256 value="e8a44d7195dc7ee4abb5cda5791e37aacd20b1b76378b13da109dd626536380f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-script-runtime" version="1.5.31">
<artifact name="kotlin-script-runtime-1.5.31.jar">
<sha256 value="24e450fee7645ed3590981dddccf397c0d9ebb725815c94c4f555cc3db2f9f96" origin="Generated by Gradle"/>
@ -2931,6 +3097,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="ca87c454cd3f2e60931f1803c59699d510d3b4b959cd7119296fb947581d722d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib" version="1.5.20">
<artifact name="kotlin-stdlib-1.5.20.jar">
<sha256 value="80cd79c26aac46d72d782de1ecb326061e93c6e688d994b48627ffd668ba63a8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib" version="1.5.31">
<artifact name="kotlin-stdlib-1.5.31.jar">
<sha256 value="4800ceacb2ec0bb9959a087154b8e35318ead1ea4eba32d4bb1b9734222a7e68" origin="Generated by Gradle"/>
@ -2966,6 +3137,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="d958ce94beda85f865829fb95012804866db7d5246615fd71a2f5aabca3e7994" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-common" version="1.5.20">
<artifact name="kotlin-stdlib-common-1.5.20.jar">
<sha256 value="9819529804bf9296e3853acd5ae824df95d8f8c61309e7768b7cae5ca1361d36" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-common" version="1.5.31">
<artifact name="kotlin-stdlib-common-1.5.31.jar">
<sha256 value="dfa2a18e26b028388ee1968d199bf6f166f737ab7049c25a5e2da614404e22ad" origin="Generated by Gradle"/>
@ -2991,6 +3167,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="75ed5680aaacfd94b93c3695d8eb8bfa7cf83893d2e46ca9788345c52d393f8a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.5.20">
<artifact name="kotlin-stdlib-jdk7-1.5.20.jar">
<sha256 value="b110f6d20204303099af0d5f2c846ac60bc6ae5663ef5f22e726ca4627359d06" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.5.31">
<artifact name="kotlin-stdlib-jdk7-1.5.31.jar">
<sha256 value="a25bf47353ce899d843cbddee516d621a73473e7fba97f8d0301e7b4aed7c15f" origin="Generated by Gradle"/>
@ -3016,6 +3197,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="270b05aa3cc92f157a7ed71ff09cf136ee3fb18cbac94f71a12931009c49f550" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk8" version="1.5.20">
<artifact name="kotlin-stdlib-jdk8-1.5.20.jar">
<sha256 value="a7e9cffe569c43eb8f0fe3139978b0943fe92abcc513f7cf04544f2797f8d38a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk8" version="1.5.31">
<artifact name="kotlin-stdlib-jdk8-1.5.31.jar">
<sha256 value="b548f7767aacf029d2417e47440742bd6d3ebede19b60386e23554ce5c4c5fdc" origin="Generated by Gradle"/>

Wyświetl plik

@ -16,6 +16,8 @@ include ':donations'
include ':donations-app'
include ':spinner'
include ':spinner-app'
include ':contacts'
include ':contacts-app'
project(':app').name = 'Signal-Android'
project(':paging').projectDir = file('paging/lib')
@ -35,6 +37,9 @@ project(':donations-app').projectDir = file('donations/app')
project(':spinner').projectDir = file('spinner/lib')
project(':spinner-app').projectDir = file('spinner/app')
project(':contacts').projectDir = file('contacts/lib')
project(':contacts-app').projectDir = file('contacts/app')
rootProject.name='Signal'
apply from: 'dependencies.gradle'