Move system contact interactions into their own module.
|
@ -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
|
||||
|
|
|
@ -1,174 +0,0 @@
|
|||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.RelativeLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.RecipientsAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.RecipientsEditor;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* Panel component combining both an editable field with a button for
|
||||
* a list-based contact selector.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class PushRecipientsPanel extends RelativeLayout implements RecipientForeverObserver {
|
||||
private final String TAG = Log.tag(PushRecipientsPanel.class);
|
||||
private RecipientsPanelChangedListener panelChangeListener;
|
||||
|
||||
private RecipientsEditor recipientsText;
|
||||
private View panel;
|
||||
|
||||
private static final int RECIPIENTS_MAX_LENGTH = 312;
|
||||
|
||||
public PushRecipientsPanel(Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public PushRecipientsPanel(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public PushRecipientsPanel(Context context, AttributeSet attrs, int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
initialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
Stream.of(getRecipients()).map(Recipient::live).forEach(r -> r.removeForeverObserver(this));
|
||||
}
|
||||
|
||||
public List<Recipient> getRecipients() {
|
||||
String rawText = recipientsText.getText().toString();
|
||||
return getRecipientsFromString(getContext(), rawText);
|
||||
}
|
||||
|
||||
public void disable() {
|
||||
recipientsText.setText("");
|
||||
panel.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setPanelChangeListener(RecipientsPanelChangedListener panelChangeListener) {
|
||||
this.panelChangeListener = panelChangeListener;
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||
inflater.inflate(R.layout.push_recipients_panel, this, true);
|
||||
|
||||
View imageButton = findViewById(R.id.contacts_button);
|
||||
((MarginLayoutParams) imageButton.getLayoutParams()).topMargin = 0;
|
||||
|
||||
panel = findViewById(R.id.recipients_panel);
|
||||
initRecipientsEditor();
|
||||
}
|
||||
|
||||
private void initRecipientsEditor() {
|
||||
|
||||
this.recipientsText = (RecipientsEditor)findViewById(R.id.recipients_text);
|
||||
|
||||
List<Recipient> recipients = getRecipients();
|
||||
|
||||
Stream.of(recipients).map(Recipient::live).forEach(r -> r.observeForever(this));
|
||||
|
||||
recipientsText.setAdapter(new RecipientsAdapter(this.getContext()));
|
||||
recipientsText.populate(recipients);
|
||||
|
||||
recipientsText.setOnFocusChangeListener(new FocusChangedListener());
|
||||
recipientsText.setOnItemClickListener(new AdapterView.OnItemClickListener() {
|
||||
@Override
|
||||
public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
|
||||
if (panelChangeListener != null) {
|
||||
panelChangeListener.onRecipientsPanelUpdate(getRecipients());
|
||||
}
|
||||
recipientsText.setText("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private @NonNull List<Recipient> getRecipientsFromString(Context context, @NonNull String rawText) {
|
||||
StringTokenizer tokenizer = new StringTokenizer(rawText, ",");
|
||||
List<Recipient> recipients = new LinkedList<>();
|
||||
|
||||
while (tokenizer.hasMoreTokens()) {
|
||||
String token = tokenizer.nextToken().trim();
|
||||
|
||||
if (!TextUtils.isEmpty(token)) {
|
||||
if (hasBracketedNumber(token)) recipients.add(Recipient.external(context, parseBracketedNumber(token)));
|
||||
else recipients.add(Recipient.external(context, token));
|
||||
}
|
||||
}
|
||||
|
||||
return recipients;
|
||||
}
|
||||
|
||||
private boolean hasBracketedNumber(String recipient) {
|
||||
int openBracketIndex = recipient.indexOf('<');
|
||||
|
||||
return (openBracketIndex != -1) &&
|
||||
(recipient.indexOf('>', openBracketIndex) != -1);
|
||||
}
|
||||
|
||||
private String parseBracketedNumber(String recipient) {
|
||||
int begin = recipient.indexOf('<');
|
||||
int end = recipient.indexOf('>', begin);
|
||||
String value = recipient.substring(begin + 1, end);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
recipientsText.populate(getRecipients());
|
||||
}
|
||||
|
||||
private class FocusChangedListener implements View.OnFocusChangeListener {
|
||||
public void onFocusChange(View v, boolean hasFocus) {
|
||||
if (!hasFocus && (panelChangeListener != null)) {
|
||||
panelChangeListener.onRecipientsPanelUpdate(getRecipients());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface RecipientsPanelChangedListener {
|
||||
public void onRecipientsPanelUpdate(List<Recipient> recipients);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,42 +1,31 @@
|
|||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* <p>
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* <p>
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* <p>
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MergeCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import android.provider.ContactsContract;
|
||||
import android.provider.ContactsContract.CommonDataKinds.Phone;
|
||||
import android.provider.ContactsContract.Contacts;
|
||||
import android.telephony.PhoneNumberUtils;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.signal.core.util.SqlUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* This class was originally a layer of indirection between
|
||||
|
@ -52,102 +41,26 @@ import java.util.Set;
|
|||
|
||||
public class ContactAccessor {
|
||||
|
||||
public static final String PUSH_COLUMN = "push";
|
||||
|
||||
private static final String GIVEN_NAME = ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME;
|
||||
private static final String FAMILY_NAME = ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME;
|
||||
|
||||
private static final ContactAccessor instance = new ContactAccessor();
|
||||
|
||||
public static synchronized ContactAccessor getInstance() {
|
||||
public static ContactAccessor getInstance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
public Set<String> getAllContactsWithNumbers(Context context) {
|
||||
Set<String> results = new HashSet<>();
|
||||
|
||||
try (Cursor cursor = context.getContentResolver().query(Phone.CONTENT_URI, new String[] {Phone.NUMBER}, null ,null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
if (!TextUtils.isEmpty(cursor.getString(0))) {
|
||||
results.add(PhoneNumberFormatter.get(context).format(cursor.getString(0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets and returns a cursor of data for all contacts, containing both phone number data and
|
||||
* structured name data.
|
||||
*
|
||||
* Cursor rows are ordered as follows:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Contact Lookup Key</li>
|
||||
* <li>Mimetype</li>
|
||||
* <li>id</li>
|
||||
* </ol>
|
||||
*
|
||||
* The lookup key is a fixed value that allows you to verify two rows in the database actually
|
||||
* belong to the same contact, since the contact uri can be unstable (if a sync fails, say.)
|
||||
*
|
||||
* We order by id explicitly here for the same contact sync failure error, which could result in
|
||||
* multiple structured name rows for the same user. By ordering by id DESC, we ensure we get
|
||||
* whatever the latest input data was.
|
||||
*
|
||||
* What this results in is a cursor that looks like:
|
||||
*
|
||||
* Alice phone 1
|
||||
* Alice phone 2
|
||||
* Alice structured name 2
|
||||
* Alice structured name 1
|
||||
* Bob phone 1
|
||||
* ... etc.
|
||||
*/
|
||||
public Cursor getAllSystemContacts(Context context) {
|
||||
Uri uri = ContactsContract.Data.CONTENT_URI;
|
||||
String[] projection = SqlUtil.buildArgs(ContactsContract.Data.MIMETYPE, Phone.NUMBER, Phone.DISPLAY_NAME, Phone.LABEL, Phone.PHOTO_URI, Phone._ID, Phone.LOOKUP_KEY, Phone.TYPE, GIVEN_NAME, FAMILY_NAME);
|
||||
String where = ContactsContract.Data.MIMETYPE + " IN (?, ?)";
|
||||
String[] args = SqlUtil.buildArgs(Phone.CONTENT_ITEM_TYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE);
|
||||
String orderBy = Phone.LOOKUP_KEY + " ASC, " + ContactsContract.Data.MIMETYPE + " DESC, " + ContactsContract.CommonDataKinds.Phone._ID + " DESC";
|
||||
|
||||
return context.getContentResolver().query(uri, projection, where, args, orderBy);
|
||||
}
|
||||
|
||||
public String getNameFromContact(Context context, Uri uri) {
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = context.getContentResolver().query(uri, new String[] {Contacts.DISPLAY_NAME},
|
||||
null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst())
|
||||
return cursor.getString(0);
|
||||
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public ContactData getContactData(Context context, Uri uri) {
|
||||
return getContactData(context, getNameFromContact(context, uri), Long.parseLong(uri.getLastPathSegment()));
|
||||
}
|
||||
String displayName = getNameFromContact(context, uri);
|
||||
long id = Long.parseLong(uri.getLastPathSegment());
|
||||
|
||||
private ContactData getContactData(Context context, String displayName, long id) {
|
||||
ContactData contactData = new ContactData(id, displayName);
|
||||
|
||||
try (Cursor numberCursor = context.getContentResolver().query(Phone.CONTENT_URI,
|
||||
null,
|
||||
Phone.CONTACT_ID + " = ?",
|
||||
new String[] {contactData.id + ""},
|
||||
new String[] { contactData.id + "" },
|
||||
null))
|
||||
{
|
||||
while (numberCursor != null && numberCursor.moveToNext()) {
|
||||
int type = numberCursor.getInt(numberCursor.getColumnIndexOrThrow(Phone.TYPE));
|
||||
int type = numberCursor.getInt(numberCursor.getColumnIndexOrThrow(Phone.TYPE));
|
||||
String label = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.LABEL));
|
||||
String number = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.NUMBER));
|
||||
String typeLabel = Phone.getTypeLabel(context.getResources(), type, label).toString();
|
||||
|
@ -159,10 +72,25 @@ public class ContactAccessor {
|
|||
return contactData;
|
||||
}
|
||||
|
||||
public CharSequence phoneTypeToString(Context mContext, int type, CharSequence label) {
|
||||
return Phone.getTypeLabel(mContext.getResources(), type, label);
|
||||
private String getNameFromContact(Context context, Uri uri) {
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = context.getContentResolver().query(uri, new String[] { Contacts.DISPLAY_NAME }, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return cursor.getString(0);
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public static class NumberData implements Parcelable {
|
||||
|
||||
public static final Parcelable.Creator<NumberData> CREATOR = new Parcelable.Creator<NumberData>() {
|
||||
|
@ -179,7 +107,7 @@ public class ContactAccessor {
|
|||
public final String type;
|
||||
|
||||
public NumberData(String type, String number) {
|
||||
this.type = type;
|
||||
this.type = type;
|
||||
this.number = number;
|
||||
}
|
||||
|
||||
|
@ -210,8 +138,8 @@ public class ContactAccessor {
|
|||
}
|
||||
};
|
||||
|
||||
public final long id;
|
||||
public final String name;
|
||||
public final long id;
|
||||
public final String name;
|
||||
public final List<NumberData> numbers;
|
||||
|
||||
public ContactData(long id, String name) {
|
||||
|
@ -237,83 +165,4 @@ public class ContactAccessor {
|
|||
dest.writeTypedList(numbers);
|
||||
}
|
||||
}
|
||||
|
||||
/***
|
||||
* If the code below looks shitty to you, that's because it was taken
|
||||
* directly from the Android source, where shitty code is all you get.
|
||||
*/
|
||||
|
||||
public Cursor getCursorForRecipientFilter(CharSequence constraint,
|
||||
ContentResolver mContentResolver)
|
||||
{
|
||||
final String SORT_ORDER = Contacts.TIMES_CONTACTED + " DESC," +
|
||||
Contacts.DISPLAY_NAME + "," +
|
||||
Contacts.Data.IS_SUPER_PRIMARY + " DESC," +
|
||||
Phone.TYPE;
|
||||
|
||||
final String[] PROJECTION_PHONE = {
|
||||
Phone._ID, // 0
|
||||
Phone.CONTACT_ID, // 1
|
||||
Phone.TYPE, // 2
|
||||
Phone.NUMBER, // 3
|
||||
Phone.LABEL, // 4
|
||||
Phone.DISPLAY_NAME, // 5
|
||||
};
|
||||
|
||||
String phone = "";
|
||||
String cons = null;
|
||||
|
||||
if (constraint != null) {
|
||||
cons = constraint.toString();
|
||||
|
||||
if (RecipientsAdapter.usefulAsDigits(cons)) {
|
||||
phone = PhoneNumberUtils.convertKeypadLettersToDigits(cons);
|
||||
if (phone.equals(cons) && !PhoneNumberUtils.isWellFormedSmsAddress(phone)) {
|
||||
phone = "";
|
||||
} else {
|
||||
phone = phone.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
Uri uri = Uri.withAppendedPath(Phone.CONTENT_FILTER_URI, Uri.encode(cons));
|
||||
String selection = String.format("%s=%s OR %s=%s OR %s=%s",
|
||||
Phone.TYPE,
|
||||
Phone.TYPE_MOBILE,
|
||||
Phone.TYPE,
|
||||
Phone.TYPE_WORK_MOBILE,
|
||||
Phone.TYPE,
|
||||
Phone.TYPE_MMS);
|
||||
|
||||
Cursor phoneCursor = mContentResolver.query(uri,
|
||||
PROJECTION_PHONE,
|
||||
null,
|
||||
null,
|
||||
SORT_ORDER);
|
||||
|
||||
if (phone.length() > 0) {
|
||||
ArrayList result = new ArrayList();
|
||||
result.add(Integer.valueOf(-1)); // ID
|
||||
result.add(Long.valueOf(-1)); // CONTACT_ID
|
||||
result.add(Integer.valueOf(Phone.TYPE_CUSTOM)); // TYPE
|
||||
result.add(phone); // NUMBER
|
||||
|
||||
/*
|
||||
* The "\u00A0" keeps Phone.getDisplayLabel() from deciding
|
||||
* to display the default label ("Home") next to the transformation
|
||||
* of the letters into numbers.
|
||||
*/
|
||||
result.add("\u00A0"); // LABEL
|
||||
result.add(cons); // NAME
|
||||
|
||||
ArrayList<ArrayList> wrap = new ArrayList<ArrayList>();
|
||||
wrap.add(result);
|
||||
|
||||
ArrayListCursor translated = new ArrayListCursor(PROJECTION_PHONE, wrap);
|
||||
|
||||
return new MergeCursor(new Cursor[] { translated, phoneCursor });
|
||||
} else {
|
||||
return phoneCursor;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -9,18 +9,21 @@ import android.os.Bundle;
|
|||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.contacts.SystemContactsRepository;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
|
||||
|
||||
|
@ -51,20 +54,23 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
|
|||
return;
|
||||
}
|
||||
|
||||
Set<String> allSystemNumbers = ContactAccessor.getInstance().getAllContactsWithNumbers(context);
|
||||
Set<String> knownSystemNumbers = SignalDatabase.recipients().getAllPhoneNumbers();
|
||||
Set<String> unknownSystemNumbers = SetUtil.difference(allSystemNumbers, knownSystemNumbers);
|
||||
Set<String> allSystemE164s = SystemContactsRepository.getAllDisplayNumbers(context)
|
||||
.stream()
|
||||
.map(number -> PhoneNumberFormatter.get(context).format(number))
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> knownSystemE164s = SignalDatabase.recipients().getAllE164s();
|
||||
Set<String> unknownSystemE164s = SetUtil.difference(allSystemE164s, knownSystemE164s);
|
||||
|
||||
if (unknownSystemNumbers.size() > FULL_SYNC_THRESHOLD) {
|
||||
Log.i(TAG, "There are " + unknownSystemNumbers.size() + " unknown contacts. Doing a full sync.");
|
||||
if (unknownSystemE164s.size() > FULL_SYNC_THRESHOLD) {
|
||||
Log.i(TAG, "There are " + unknownSystemE164s.size() + " unknown contacts. Doing a full sync.");
|
||||
try {
|
||||
ContactDiscovery.refreshAll(context, true);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
} else if (unknownSystemNumbers.size() > 0) {
|
||||
Log.i(TAG, "There are " + unknownSystemNumbers.size() + " unknown contacts. Doing an individual sync.");
|
||||
List<Recipient> recipients = Stream.of(unknownSystemNumbers)
|
||||
} else if (unknownSystemE164s.size() > 0) {
|
||||
Log.i(TAG, "There are " + unknownSystemE164s.size() + " unknown contacts. Doing an individual sync.");
|
||||
List<Recipient> recipients = Stream.of(unknownSystemE164s)
|
||||
.filter(s -> s.startsWith("+"))
|
||||
.map(s -> Recipient.external(getContext(), s))
|
||||
.toList();
|
||||
|
|
|
@ -1,153 +0,0 @@
|
|||
/*
|
||||
* Copyright (C) 2008 Esmertec AG.
|
||||
* Copyright (C) 2008 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.text.Annotation;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.ResourceCursorAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientsFormatter;
|
||||
|
||||
/**
|
||||
* This adapter is used to filter contacts on both name and number.
|
||||
*/
|
||||
public class RecipientsAdapter extends ResourceCursorAdapter {
|
||||
|
||||
public static final int CONTACT_ID_INDEX = 1;
|
||||
public static final int TYPE_INDEX = 2;
|
||||
public static final int NUMBER_INDEX = 3;
|
||||
public static final int LABEL_INDEX = 4;
|
||||
public static final int NAME_INDEX = 5;
|
||||
|
||||
private final Context mContext;
|
||||
private final ContentResolver mContentResolver;
|
||||
private ContactAccessor mContactAccessor;
|
||||
|
||||
public RecipientsAdapter(Context context) {
|
||||
super(context, R.layout.recipient_filter_item, null);
|
||||
mContext = context;
|
||||
mContentResolver = context.getContentResolver();
|
||||
mContactAccessor = ContactAccessor.getInstance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public final CharSequence convertToString(Cursor cursor) {
|
||||
String name = cursor.getString(RecipientsAdapter.NAME_INDEX);
|
||||
int type = cursor.getInt(RecipientsAdapter.TYPE_INDEX);
|
||||
String number = cursor.getString(RecipientsAdapter.NUMBER_INDEX).trim();
|
||||
|
||||
String label = cursor.getString(RecipientsAdapter.LABEL_INDEX);
|
||||
CharSequence displayLabel = mContactAccessor.phoneTypeToString(mContext, type, label);
|
||||
|
||||
if (number.length() == 0) {
|
||||
return number;
|
||||
}
|
||||
|
||||
if (name == null) {
|
||||
name = "";
|
||||
} else {
|
||||
// Names with commas are the bane of the recipient editor's existence.
|
||||
// We've worked around them by using spans, but there are edge cases
|
||||
// where the spans get deleted. Furthermore, having commas in names
|
||||
// can be confusing to the user since commas are used as separators
|
||||
// between recipients. The best solution is to simply remove commas
|
||||
// from names.
|
||||
name = name.replace(", ", " ")
|
||||
.replace(",", " "); // Make sure we leave a space between parts of names.
|
||||
}
|
||||
|
||||
String nameAndNumber = RecipientsFormatter.formatNameAndNumber(name, number);
|
||||
|
||||
SpannableString out = new SpannableString(nameAndNumber);
|
||||
int len = out.length();
|
||||
|
||||
if (!TextUtils.isEmpty(name)) {
|
||||
out.setSpan(new Annotation("name", name), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
} else {
|
||||
out.setSpan(new Annotation("name", number), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
String person_id = cursor.getString(RecipientsAdapter.CONTACT_ID_INDEX);
|
||||
out.setSpan(new Annotation("person_id", person_id), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
out.setSpan(new Annotation("label", displayLabel.toString()), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
out.setSpan(new Annotation("number", number), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void bindView(View view, Context context, Cursor cursor) {
|
||||
TextView name = (TextView) view.findViewById(R.id.name);
|
||||
name.setText(cursor.getString(NAME_INDEX));
|
||||
|
||||
TextView label = (TextView) view.findViewById(R.id.label);
|
||||
int type = cursor.getInt(TYPE_INDEX);
|
||||
label.setText(mContactAccessor.phoneTypeToString(mContext, type, cursor.getString(LABEL_INDEX)));
|
||||
|
||||
TextView number = (TextView) view.findViewById(R.id.number);
|
||||
number.setText("(" + cursor.getString(NUMBER_INDEX) + ")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor runQueryOnBackgroundThread(CharSequence constraint) {
|
||||
return mContactAccessor.getCursorForRecipientFilter( constraint, mContentResolver );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if all the characters are meaningful as digits
|
||||
* in a phone number -- letters, digits, and a few punctuation marks.
|
||||
*/
|
||||
public static boolean usefulAsDigits(CharSequence cons) {
|
||||
int len = cons.length();
|
||||
|
||||
for (int i = 0; i < len; i++) {
|
||||
char c = cons.charAt(i);
|
||||
|
||||
if ((c >= '0') && (c <= '9')) {
|
||||
continue;
|
||||
}
|
||||
if ((c == ' ') || (c == '-') || (c == '(') || (c == ')') || (c == '.') || (c == '+')
|
||||
|| (c == '#') || (c == '*')) {
|
||||
continue;
|
||||
}
|
||||
if ((c >= 'A') && (c <= 'Z')) {
|
||||
continue;
|
||||
}
|
||||
if ((c >= 'a') && (c <= 'z')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -1,419 +0,0 @@
|
|||
/*
|
||||
* Copyright (C) 2008 Esmertec AG.
|
||||
* Copyright (C) 2008 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.content.Context;
|
||||
import android.telephony.PhoneNumberUtils;
|
||||
import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.Layout;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ContextMenu.ContextMenuInfo;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.MultiAutoCompleteTextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientsFormatter;
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Provide UI for editing the recipients of multi-media messages.
|
||||
*/
|
||||
public class RecipientsEditor extends AppCompatMultiAutoCompleteTextView {
|
||||
private int mLongPressedPosition = -1;
|
||||
private final RecipientsEditorTokenizer mTokenizer;
|
||||
private char mLastSeparator = ',';
|
||||
private Context mContext;
|
||||
|
||||
public RecipientsEditor(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
mContext = context;
|
||||
mTokenizer = new RecipientsEditorTokenizer(context, this);
|
||||
setTokenizer(mTokenizer);
|
||||
// For the focus to move to the message body when soft Next is pressed
|
||||
setImeOptions(EditorInfo.IME_ACTION_NEXT);
|
||||
|
||||
/*
|
||||
* The point of this TextWatcher is that when the user chooses
|
||||
* an address completion from the AutoCompleteTextView menu, it
|
||||
* is marked up with Annotation objects to tie it back to the
|
||||
* address book entry that it came from. If the user then goes
|
||||
* back and edits that part of the text, it no longer corresponds
|
||||
* to that address book entry and needs to have the Annotations
|
||||
* claiming that it does removed.
|
||||
*/
|
||||
addTextChangedListener(new TextWatcher() {
|
||||
private Annotation[] mAffected;
|
||||
|
||||
public void beforeTextChanged(CharSequence s, int start,
|
||||
int count, int after) {
|
||||
mAffected = ((Spanned) s).getSpans(start, start + count,
|
||||
Annotation.class);
|
||||
}
|
||||
|
||||
public void onTextChanged(CharSequence s, int start,
|
||||
int before, int after) {
|
||||
if (before == 0 && after == 1) { // inserting a character
|
||||
char c = s.charAt(start);
|
||||
if (c == ',' || c == ';') {
|
||||
// Remember the delimiter the user typed to end this recipient. We'll
|
||||
// need it shortly in terminateToken().
|
||||
mLastSeparator = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void afterTextChanged(Editable s) {
|
||||
if (mAffected != null) {
|
||||
for (Annotation a : mAffected) {
|
||||
s.removeSpan(a);
|
||||
}
|
||||
}
|
||||
|
||||
mAffected = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean enoughToFilter() {
|
||||
if (!super.enoughToFilter()) {
|
||||
return false;
|
||||
}
|
||||
// If the user is in the middle of editing an existing recipient, don't offer the
|
||||
// auto-complete menu. Without this, when the user selects an auto-complete menu item,
|
||||
// it will get added to the list of recipients so we end up with the old before-editing
|
||||
// recipient and the new post-editing recipient. As a precedent, gmail does not show
|
||||
// the auto-complete menu when editing an existing recipient.
|
||||
int end = getSelectionEnd();
|
||||
int len = getText().length();
|
||||
|
||||
return end == len;
|
||||
}
|
||||
|
||||
public int getRecipientCount() {
|
||||
return mTokenizer.getNumbers().size();
|
||||
}
|
||||
|
||||
public List<String> getNumbers() {
|
||||
return mTokenizer.getNumbers();
|
||||
}
|
||||
|
||||
// public Recipients constructContactsFromInput() {
|
||||
// return RecipientFactory.getRecipientsFromString(mContext, mTokenizer.getRawString(), false);
|
||||
// }
|
||||
|
||||
private boolean isValidAddress(String number, boolean isMms) {
|
||||
/*if (isMms) {
|
||||
return MessageUtils.isValidMmsAddress(number);
|
||||
} else {*/
|
||||
// TODO: PhoneNumberUtils.isWellFormedSmsAddress() only check if the number is a valid
|
||||
// GSM SMS address. If the address contains a dialable char, it considers it a well
|
||||
// formed SMS addr. CDMA doesn't work that way and has a different parser for SMS
|
||||
// address (see CdmaSmsAddress.parse(String address)). We should definitely fix this!!!
|
||||
return PhoneNumberUtils.isWellFormedSmsAddress(number);
|
||||
}
|
||||
|
||||
public boolean hasValidRecipient(boolean isMms) {
|
||||
for (String number : mTokenizer.getNumbers()) {
|
||||
if (isValidAddress(number, isMms))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/*public boolean hasInvalidRecipient(boolean isMms) {
|
||||
for (String number : mTokenizer.getNumbers()) {
|
||||
if (!isValidAddress(number, isMms)) {
|
||||
/* TODO if (MmsConfig.getEmailGateway() == null) {
|
||||
return true;
|
||||
} else if (!MessageUtils.isAlias(number)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}*/
|
||||
|
||||
public String formatInvalidNumbers(boolean isMms) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String number : mTokenizer.getNumbers()) {
|
||||
if (!isValidAddress(number, isMms)) {
|
||||
if (sb.length() != 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
sb.append(number);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/*public boolean containsEmail() {
|
||||
if (TextUtils.indexOf(getText(), '@') == -1)
|
||||
return false;
|
||||
|
||||
List<String> numbers = mTokenizer.getNumbers();
|
||||
for (String number : numbers) {
|
||||
if (Mms.isEmailAddress(number))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}*/
|
||||
|
||||
public static CharSequence contactToToken(@NonNull Context context, @NonNull Recipient c) {
|
||||
String name = c.getDisplayName(context);
|
||||
String number = OptionalUtil.or(c.getE164(), c.getEmail()).orElse("");
|
||||
SpannableString s = new SpannableString(RecipientsFormatter.formatNameAndNumber(name, number));
|
||||
int len = s.length();
|
||||
|
||||
if (len == 0) {
|
||||
return s;
|
||||
}
|
||||
|
||||
s.setSpan(new Annotation("number", number), 0, len,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
public void populate(List<Recipient> list) {
|
||||
SpannableStringBuilder sb = new SpannableStringBuilder();
|
||||
|
||||
for (Recipient c : list) {
|
||||
if (sb.length() != 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
|
||||
sb.append(contactToToken(mContext, c));
|
||||
}
|
||||
|
||||
setText(sb);
|
||||
}
|
||||
|
||||
private int pointToPosition(int x, int y) {
|
||||
x -= getCompoundPaddingLeft();
|
||||
y -= getExtendedPaddingTop();
|
||||
|
||||
x += getScrollX();
|
||||
y += getScrollY();
|
||||
|
||||
Layout layout = getLayout();
|
||||
if (layout == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int line = layout.getLineForVertical(y);
|
||||
int off = layout.getOffsetForHorizontal(line, x);
|
||||
|
||||
return off;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent ev) {
|
||||
final int action = ev.getAction();
|
||||
final int x = (int) ev.getX();
|
||||
final int y = (int) ev.getY();
|
||||
|
||||
if (action == MotionEvent.ACTION_DOWN) {
|
||||
mLongPressedPosition = pointToPosition(x, y);
|
||||
}
|
||||
|
||||
return super.onTouchEvent(ev);
|
||||
}
|
||||
|
||||
private static String getNumberAt(Spanned sp, int start, int end, Context context) {
|
||||
return getFieldAt("number", sp, start, end, context);
|
||||
}
|
||||
|
||||
private static int getSpanLength(Spanned sp, int start, int end, Context context) {
|
||||
// TODO: there's a situation where the span can lose its annotations:
|
||||
// - add an auto-complete contact
|
||||
// - add another auto-complete contact
|
||||
// - delete that second contact and keep deleting into the first
|
||||
// - we lose the annotation and can no longer get the span.
|
||||
// Need to fix this case because it breaks auto-complete contacts with commas in the name.
|
||||
Annotation[] a = sp.getSpans(start, end, Annotation.class);
|
||||
if (a.length > 0) {
|
||||
return sp.getSpanEnd(a[0]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static String getFieldAt(String field, Spanned sp, int start, int end,
|
||||
Context context) {
|
||||
Annotation[] a = sp.getSpans(start, end, Annotation.class);
|
||||
String fieldValue = getAnnotation(a, field);
|
||||
if (TextUtils.isEmpty(fieldValue)) {
|
||||
fieldValue = TextUtils.substring(sp, start, end);
|
||||
}
|
||||
return fieldValue;
|
||||
|
||||
}
|
||||
|
||||
private static String getAnnotation(Annotation[] a, String key) {
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i].getKey().equals(key)) {
|
||||
return a[i].getValue();
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private class RecipientsEditorTokenizer
|
||||
implements MultiAutoCompleteTextView.Tokenizer {
|
||||
private final MultiAutoCompleteTextView mList;
|
||||
private final Context mContext;
|
||||
|
||||
RecipientsEditorTokenizer(Context context, MultiAutoCompleteTextView list) {
|
||||
mList = list;
|
||||
mContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the start of the token that ends at offset
|
||||
* <code>cursor</code> within <code>text</code>.
|
||||
* It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
|
||||
*/
|
||||
public int findTokenStart(CharSequence text, int cursor) {
|
||||
int i = cursor;
|
||||
char c;
|
||||
|
||||
while (i > 0 && (c = text.charAt(i - 1)) != ',' && c != ';') {
|
||||
i--;
|
||||
}
|
||||
while (i < cursor && text.charAt(i) == ' ') {
|
||||
i++;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the end of the token (minus trailing punctuation)
|
||||
* that begins at offset <code>cursor</code> within <code>text</code>.
|
||||
* It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
|
||||
*/
|
||||
public int findTokenEnd(CharSequence text, int cursor) {
|
||||
int i = cursor;
|
||||
int len = text.length();
|
||||
char c;
|
||||
|
||||
while (i < len) {
|
||||
if ((c = text.charAt(i)) == ',' || c == ';') {
|
||||
return i;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns <code>text</code>, modified, if necessary, to ensure that
|
||||
* it ends with a token terminator (for example a space or comma).
|
||||
* It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
|
||||
*/
|
||||
public CharSequence terminateToken(CharSequence text) {
|
||||
int i = text.length();
|
||||
|
||||
while (i > 0 && text.charAt(i - 1) == ' ') {
|
||||
i--;
|
||||
}
|
||||
|
||||
char c;
|
||||
if (i > 0 && ((c = text.charAt(i - 1)) == ',' || c == ';')) {
|
||||
return text;
|
||||
} else {
|
||||
// Use the same delimiter the user just typed.
|
||||
// This lets them have a mixture of commas and semicolons in their list.
|
||||
String separator = mLastSeparator + " ";
|
||||
if (text instanceof Spanned) {
|
||||
SpannableString sp = new SpannableString(text + separator);
|
||||
TextUtils.copySpansFrom((Spanned) text, 0, text.length(),
|
||||
Object.class, sp, 0);
|
||||
return sp;
|
||||
} else {
|
||||
return text + separator;
|
||||
}
|
||||
}
|
||||
}
|
||||
public String getRawString() {
|
||||
return mList.getText().toString();
|
||||
}
|
||||
public List<String> getNumbers() {
|
||||
Spanned sp = mList.getText();
|
||||
int len = sp.length();
|
||||
List<String> list = new ArrayList<String>();
|
||||
|
||||
int start = 0;
|
||||
int i = 0;
|
||||
while (i < len + 1) {
|
||||
char c;
|
||||
if ((i == len) || ((c = sp.charAt(i)) == ',') || (c == ';')) {
|
||||
if (i > start) {
|
||||
list.add(getNumberAt(sp, start, i, mContext));
|
||||
|
||||
// calculate the recipients total length. This is so if the name contains
|
||||
// commas or semis, we'll skip over the whole name to the next
|
||||
// recipient, rather than parsing this single name into multiple
|
||||
// recipients.
|
||||
int spanLen = getSpanLength(sp, start, i, mContext);
|
||||
if (spanLen > i) {
|
||||
i = spanLen;
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
|
||||
while ((i < len) && (sp.charAt(i) == ' ')) {
|
||||
i++;
|
||||
}
|
||||
|
||||
start = i;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
static class RecipientContextMenuInfo implements ContextMenuInfo {
|
||||
final Recipient recipient;
|
||||
|
||||
RecipientContextMenuInfo(Recipient r) {
|
||||
recipient = r;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,12 @@
|
|||
package org.thoughtcrime.securesms.contacts.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.contacts.ContactLinkConfiguration
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.io.IOException
|
||||
|
||||
|
@ -11,6 +15,9 @@ import java.io.IOException
|
|||
*/
|
||||
object ContactDiscovery {
|
||||
|
||||
private const val MESSAGE_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"
|
||||
private const val CALL_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call"
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
@WorkerThread
|
||||
|
@ -37,4 +44,17 @@ object ContactDiscovery {
|
|||
fun syncRecipientInfoWithSystemContacts(context: Context) {
|
||||
DirectoryHelper.syncRecipientInfoWithSystemContacts(context)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun buildContactLinkConfiguration(context: Context, account: Account): ContactLinkConfiguration {
|
||||
return ContactLinkConfiguration(
|
||||
account = account,
|
||||
appName = context.getString(R.string.app_name),
|
||||
messagePrompt = { e164 -> context.getString(R.string.ContactsDatabase_message_s, e164) },
|
||||
callPrompt = { e164 -> context.getString(R.string.ContactsDatabase_signal_call_s, e164) },
|
||||
e164Formatter = { number -> PhoneNumberFormatter.get(context).format(number) },
|
||||
messageMimetype = MESSAGE_MIMETYPE,
|
||||
callMimetype = CALL_MIMETYPE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,21 +17,12 @@ final class ContactHolder {
|
|||
|
||||
private static final String TAG = Log.tag(ContactHolder.class);
|
||||
|
||||
private final String lookupKey;
|
||||
private final List<PhoneNumberRecord> phoneNumberRecords = new LinkedList<>();
|
||||
|
||||
private StructuredNameRecord structuredNameRecord;
|
||||
|
||||
ContactHolder(@NonNull String lookupKey) {
|
||||
this.lookupKey = lookupKey;
|
||||
}
|
||||
|
||||
@NonNull String getLookupKey() {
|
||||
return lookupKey;
|
||||
}
|
||||
|
||||
public void addPhoneNumberRecord(@NonNull PhoneNumberRecord phoneNumberRecord) {
|
||||
phoneNumberRecords.add(phoneNumberRecord);
|
||||
public void addPhoneNumberRecords(@NonNull List<PhoneNumberRecord> phoneNumberRecords) {
|
||||
this.phoneNumberRecords.addAll(phoneNumberRecords);
|
||||
}
|
||||
|
||||
public void setStructuredNameRecord(@NonNull StructuredNameRecord structuredNameRecord) {
|
||||
|
|
|
@ -4,20 +4,20 @@ import android.Manifest;
|
|||
import android.accounts.Account;
|
||||
import android.content.Context;
|
||||
import android.content.OperationApplicationException;
|
||||
import android.database.Cursor;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.ContactsContract;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.contacts.SystemContactsRepository;
|
||||
import org.signal.contacts.SystemContactsRepository.ContactDetails;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.BulkOperationsHandle;
|
||||
|
@ -36,13 +36,11 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
|||
import org.thoughtcrime.securesms.registration.RegistrationUtil;
|
||||
import org.thoughtcrime.securesms.sms.IncomingJoinedMessage;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.signal.core.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
||||
|
@ -50,7 +48,6 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
|||
import org.whispersystems.signalservice.api.push.ACI;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.services.ProfileService;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
@ -60,7 +57,6 @@ import java.util.Collections;
|
|||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
@ -94,10 +90,12 @@ class DirectoryHelper {
|
|||
}
|
||||
|
||||
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
|
||||
Set<String> databaseNumbers = sanitizeNumbers(recipientDatabase.getAllPhoneNumbers());
|
||||
Set<String> systemNumbers = sanitizeNumbers(ContactAccessor.getInstance().getAllContactsWithNumbers(context));
|
||||
Set<String> databaseE164s = sanitizeNumbers(recipientDatabase.getAllE164s());
|
||||
Set<String> systemE164s = sanitizeNumbers(Stream.of(SystemContactsRepository.getAllDisplayNumbers(context))
|
||||
.map(number -> PhoneNumberFormatter.get(context).format(number))
|
||||
.collect(Collectors.toSet()));
|
||||
|
||||
refreshNumbers(context, databaseNumbers, systemNumbers, notifyOfNewUsers, true);
|
||||
refreshNumbers(context, databaseE164s, systemE164s, notifyOfNewUsers, true);
|
||||
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
}
|
||||
|
@ -302,7 +300,10 @@ class DirectoryHelper {
|
|||
return;
|
||||
}
|
||||
|
||||
Account account = SystemContactsRepository.getOrCreateSystemAccount(context);
|
||||
Stopwatch stopwatch = new Stopwatch("contacts");
|
||||
|
||||
Account account = SystemContactsRepository.getOrCreateSystemAccount(context, BuildConfig.APPLICATION_ID, context.getString(R.string.app_name));
|
||||
stopwatch.split("account");
|
||||
|
||||
if (account == null) {
|
||||
Log.w(TAG, "Failed to create an account!");
|
||||
|
@ -310,16 +311,23 @@ class DirectoryHelper {
|
|||
}
|
||||
|
||||
try {
|
||||
List<String> activeAddresses = Stream.of(activeIds)
|
||||
.map(Recipient::resolved)
|
||||
.filter(Recipient::hasE164)
|
||||
.map(Recipient::requireE164)
|
||||
.toList();
|
||||
Set<String> activeE164s = Stream.of(activeIds)
|
||||
.map(Recipient::resolved)
|
||||
.filter(Recipient::hasE164)
|
||||
.map(Recipient::requireE164)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
SystemContactsRepository.removeDeletedRawContacts(context, account);
|
||||
SystemContactsRepository.setRegisteredUsers(context, account, activeAddresses, removeMissing);
|
||||
SystemContactsRepository.removeDeletedRawContactsForAccount(context, account);
|
||||
stopwatch.split("remove-deleted");
|
||||
SystemContactsRepository.addMessageAndCallLinksToContacts(context,
|
||||
ContactDiscovery.buildContactLinkConfiguration(context, account),
|
||||
activeE164s,
|
||||
removeMissing);
|
||||
stopwatch.split("add-links");
|
||||
|
||||
syncRecipientInfoWithSystemContacts(context, rewrites);
|
||||
stopwatch.split("sync-info");
|
||||
stopwatch.stop(TAG);
|
||||
} catch (RemoteException | OperationApplicationException e) {
|
||||
Log.w(TAG, "Failed to update contacts.", e);
|
||||
}
|
||||
|
@ -329,59 +337,25 @@ class DirectoryHelper {
|
|||
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
|
||||
BulkOperationsHandle handle = recipientDatabase.beginBulkSystemContactUpdate();
|
||||
|
||||
try (Cursor cursor = ContactAccessor.getInstance().getAllSystemContacts(context)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String mimeType = getMimeType(cursor);
|
||||
try (SystemContactsRepository.ContactIterator iterator = SystemContactsRepository.getAllSystemContacts(context, rewrites, (number) -> PhoneNumberFormatter.get(context).format(number))) {
|
||||
while (iterator.hasNext()) {
|
||||
ContactDetails contact = iterator.next();
|
||||
ContactHolder holder = new ContactHolder();
|
||||
StructuredNameRecord name = new StructuredNameRecord(contact.getGivenName(), contact.getFamilyName());
|
||||
List<PhoneNumberRecord> phones = Stream.of(contact.getNumbers())
|
||||
.map(number -> {
|
||||
return new PhoneNumberRecord.Builder()
|
||||
.withRecipientId(Recipient.externalContact(context, number.getNumber()).getId())
|
||||
.withContactUri(number.getContactUri())
|
||||
.withDisplayName(number.getDisplayName())
|
||||
.withContactPhotoUri(number.getPhotoUri())
|
||||
.withContactLabel(number.getLabel())
|
||||
.build();
|
||||
}).toList();
|
||||
|
||||
if (!isPhoneMimeType(mimeType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String lookupKey = getLookupKey(cursor);
|
||||
ContactHolder contactHolder = new ContactHolder(lookupKey);
|
||||
|
||||
while (!cursor.isAfterLast() && getLookupKey(cursor).equals(lookupKey) && isPhoneMimeType(getMimeType(cursor))) {
|
||||
String number = CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.NUMBER);
|
||||
|
||||
if (isValidContactNumber(number)) {
|
||||
String formattedNumber = PhoneNumberFormatter.get(context).format(number);
|
||||
String realNumber = Util.getFirstNonEmpty(rewrites.get(formattedNumber), formattedNumber);
|
||||
|
||||
PhoneNumberRecord.Builder builder = new PhoneNumberRecord.Builder();
|
||||
|
||||
builder.withRecipientId(Recipient.externalContact(context, realNumber).getId());
|
||||
builder.withDisplayName(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
|
||||
builder.withContactPhotoUri(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.PHOTO_URI));
|
||||
builder.withContactLabel(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.LABEL));
|
||||
builder.withPhoneType(CursorUtil.requireInt(cursor, ContactsContract.CommonDataKinds.Phone.TYPE));
|
||||
builder.withContactUri(ContactsContract.Contacts.getLookupUri(CursorUtil.requireLong(cursor, ContactsContract.CommonDataKinds.Phone._ID),
|
||||
CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY)));
|
||||
|
||||
contactHolder.addPhoneNumberRecord(builder.build());
|
||||
} else {
|
||||
Log.w(TAG, "Skipping phone entry with invalid number");
|
||||
}
|
||||
|
||||
cursor.moveToNext();
|
||||
}
|
||||
|
||||
if (!cursor.isAfterLast() && getLookupKey(cursor).equals(lookupKey)) {
|
||||
if (isStructuredNameMimeType(getMimeType(cursor))) {
|
||||
StructuredNameRecord.Builder builder = new StructuredNameRecord.Builder();
|
||||
|
||||
builder.withGivenName(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME));
|
||||
builder.withFamilyName(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME));
|
||||
|
||||
contactHolder.setStructuredNameRecord(builder.build());
|
||||
} else {
|
||||
Log.i(TAG, "Skipping invalid mimeType " + mimeType);
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "No structured name for user, rolling back cursor.");
|
||||
cursor.moveToPrevious();
|
||||
}
|
||||
|
||||
contactHolder.commit(handle);
|
||||
holder.setStructuredNameRecord(name);
|
||||
holder.addPhoneNumberRecords(phones);
|
||||
holder.commit(handle);
|
||||
}
|
||||
} catch (IllegalStateException e) {
|
||||
Log.w(TAG, "Hit an issue with the cursor while reading!", e);
|
||||
|
@ -399,26 +373,6 @@ class DirectoryHelper {
|
|||
}
|
||||
}
|
||||
|
||||
private static boolean isPhoneMimeType(@NonNull String mimeType) {
|
||||
return ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mimeType);
|
||||
}
|
||||
|
||||
private static boolean isStructuredNameMimeType(@NonNull String mimeType) {
|
||||
return ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE.equals(mimeType);
|
||||
}
|
||||
|
||||
private static boolean isValidContactNumber(@Nullable String number) {
|
||||
return !TextUtils.isEmpty(number) && !UuidUtil.isUuid(number);
|
||||
}
|
||||
|
||||
private static @NonNull String getLookupKey(@NonNull Cursor cursor) {
|
||||
return Objects.requireNonNull(CursorUtil.requireString(cursor, ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY));
|
||||
}
|
||||
|
||||
private static @NonNull String getMimeType(@NonNull Cursor cursor) {
|
||||
return CursorUtil.requireString(cursor, ContactsContract.Data.MIMETYPE);
|
||||
}
|
||||
|
||||
private static void notifyNewUsers(@NonNull Context context,
|
||||
@NonNull Collection<RecipientId> newUsers)
|
||||
{
|
||||
|
|
|
@ -77,12 +77,12 @@ final class PhoneNumberRecord {
|
|||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withContactLabel(@NonNull String contactLabel) {
|
||||
@NonNull Builder withContactLabel(@Nullable String contactLabel) {
|
||||
this.contactLabel = contactLabel;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withContactPhotoUri(@NonNull String contactPhotoUri) {
|
||||
@NonNull Builder withContactPhotoUri(@Nullable String contactPhotoUri) {
|
||||
this.contactPhotoUri = contactPhotoUri;
|
||||
return this;
|
||||
}
|
||||
|
|
|
@ -12,9 +12,9 @@ final class StructuredNameRecord {
|
|||
private final String givenName;
|
||||
private final String familyName;
|
||||
|
||||
StructuredNameRecord(@NonNull StructuredNameRecord.Builder builder) {
|
||||
this.givenName = builder.givenName;
|
||||
this.familyName = builder.familyName;
|
||||
public StructuredNameRecord(@Nullable String givenName, @Nullable String familyName) {
|
||||
this.givenName = givenName;
|
||||
this.familyName = familyName;
|
||||
}
|
||||
|
||||
public boolean hasGivenName() {
|
||||
|
@ -24,23 +24,4 @@ final class StructuredNameRecord {
|
|||
public @NonNull ProfileName asProfileName() {
|
||||
return ProfileName.fromParts(givenName, familyName);
|
||||
}
|
||||
|
||||
final static class Builder {
|
||||
private String givenName;
|
||||
private String familyName;
|
||||
|
||||
@NonNull Builder withGivenName(@Nullable String givenName) {
|
||||
this.givenName = givenName;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder withFamilyName(@Nullable String familyName) {
|
||||
this.familyName = familyName;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull StructuredNameRecord build() {
|
||||
return new StructuredNameRecord(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,540 +0,0 @@
|
|||
package org.thoughtcrime.securesms.contacts.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.OperationApplicationException
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import android.provider.BaseColumns
|
||||
import android.provider.ContactsContract
|
||||
import org.signal.core.util.ListUtil
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireString
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import java.util.ArrayList
|
||||
import java.util.HashMap
|
||||
|
||||
/**
|
||||
* A way to retrieve and update data in the Android system contacts.
|
||||
*/
|
||||
object SystemContactsRepository {
|
||||
|
||||
private val TAG = Log.tag(SystemContactsRepository::class.java)
|
||||
private const val CONTACT_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"
|
||||
private const val CALL_MIMETYPE = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.call"
|
||||
private const val SYNC = "__TS"
|
||||
|
||||
@JvmStatic
|
||||
fun getOrCreateSystemAccount(context: Context): Account? {
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
val accounts: Array<Account> = accountManager.getAccountsByType(BuildConfig.APPLICATION_ID)
|
||||
var account: Account? = if (accounts.isNotEmpty()) accounts[0] else null
|
||||
|
||||
if (account == null) {
|
||||
Log.i(TAG, "Attempting to create a new account...")
|
||||
val newAccount = Account(context.getString(R.string.app_name), BuildConfig.APPLICATION_ID)
|
||||
|
||||
if (accountManager.addAccountExplicitly(newAccount, null, null)) {
|
||||
Log.i(TAG, "Successfully created a new account.")
|
||||
ContentResolver.setIsSyncable(newAccount, ContactsContract.AUTHORITY, 1)
|
||||
account = newAccount
|
||||
} else {
|
||||
Log.w(TAG, "Failed to create a new account!")
|
||||
}
|
||||
}
|
||||
|
||||
if (account != null && !ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) {
|
||||
Log.i(TAG, "Updated account to sync automatically.")
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
fun removeDeletedRawContacts(context: Context, account: Account) {
|
||||
val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.build()
|
||||
|
||||
val projection = arrayOf(BaseColumns._ID, ContactsContract.RawContacts.SYNC1)
|
||||
|
||||
context.contentResolver.query(currentContactsUri, projection, "${ContactsContract.RawContacts.DELETED} = ?", SqlUtil.buildArgs(1), null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val rawContactId = cursor.getLong(0)
|
||||
|
||||
Log.i(TAG, """Deleting raw contact: ${cursor.getString(1)}, $rawContactId""")
|
||||
context.contentResolver.delete(currentContactsUri, "${ContactsContract.RawContacts._ID} = ?", arrayOf(rawContactId.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
@Throws(RemoteException::class, OperationApplicationException::class)
|
||||
fun setRegisteredUsers(
|
||||
context: Context,
|
||||
account: Account,
|
||||
registeredAddressList: List<String>,
|
||||
remove: Boolean
|
||||
) {
|
||||
val registeredAddressSet: Set<String> = registeredAddressList.toSet()
|
||||
val operations: ArrayList<ContentProviderOperation> = ArrayList()
|
||||
val currentContacts: Map<String, SignalContact> = getSignalRawContacts(context, account)
|
||||
|
||||
val registeredChunks: List<List<String>> = ListUtil.chunk(registeredAddressList, 50)
|
||||
for (registeredChunk in registeredChunks) {
|
||||
for (registeredAddress in registeredChunk) {
|
||||
if (!currentContacts.containsKey(registeredAddress)) {
|
||||
val systemContactInfo: SystemContactInfo? = getSystemContactInfo(context, registeredAddress)
|
||||
if (systemContactInfo != null) {
|
||||
Log.i(TAG, "Adding number: $registeredAddress")
|
||||
addTextSecureRawContact(
|
||||
context = context,
|
||||
operations = operations,
|
||||
account = account,
|
||||
e164number = systemContactInfo.number,
|
||||
displayName = systemContactInfo.name,
|
||||
aggregateId = systemContactInfo.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operations.isNotEmpty()) {
|
||||
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, operations)
|
||||
operations.clear()
|
||||
}
|
||||
}
|
||||
|
||||
for ((key, value) in currentContacts) {
|
||||
if (!registeredAddressSet.contains(key)) {
|
||||
if (remove) {
|
||||
Log.i(TAG, "Removing number: $key")
|
||||
removeTextSecureRawContact(operations, account, value.id)
|
||||
}
|
||||
} else if (!value.isVoiceSupported()) {
|
||||
Log.i(TAG, "Adding voice support: $key")
|
||||
addContactVoiceSupport(context, operations, key, value.id)
|
||||
} else if (!Util.isStringEquals(value.rawDisplayName, value.aggregateDisplayName)) {
|
||||
Log.i(TAG, "Updating display name: $key")
|
||||
updateDisplayName(operations, value.aggregateDisplayName, value.id, value.displayNameSource)
|
||||
}
|
||||
}
|
||||
|
||||
if (operations.isNotEmpty()) {
|
||||
applyOperationsInBatches(context.contentResolver, ContactsContract.AUTHORITY, operations, 50)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getNameDetails(context: Context, contactId: Long): NameDetails? {
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.PREFIX,
|
||||
ContactsContract.CommonDataKinds.StructuredName.SUFFIX,
|
||||
ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME
|
||||
)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
|
||||
return context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
NameDetails(
|
||||
displayName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME),
|
||||
givenName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME),
|
||||
familyName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME),
|
||||
prefix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.PREFIX),
|
||||
suffix = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.SUFFIX),
|
||||
middleName = cursor.requireString(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getOrganizationName(context: Context, contactId: Long): String? {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
|
||||
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getPhoneDetails(context: Context, contactId: Long): List<PhoneDetails> {
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
ContactsContract.CommonDataKinds.Phone.TYPE,
|
||||
ContactsContract.CommonDataKinds.Phone.LABEL
|
||||
)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
|
||||
|
||||
val phoneDetails: MutableList<PhoneDetails> = mutableListOf()
|
||||
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
phoneDetails += PhoneDetails(
|
||||
number = cursor.requireString(ContactsContract.CommonDataKinds.Phone.NUMBER),
|
||||
type = cursor.requireInt(ContactsContract.CommonDataKinds.Phone.TYPE),
|
||||
label = cursor.requireString(ContactsContract.CommonDataKinds.Phone.LABEL)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return phoneDetails
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getEmailDetails(context: Context, contactId: Long): List<EmailDetails> {
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Email.ADDRESS,
|
||||
ContactsContract.CommonDataKinds.Email.TYPE,
|
||||
ContactsContract.CommonDataKinds.Email.LABEL
|
||||
)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
|
||||
|
||||
val emailDetails: MutableList<EmailDetails> = mutableListOf()
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
emailDetails += EmailDetails(
|
||||
address = cursor.requireString(ContactsContract.CommonDataKinds.Email.ADDRESS),
|
||||
type = cursor.requireInt(ContactsContract.CommonDataKinds.Email.TYPE),
|
||||
label = cursor.requireString(ContactsContract.CommonDataKinds.Email.LABEL)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return emailDetails
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getPostalAddressDetails(context: Context, contactId: Long): List<PostalAddressDetails> {
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.TYPE,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.LABEL,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.STREET,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.POBOX,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.CITY,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.REGION,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY
|
||||
)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE)
|
||||
|
||||
val postalDetails: MutableList<PostalAddressDetails> = mutableListOf()
|
||||
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
postalDetails += PostalAddressDetails(
|
||||
type = cursor.requireInt(ContactsContract.CommonDataKinds.StructuredPostal.TYPE),
|
||||
label = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.LABEL),
|
||||
street = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.STREET),
|
||||
poBox = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POBOX),
|
||||
neighborhood = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.NEIGHBORHOOD),
|
||||
city = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.CITY),
|
||||
region = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.REGION),
|
||||
postal = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.POSTCODE),
|
||||
country = cursor.requireString(ContactsContract.CommonDataKinds.StructuredPostal.COUNTRY)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return postalDetails
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getAvatarUri(context: Context, contactId: Long): Uri? {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_URI)
|
||||
val selection = "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId, ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
|
||||
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val uri = cursor.getString(0)
|
||||
if (uri != null) {
|
||||
return Uri.parse(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun addContactVoiceSupport(context: Context, operations: MutableList<ContentProviderOperation>, address: String, rawContactId: Long) {
|
||||
operations.add(
|
||||
ContentProviderOperation.newUpdate(ContactsContract.RawContacts.CONTENT_URI)
|
||||
.withSelection("${ContactsContract.RawContacts._ID} = ?", arrayOf(rawContactId.toString()))
|
||||
.withValue(ContactsContract.RawContacts.SYNC4, "true")
|
||||
.build()
|
||||
)
|
||||
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build())
|
||||
.withValue(ContactsContract.Data.RAW_CONTACT_ID, rawContactId)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE)
|
||||
.withValue(ContactsContract.Data.DATA1, address)
|
||||
.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
|
||||
.withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, address))
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateDisplayName(operations: MutableList<ContentProviderOperation>, displayName: String?, rawContactId: Long, displayNameSource: Int) {
|
||||
val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.build()
|
||||
|
||||
if (displayNameSource != ContactsContract.DisplayNameSources.STRUCTURED_NAME) {
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(dataUri)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, rawContactId)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
operations.add(
|
||||
ContentProviderOperation.newUpdate(dataUri)
|
||||
.withSelection("${ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?", SqlUtil.buildArgs(rawContactId.toString(), ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE))
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addTextSecureRawContact(
|
||||
context: Context,
|
||||
operations: MutableList<ContentProviderOperation>,
|
||||
account: Account,
|
||||
e164number: String,
|
||||
displayName: String,
|
||||
aggregateId: Long
|
||||
) {
|
||||
val index = operations.size
|
||||
val dataUri = ContactsContract.Data.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.build()
|
||||
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.withValue(ContactsContract.RawContacts.SYNC1, e164number)
|
||||
.withValue(ContactsContract.RawContacts.SYNC4, true.toString())
|
||||
.build()
|
||||
)
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(dataUri)
|
||||
.withValueBackReference(ContactsContract.CommonDataKinds.StructuredName.RAW_CONTACT_ID, index)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
.build()
|
||||
)
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(dataUri)
|
||||
.withValueBackReference(ContactsContract.CommonDataKinds.Phone.RAW_CONTACT_ID, index)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, e164number)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_OTHER)
|
||||
.withValue(ContactsContract.Data.SYNC2, SYNC)
|
||||
.build()
|
||||
)
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(dataUri)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, CONTACT_MIMETYPE)
|
||||
.withValue(ContactsContract.Data.DATA1, e164number)
|
||||
.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
|
||||
.withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_message_s, e164number))
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
)
|
||||
operations.add(
|
||||
ContentProviderOperation.newInsert(dataUri)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, index)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, CALL_MIMETYPE)
|
||||
.withValue(ContactsContract.Data.DATA1, e164number)
|
||||
.withValue(ContactsContract.Data.DATA2, context.getString(R.string.app_name))
|
||||
.withValue(ContactsContract.Data.DATA3, context.getString(R.string.ContactsDatabase_signal_call_s, e164number))
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
)
|
||||
operations.add(
|
||||
ContentProviderOperation.newUpdate(ContactsContract.AggregationExceptions.CONTENT_URI)
|
||||
.withValue(ContactsContract.AggregationExceptions.RAW_CONTACT_ID1, aggregateId)
|
||||
.withValueBackReference(ContactsContract.AggregationExceptions.RAW_CONTACT_ID2, index)
|
||||
.withValue(ContactsContract.AggregationExceptions.TYPE, ContactsContract.AggregationExceptions.TYPE_KEEP_TOGETHER)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeTextSecureRawContact(operations: MutableList<ContentProviderOperation>, account: Account, rowId: Long) {
|
||||
operations.add(
|
||||
ContentProviderOperation.newDelete(
|
||||
ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()
|
||||
)
|
||||
.withYieldAllowed(true)
|
||||
.withSelection("${BaseColumns._ID} = ?", SqlUtil.buildArgs(rowId))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun getSignalRawContacts(context: Context, account: Account): Map<String, SignalContact> {
|
||||
val currentContactsUri = ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type).build()
|
||||
val projection = arrayOf(BaseColumns._ID, ContactsContract.RawContacts.SYNC1, ContactsContract.RawContacts.SYNC4, ContactsContract.RawContacts.CONTACT_ID, ContactsContract.RawContacts.DISPLAY_NAME_PRIMARY, ContactsContract.RawContacts.DISPLAY_NAME_SOURCE)
|
||||
|
||||
val signalContacts: MutableMap<String, SignalContact> = HashMap()
|
||||
|
||||
context.contentResolver.query(currentContactsUri, projection, null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val currentAddress = PhoneNumberFormatter.get(context).format(cursor.getString(1))
|
||||
|
||||
signalContacts[currentAddress] = SignalContact(
|
||||
id = cursor.getLong(0),
|
||||
supportsVoice = cursor.getString(2),
|
||||
rawDisplayName = cursor.getString(4),
|
||||
aggregateDisplayName = getDisplayName(context, cursor.getLong(3)),
|
||||
displayNameSource = cursor.getInt(5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return signalContacts
|
||||
}
|
||||
|
||||
private fun getSystemContactInfo(context: Context, address: String): SystemContactInfo? {
|
||||
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address))
|
||||
val projection = arrayOf(
|
||||
ContactsContract.PhoneLookup.NUMBER,
|
||||
ContactsContract.PhoneLookup._ID,
|
||||
ContactsContract.PhoneLookup.DISPLAY_NAME
|
||||
)
|
||||
|
||||
context.contentResolver.query(uri, projection, null, null, null)?.use { numberCursor ->
|
||||
while (numberCursor.moveToNext()) {
|
||||
val systemNumber = numberCursor.getString(0)
|
||||
val systemAddress = PhoneNumberFormatter.get(context).format(systemNumber)
|
||||
if (systemAddress == address) {
|
||||
context.contentResolver.query(ContactsContract.RawContacts.CONTENT_URI, arrayOf(ContactsContract.RawContacts._ID), "${ContactsContract.RawContacts.CONTACT_ID} = ? ", SqlUtil.buildArgs(numberCursor.getLong(1)), null)?.use { idCursor ->
|
||||
if (idCursor.moveToNext()) {
|
||||
return SystemContactInfo(
|
||||
name = numberCursor.getString(2),
|
||||
number = numberCursor.getString(0),
|
||||
id = idCursor.getLong(0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getDisplayName(context: Context, contactId: Long): String? {
|
||||
val projection = arrayOf(ContactsContract.Contacts.DISPLAY_NAME)
|
||||
val selection = "${ContactsContract.Contacts._ID} = ?"
|
||||
val args = SqlUtil.buildArgs(contactId)
|
||||
|
||||
context.contentResolver.query(ContactsContract.Contacts.CONTENT_URI, projection, selection, args, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@Throws(OperationApplicationException::class, RemoteException::class)
|
||||
private fun applyOperationsInBatches(
|
||||
contentResolver: ContentResolver,
|
||||
authority: String,
|
||||
operations: List<ContentProviderOperation>,
|
||||
batchSize: Int
|
||||
) {
|
||||
val batches = ListUtil.chunk(operations, batchSize)
|
||||
for (batch in batches) {
|
||||
contentResolver.applyBatch(authority, ArrayList(batch))
|
||||
}
|
||||
}
|
||||
|
||||
private data class SystemContactInfo(val name: String, val number: String, val id: Long)
|
||||
|
||||
private data class SignalContact(
|
||||
val id: Long,
|
||||
val supportsVoice: String?,
|
||||
val rawDisplayName: String?,
|
||||
val aggregateDisplayName: String?,
|
||||
val displayNameSource: Int
|
||||
) {
|
||||
fun isVoiceSupported(): Boolean {
|
||||
return "true" == supportsVoice
|
||||
}
|
||||
}
|
||||
|
||||
data class NameDetails(
|
||||
val displayName: String?,
|
||||
val givenName: String?,
|
||||
val familyName: String?,
|
||||
val prefix: String?,
|
||||
val suffix: String?,
|
||||
val middleName: String?
|
||||
)
|
||||
|
||||
data class PhoneDetails(
|
||||
val number: String?,
|
||||
val type: Int,
|
||||
val label: String?
|
||||
)
|
||||
|
||||
data class EmailDetails(
|
||||
val address: String?,
|
||||
val type: Int,
|
||||
val label: String?
|
||||
)
|
||||
|
||||
data class PostalAddressDetails(
|
||||
val type: Int,
|
||||
val label: String?,
|
||||
val street: String?,
|
||||
val poBox: String?,
|
||||
val neighborhood: String?,
|
||||
val city: String?,
|
||||
val region: String?,
|
||||
val postal: String?,
|
||||
val country: String?
|
||||
)
|
||||
}
|
|
@ -11,9 +11,9 @@ import androidx.annotation.WorkerThread;
|
|||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.sync.SystemContactsRepository;
|
||||
import org.thoughtcrime.securesms.contacts.sync.SystemContactsRepository.NameDetails;
|
||||
import org.thoughtcrime.securesms.contacts.sync.SystemContactsRepository.PhoneDetails;
|
||||
import org.signal.contacts.SystemContactsRepository;
|
||||
import org.signal.contacts.SystemContactsRepository.NameDetails;
|
||||
import org.signal.contacts.SystemContactsRepository.PhoneDetails;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact.Email;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact.Name;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact.Phone;
|
||||
|
|
|
@ -1901,7 +1901,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
|||
}
|
||||
}
|
||||
|
||||
fun getAllPhoneNumbers(): Set<String> {
|
||||
fun getAllE164s(): Set<String> {
|
||||
val results: MutableSet<String> = HashSet()
|
||||
readableDatabase.query(TABLE_NAME, arrayOf(PHONE), null, null, null, null, null).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
|
|
|
@ -106,7 +106,7 @@ public class PhoneNumberFormatter {
|
|||
}
|
||||
|
||||
|
||||
public String format(@Nullable String number) {
|
||||
public @NonNull String format(@Nullable String number) {
|
||||
if (number == null) return "Unknown";
|
||||
if (GroupId.isEncodedGroup(number)) return number;
|
||||
if (ALPHA_PATTERN.matcher(number).find()) return number.trim();
|
||||
|
|
|
@ -472,11 +472,6 @@ public class Util {
|
|||
return (int)value;
|
||||
}
|
||||
|
||||
public static boolean isStringEquals(String first, String second) {
|
||||
if (first == null) return second == null;
|
||||
return first.equals(second);
|
||||
}
|
||||
|
||||
public static boolean isEquals(@Nullable Long first, long second) {
|
||||
return first != null && first == second;
|
||||
}
|
||||
|
|
|
@ -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>
|
50
contacts/app/build.gradle
Normal file
|
@ -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')
|
||||
}
|
39
contacts/app/src/main/AndroidManifest.xml
Normal file
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
171
contacts/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
@ -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>
|
15
contacts/app/src/main/res/layout/activity_contacts.xml
Normal file
|
@ -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>
|
19
contacts/app/src/main/res/layout/activity_main.xml
Normal file
|
@ -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>
|
67
contacts/app/src/main/res/layout/child_item.xml
Normal file
|
@ -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>
|
54
contacts/app/src/main/res/layout/parent_item.xml
Normal file
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
BIN
contacts/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
contacts/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
contacts/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
contacts/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
contacts/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.8 KiB |
BIN
contacts/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 7.3 KiB |
BIN
contacts/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
contacts/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
contacts/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
contacts/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 16 KiB |
16
contacts/app/src/main/res/values-night/themes.xml
Normal file
|
@ -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>
|
10
contacts/app/src/main/res/values/colors.xml
Normal file
|
@ -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>
|
3
contacts/app/src/main/res/values/strings.xml
Normal file
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">ContactsTest</string>
|
||||
</resources>
|
16
contacts/app/src/main/res/values/themes.xml
Normal file
|
@ -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>
|
6
contacts/app/src/main/res/xml/authenticator.xml
Normal file
|
@ -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"/>
|
||||
|
49
contacts/lib/build.gradle
Normal file
|
@ -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
|
||||
}
|
13
contacts/lib/src/main/AndroidManifest.xml
Normal file
|
@ -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>
|
|
@ -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,
|
||||
)
|
|
@ -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?)
|
||||
}
|
|
@ -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"/>
|
||||
|
|
|
@ -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'
|
||||
|
|