Merge branch 'sync-tel-contacts'

* sync-tel-contacts:
  Sync system contacts by phone number
This commit is contained in:
Stephen Paul Weber 2022-02-22 13:26:07 -05:00
commit f3fb32af66
No known key found for this signature in database
GPG key ID: D11C2911CE519CDE
4 changed files with 322 additions and 7 deletions

View file

@ -90,7 +90,7 @@ dependencies {
implementation "com.squareup.okhttp3:okhttp:4.9.3"
implementation 'com.google.guava:guava:30.1.1-android'
quicksyImplementation 'io.michaelrocks:libphonenumber-android:8.12.36'
implementation 'io.michaelrocks:libphonenumber-android:8.12.36'
implementation urlFile('https://cloudflare-ipfs.com/ipfs/QmeqMiLxHi8AAjXobxr3QTfa1bSSLyAu86YviAqQnjxCjM/libwebrtc.aar', 'libwebrtc.aar')
// INSERT
}

View file

@ -0,0 +1,90 @@
package eu.siacs.conversations.android;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.ContactsContract;
import android.util.Log;
import com.google.common.collect.ImmutableMap;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
import io.michaelrocks.libphonenumber.android.NumberParseException;
public class PhoneNumberContact extends AbstractPhoneContact {
private final String phoneNumber;
public String getPhoneNumber() {
return phoneNumber;
}
private PhoneNumberContact(Context context, Cursor cursor) throws IllegalArgumentException {
super(cursor);
try {
this.phoneNumber = PhoneNumberUtilWrapper.normalize(context, cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)));
} catch (NumberParseException | NullPointerException e) {
throw new IllegalArgumentException(e);
}
}
public static ImmutableMap<String, PhoneNumberContact> load(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
return ImmutableMap.of();
}
final String[] PROJECTION = new String[]{ContactsContract.Data._ID,
ContactsContract.Data.DISPLAY_NAME,
ContactsContract.Data.PHOTO_URI,
ContactsContract.Data.LOOKUP_KEY,
ContactsContract.CommonDataKinds.Phone.NUMBER};
final HashMap<String, PhoneNumberContact> contacts = new HashMap<>();
try (final Cursor cursor = context.getContentResolver().query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, PROJECTION, null, null, null)){
while (cursor != null && cursor.moveToNext()) {
try {
final PhoneNumberContact contact = new PhoneNumberContact(context, cursor);
final PhoneNumberContact preexisting = contacts.get(contact.getPhoneNumber());
if (preexisting == null || preexisting.rating() < contact.rating()) {
contacts.put(contact.getPhoneNumber(), contact);
}
} catch (final IllegalArgumentException ignored) {
}
}
} catch (final Exception e) {
return ImmutableMap.of();
}
return ImmutableMap.copyOf(contacts);
}
public static PhoneNumberContact findByUriOrNumber(Collection<PhoneNumberContact> haystack, Uri uri, String number) {
final PhoneNumberContact byUri = findByUri(haystack, uri);
return byUri != null || number == null ? byUri : findByNumber(haystack, number);
}
public static PhoneNumberContact findByUri(Collection<PhoneNumberContact> haystack, Uri needle) {
for (PhoneNumberContact contact : haystack) {
if (needle.equals(contact.getLookupUri())) {
return contact;
}
}
return null;
}
private static PhoneNumberContact findByNumber(Collection<PhoneNumberContact> haystack, String needle) {
for (PhoneNumberContact contact : haystack) {
if (needle.equals(contact.getPhoneNumber())) {
return contact;
}
}
return null;
}
}

View file

