Only show "add connection" button if relevant

- We don't want to present the user with the "add connection"
button if the user doesn't use the account that provides the
service

- Check if app contributing the account type is disabled or not

- Check if there is an activity to handle the "add connection"
intent

- Check if there are raw contacts in the database with that
account type

- Store this in a cache, and refresh it after a certain
period of time (i.e. 1 second) using an AsyncTask.
This is to prevent computing the list each time the contact
is loaded (which can happen many times especially when looking
at a detail page during a sync).

- Make sure public AccountTypeManager methods
first check ensureAccountsLoaded()

Bug: 5398529
Change-Id: I004f9562a587035a3168aaddb6eb43710fd201e6
diff --git a/src/com/android/contacts/ContactLoader.java b/src/com/android/contacts/ContactLoader.java
index 007c1e0..c9fbeae 100644
--- a/src/com/android/contacts/ContactLoader.java
+++ b/src/com/android/contacts/ContactLoader.java
@@ -850,17 +850,15 @@
 
         /**
          * Sets the "invitable" account types to {@link Result#mInvitableAccountTypes}.
-         *
-         * TODO Exclude the ones with no raw contacts in the database.
          */
         private void loadInvitableAccountTypes(Result contactData) {
-            Map<AccountTypeWithDataSet, AccountType> allInvitables =
-                    AccountTypeManager.getInstance(getContext()).getInvitableAccountTypes();
-            if (allInvitables.isEmpty()) {
+            Map<AccountTypeWithDataSet, AccountType> invitables =
+                    AccountTypeManager.getInstance(getContext()).getUsableInvitableAccountTypes();
+            if (invitables.isEmpty()) {
                 return;
             }
 
-            HashMap<AccountTypeWithDataSet, AccountType> result = Maps.newHashMap(allInvitables);
+            HashMap<AccountTypeWithDataSet, AccountType> result = Maps.newHashMap(invitables);
 
             // Remove the ones that already have a raw contact in the current contact
             for (Entity entity : contactData.getEntities()) {
diff --git a/src/com/android/contacts/ContactsUtils.java b/src/com/android/contacts/ContactsUtils.java
index 9a3f2ef..b0c0508 100644
--- a/src/com/android/contacts/ContactsUtils.java
+++ b/src/com/android/contacts/ContactsUtils.java
@@ -25,6 +25,8 @@
 import android.content.Context;
 import android.content.Intent;
 import android.location.CountryDetector;
+import android.net.Uri;
+import android.provider.ContactsContract;
 import android.provider.ContactsContract.CommonDataKinds.Im;
 import android.provider.ContactsContract.CommonDataKinds.Phone;
 import android.telephony.PhoneNumberUtils;
@@ -184,4 +186,26 @@
                 AccountTypeManager.getInstance(context).getGroupWritableAccounts();
         return !accounts.isEmpty();
     }
+
+    /**
+     * Returns the intent to launch for the given invitable account type and contact lookup URI.
+     * This will return null if the account type is not invitable (i.e. there is no
+     * {@link AccountType#getInviteContactActivityClassName()} or
+     * {@link AccountType#resPackageName}).
+     */
+    public static Intent getInvitableIntent(AccountType accountType, Uri lookupUri) {
+        String resPackageName = accountType.resPackageName;
+        String className = accountType.getInviteContactActivityClassName();
+        if (TextUtils.isEmpty(resPackageName) || TextUtils.isEmpty(className)) {
+            return null;
+        }
+        Intent intent = new Intent();
+        intent.setClassName(resPackageName, className);
+
+        intent.setAction(ContactsContract.Intents.INVITE_CONTACT);
+
+        // Data is the lookup URI.
+        intent.setData(lookupUri);
+        return intent;
+    }
 }
diff --git a/src/com/android/contacts/activities/ContactDetailActivity.java b/src/com/android/contacts/activities/ContactDetailActivity.java
index 49d6672..1a8e383 100644
--- a/src/com/android/contacts/activities/ContactDetailActivity.java
+++ b/src/com/android/contacts/activities/ContactDetailActivity.java
@@ -244,6 +244,9 @@
             new ContactDetailFragment.Listener() {
         @Override
         public void onItemClicked(Intent intent) {
+            if (intent == null) {
+                return;
+            }
             try {
                 startActivity(intent);
             } catch (ActivityNotFoundException e) {
diff --git a/src/com/android/contacts/activities/PeopleActivity.java b/src/com/android/contacts/activities/PeopleActivity.java
index 0c3f448..24992cc 100644
--- a/src/com/android/contacts/activities/PeopleActivity.java
+++ b/src/com/android/contacts/activities/PeopleActivity.java
@@ -1098,6 +1098,9 @@
     public class ContactDetailFragmentListener implements ContactDetailFragment.Listener {
         @Override
         public void onItemClicked(Intent intent) {
+            if (intent == null) {
+                return;
+            }
             try {
                 startActivity(intent);
             } catch (ActivityNotFoundException e) {
diff --git a/src/com/android/contacts/detail/ContactDetailFragment.java b/src/com/android/contacts/detail/ContactDetailFragment.java
index aa49481..84dbb83 100644
--- a/src/com/android/contacts/detail/ContactDetailFragment.java
+++ b/src/com/android/contacts/detail/ContactDetailFragment.java
@@ -878,8 +878,10 @@
             @Override
             public void onItemClick(AdapterView<?> parent, View view, int position,
                     long id) {
-                if (mListener != null) {
-                    mListener.onItemClicked(popupAdapter.getIntent(mContext, position));
+                if (mListener != null && mContactData != null) {
+                    mListener.onItemClicked(ContactsUtils.getInvitableIntent(
+                            popupAdapter.getItem(position) /* account type */,
+                            mContactData.getLookupUri()));
                 }
             }
         };
@@ -2102,7 +2104,7 @@
 
     public static interface Listener {
         /**
-         * User clicked a single item (e.g. mail)
+         * User clicked a single item (e.g. mail). The intent passed in could be null.
          */
         public void onItemClicked(Intent intent);
 
@@ -2167,19 +2169,6 @@
             return resultView;
         }
 
-        public Intent getIntent(Context context, int position) {
-            final AccountType accountType = mAccountTypes.get(position);
-            Intent intent = new Intent();
-            intent.setClassName(accountType.resPackageName,
-                    accountType.getInviteContactActivityClassName());
-
-            intent.setAction(ContactsContract.Intents.INVITE_CONTACT);
-
-            // Data is the lookup URI.
-            intent.setData(mContactData.getLookupUri());
-            return intent;
-        }
-
         @Override
         public int getCount() {
             return mAccountTypes.size();
diff --git a/src/com/android/contacts/model/AccountTypeManager.java b/src/com/android/contacts/model/AccountTypeManager.java
index 5443196..6a438c6 100644
--- a/src/com/android/contacts/model/AccountTypeManager.java
+++ b/src/com/android/contacts/model/AccountTypeManager.java
@@ -16,6 +16,7 @@
 
 package com.android.contacts.model;
 
+import com.android.contacts.ContactsUtils;
 import com.android.contacts.util.Constants;
 import com.android.i18n.phonenumbers.PhoneNumberUtil;
 import com.android.internal.util.Objects;
@@ -37,6 +38,9 @@
 import android.content.SyncAdapterType;
 import android.content.SyncStatusObserver;
 import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.AsyncTask;
 import android.os.Handler;
 import android.os.HandlerThread;
 import android.os.Message;
@@ -56,6 +60,7 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * Singleton holder for all parsed {@link AccountType} available on the
@@ -110,8 +115,17 @@
     /**
      * @return Unmodifiable map from {@link AccountTypeWithDataSet}s to {@link AccountType}s
      * which support the "invite" feature and have one or more account.
+     *
+     * This is a filtered down and more "usable" list compared to
+     * {@link #getAllInvitableAccountTypes}, where usable is defined as:
+     * (1) making sure that the app that contributed the account type is not disabled
+     * (in order to avoid presenting the user with an option that does nothing), and
+     * (2) that there is at least one raw contact with that account type in the database
+     * (assuming that the user probably doesn't use that account type).
+     *
+     * Warning: Don't use on the UI thread because this can scan the database.
      */
-    public abstract Map<AccountTypeWithDataSet, AccountType> getInvitableAccountTypes();
+    public abstract Map<AccountTypeWithDataSet, AccountType> getUsableInvitableAccountTypes();
 
     /**
      * Find the best {@link DataKind} matching the requested
@@ -134,6 +148,19 @@
 class AccountTypeManagerImpl extends AccountTypeManager
         implements OnAccountsUpdateListener, SyncStatusObserver {
 
+    private static final Map<AccountTypeWithDataSet, AccountType>
+            EMPTY_UNMODIFIABLE_ACCOUNT_TYPE_MAP =
+            Collections.unmodifiableMap(new HashMap<AccountTypeWithDataSet, AccountType>());
+
+    /**
+     * A sample contact URI used to test whether any activities will respond to an
+     * invitable intent with the given URI as the intent data. This doesn't need to be
+     * specific to a real contact because an app that intercepts the intent should probably do so
+     * for all types of contact URIs.
+     */
+    private static final Uri SAMPLE_CONTACT_URI = ContactsContract.Contacts.getLookupUri(
+            1, "xxx");
+
     private Context mContext;
     private AccountManager mAccountManager;
 
@@ -144,7 +171,21 @@
     private List<AccountWithDataSet> mGroupWritableAccounts = Lists.newArrayList();
     private Map<AccountTypeWithDataSet, AccountType> mAccountTypesWithDataSets = Maps.newHashMap();
     private Map<AccountTypeWithDataSet, AccountType> mInvitableAccountTypes =
-            Collections.unmodifiableMap(new HashMap<AccountTypeWithDataSet, AccountType>());
+            EMPTY_UNMODIFIABLE_ACCOUNT_TYPE_MAP;
+
+    private final InvitableAccountTypeCache mInvitableAccountTypeCache;
+
+    /**
+     * The boolean value is equal to true if the {@link InvitableAccountTypeCache} has been
+     * initialized. False otherwise.
+     */
+    private final AtomicBoolean mInvitablesCacheIsInitialized = new AtomicBoolean(false);
+
+    /**
+     * The boolean value is equal to true if the {@link FindInvitablesTask} is still executing.
+     * False otherwise.
+     */
+    private final AtomicBoolean mInvitablesTaskIsRunning = new AtomicBoolean(false);
 
     private static final int MESSAGE_LOAD_DATA = 0;
     private static final int MESSAGE_PROCESS_BROADCAST_INTENT = 1;
@@ -229,6 +270,8 @@
             }
         };
 
+        mInvitableAccountTypeCache = new InvitableAccountTypeCache();
+
         // Request updates when packages or accounts change
         IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED);
         filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
@@ -436,7 +479,7 @@
             mAccounts = allAccounts;
             mContactWritableAccounts = contactWritableAccounts;
             mGroupWritableAccounts = groupWritableAccounts;
-            mInvitableAccountTypes = findInvitableAccountTypes(
+            mInvitableAccountTypes = findAllInvitableAccountTypes(
                     mContext, allAccounts, accountTypesByTypeAndDataSet);
         }
 
@@ -542,17 +585,54 @@
         }
     }
 
-    @Override
-    public Map<AccountTypeWithDataSet, AccountType> getInvitableAccountTypes() {
+    /**
+     * @return Unmodifiable map from {@link AccountTypeWithDataSet}s to {@link AccountType}s
+     * which support the "invite" feature and have one or more account. This is an unfiltered
+     * list. See {@link #getUsableInvitableAccountTypes()}.
+     */
+    private Map<AccountTypeWithDataSet, AccountType> getAllInvitableAccountTypes() {
+        ensureAccountsLoaded();
         return mInvitableAccountTypes;
     }
 
+    @Override
+    public Map<AccountTypeWithDataSet, AccountType> getUsableInvitableAccountTypes() {
+        ensureAccountsLoaded();
+        // Since this method is not thread-safe, it's possible for multiple threads to encounter
+        // the situation where (1) the cache has not been initialized yet or
+        // (2) an async task to refresh the account type list in the cache has already been
+        // started. Hence we use {@link AtomicBoolean}s and return cached values immediately
+        // while we compute the actual result in the background. We use this approach instead of
+        // using "synchronized" because computing the account type list involves a DB read, and
+        // can potentially cause a deadlock situation if this method is called from code which
+        // holds the DB lock. The trade-off of potentially having an incorrect list of invitable
+        // account types for a short period of time seems more manageable than enforcing the
+        // context in which this method is called.
+
+        // Computing the list of usable invitable account types is done on the fly as requested.
+        // If this method has never been called before, then block until the list has been computed.
+        if (!mInvitablesCacheIsInitialized.get()) {
+            mInvitableAccountTypeCache.setCachedValue(findUsableInvitableAccountTypes(mContext));
+            mInvitablesCacheIsInitialized.set(true);
+        } else {
+            // Otherwise, there is a value in the cache. If the value has expired and
+            // an async task has not already been started by another thread, then kick off a new
+            // async task to compute the list.
+            if (mInvitableAccountTypeCache.isExpired() &&
+                    mInvitablesTaskIsRunning.compareAndSet(false, true)) {
+                new FindInvitablesTask().execute();
+            }
+        }
+
+        return mInvitableAccountTypeCache.getCachedValue();
+    }
+
     /**
      * Return all {@link AccountType}s with at least one account which supports "invite", i.e.
      * its {@link AccountType#getInviteContactActivityClassName()} is not empty.
      */
     @VisibleForTesting
-    static Map<AccountTypeWithDataSet, AccountType> findInvitableAccountTypes(Context context,
+    static Map<AccountTypeWithDataSet, AccountType> findAllInvitableAccountTypes(Context context,
             Collection<AccountWithDataSet> accounts,
             Map<AccountTypeWithDataSet, AccountType> accountTypesByTypeAndDataSet) {
         HashMap<AccountTypeWithDataSet, AccountType> result = Maps.newHashMap();
@@ -573,8 +653,58 @@
         return Collections.unmodifiableMap(result);
     }
 
+    /**
+     * Return all usable {@link AccountType}s that support the "invite" feature from the
+     * list of all potential invitable account types (retrieved from
+     * {@link #getAllInvitableAccountTypes}). A usable invitable account type means:
+     * (1) there is at least 1 raw contact in the database with that account type, and
+     * (2) the app contributing the account type is not disabled.
+     *
+     * Warning: Don't use on the UI thread because this can scan the database.
+     */
+    private Map<AccountTypeWithDataSet, AccountType> findUsableInvitableAccountTypes(
+            Context context) {
+        Map<AccountTypeWithDataSet, AccountType> allInvitables = getAllInvitableAccountTypes();
+        if (allInvitables.isEmpty()) {
+            return EMPTY_UNMODIFIABLE_ACCOUNT_TYPE_MAP;
+        }
+
+        final HashMap<AccountTypeWithDataSet, AccountType> result = Maps.newHashMap();
+        result.putAll(allInvitables);
+
+        final PackageManager packageManager = context.getPackageManager();
+        for (AccountTypeWithDataSet accountTypeWithDataSet : allInvitables.keySet()) {
+            AccountType accountType = allInvitables.get(accountTypeWithDataSet);
+
+            // Make sure that account types don't come from apps that are disabled.
+            Intent invitableIntent = ContactsUtils.getInvitableIntent(accountType,
+                    SAMPLE_CONTACT_URI);
+            if (invitableIntent == null) {
+                result.remove(accountTypeWithDataSet);
+                continue;
+            }
+            ResolveInfo resolveInfo = packageManager.resolveActivity(invitableIntent,
+                    PackageManager.MATCH_DEFAULT_ONLY);
+            if (resolveInfo == null) {
+                // If we can't find an activity to start for this intent, then there's no point in
+                // showing this option to the user.
+                result.remove(accountTypeWithDataSet);
+                continue;
+            }
+
+            // Make sure that there is at least 1 raw contact with this account type. This check
+            // is non-trivial and should not be done on the UI thread.
+            if (!accountTypeWithDataSet.hasData(context)) {
+                result.remove(accountTypeWithDataSet);
+            }
+        }
+
+        return Collections.unmodifiableMap(result);
+    }
+
     @Override
     public List<AccountType> getAccountTypes(boolean contactWritableOnly) {
+        ensureAccountsLoaded();
         final List<AccountType> accountTypes = Lists.newArrayList();
         synchronized (this) {
             for (AccountType type : mAccountTypesWithDataSets.values()) {
@@ -585,4 +715,64 @@
         }
         return accountTypes;
     }
+
+    /**
+     * Background task to find all usable {@link AccountType}s that support the "invite" feature
+     * from the list of all potential invitable account types. Once the work is completed,
+     * the list of account types is stored in the {@link AccountTypeManager}'s
+     * {@link InvitableAccountTypeCache}.
+     */
+    private class FindInvitablesTask extends AsyncTask<Void, Void,
+            Map<AccountTypeWithDataSet, AccountType>> {
+
+        @Override
+        protected Map<AccountTypeWithDataSet, AccountType> doInBackground(Void... params) {
+            return findUsableInvitableAccountTypes(mContext);
+        }
+
+        @Override
+        protected void onPostExecute(Map<AccountTypeWithDataSet, AccountType> accountTypes) {
+            mInvitableAccountTypeCache.setCachedValue(accountTypes);
+            mInvitablesTaskIsRunning.set(false);
+        }
+    }
+
+    /**
+     * This cache holds a list of invitable {@link AccountTypeWithDataSet}s, in the form of a
+     * {@link Map<AccountTypeWithDataSet, AccountType>}. Note that the cached value is valid only
+     * for {@link #TIME_TO_LIVE} milliseconds.
+     */
+    private static final class InvitableAccountTypeCache {
+
+        /**
+         * The cached {@link #mInvitableAccountTypes} list expires after this number of milliseconds
+         * has elapsed.
+         */
+        private static final long TIME_TO_LIVE = 60000;
+
+        private Map<AccountTypeWithDataSet, AccountType> mInvitableAccountTypes;
+
+        private long mTimeLastSet;
+
+        /**
+         * Returns true if the data in this cache is stale and needs to be refreshed. Returns false
+         * otherwise.
+         */
+        public boolean isExpired() {
+             return SystemClock.elapsedRealtime() - mTimeLastSet > TIME_TO_LIVE;
+        }
+
+        /**
+         * Returns the cached value. Note that the caller is responsible for checking
+         * {@link #isExpired()} to ensure that the value is not stale.
+         */
+        public Map<AccountTypeWithDataSet, AccountType> getCachedValue() {
+            return mInvitableAccountTypes;
+        }
+
+        public void setCachedValue(Map<AccountTypeWithDataSet, AccountType> map) {
+            mInvitableAccountTypes = map;
+            mTimeLastSet = SystemClock.elapsedRealtime();
+        }
+    }
 }
diff --git a/src/com/android/contacts/model/AccountTypeWithDataSet.java b/src/com/android/contacts/model/AccountTypeWithDataSet.java
index f1b2344..b103755 100644
--- a/src/com/android/contacts/model/AccountTypeWithDataSet.java
+++ b/src/com/android/contacts/model/AccountTypeWithDataSet.java
@@ -18,6 +18,12 @@
 
 import com.google.common.base.Objects;
 
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.BaseColumns;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.RawContacts;
 import android.text.TextUtils;
 
 
@@ -25,6 +31,11 @@
  * Encapsulates an "account type" string and a "data set" string.
  */
 public class AccountTypeWithDataSet {
+
+    private static final String[] ID_PROJECTION = new String[] {BaseColumns._ID};
+    private static final Uri RAW_CONTACTS_URI_LIMIT_1 = RawContacts.CONTENT_URI.buildUpon()
+            .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, "1").build();
+
     /** account type.  Can be null for fallback type. */
     public final String accountType;
 
@@ -40,6 +51,32 @@
         return new AccountTypeWithDataSet(accountType, dataSet);
     }
 
+    /**
+     * Return true if there are any contacts in the database with this account type and data set.
+     * Touches DB. Don't use in the UI thread.
+     */
+    public boolean hasData(Context context) {
+        final String BASE_SELECTION = RawContacts.ACCOUNT_TYPE + " = ?";
+        final String selection;
+        final String[] args;
+        if (TextUtils.isEmpty(dataSet)) {
+            selection = BASE_SELECTION + " AND " + RawContacts.DATA_SET + " IS NULL";
+            args = new String[] {accountType};
+        } else {
+            selection = BASE_SELECTION + " AND " + RawContacts.DATA_SET + " = ?";
+            args = new String[] {accountType, dataSet};
+        }
+
+        final Cursor c = context.getContentResolver().query(RAW_CONTACTS_URI_LIMIT_1,
+                ID_PROJECTION, selection, args, null);
+        if (c == null) return false;
+        try {
+            return c.moveToFirst();
+        } finally {
+            c.close();
+        }
+    }
+
     @Override
     public boolean equals(Object o) {
         if (!(o instanceof AccountTypeWithDataSet)) return false;
diff --git a/tests/src/com/android/contacts/model/AccountTypeManagerTest.java b/tests/src/com/android/contacts/model/AccountTypeManagerTest.java
index aadf411..09902a3 100644
--- a/tests/src/com/android/contacts/model/AccountTypeManagerTest.java
+++ b/tests/src/com/android/contacts/model/AccountTypeManagerTest.java
@@ -36,7 +36,7 @@
  */
 @SmallTest
 public class AccountTypeManagerTest extends AndroidTestCase {
-    public void testFindInvitableAccountTypes() {
+    public void testFindAllInvitableAccountTypes() {
         final Context c = getContext();
 
         // Define account types.
@@ -53,7 +53,7 @@
 
         // empty - empty
         Map<AccountTypeWithDataSet, AccountType> types =
-                AccountTypeManagerImpl.findInvitableAccountTypes(c,
+                AccountTypeManagerImpl.findAllInvitableAccountTypes(c,
                         buildAccounts(), buildAccountTypes());
         assertEquals(0, types.size());
         try {
@@ -159,7 +159,7 @@
             AccountType... expectedInvitableTypes
             ) {
         Map<AccountTypeWithDataSet, AccountType> result =
-                AccountTypeManagerImpl.findInvitableAccountTypes(getContext(), accounts, types);
+                AccountTypeManagerImpl.findAllInvitableAccountTypes(getContext(), accounts, types);
         for (AccountType type : expectedInvitableTypes) {
             assertTrue("Result doesn't contain type=" + type.getAccountTypeAndDataSet(),
                     result.containsKey(type.getAccountTypeAndDataSet()));
diff --git a/tests/src/com/android/contacts/tests/mocks/MockAccountTypeManager.java b/tests/src/com/android/contacts/tests/mocks/MockAccountTypeManager.java
index 5ca1ccd..9084ef0 100644
--- a/tests/src/com/android/contacts/tests/mocks/MockAccountTypeManager.java
+++ b/tests/src/com/android/contacts/tests/mocks/MockAccountTypeManager.java
@@ -64,7 +64,7 @@
     }
 
     @Override
-    public Map<AccountTypeWithDataSet, AccountType> getInvitableAccountTypes() {
+    public Map<AccountTypeWithDataSet, AccountType> getUsableInvitableAccountTypes() {
         return Maps.newHashMap(); // Always returns empty
     }