~singpolyma/cheogram-android

3f4d9b7fd0fbc9d296dee030a4f578d48ea2f5a9 — Stephen Paul Weber 1 year, 9 months ago 120406c
Sync system contacts by phone number

Using the same logic as Quicksy, but not submitting to an API just assume that
tel@pstn-or-sms-gateway is a valid Jabber ID.

Does not add to roster or reveal presence or send anything to a server, just
affects local UI.
M build.gradle => build.gradle +1 -1
@@ 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
}

A src/cheogram/java/eu/siacs/conversations/android/PhoneNumberContact.java => src/cheogram/java/eu/siacs/conversations/android/PhoneNumberContact.java +90 -0
@@ 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;
    }
}

M src/cheogram/java/eu/siacs/conversations/services/QuickConversationsService.java => src/cheogram/java/eu/siacs/conversations/services/QuickConversationsService.java +139 -4
@@ 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");
    }
}
\ No newline at end of file

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

M src/cheogram/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java => src/cheogram/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java +91 -1
@@ 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);
        }
    }

}