Merge branch 'sync-tel-contacts'

Stephen Paul Weber created

* sync-tel-contacts:
  Sync system contacts by phone number

Change summary

build.gradle                                                                     |   2 
src/cheogram/java/eu/siacs/conversations/android/PhoneNumberContact.java         |  90 
src/cheogram/java/eu/siacs/conversations/services/QuickConversationsService.java | 143 
src/cheogram/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java       |  92 
4 files changed, 321 insertions(+), 6 deletions(-)

Detailed changes

build.gradle 🔗

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

src/cheogram/java/eu/siacs/conversations/android/PhoneNumberContact.java 🔗

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

src/cheogram/java/eu/siacs/conversations/services/QuickConversationsService.java 🔗

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

src/cheogram/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java 🔗

@@ -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 {
+
+    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) {
-        throw new AssertionError("This method is not implemented in Conversations");
+        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);
+        }
+    }
+
 }