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;