Merge "Check for cursor closed." into ics-mr0
diff --git a/res/drawable-hdpi/ic_play_active_holo_dark.png b/res/drawable-hdpi/ic_play_active_holo_dark.png
new file mode 100644
index 0000000..179b5a1
--- /dev/null
+++ b/res/drawable-hdpi/ic_play_active_holo_dark.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_sound_off_speakerphone_disabled_holo_dark.png b/res/drawable-hdpi/ic_sound_off_speakerphone_disabled_holo_dark.png
index e2cb0e4..f1a9154 100644
--- a/res/drawable-hdpi/ic_sound_off_speakerphone_disabled_holo_dark.png
+++ b/res/drawable-hdpi/ic_sound_off_speakerphone_disabled_holo_dark.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_play_active_holo_dark.png b/res/drawable-mdpi/ic_play_active_holo_dark.png
new file mode 100644
index 0000000..042d8c1
--- /dev/null
+++ b/res/drawable-mdpi/ic_play_active_holo_dark.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_sound_off_speakerphone_disabled_holo_dark.png b/res/drawable-mdpi/ic_sound_off_speakerphone_disabled_holo_dark.png
index 2e4cee7..0a83d81 100644
--- a/res/drawable-mdpi/ic_sound_off_speakerphone_disabled_holo_dark.png
+++ b/res/drawable-mdpi/ic_sound_off_speakerphone_disabled_holo_dark.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_play_active_holo_dark.png b/res/drawable-xhdpi/ic_play_active_holo_dark.png
new file mode 100644
index 0000000..20d0583
--- /dev/null
+++ b/res/drawable-xhdpi/ic_play_active_holo_dark.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_sound_off_speakerphone_disabled_holo_dark.png b/res/drawable-xhdpi/ic_sound_off_speakerphone_disabled_holo_dark.png
index 8381be1..764ff65 100644
--- a/res/drawable-xhdpi/ic_sound_off_speakerphone_disabled_holo_dark.png
+++ b/res/drawable-xhdpi/ic_sound_off_speakerphone_disabled_holo_dark.png
Binary files differ
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/DialtactsActivity.java b/src/com/android/contacts/activities/DialtactsActivity.java
index 05951d6..0ce7309 100644
--- a/src/com/android/contacts/activities/DialtactsActivity.java
+++ b/src/com/android/contacts/activities/DialtactsActivity.java
@@ -302,8 +302,11 @@
new OnPhoneNumberPickerActionListener() {
@Override
public void onPickPhoneNumberAction(Uri dataUri) {
+ // Specify call-origin so that users will see the previous tab instead of
+ // CallLog screen (search UI will be automatically exited).
PhoneNumberInteraction.startInteractionForPhoneCall(
- DialtactsActivity.this, dataUri);
+ DialtactsActivity.this, dataUri,
+ CALL_ORIGIN_DIALTACTS);
}
@Override
@@ -553,7 +556,7 @@
private void setupFavorites() {
final Tab tab = getActionBar().newTab();
tab.setContentDescription(R.string.contactsFavoritesLabel);
- tab.setIcon(R.drawable.ic_tab_starred);
+ tab.setIcon(R.drawable.ic_tab_all);
tab.setTabListener(mTabListener);
getActionBar().addTab(tab);
}
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/calllog/CallLogAdapter.java b/src/com/android/contacts/calllog/CallLogAdapter.java
index 4f274a9..7e6770b 100644
--- a/src/com/android/contacts/calllog/CallLogAdapter.java
+++ b/src/com/android/contacts/calllog/CallLogAdapter.java
@@ -534,7 +534,7 @@
callTypes, date, duration, name, ntype, label, lookupUri, null);
}
- final boolean isNew = CallLogQuery.isNewSection(c);
+ final boolean isNew = c.getInt(CallLogQuery.IS_READ) == 0;
// New items also use the highlighted version of the text.
final boolean isHighlighted = isNew;
mCallLogViewsHelper.setPhoneCallDetails(views, details, isHighlighted);
diff --git a/src/com/android/contacts/calllog/CallLogListItemHelper.java b/src/com/android/contacts/calllog/CallLogListItemHelper.java
index 6378c5e..bfedba5 100644
--- a/src/com/android/contacts/calllog/CallLogListItemHelper.java
+++ b/src/com/android/contacts/calllog/CallLogListItemHelper.java
@@ -65,7 +65,7 @@
if (canPlay) {
// Playback action takes preference.
- configurePlaySecondaryAction(views);
+ configurePlaySecondaryAction(views, isHighlighted);
views.dividerView.setVisibility(View.VISIBLE);
} else if (canCall) {
// Call is the secondary action.
@@ -99,9 +99,10 @@
}
/** Sets the secondary action to correspond to the play button. */
- private void configurePlaySecondaryAction(CallLogListItemViews views) {
+ private void configurePlaySecondaryAction(CallLogListItemViews views, boolean isHighlighted) {
views.secondaryActionView.setVisibility(View.VISIBLE);
- views.secondaryActionView.setImageResource(R.drawable.ic_play);
+ views.secondaryActionView.setImageResource(
+ isHighlighted ? R.drawable.ic_play_active_holo_dark : R.drawable.ic_play_holo_dark);
views.secondaryActionView.setContentDescription(
mResources.getString(R.string.description_call_log_play_button));
}
diff --git a/src/com/android/contacts/calllog/CallLogQuery.java b/src/com/android/contacts/calllog/CallLogQuery.java
index e622b3d..90017b7 100644
--- a/src/com/android/contacts/calllog/CallLogQuery.java
+++ b/src/com/android/contacts/calllog/CallLogQuery.java
@@ -42,6 +42,7 @@
Calls.CACHED_NORMALIZED_NUMBER, // 13
Calls.CACHED_PHOTO_ID, // 14
Calls.CACHED_FORMATTED_NUMBER, // 15
+ Calls.IS_READ, // 16
};
public static final int ID = 0;
@@ -60,8 +61,9 @@
public static final int CACHED_NORMALIZED_NUMBER = 13;
public static final int CACHED_PHOTO_ID = 14;
public static final int CACHED_FORMATTED_NUMBER = 15;
+ public static final int IS_READ = 16;
/** The index of the synthetic "section" column in the extended projection. */
- public static final int SECTION = 16;
+ public static final int SECTION = 17;
/**
* The name of the synthetic "section" column.
diff --git a/src/com/android/contacts/calllog/CallLogQueryHandler.java b/src/com/android/contacts/calllog/CallLogQueryHandler.java
index 2a11cc3..affdd1d 100644
--- a/src/com/android/contacts/calllog/CallLogQueryHandler.java
+++ b/src/com/android/contacts/calllog/CallLogQueryHandler.java
@@ -18,6 +18,7 @@
import com.android.common.io.MoreCloseables;
import com.android.contacts.voicemail.VoicemailStatusHelperImpl;
+import com.google.android.collect.Lists;
import android.content.AsyncQueryHandler;
import android.content.ContentResolver;
@@ -37,11 +38,15 @@
import android.util.Log;
import java.lang.ref.WeakReference;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
import javax.annotation.concurrent.GuardedBy;
/** Handles asynchronous queries to the call log. */
/*package*/ class CallLogQueryHandler extends AsyncQueryHandler {
+ private static final String[] EMPTY_STRING_ARRAY = new String[0];
+
private static final String TAG = "CallLogQueryHandler";
/** The token for the query to fetch the new entries from the call log. */
@@ -58,6 +63,12 @@
/** The token for the query to fetch voicemail status messages. */
private static final int QUERY_VOICEMAIL_STATUS_TOKEN = 58;
+ /**
+ * The time window from the current time within which an unread entry will be added to the new
+ * section.
+ */
+ private static final long NEW_SECTION_TIME_WINDOW = TimeUnit.DAYS.toMillis(7);
+
private final WeakReference<Listener> mListener;
/** The cursor containing the new calls, or null if they have not yet been fetched. */
@@ -107,7 +118,8 @@
// The values in this row correspond to default values for _PROJECTION from CallLogQuery
// plus the section value.
matrixCursor.addRow(new Object[]{
- 0L, "", 0L, 0L, 0, "", "", "", null, 0, null, null, null, null, 0L, null, section
+ 0L, "", 0L, 0L, 0, "", "", "", null, 0, null, null, null, null, 0L, null, 0,
+ section
});
return matrixCursor;
}
@@ -157,8 +169,10 @@
// We need to check for NULL explicitly otherwise entries with where READ is NULL
// may not match either the query or its negation.
// We consider the calls that are not yet consumed (i.e. IS_READ = 0) as "new".
- String selection = String.format("%s IS NOT NULL AND %s = 0", Calls.IS_READ, Calls.IS_READ);
- String[] selectionArgs = null;
+ String selection = String.format("%s IS NOT NULL AND %s = 0 AND %s > ?",
+ Calls.IS_READ, Calls.IS_READ, Calls.DATE);
+ List<String> selectionArgs = Lists.newArrayList(
+ Long.toString(System.currentTimeMillis() - NEW_SECTION_TIME_WINDOW));
if (!isNew) {
// Negate the query.
selection = String.format("NOT (%s)", selection);
@@ -166,12 +180,11 @@
if (voicemailOnly) {
// Add a clause to fetch only items of type voicemail.
selection = String.format("(%s) AND (%s = ?)", selection, Calls.TYPE);
- selectionArgs = new String[]{
- Integer.toString(Calls.VOICEMAIL_TYPE),
- };
+ selectionArgs.add(Integer.toString(Calls.VOICEMAIL_TYPE));
}
startQuery(token, null, Calls.CONTENT_URI_WITH_VOICEMAIL,
- CallLogQuery._PROJECTION, selection, selectionArgs, Calls.DEFAULT_SORT_ORDER);
+ CallLogQuery._PROJECTION, selection, selectionArgs.toArray(EMPTY_STRING_ARRAY),
+ Calls.DEFAULT_SORT_ORDER);
}
/** Cancel any pending fetch request. */
diff --git a/src/com/android/contacts/calllog/ContactInfoHelper.java b/src/com/android/contacts/calllog/ContactInfoHelper.java
index f4b7daf..c837c9a 100644
--- a/src/com/android/contacts/calllog/ContactInfoHelper.java
+++ b/src/com/android/contacts/calllog/ContactInfoHelper.java
@@ -206,8 +206,7 @@
info.photoId = phonesCursor.getLong(PhoneQuery.PHOTO_ID);
info.photoUri =
UriUtils.parseUriOrNull(phonesCursor.getString(PhoneQuery.PHOTO_URI));
- info.formattedNumber = formatPhoneNumber(info.number, info.formattedNumber,
- countryIso);
+ info.formattedNumber = formatPhoneNumber(number, null, countryIso);
} else {
info = ContactInfo.EMPTY;
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/dialpad/DialpadFragment.java b/src/com/android/contacts/dialpad/DialpadFragment.java
index 1790b9e..064e054 100644
--- a/src/com/android/contacts/dialpad/DialpadFragment.java
+++ b/src/com/android/contacts/dialpad/DialpadFragment.java
@@ -248,6 +248,7 @@
mDigits.setKeyListener(DialerKeyListener.getInstance());
mDigits.setOnClickListener(this);
mDigits.setOnKeyListener(this);
+ mDigits.setOnLongClickListener(this);
mDigits.addTextChangedListener(this);
PhoneNumberFormatter.setPhoneNumberFormattingTextWatcher(getActivity(), mDigits);
@@ -653,6 +654,12 @@
mHaptic.vibrate();
KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCode);
mDigits.onKeyDown(keyCode, event);
+
+ // If the cursor is at the end of the text we hide it.
+ final int length = mDigits.length();
+ if (length == mDigits.getSelectionStart() && length == mDigits.getSelectionEnd()) {
+ mDigits.setCursorVisible(false);
+ }
}
public boolean onKey(View view, int keyCode, KeyEvent event) {
@@ -804,6 +811,13 @@
keyPressed(KeyEvent.KEYCODE_PLUS);
return true;
}
+ case R.id.digits: {
+ // Right now EditText does not show the "paste" option when cursor is not visible.
+ // To show that, make the cursor visible, and return false, letting the EditText
+ // show the option by itself.
+ mDigits.setCursorVisible(true);
+ return false;
+ }
}
return false;
}
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/calllog/CallLogQueryTestUtils.java b/tests/src/com/android/contacts/calllog/CallLogQueryTestUtils.java
index 331d388..a88bf4f 100644
--- a/tests/src/com/android/contacts/calllog/CallLogQueryTestUtils.java
+++ b/tests/src/com/android/contacts/calllog/CallLogQueryTestUtils.java
@@ -29,7 +29,7 @@
public static Object[] createTestValues() {
Object[] values = new Object[]{
0L, "", 0L, 0L, Calls.INCOMING_TYPE, "", "", "", null, 0, null, null, null, null,
- 0L, null
+ 0L, null, 0,
};
assertEquals(CallLogQuery._PROJECTION.length, values.length);
return values;
@@ -38,7 +38,7 @@
public static Object[] createTestExtendedValues() {
Object[] values = new Object[]{
0L, "", 0L, 0L, Calls.INCOMING_TYPE, "", "", "", null, 0, null, null, null, null,
- 0L, null, CallLogQuery.SECTION_OLD_ITEM
+ 0L, null, 1, CallLogQuery.SECTION_OLD_ITEM
};
Assert.assertEquals(CallLogQuery.EXTENDED_PROJECTION.length, values.length);
return values;
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
}