@ -1,19 +1,39 @@
package eu.siacs.conversations.services;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.Collection;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.google.common.collect.ImmutableMap;
import android.content.Intent;
import android.os.SystemClock;
import android.net.Uri;
import android.util.Log;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.android.PhoneNumberContact;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
import eu.siacs.conversations.xmpp.Jid;
public class QuickConversationsService extends AbstractQuickConversationsService {
protected final AtomicInteger mRunningSyncJobs = new AtomicInteger(0);
protected final SerialSingleThreadExecutor mSerialSingleThreadExecutor = new SerialSingleThreadExecutor(QuickConversationsService.class.getSimpleName());
protected Attempt mLastSyncAttempt = Attempt.NULL;
QuickConversationsService(XmppConnectionService xmppConnectionService) {
super(xmppConnectionService);
}
@Override
public void considerSync() {
considerSync(false);
}
@Override
@ -23,16 +43,131 @@ public class QuickConversationsService extends AbstractQuickConversationsService
@Override
public boolean isSynchronizing() {
return false;
return mRunningSyncJobs.get() > 0;
}
@Override
public void considerSyncBackground(boolean force) {
mRunningSyncJobs.incrementAndGet();
mSerialSingleThreadExecutor.execute(() -> {
considerSync(force);
if (mRunningSyncJobs.decrementAndGet() == 0) {
service.updateRosterUi();
}
});
}
@Override
public void handleSmsReceived(Intent intent) {
Log.d(Config.LOGTAG,"ignoring received SMS");
}
}
protected static String getNumber(final List<String> gateways, final Contact contact) {
final Jid jid = contact.getJid();
if (jid.getLocal() != null && ("quicksy.im".equals(jid.getDomain()) || gateways.contains(jid.getDomain()))) {
return jid.getLocal();
}
return null;
}
protected void refresh(Account account, final List<String> gateways, Collection<PhoneNumberContact> phoneNumberContacts) {
for (Contact contact : account.getRoster().getWithSystemAccounts(PhoneNumberContact.class)) {
final Uri uri = contact.getSystemAccount();
if (uri == null) {
continue;
}
final String number = getNumber(gateways, contact);
final PhoneNumberContact phoneNumberContact = PhoneNumberContact.findByUriOrNumber(phoneNumberContacts, uri, number);
final boolean needsCacheClean;
if (phoneNumberContact != null) {
if (!uri.equals(phoneNumberContact.getLookupUri())) {
Log.d(Config.LOGTAG, "lookupUri has changed from " + uri + " to " + phoneNumberContact.getLookupUri());
}
needsCacheClean = contact.setPhoneContact(phoneNumberContact);
} else {
needsCacheClean = contact.unsetPhoneContact(PhoneNumberContact.class);
Log.d(Config.LOGTAG, uri.toString() + " vanished from address book");
}
if (needsCacheClean) {
service.getAvatarService().clear(contact);
}
}
}
protected void considerSync(boolean forced) {
final ImmutableMap<String, PhoneNumberContact> allContacts = PhoneNumberContact.load(service);
for (final Account account : service.getAccounts()) {
List<String> gateways = gateways(account);
refresh(account, gateways, allContacts.values());
if (!considerSync(account, gateways, allContacts, forced)) {
service.syncRoster(account);
}
}
}
protected List<String> gateways(final Account account) {
List<String> gateways = new ArrayList();
for (final Contact contact : account.getRoster().getContacts()) {
if (contact.showInRoster() && (contact.getPresences().anyIdentity("gateway", "pstn") || contact.getPresences().anyIdentity("gateway", "sms"))) {
gateways.add(contact.getJid().asBareJid().toString());
}
}
return gateways;
}
protected boolean considerSync(final Account account, final List<String> gateways, final Map<String, PhoneNumberContact> contacts, final boolean forced) {
final int hash = contacts.keySet().hashCode();
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": consider sync of " + hash);
if (!mLastSyncAttempt.retry(hash) && !forced) {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": do not attempt sync");
return false;
}
mRunningSyncJobs.incrementAndGet();
mLastSyncAttempt = Attempt.create(hash);
final List<Contact> withSystemAccounts = account.getRoster().getWithSystemAccounts(PhoneNumberContact.class);
for (Map.Entry<String, PhoneNumberContact> item : contacts.entrySet()) {
PhoneNumberContact phoneContact = item.getValue();
for(String gateway : gateways) {
final Jid jid = Jid.ofLocalAndDomain(phoneContact.getPhoneNumber(), gateway);
final Contact contact = account.getRoster().getContact(jid);
final boolean needsCacheClean = contact.setPhoneContact(phoneContact);
if (needsCacheClean) {
service.getAvatarService().clear(contact);
}
withSystemAccounts.remove(contact);
}
}
for (final Contact contact : withSystemAccounts) {
final boolean needsCacheClean = contact.unsetPhoneContact(PhoneNumberContact.class);
if (needsCacheClean) {
service.getAvatarService().clear(contact);
}
}
mRunningSyncJobs.decrementAndGet();
service.syncRoster(account);
service.updateRosterUi();
return true;
}
protected static class Attempt {
private final long timestamp;
private final int hash;
private static final Attempt NULL = new Attempt(0, 0);
private Attempt(long timestamp, int hash) {
this.timestamp = timestamp;
this.hash = hash;
}
public static Attempt create(int hash) {
return new Attempt(SystemClock.elapsedRealtime(), hash);
}
public boolean retry(int hash) {
return hash != this.hash || SystemClock.elapsedRealtime() - timestamp >= Config.CONTACT_SYNC_RETRY_INTERVAL;
}
}
}

