diff --git a/build.gradle b/build.gradle index 542d60cb2d68252330d125b3e66c5b89bf9dd741..30264fab3672364bafa9069cc12dde7df9bfb7d2 100644 --- a/build.gradle +++ b/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 } diff --git a/src/cheogram/java/eu/siacs/conversations/android/PhoneNumberContact.java b/src/cheogram/java/eu/siacs/conversations/android/PhoneNumberContact.java new file mode 100644 index 0000000000000000000000000000000000000000..a8bbf88d9579451ba8e6c88d4df7649352531521 --- /dev/null +++ b/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 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 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 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 haystack, Uri needle) { + for (PhoneNumberContact contact : haystack) { + if (needle.equals(contact.getLookupUri())) { + return contact; + } + } + return null; + } + + private static PhoneNumberContact findByNumber(Collection haystack, String needle) { + for (PhoneNumberContact contact : haystack) { + if (needle.equals(contact.getPhoneNumber())) { + return contact; + } + } + return null; + } +} diff --git a/src/cheogram/java/eu/siacs/conversations/services/QuickConversationsService.java b/src/cheogram/java/eu/siacs/conversations/services/QuickConversationsService.java index b2a0d17f4acc8caa60e316cc64217e3a3604c9b7..0da279f82f76093f25633b72ce8ccabd536f787f 100644 --- a/src/cheogram/java/eu/siacs/conversations/services/QuickConversationsService.java +++ b/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"); } -} \ No newline at end of file + + protected static String getNumber(final List 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 gateways, Collection 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 allContacts = PhoneNumberContact.load(service); + for (final Account account : service.getAccounts()) { + List gateways = gateways(account); + refresh(account, gateways, allContacts.values()); + if (!considerSync(account, gateways, allContacts, forced)) { + service.syncRoster(account); + } + } + } + + protected List gateways(final Account account) { + List 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 gateways, final Map 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 withSystemAccounts = account.getRoster().getWithSystemAccounts(PhoneNumberContact.class); + for (Map.Entry 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; + } + } +} diff --git a/src/cheogram/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java b/src/cheogram/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java index 2f7963cf69d20ae746b3aff661a036606e86415f..4bc18b886c9662fe2a7eb035346a69660de6eb4f 100644 --- a/src/cheogram/java/eu/siacs/conversations/utils/PhoneNumberUtilWrapper.java +++ b/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 getCountries(final Context context) { + List 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 { + 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); + } + } + }