View file

@ -1,11 +1,101 @@
package eu.siacs.conversations.utils;
import android.content.Context;
import android.telephony.TelephonyManager;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import eu.siacs.conversations.xmpp.Jid;
import io.michaelrocks.libphonenumber.android.NumberParseException;
import io.michaelrocks.libphonenumber.android.PhoneNumberUtil;
import io.michaelrocks.libphonenumber.android.Phonenumber;
public class PhoneNumberUtilWrapper {
public static String toFormattedPhoneNumber(Context context, Jid jid) {
throw new AssertionError("This method is not implemented in Conversations");
private static volatile PhoneNumberUtil instance;
public static String getCountryForCode(String code) {
Locale locale = new Locale("", code);
return locale.getDisplayCountry();
}
public static String toFormattedPhoneNumber(Context context, Jid jid) {
try {
return getInstance(context).format(toPhoneNumber(context, jid), PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL).replace(' ','\u202F');
} catch (Exception e) {
return jid.getEscapedLocal();
}
}
public static Phonenumber.PhoneNumber toPhoneNumber(Context context, Jid jid) throws NumberParseException {
return getInstance(context).parse(jid.getEscapedLocal(), "de");
}
public static String normalize(Context context, String input) throws IllegalArgumentException, NumberParseException {
final Phonenumber.PhoneNumber number = getInstance(context).parse(input, LocationProvider.getUserCountry(context));
if (!getInstance(context).isValidNumber(number)) {
throw new IllegalArgumentException(String.format("%s is not a valid phone number", input));
}
return normalize(context, number);
}
public static String normalize(Context context, Phonenumber.PhoneNumber phoneNumber) {
return getInstance(context).format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);
}
public static PhoneNumberUtil getInstance(final Context context) {
PhoneNumberUtil localInstance = instance;
if (localInstance == null) {
synchronized (PhoneNumberUtilWrapper.class) {
localInstance = instance;
if (localInstance == null) {
instance = localInstance = PhoneNumberUtil.createInstance(context);
}
}
}
return localInstance;
}
public static List<Country> getCountries(final Context context) {
List<Country> countries = new ArrayList<>();
for (String region : getInstance(context).getSupportedRegions()) {
countries.add(new Country(region, getInstance(context).getCountryCodeForRegion(region)));
}
return countries;
}
public static class Country implements Comparable<Country> {
private final String name;
private final String region;
private final int code;
Country(String region, int code) {
this.name = getCountryForCode(region);
this.region = region;
this.code = code;
}
public String getName() {
return name;
}
public String getRegion() {
return region;
}
public String getCode() {
return '+' + String.valueOf(code);
}
@Override
public int compareTo(Country o) {
return name.compareTo(o.name);
}
}
}