diff options
author | Chiao Cheng <chiaocheng@google.com> | 2012-11-28 18:06:44 -0800 |
---|---|---|
committer | Chiao Cheng <chiaocheng@google.com> | 2012-12-03 11:50:42 -0800 |
commit | ba2c125b07086a88a3517fcf381a3a400c42afd3 (patch) | |
tree | f524ecf7e9e00030b060135e668066065375fccf /src/com/android/contacts | |
parent | 8e3d59a4b2f9140f0b612055cf149cf73e4dde6d (diff) | |
download | android_packages_apps_ContactsCommon-ba2c125b07086a88a3517fcf381a3a400c42afd3.tar.gz android_packages_apps_ContactsCommon-ba2c125b07086a88a3517fcf381a3a400c42afd3.tar.bz2 android_packages_apps_ContactsCommon-ba2c125b07086a88a3517fcf381a3a400c42afd3.zip |
Further clean-up of PhoneFavoriteFragment in Dialer app.
Moving dependencies of Dialer PhoneFavoriteFragment. Mostly filtering
dependencies.
Bug: 6993891
Change-Id: Ic2b29b80ae2367f54e619b30bdb71b098c8a0deb
Diffstat (limited to 'src/com/android/contacts')
8 files changed, 2178 insertions, 0 deletions
diff --git a/src/com/android/contacts/common/list/AccountFilterActivity.java b/src/com/android/contacts/common/list/AccountFilterActivity.java new file mode 100644 index 00000000..58450c65 --- /dev/null +++ b/src/com/android/contacts/common/list/AccountFilterActivity.java @@ -0,0 +1,263 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.app.ActionBar; +import android.app.Activity; +import android.app.LoaderManager.LoaderCallbacks; +import android.content.AsyncTaskLoader; +import android.content.Context; +import android.content.Intent; +import android.content.Loader; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.ListView; + +import com.android.contacts.common.R; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountType; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.google.common.collect.Lists; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows a list of all available accounts, letting the user select under which account to view + * contacts. + */ +public class AccountFilterActivity extends Activity implements AdapterView.OnItemClickListener { + + private static final String TAG = AccountFilterActivity.class.getSimpleName(); + + private static final int SUBACTIVITY_CUSTOMIZE_FILTER = 0; + + public static final String KEY_EXTRA_CONTACT_LIST_FILTER = "contactListFilter"; + public static final String KEY_EXTRA_CURRENT_FILTER = "currentFilter"; + + private static final int FILTER_LOADER_ID = 0; + + private ListView mListView; + + private ContactListFilter mCurrentFilter; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + setContentView(R.layout.contact_list_filter); + + mListView = (ListView) findViewById(android.R.id.list); + mListView.setOnItemClickListener(this); + + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + + mCurrentFilter = getIntent().getParcelableExtra(KEY_EXTRA_CURRENT_FILTER); + + getLoaderManager().initLoader(FILTER_LOADER_ID, null, new MyLoaderCallbacks()); + } + + private static class FilterLoader extends AsyncTaskLoader<List<ContactListFilter>> { + private Context mContext; + + public FilterLoader(Context context) { + super(context); + mContext = context; + } + + @Override + public List<ContactListFilter> loadInBackground() { + return loadAccountFilters(mContext); + } + + @Override + protected void onStartLoading() { + forceLoad(); + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + protected void onReset() { + onStopLoading(); + } + } + + private static List<ContactListFilter> loadAccountFilters(Context context) { + final ArrayList<ContactListFilter> result = Lists.newArrayList(); + final ArrayList<ContactListFilter> accountFilters = Lists.newArrayList(); + final AccountTypeManager accountTypes = AccountTypeManager.getInstance(context); + List<AccountWithDataSet> accounts = accountTypes.getAccounts(false); + for (AccountWithDataSet account : accounts) { + AccountType accountType = accountTypes.getAccountType(account.type, account.dataSet); + if (accountType.isExtension() && !account.hasData(context)) { + // Hide extensions with no raw_contacts. + continue; + } + Drawable icon = accountType != null ? accountType.getDisplayIcon(context) : null; + accountFilters.add(ContactListFilter.createAccountFilter( + account.type, account.name, account.dataSet, icon)); + } + + // Always show "All", even when there's no accounts. (We may have local contacts) + result.add(ContactListFilter.createFilterWithType( + ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS)); + + final int count = accountFilters.size(); + if (count >= 1) { + // If we only have one account, don't show it as "account", instead show it as "all" + if (count > 1) { + result.addAll(accountFilters); + } + result.add(ContactListFilter.createFilterWithType( + ContactListFilter.FILTER_TYPE_CUSTOM)); + } + return result; + } + + private class MyLoaderCallbacks implements LoaderCallbacks<List<ContactListFilter>> { + @Override + public Loader<List<ContactListFilter>> onCreateLoader(int id, Bundle args) { + return new FilterLoader(AccountFilterActivity.this); + } + + @Override + public void onLoadFinished( + Loader<List<ContactListFilter>> loader, List<ContactListFilter> data) { + if (data == null) { // Just in case... + Log.e(TAG, "Failed to load filters"); + return; + } + mListView.setAdapter( + new FilterListAdapter(AccountFilterActivity.this, data, mCurrentFilter)); + } + + @Override + public void onLoaderReset(Loader<List<ContactListFilter>> loader) { + } + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + final ContactListFilter filter = (ContactListFilter) view.getTag(); + if (filter == null) return; // Just in case + if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) { + final Intent intent = new Intent(this, + CustomContactListFilterActivity.class); + startActivityForResult(intent, SUBACTIVITY_CUSTOMIZE_FILTER); + } else { + final Intent intent = new Intent(); + intent.putExtra(KEY_EXTRA_CONTACT_LIST_FILTER, filter); + setResult(Activity.RESULT_OK, intent); + finish(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (resultCode != Activity.RESULT_OK) { + return; + } + + switch (requestCode) { + case SUBACTIVITY_CUSTOMIZE_FILTER: { + final Intent intent = new Intent(); + ContactListFilter filter = ContactListFilter.createFilterWithType( + ContactListFilter.FILTER_TYPE_CUSTOM); + intent.putExtra(KEY_EXTRA_CONTACT_LIST_FILTER, filter); + setResult(Activity.RESULT_OK, intent); + finish(); + break; + } + } + } + + private static class FilterListAdapter extends BaseAdapter { + private final List<ContactListFilter> mFilters; + private final LayoutInflater mLayoutInflater; + private final AccountTypeManager mAccountTypes; + private final ContactListFilter mCurrentFilter; + + public FilterListAdapter( + Context context, List<ContactListFilter> filters, ContactListFilter current) { + mLayoutInflater = (LayoutInflater) context.getSystemService + (Context.LAYOUT_INFLATER_SERVICE); + mFilters = filters; + mCurrentFilter = current; + mAccountTypes = AccountTypeManager.getInstance(context); + } + + @Override + public int getCount() { + return mFilters.size(); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public ContactListFilter getItem(int position) { + return mFilters.get(position); + } + + public View getView(int position, View convertView, ViewGroup parent) { + final ContactListFilterView view; + if (convertView != null) { + view = (ContactListFilterView) convertView; + } else { + view = (ContactListFilterView) mLayoutInflater.inflate( + R.layout.contact_list_filter_item, parent, false); + } + view.setSingleAccount(mFilters.size() == 1); + final ContactListFilter filter = mFilters.get(position); + view.setContactListFilter(filter); + view.bindView(mAccountTypes); + view.setTag(filter); + view.setActivated(filter.equals(mCurrentFilter)); + return view; + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // We have two logical "up" Activities: People and Phone. + // Instead of having one static "up" direction, behave like back as an + // exceptional case. + onBackPressed(); + return true; + default: + break; + } + return super.onOptionsItemSelected(item); + } +} diff --git a/src/com/android/contacts/common/list/ContactListFilterView.java b/src/com/android/contacts/common/list/ContactListFilterView.java new file mode 100644 index 00000000..4cea7558 --- /dev/null +++ b/src/com/android/contacts/common/list/ContactListFilterView.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.TextView; + +import com.android.contacts.common.R; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountType; + +/** + * Contact list filter parameters. + */ +public class ContactListFilterView extends LinearLayout { + + private static final String TAG = ContactListFilterView.class.getSimpleName(); + + private ImageView mIcon; + private TextView mAccountType; + private TextView mAccountUserName; + private RadioButton mRadioButton; + private ContactListFilter mFilter; + private boolean mSingleAccount; + + public ContactListFilterView(Context context) { + super(context); + } + + public ContactListFilterView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setContactListFilter(ContactListFilter filter) { + mFilter = filter; + } + + public ContactListFilter getContactListFilter() { + return mFilter; + } + + public void setSingleAccount(boolean flag) { + this.mSingleAccount = flag; + } + + @Override + public void setActivated(boolean activated) { + super.setActivated(activated); + if (mRadioButton != null) { + mRadioButton.setChecked(activated); + } else { + // We're guarding against null-pointer exceptions, + // but otherwise this code is not expected to work + // properly if the button hasn't been initialized. + Log.wtf(TAG, "radio-button cannot be activated because it is null"); + } + } + + public void bindView(AccountTypeManager accountTypes) { + if (mAccountType == null) { + mIcon = (ImageView) findViewById(R.id.icon); + mAccountType = (TextView) findViewById(R.id.accountType); + mAccountUserName = (TextView) findViewById(R.id.accountUserName); + mRadioButton = (RadioButton) findViewById(R.id.radioButton); + mRadioButton.setChecked(isActivated()); + } + + if (mFilter == null) { + mAccountType.setText(R.string.contactsList); + return; + } + + mAccountUserName.setVisibility(View.GONE); + switch (mFilter.filterType) { + case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS: { + bindView(0, R.string.list_filter_all_accounts); + break; + } + case ContactListFilter.FILTER_TYPE_STARRED: { + bindView(R.drawable.ic_menu_star_holo_light, R.string.list_filter_all_starred); + break; + } + case ContactListFilter.FILTER_TYPE_CUSTOM: { + bindView(R.drawable.ic_menu_settings_holo_light, R.string.list_filter_customize); + break; + } + case ContactListFilter.FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: { + bindView(0, R.string.list_filter_phones); + break; + } + case ContactListFilter.FILTER_TYPE_SINGLE_CONTACT: { + bindView(0, R.string.list_filter_single); + break; + } + case ContactListFilter.FILTER_TYPE_ACCOUNT: { + mAccountUserName.setVisibility(View.VISIBLE); + mIcon.setVisibility(View.VISIBLE); + if (mFilter.icon != null) { + mIcon.setImageDrawable(mFilter.icon); + } else { + mIcon.setImageResource(R.drawable.unknown_source); + } + final AccountType accountType = + accountTypes.getAccountType(mFilter.accountType, mFilter.dataSet); + mAccountUserName.setText(mFilter.accountName); + mAccountType.setText(accountType.getDisplayLabel(getContext())); + break; + } + } + } + + private void bindView(int iconResource, int textResource) { + if (iconResource != 0) { + mIcon.setVisibility(View.VISIBLE); + mIcon.setImageResource(iconResource); + } else { + mIcon.setVisibility(View.GONE); + } + + mAccountType.setText(textResource); + } +} diff --git a/src/com/android/contacts/common/list/CustomContactListFilterActivity.java b/src/com/android/contacts/common/list/CustomContactListFilterActivity.java new file mode 100644 index 00000000..feb7df23 --- /dev/null +++ b/src/com/android/contacts/common/list/CustomContactListFilterActivity.java @@ -0,0 +1,923 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.list; + +import android.app.ActionBar; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.LoaderManager.LoaderCallbacks; +import android.app.ProgressDialog; +import android.content.AsyncTaskLoader; +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.Loader; +import android.content.OperationApplicationException; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.RemoteException; +import android.preference.PreferenceManager; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Groups; +import android.provider.ContactsContract.Settings; +import android.util.Log; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.MenuItem.OnMenuItemClickListener; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; +import android.widget.CheckBox; +import android.widget.ExpandableListAdapter; +import android.widget.ExpandableListView; +import android.widget.ExpandableListView.ExpandableListContextMenuInfo; +import android.widget.TextView; + +import com.android.contacts.common.R; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.ValuesDelta; +import com.android.contacts.common.model.account.AccountType; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.common.model.account.GoogleAccountType; +import com.android.contacts.common.util.EmptyService; +import com.android.contacts.common.util.LocalizedNameResolver; +import com.android.contacts.common.util.WeakAsyncTask; +import com.google.common.collect.Lists; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; + +/** + * Shows a list of all available {@link Groups} available, letting the user + * select which ones they want to be visible. + */ +public class CustomContactListFilterActivity extends Activity + implements View.OnClickListener, ExpandableListView.OnChildClickListener, + LoaderCallbacks<CustomContactListFilterActivity.AccountSet> +{ + private static final String TAG = "CustomContactListFilterActivity"; + + private static final int ACCOUNT_SET_LOADER_ID = 1; + + private ExpandableListView mList; + private DisplayAdapter mAdapter; + + private SharedPreferences mPrefs; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + setContentView(R.layout.contact_list_filter_custom); + + mList = (ExpandableListView) findViewById(android.R.id.list); + mList.setOnChildClickListener(this); + mList.setHeaderDividersEnabled(true); + mPrefs = PreferenceManager.getDefaultSharedPreferences(this); + mAdapter = new DisplayAdapter(this); + + final LayoutInflater inflater = getLayoutInflater(); + + findViewById(R.id.btn_done).setOnClickListener(this); + findViewById(R.id.btn_discard).setOnClickListener(this); + + mList.setOnCreateContextMenuListener(this); + + mList.setAdapter(mAdapter); + + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + // android.R.id.home will be triggered in onOptionsItemSelected() + actionBar.setDisplayHomeAsUpEnabled(true); + } + } + + public static class CustomFilterConfigurationLoader extends AsyncTaskLoader<AccountSet> { + + private AccountSet mAccountSet; + + public CustomFilterConfigurationLoader(Context context) { + super(context); + } + + @Override + public AccountSet loadInBackground() { + Context context = getContext(); + final AccountTypeManager accountTypes = AccountTypeManager.getInstance(context); + final ContentResolver resolver = context.getContentResolver(); + + final AccountSet accounts = new AccountSet(); + for (AccountWithDataSet account : accountTypes.getAccounts(false)) { + final AccountType accountType = accountTypes.getAccountTypeForAccount(account); + if (accountType.isExtension() && !account.hasData(context)) { + // Extension with no data -- skip. + continue; + } + + AccountDisplay accountDisplay = + new AccountDisplay(resolver, account.name, account.type, account.dataSet); + + final Uri.Builder groupsUri = Groups.CONTENT_URI.buildUpon() + .appendQueryParameter(Groups.ACCOUNT_NAME, account.name) + .appendQueryParameter(Groups.ACCOUNT_TYPE, account.type); + if (account.dataSet != null) { + groupsUri.appendQueryParameter(Groups.DATA_SET, account.dataSet).build(); + } + android.content.EntityIterator iterator = + ContactsContract.Groups.newEntityIterator(resolver.query( + groupsUri.build(), null, null, null, null)); + try { + boolean hasGroups = false; + + // Create entries for each known group + while (iterator.hasNext()) { + final ContentValues values = iterator.next().getEntityValues(); + final GroupDelta group = GroupDelta.fromBefore(values); + accountDisplay.addGroup(group); + hasGroups = true; + } + // Create single entry handling ungrouped status + accountDisplay.mUngrouped = + GroupDelta.fromSettings(resolver, account.name, account.type, + account.dataSet, hasGroups); + accountDisplay.addGroup(accountDisplay.mUngrouped); + } finally { + iterator.close(); + } + + accounts.add(accountDisplay); + } + + return accounts; + } + + @Override + public void deliverResult(AccountSet cursor) { + if (isReset()) { + return; + } + + mAccountSet = cursor; + + if (isStarted()) { + super.deliverResult(cursor); + } + } + + @Override + protected void onStartLoading() { + if (mAccountSet != null) { + deliverResult(mAccountSet); + } + if (takeContentChanged() || mAccountSet == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + protected void onReset() { + super.onReset(); + onStopLoading(); + mAccountSet = null; + } + } + + @Override + protected void onStart() { + getLoaderManager().initLoader(ACCOUNT_SET_LOADER_ID, null, this); + super.onStart(); + } + + @Override + public Loader<AccountSet> onCreateLoader(int id, Bundle args) { + return new CustomFilterConfigurationLoader(this); + } + + @Override + public void onLoadFinished(Loader<AccountSet> loader, AccountSet data) { + mAdapter.setAccounts(data); + } + + @Override + public void onLoaderReset(Loader<AccountSet> loader) { + mAdapter.setAccounts(null); + } + + private static final int DEFAULT_SHOULD_SYNC = 1; + private static final int DEFAULT_VISIBLE = 0; + + /** + * Entry holding any changes to {@link Groups} or {@link Settings} rows, + * such as {@link Groups#SHOULD_SYNC} or {@link Groups#GROUP_VISIBLE}. + */ + protected static class GroupDelta extends ValuesDelta { + private boolean mUngrouped = false; + private boolean mAccountHasGroups; + + private GroupDelta() { + super(); + } + + /** + * Build {@link GroupDelta} from the {@link Settings} row for the given + * {@link Settings#ACCOUNT_NAME}, {@link Settings#ACCOUNT_TYPE}, and + * {@link Settings#DATA_SET}. + */ + public static GroupDelta fromSettings(ContentResolver resolver, String accountName, + String accountType, String dataSet, boolean accountHasGroups) { + final Uri.Builder settingsUri = Settings.CONTENT_URI.buildUpon() + .appendQueryParameter(Settings.ACCOUNT_NAME, accountName) + .appendQueryParameter(Settings.ACCOUNT_TYPE, accountType); + if (dataSet != null) { + settingsUri.appendQueryParameter(Settings.DATA_SET, dataSet); + } + final Cursor cursor = resolver.query(settingsUri.build(), new String[] { + Settings.SHOULD_SYNC, Settings.UNGROUPED_VISIBLE + }, null, null, null); + + try { + final ContentValues values = new ContentValues(); + values.put(Settings.ACCOUNT_NAME, accountName); + values.put(Settings.ACCOUNT_TYPE, accountType); + values.put(Settings.DATA_SET, dataSet); + + if (cursor != null && cursor.moveToFirst()) { + // Read existing values when present + values.put(Settings.SHOULD_SYNC, cursor.getInt(0)); + values.put(Settings.UNGROUPED_VISIBLE, cursor.getInt(1)); + return fromBefore(values).setUngrouped(accountHasGroups); + } else { + // Nothing found, so treat as create + values.put(Settings.SHOULD_SYNC, DEFAULT_SHOULD_SYNC); + values.put(Settings.UNGROUPED_VISIBLE, DEFAULT_VISIBLE); + return fromAfter(values).setUngrouped(accountHasGroups); + } + } finally { + if (cursor != null) cursor.close(); + } + } + + public static GroupDelta fromBefore(ContentValues before) { + final GroupDelta entry = new GroupDelta(); + entry.mBefore = before; + entry.mAfter = new ContentValues(); + return entry; + } + + public static GroupDelta fromAfter(ContentValues after) { + final GroupDelta entry = new GroupDelta(); + entry.mBefore = null; + entry.mAfter = after; + return entry; + } + + protected GroupDelta setUngrouped(boolean accountHasGroups) { + mUngrouped = true; + mAccountHasGroups = accountHasGroups; + return this; + } + + @Override + public boolean beforeExists() { + return mBefore != null; + } + + public boolean getShouldSync() { + return getAsInteger(mUngrouped ? Settings.SHOULD_SYNC : Groups.SHOULD_SYNC, + DEFAULT_SHOULD_SYNC) != 0; + } + + public boolean getVisible() { + return getAsInteger(mUngrouped ? Settings.UNGROUPED_VISIBLE : Groups.GROUP_VISIBLE, + DEFAULT_VISIBLE) != 0; + } + + public void putShouldSync(boolean shouldSync) { + put(mUngrouped ? Settings.SHOULD_SYNC : Groups.SHOULD_SYNC, shouldSync ? 1 : 0); + } + + public void putVisible(boolean visible) { + put(mUngrouped ? Settings.UNGROUPED_VISIBLE : Groups.GROUP_VISIBLE, visible ? 1 : 0); + } + + private String getAccountType() { + return (mBefore == null ? mAfter : mBefore).getAsString(Settings.ACCOUNT_TYPE); + } + + public CharSequence getTitle(Context context) { + if (mUngrouped) { + final String customAllContactsName = + LocalizedNameResolver.getAllContactsName(context, getAccountType()); + if (customAllContactsName != null) { + return customAllContactsName; + } + if (mAccountHasGroups) { + return context.getText(R.string.display_ungrouped); + } else { + return context.getText(R.string.display_all_contacts); + } + } else { + final Integer titleRes = getAsInteger(Groups.TITLE_RES); + if (titleRes != null) { + final String packageName = getAsString(Groups.RES_PACKAGE); + return context.getPackageManager().getText(packageName, titleRes, null); + } else { + return getAsString(Groups.TITLE); + } + } + } + + /** + * Build a possible {@link ContentProviderOperation} to persist any + * changes to the {@link Groups} or {@link Settings} row described by + * this {@link GroupDelta}. + */ + public ContentProviderOperation buildDiff() { + if (isInsert()) { + // Only allow inserts for Settings + if (mUngrouped) { + mAfter.remove(mIdColumn); + return ContentProviderOperation.newInsert(Settings.CONTENT_URI) + .withValues(mAfter) + .build(); + } + else { + throw new IllegalStateException("Unexpected diff"); + } + } else if (isUpdate()) { + if (mUngrouped) { + String accountName = this.getAsString(Settings.ACCOUNT_NAME); + String accountType = this.getAsString(Settings.ACCOUNT_TYPE); + String dataSet = this.getAsString(Settings.DATA_SET); + StringBuilder selection = new StringBuilder(Settings.ACCOUNT_NAME + "=? AND " + + Settings.ACCOUNT_TYPE + "=?"); + String[] selectionArgs; + if (dataSet == null) { + selection.append(" AND " + Settings.DATA_SET + " IS NULL"); + selectionArgs = new String[] {accountName, accountType}; + } else { + selection.append(" AND " + Settings.DATA_SET + "=?"); + selectionArgs = new String[] {accountName, accountType, dataSet}; + } + return ContentProviderOperation.newUpdate(Settings.CONTENT_URI) + .withSelection(selection.toString(), selectionArgs) + .withValues(mAfter) + .build(); + } else { + return ContentProviderOperation.newUpdate( + addCallerIsSyncAdapterParameter(Groups.CONTENT_URI)) + .withSelection(Groups._ID + "=" + this.getId(), null) + .withValues(mAfter) + .build(); + } + } else { + return null; + } + } + } + + private static Uri addCallerIsSyncAdapterParameter(Uri uri) { + return uri.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build(); + } + + /** + * {@link Comparator} to sort by {@link Groups#_ID}. + */ + private static Comparator<GroupDelta> sIdComparator = new Comparator<GroupDelta>() { + public int compare(GroupDelta object1, GroupDelta object2) { + final Long id1 = object1.getId(); + final Long id2 = object2.getId(); + if (id1 == null && id2 == null) { + return 0; + } else if (id1 == null) { + return -1; + } else if (id2 == null) { + return 1; + } else if (id1 < id2) { + return -1; + } else if (id1 > id2) { + return 1; + } else { + return 0; + } + } + }; + + /** + * Set of all {@link AccountDisplay} entries, one for each source. + */ + protected static class AccountSet extends ArrayList<AccountDisplay> { + public ArrayList<ContentProviderOperation> buildDiff() { + final ArrayList<ContentProviderOperation> diff = Lists.newArrayList(); + for (AccountDisplay account : this) { + account.buildDiff(diff); + } + return diff; + } + } + + /** + * {@link GroupDelta} details for a single {@link AccountWithDataSet}, usually shown as + * children under a single expandable group. + */ + protected static class AccountDisplay { + public final String mName; + public final String mType; + public final String mDataSet; + + public GroupDelta mUngrouped; + public ArrayList<GroupDelta> mSyncedGroups = Lists.newArrayList(); + public ArrayList<GroupDelta> mUnsyncedGroups = Lists.newArrayList(); + + /** + * Build an {@link AccountDisplay} covering all {@link Groups} under the + * given {@link AccountWithDataSet}. + */ + public AccountDisplay(ContentResolver resolver, String accountName, String accountType, + String dataSet) { + mName = accountName; + mType = accountType; + mDataSet = dataSet; + } + + /** + * Add the given {@link GroupDelta} internally, filing based on its + * {@link GroupDelta#getShouldSync()} status. + */ + private void addGroup(GroupDelta group) { + if (group.getShouldSync()) { + mSyncedGroups.add(group); + } else { + mUnsyncedGroups.add(group); + } + } + + /** + * Set the {@link GroupDelta#putShouldSync(boolean)} value for all + * children {@link GroupDelta} rows. + */ + public void setShouldSync(boolean shouldSync) { + final Iterator<GroupDelta> oppositeChildren = shouldSync ? + mUnsyncedGroups.iterator() : mSyncedGroups.iterator(); + while (oppositeChildren.hasNext()) { + final GroupDelta child = oppositeChildren.next(); + setShouldSync(child, shouldSync, false); + oppositeChildren.remove(); + } + } + + public void setShouldSync(GroupDelta child, boolean shouldSync) { + setShouldSync(child, shouldSync, true); + } + + /** + * Set {@link GroupDelta#putShouldSync(boolean)}, and file internally + * based on updated state. + */ + public void setShouldSync(GroupDelta child, boolean shouldSync, boolean attemptRemove) { + child.putShouldSync(shouldSync); + if (shouldSync) { + if (attemptRemove) { + mUnsyncedGroups.remove(child); + } + mSyncedGroups.add(child); + Collections.sort(mSyncedGroups, sIdComparator); + } else { + if (attemptRemove) { + mSyncedGroups.remove(child); + } + mUnsyncedGroups.add(child); + } + } + + /** + * Build set of {@link ContentProviderOperation} to persist any user + * changes to {@link GroupDelta} rows under this {@link AccountWithDataSet}. + */ + public void buildDiff(ArrayList<ContentProviderOperation> diff) { + for (GroupDelta group : mSyncedGroups) { + final ContentProviderOperation oper = group.buildDiff(); + if (oper != null) diff.add(oper); + } + for (GroupDelta group : mUnsyncedGroups) { + final ContentProviderOperation oper = group.buildDiff(); + if (oper != null) diff.add(oper); + } + } + } + + /** + * {@link ExpandableListAdapter} that shows {@link GroupDelta} settings, + * grouped by {@link AccountWithDataSet} type. Shows footer row when any groups are + * unsynced, as determined through {@link AccountDisplay#mUnsyncedGroups}. + */ + protected static class DisplayAdapter extends BaseExpandableListAdapter { + private Context mContext; + private LayoutInflater mInflater; + private AccountTypeManager mAccountTypes; + private AccountSet mAccounts; + + private boolean mChildWithPhones = false; + + public DisplayAdapter(Context context) { + mContext = context; + mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mAccountTypes = AccountTypeManager.getInstance(context); + } + + public void setAccounts(AccountSet accounts) { + mAccounts = accounts; + notifyDataSetChanged(); + } + + /** + * In group descriptions, show the number of contacts with phone + * numbers, in addition to the total contacts. + */ + public void setChildDescripWithPhones(boolean withPhones) { + mChildWithPhones = withPhones; + } + + @Override + public View getGroupView(int groupPosition, boolean isExpanded, View convertView, + ViewGroup parent) { + if (convertView == null) { + convertView = mInflater.inflate( + R.layout.custom_contact_list_filter_account, parent, false); + } + + final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1); + final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2); + + final AccountDisplay account = (AccountDisplay)this.getGroup(groupPosition); + + final AccountType accountType = mAccountTypes.getAccountType( + account.mType, account.mDataSet); + + text1.setText(account.mName); + text1.setVisibility(account.mName == null ? View.GONE : View.VISIBLE); + text2.setText(accountType.getDisplayLabel(mContext)); + + return convertView; + } + + @Override + public View getChildView(int groupPosition, int childPosition, boolean isLastChild, + View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = mInflater.inflate( + R.layout.custom_contact_list_filter_group, parent, false); + } + + final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1); + final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2); + final CheckBox checkbox = (CheckBox)convertView.findViewById(android.R.id.checkbox); + + final AccountDisplay account = mAccounts.get(groupPosition); + final GroupDelta child = (GroupDelta)this.getChild(groupPosition, childPosition); + if (child != null) { + // Handle normal group, with title and checkbox + final boolean groupVisible = child.getVisible(); + checkbox.setVisibility(View.VISIBLE); + checkbox.setChecked(groupVisible); + + final CharSequence groupTitle = child.getTitle(mContext); + text1.setText(groupTitle); + text2.setVisibility(View.GONE); + } else { + // When unknown child, this is "more" footer view + checkbox.setVisibility(View.GONE); + text1.setText(R.string.display_more_groups); + text2.setVisibility(View.GONE); + } + + return convertView; + } + + @Override + public Object getChild(int groupPosition, int childPosition) { + final AccountDisplay account = mAccounts.get(groupPosition); + final boolean validChild = childPosition >= 0 + && childPosition < account.mSyncedGroups.size(); + if (validChild) { + return account.mSyncedGroups.get(childPosition); + } else { + return null; + } + } + + @Override + public long getChildId(int groupPosition, int childPosition) { + final GroupDelta child = (GroupDelta)getChild(groupPosition, childPosition); + if (child != null) { + final Long childId = child.getId(); + return childId != null ? childId : Long.MIN_VALUE; + } else { + return Long.MIN_VALUE; + } + } + + @Override + public int getChildrenCount(int groupPosition) { + // Count is any synced groups, plus possible footer + final AccountDisplay account = mAccounts.get(groupPosition); + final boolean anyHidden = account.mUnsyncedGroups.size() > 0; + return account.mSyncedGroups.size() + (anyHidden ? 1 : 0); + } + + @Override + public Object getGroup(int groupPosition) { + return mAccounts.get(groupPosition); + } + + @Override + public int getGroupCount() { + if (mAccounts == null) { + return 0; + } + return mAccounts.size(); + } + + @Override + public long getGroupId(int groupPosition) { + return groupPosition; + } + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return true; + } + } + + /** {@inheritDoc} */ + public void onClick(View view) { + switch (view.getId()) { + case R.id.btn_done: { + this.doSaveAction(); + break; + } + case R.id.btn_discard: { + this.finish(); + break; + } + } + } + + /** + * Handle any clicks on {@link ExpandableListAdapter} children, which + * usually mean toggling its visible state. + */ + @Override + public boolean onChildClick(ExpandableListView parent, View view, int groupPosition, + int childPosition, long id) { + final CheckBox checkbox = (CheckBox)view.findViewById(android.R.id.checkbox); + + final AccountDisplay account = (AccountDisplay)mAdapter.getGroup(groupPosition); + final GroupDelta child = (GroupDelta)mAdapter.getChild(groupPosition, childPosition); + if (child != null) { + checkbox.toggle(); + child.putVisible(checkbox.isChecked()); + } else { + // Open context menu for bringing back unsynced + this.openContextMenu(view); + } + return true; + } + + // TODO: move these definitions to framework constants when we begin + // defining this mode through <sync-adapter> tags + private static final int SYNC_MODE_UNSUPPORTED = 0; + private static final int SYNC_MODE_UNGROUPED = 1; + private static final int SYNC_MODE_EVERYTHING = 2; + + protected int getSyncMode(AccountDisplay account) { + // TODO: read sync mode through <sync-adapter> definition + if (GoogleAccountType.ACCOUNT_TYPE.equals(account.mType) && account.mDataSet == null) { + return SYNC_MODE_EVERYTHING; + } else { + return SYNC_MODE_UNSUPPORTED; + } + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, + ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, view, menuInfo); + + // Bail if not working with expandable long-press, or if not child + if (!(menuInfo instanceof ExpandableListContextMenuInfo)) return; + + final ExpandableListContextMenuInfo info = (ExpandableListContextMenuInfo) menuInfo; + final int groupPosition = ExpandableListView.getPackedPositionGroup(info.packedPosition); + final int childPosition = ExpandableListView.getPackedPositionChild(info.packedPosition); + + // Skip long-press on expandable parents + if (childPosition == -1) return; + + final AccountDisplay account = (AccountDisplay)mAdapter.getGroup(groupPosition); + final GroupDelta child = (GroupDelta)mAdapter.getChild(groupPosition, childPosition); + + // Ignore when selective syncing unsupported + final int syncMode = getSyncMode(account); + if (syncMode == SYNC_MODE_UNSUPPORTED) return; + + if (child != null) { + showRemoveSync(menu, account, child, syncMode); + } else { + showAddSync(menu, account, syncMode); + } + } + + protected void showRemoveSync(ContextMenu menu, final AccountDisplay account, + final GroupDelta child, final int syncMode) { + final CharSequence title = child.getTitle(this); + + menu.setHeaderTitle(title); + menu.add(R.string.menu_sync_remove).setOnMenuItemClickListener( + new OnMenuItemClickListener() { + public boolean onMenuItemClick(MenuItem item) { + handleRemoveSync(account, child, syncMode, title); + return true; + } + }); + } + + protected void handleRemoveSync(final AccountDisplay account, final GroupDelta child, + final int syncMode, CharSequence title) { + final boolean shouldSyncUngrouped = account.mUngrouped.getShouldSync(); + if (syncMode == SYNC_MODE_EVERYTHING && shouldSyncUngrouped + && !child.equals(account.mUngrouped)) { + // Warn before removing this group when it would cause ungrouped to stop syncing + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + final CharSequence removeMessage = this.getString( + R.string.display_warn_remove_ungrouped, title); + builder.setTitle(R.string.menu_sync_remove); + builder.setMessage(removeMessage); + builder.setNegativeButton(android.R.string.cancel, null); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + // Mark both this group and ungrouped to stop syncing + account.setShouldSync(account.mUngrouped, false); + account.setShouldSync(child, false); + mAdapter.notifyDataSetChanged(); + } + }); + builder.show(); + } else { + // Mark this group to not sync + account.setShouldSync(child, false); + mAdapter.notifyDataSetChanged(); + } + } + + protected void showAddSync(ContextMenu menu, final AccountDisplay account, final int syncMode) { + menu.setHeaderTitle(R.string.dialog_sync_add); + + // Create item for each available, unsynced group + for (final GroupDelta child : account.mUnsyncedGroups) { + if (!child.getShouldSync()) { + final CharSequence title = child.getTitle(this); + menu.add(title).setOnMenuItemClickListener(new OnMenuItemClickListener() { + public boolean onMenuItemClick(MenuItem item) { + // Adding specific group for syncing + if (child.mUngrouped && syncMode == SYNC_MODE_EVERYTHING) { + account.setShouldSync(true); + } else { + account.setShouldSync(child, true); + } + mAdapter.notifyDataSetChanged(); + return true; + } + }); + } + } + } + + @SuppressWarnings("unchecked") + private void doSaveAction() { + if (mAdapter == null || mAdapter.mAccounts == null) { + finish(); + return; + } + + setResult(RESULT_OK); + + final ArrayList<ContentProviderOperation> diff = mAdapter.mAccounts.buildDiff(); + if (diff.isEmpty()) { + finish(); + return; + } + + new UpdateTask(this).execute(diff); + } + + /** + * Background task that persists changes to {@link Groups#GROUP_VISIBLE}, + * showing spinner dialog to user while updating. + */ + public static class UpdateTask extends + WeakAsyncTask<ArrayList<ContentProviderOperation>, Void, Void, Activity> { + private ProgressDialog mProgress; + + public UpdateTask(Activity target) { + super(target); + } + + /** {@inheritDoc} */ + @Override + protected void onPreExecute(Activity target) { + final Context context = target; + + mProgress = ProgressDialog.show( + context, null, context.getText(R.string.savingDisplayGroups)); + + // Before starting this task, start an empty service to protect our + // process from being reclaimed by the system. + context.startService(new Intent(context, EmptyService.class)); + } + + /** {@inheritDoc} */ + @Override + protected Void doInBackground( + Activity target, ArrayList<ContentProviderOperation>... params) { + final Context context = target; + final ContentValues values = new ContentValues(); + final ContentResolver resolver = context.getContentResolver(); + + try { + final ArrayList<ContentProviderOperation> diff = params[0]; + resolver.applyBatch(ContactsContract.AUTHORITY, diff); + } catch (RemoteException e) { + Log.e(TAG, "Problem saving display groups", e); + } catch (OperationApplicationException e) { + Log.e(TAG, "Problem saving display groups", e); + } + + return null; + } + + /** {@inheritDoc} */ + @Override + protected void onPostExecute(Activity target, Void result) { + final Context context = target; + + try { + mProgress.dismiss(); + } catch (Exception e) { + Log.e(TAG, "Error dismissing progress dialog", e); + } + + target.finish(); + + // Stop the service that was protecting us + context.stopService(new Intent(context, EmptyService.class)); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // Pretend cancel. + setResult(Activity.RESULT_CANCELED); + finish(); + return true; + default: + break; + } + return super.onOptionsItemSelected(item); + } +} diff --git a/src/com/android/contacts/common/list/ShortcutIntentBuilder.java b/src/com/android/contacts/common/list/ShortcutIntentBuilder.java new file mode 100644 index 00000000..4ac06644 --- /dev/null +++ b/src/com/android/contacts/common/list/ShortcutIntentBuilder.java @@ -0,0 +1,421 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.list; + +import android.app.ActivityManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Paint.FontMetricsInt; +import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Photo; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Data; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.TextUtils.TruncateAt; + +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.R; + +/** + * Constructs shortcut intents. + */ +public class ShortcutIntentBuilder { + + private static final String[] CONTACT_COLUMNS = { + Contacts.DISPLAY_NAME, + Contacts.PHOTO_ID, + }; + + private static final int CONTACT_DISPLAY_NAME_COLUMN_INDEX = 0; + private static final int CONTACT_PHOTO_ID_COLUMN_INDEX = 1; + + private static final String[] PHONE_COLUMNS = { + Phone.DISPLAY_NAME, + Phone.PHOTO_ID, + Phone.NUMBER, + Phone.TYPE, + Phone.LABEL + }; + + private static final int PHONE_DISPLAY_NAME_COLUMN_INDEX = 0; + private static final int PHONE_PHOTO_ID_COLUMN_INDEX = 1; + private static final int PHONE_NUMBER_COLUMN_INDEX = 2; + private static final int PHONE_TYPE_COLUMN_INDEX = 3; + private static final int PHONE_LABEL_COLUMN_INDEX = 4; + + private static final String[] PHOTO_COLUMNS = { + Photo.PHOTO, + }; + + private static final int PHOTO_PHOTO_COLUMN_INDEX = 0; + + private static final String PHOTO_SELECTION = Photo._ID + "=?"; + + private final OnShortcutIntentCreatedListener mListener; + private final Context mContext; + private int mIconSize; + private final int mIconDensity; + private final int mBorderWidth; + private final int mBorderColor; + + /** + * This is a hidden API of the launcher in JellyBean that allows us to disable the animation + * that it would usually do, because it interferes with our own animation for QuickContact + */ + public static final String INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION = + "com.android.launcher.intent.extra.shortcut.INGORE_LAUNCH_ANIMATION"; + + /** + * Listener interface. + */ + public interface OnShortcutIntentCreatedListener { + + /** + * Callback for shortcut intent creation. + * + * @param uri the original URI for which the shortcut intent has been + * created. + * @param shortcutIntent resulting shortcut intent. + */ + void onShortcutIntentCreated(Uri uri, Intent shortcutIntent); + } + + public ShortcutIntentBuilder(Context context, OnShortcutIntentCreatedListener listener) { + mContext = context; + mListener = listener; + + final Resources r = context.getResources(); + final ActivityManager am = (ActivityManager) context + .getSystemService(Context.ACTIVITY_SERVICE); + mIconSize = r.getDimensionPixelSize(R.dimen.shortcut_icon_size); + if (mIconSize == 0) { + mIconSize = am.getLauncherLargeIconSize(); + } + mIconDensity = am.getLauncherLargeIconDensity(); + mBorderWidth = r.getDimensionPixelOffset( + R.dimen.shortcut_icon_border_width); + mBorderColor = r.getColor(R.color.shortcut_overlay_text_background); + } + + public void createContactShortcutIntent(Uri contactUri) { + new ContactLoadingAsyncTask(contactUri).execute(); + } + + public void createPhoneNumberShortcutIntent(Uri dataUri, String shortcutAction) { + new PhoneNumberLoadingAsyncTask(dataUri, shortcutAction).execute(); + } + + /** + * An asynchronous task that loads name, photo and other data from the database. + */ + private abstract class LoadingAsyncTask extends AsyncTask<Void, Void, Void> { + protected Uri mUri; + protected String mContentType; + protected String mDisplayName; + protected byte[] mBitmapData; + protected long mPhotoId; + + public LoadingAsyncTask(Uri uri) { + mUri = uri; + } + + @Override + protected Void doInBackground(Void... params) { + mContentType = mContext.getContentResolver().getType(mUri); + loadData(); + loadPhoto(); + return null; + } + + protected abstract void loadData(); + + private void loadPhoto() { + if (mPhotoId == 0) { + return; + } + + ContentResolver resolver = mContext.getContentResolver(); + Cursor cursor = resolver.query(Data.CONTENT_URI, PHOTO_COLUMNS, PHOTO_SELECTION, + new String[] { String.valueOf(mPhotoId) }, null); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + mBitmapData = cursor.getBlob(PHOTO_PHOTO_COLUMN_INDEX); + } + } finally { + cursor.close(); + } + } + } + } + + private final class ContactLoadingAsyncTask extends LoadingAsyncTask { + public ContactLoadingAsyncTask(Uri uri) { + super(uri); + } + + @Override + protected void loadData() { + ContentResolver resolver = mContext.getContentResolver(); + Cursor cursor = resolver.query(mUri, CONTACT_COLUMNS, null, null, null); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + mDisplayName = cursor.getString(CONTACT_DISPLAY_NAME_COLUMN_INDEX); + mPhotoId = cursor.getLong(CONTACT_PHOTO_ID_COLUMN_INDEX); + } + } finally { + cursor.close(); + } + } + } + @Override + protected void onPostExecute(Void result) { + createContactShortcutIntent(mUri, mContentType, mDisplayName, mBitmapData); + } + } + + private final class PhoneNumberLoadingAsyncTask extends LoadingAsyncTask { + private final String mShortcutAction; + private String mPhoneNumber; + private int mPhoneType; + private String mPhoneLabel; + + public PhoneNumberLoadingAsyncTask(Uri uri, String shortcutAction) { + super(uri); + mShortcutAction = shortcutAction; + } + + @Override + protected void loadData() { + ContentResolver resolver = mContext.getContentResolver(); + Cursor cursor = resolver.query(mUri, PHONE_COLUMNS, null, null, null); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + mDisplayName = cursor.getString(PHONE_DISPLAY_NAME_COLUMN_INDEX); + mPhotoId = cursor.getLong(PHONE_PHOTO_ID_COLUMN_INDEX); + mPhoneNumber = cursor.getString(PHONE_NUMBER_COLUMN_INDEX); + mPhoneType = cursor.getInt(PHONE_TYPE_COLUMN_INDEX); + mPhoneLabel = cursor.getString(PHONE_LABEL_COLUMN_INDEX); + } + } finally { + cursor.close(); + } + } + } + + @Override + protected void onPostExecute(Void result) { + createPhoneNumberShortcutIntent(mUri, mDisplayName, mBitmapData, mPhoneNumber, + mPhoneType, mPhoneLabel, mShortcutAction); + } + } + + private Bitmap getPhotoBitmap(byte[] bitmapData) { + Bitmap bitmap; + if (bitmapData != null) { + bitmap = BitmapFactory.decodeByteArray(bitmapData, 0, bitmapData.length, null); + } else { + bitmap = ((BitmapDrawable) mContext.getResources().getDrawableForDensity( + R.drawable.ic_contact_picture_holo_light, mIconDensity)).getBitmap(); + } + return bitmap; + } + + private void createContactShortcutIntent(Uri contactUri, String contentType, String displayName, + byte[] bitmapData) { + Bitmap bitmap = getPhotoBitmap(bitmapData); + + Intent shortcutIntent = new Intent(ContactsContract.QuickContact.ACTION_QUICK_CONTACT); + + // When starting from the launcher, start in a new, cleared task. + // CLEAR_WHEN_TASK_RESET cannot reset the root of a task, so we + // clear the whole thing preemptively here since QuickContactActivity will + // finish itself when launching other detail activities. + shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + + // Tell the launcher to not do its animation, because we are doing our own + shortcutIntent.putExtra(INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION, true); + + shortcutIntent.setDataAndType(contactUri, contentType); + shortcutIntent.putExtra(ContactsContract.QuickContact.EXTRA_MODE, + ContactsContract.QuickContact.MODE_LARGE); + shortcutIntent.putExtra(ContactsContract.QuickContact.EXTRA_EXCLUDE_MIMES, + (String[]) null); + + final Bitmap icon = generateQuickContactIcon(bitmap); + + Intent intent = new Intent(); + intent.putExtra(Intent.EXTRA_SHORTCUT_ICON, icon); + intent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent); + if (TextUtils.isEmpty(displayName)) { + intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, mContext.getResources().getString( + R.string.missing_name)); + } else { + intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, displayName); + } + + mListener.onShortcutIntentCreated(contactUri, intent); + } + + private void createPhoneNumberShortcutIntent(Uri uri, String displayName, byte[] bitmapData, + String phoneNumber, int phoneType, String phoneLabel, String shortcutAction) { + Bitmap bitmap = getPhotoBitmap(bitmapData); + + Uri phoneUri; + if (Intent.ACTION_CALL.equals(shortcutAction)) { + // Make the URI a direct tel: URI so that it will always continue to work + phoneUri = Uri.fromParts(CallUtil.SCHEME_TEL, phoneNumber, null); + bitmap = generatePhoneNumberIcon(bitmap, phoneType, phoneLabel, + R.drawable.badge_action_call); + } else { + phoneUri = Uri.fromParts(CallUtil.SCHEME_SMSTO, phoneNumber, null); + bitmap = generatePhoneNumberIcon(bitmap, phoneType, phoneLabel, + R.drawable.badge_action_sms); + } + + Intent shortcutIntent = new Intent(shortcutAction, phoneUri); + shortcutIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + + Intent intent = new Intent(); + intent.putExtra(Intent.EXTRA_SHORTCUT_ICON, bitmap); + intent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent); + intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, displayName); + + mListener.onShortcutIntentCreated(uri, intent); + } + + private void drawBorder(Canvas canvas, Rect dst) { + // Darken the border + final Paint workPaint = new Paint(); + workPaint.setColor(mBorderColor); + workPaint.setStyle(Paint.Style.STROKE); + // The stroke is drawn centered on the rect bounds, and since half will be drawn outside the + // bounds, we need to double the width for it to appear as intended. + workPaint.setStrokeWidth(mBorderWidth * 2); + canvas.drawRect(dst, workPaint); + } + + private Bitmap generateQuickContactIcon(Bitmap photo) { + + // Setup the drawing classes + Bitmap icon = Bitmap.createBitmap(mIconSize, mIconSize, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(icon); + + // Copy in the photo + Paint photoPaint = new Paint(); + photoPaint.setDither(true); + photoPaint.setFilterBitmap(true); + Rect src = new Rect(0,0, photo.getWidth(),photo.getHeight()); + Rect dst = new Rect(0,0, mIconSize, mIconSize); + canvas.drawBitmap(photo, src, dst, photoPaint); + + drawBorder(canvas, dst); + + Drawable overlay = mContext.getResources().getDrawableForDensity( + com.android.internal.R.drawable.quickcontact_badge_overlay_dark, mIconDensity); + + overlay.setBounds(dst); + overlay.draw(canvas); + canvas.setBitmap(null); + + return icon; + } + + /** + * Generates a phone number shortcut icon. Adds an overlay describing the type of the phone + * number, and if there is a photo also adds the call action icon. + */ + private Bitmap generatePhoneNumberIcon(Bitmap photo, int phoneType, String phoneLabel, + int actionResId) { + final Resources r = mContext.getResources(); + final float density = r.getDisplayMetrics().density; + + Bitmap phoneIcon = ((BitmapDrawable) r.getDrawableForDensity(actionResId, mIconDensity)) + .getBitmap(); + + // Setup the drawing classes + Bitmap icon = Bitmap.createBitmap(mIconSize, mIconSize, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(icon); + + // Copy in the photo + Paint photoPaint = new Paint(); + photoPaint.setDither(true); + photoPaint.setFilterBitmap(true); + Rect src = new Rect(0, 0, photo.getWidth(), photo.getHeight()); + Rect dst = new Rect(0, 0, mIconSize, mIconSize); + canvas.drawBitmap(photo, src, dst, photoPaint); + + drawBorder(canvas, dst); + + // Create an overlay for the phone number type + CharSequence overlay = Phone.getTypeLabel(r, phoneType, phoneLabel); + + if (overlay != null) { + TextPaint textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DEV_KERN_TEXT_FLAG); + textPaint.setTextSize(r.getDimension(R.dimen.shortcut_overlay_text_size)); + textPaint.setColor(r.getColor(R.color.textColorIconOverlay)); + textPaint.setShadowLayer(4f, 0, 2f, r.getColor(R.color.textColorIconOverlayShadow)); + + final FontMetricsInt fmi = textPaint.getFontMetricsInt(); + + // First fill in a darker background around the text to be drawn + final Paint workPaint = new Paint(); + workPaint.setColor(mBorderColor); + workPaint.setStyle(Paint.Style.FILL); + final int textPadding = r + .getDimensionPixelOffset(R.dimen.shortcut_overlay_text_background_padding); + final int textBandHeight = (fmi.descent - fmi.ascent) + textPadding * 2; + dst.set(0 + mBorderWidth, mIconSize - textBandHeight, mIconSize - mBorderWidth, + mIconSize - mBorderWidth); + canvas.drawRect(dst, workPaint); + + final float sidePadding = mBorderWidth; + overlay = TextUtils.ellipsize(overlay, textPaint, mIconSize - 2 * sidePadding, + TruncateAt.END_SMALL); + final float textWidth = textPaint.measureText(overlay, 0, overlay.length()); + canvas.drawText(overlay, 0, overlay.length(), (mIconSize - textWidth) / 2, mIconSize + - fmi.descent - textPadding, textPaint); + } + + // Draw the phone action icon as an overlay + src.set(0, 0, phoneIcon.getWidth(), phoneIcon.getHeight()); + int iconWidth = icon.getWidth(); + dst.set(iconWidth - ((int) (20 * density)), -1, + iconWidth, ((int) (19 * density))); + dst.offset(-mBorderWidth, mBorderWidth); + canvas.drawBitmap(phoneIcon, src, dst, photoPaint); + + canvas.setBitmap(null); + + return icon; + } +} diff --git a/src/com/android/contacts/common/util/AccountFilterUtil.java b/src/com/android/contacts/common/util/AccountFilterUtil.java new file mode 100644 index 00000000..d1820c85 --- /dev/null +++ b/src/com/android/contacts/common/util/AccountFilterUtil.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.util; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import android.view.View; +import android.widget.TextView; + +import com.android.contacts.common.R; +import com.android.contacts.common.list.AccountFilterActivity; +import com.android.contacts.common.list.ContactListFilter; +import com.android.contacts.common.list.ContactListFilterController; + +/** + * Utility class for account filter manipulation. + */ +public class AccountFilterUtil { + private static final String TAG = AccountFilterUtil.class.getSimpleName(); + + /** + * Find TextView with the id "account_filter_header" and set correct text for the account + * filter header. + * + * @param filterContainer View containing TextView with id "account_filter_header" + * @return true when header text is set in the call. You may use this for conditionally + * showing or hiding this entire view. + */ + public static boolean updateAccountFilterTitleForPeople(View filterContainer, + ContactListFilter filter, boolean showTitleForAllAccounts) { + return updateAccountFilterTitle(filterContainer, filter, showTitleForAllAccounts, false); + } + + /** + * Similar to {@link #updateAccountFilterTitleForPeople(View, ContactListFilter, boolean, + * boolean)}, but for Phone UI. + */ + public static boolean updateAccountFilterTitleForPhone(View filterContainer, + ContactListFilter filter, boolean showTitleForAllAccounts) { + return updateAccountFilterTitle( + filterContainer, filter, showTitleForAllAccounts, true); + } + + private static boolean updateAccountFilterTitle(View filterContainer, + ContactListFilter filter, boolean showTitleForAllAccounts, + boolean forPhone) { + final Context context = filterContainer.getContext(); + final TextView headerTextView = (TextView) + filterContainer.findViewById(R.id.account_filter_header); + + boolean textWasSet = false; + if (filter != null) { + if (forPhone) { + if (filter.filterType == ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS) { + if (showTitleForAllAccounts) { + headerTextView.setText(R.string.list_filter_phones); + textWasSet = true; + } + } else if (filter.filterType == ContactListFilter.FILTER_TYPE_ACCOUNT) { + headerTextView.setText(context.getString( + R.string.listAllContactsInAccount, filter.accountName)); + textWasSet = true; + } else if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) { + headerTextView.setText(R.string.listCustomView); + textWasSet = true; + } else { + Log.w(TAG, "Filter type \"" + filter.filterType + "\" isn't expected."); + } + } else { + if (filter.filterType == ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS) { + if (showTitleForAllAccounts) { + headerTextView.setText(R.string.list_filter_all_accounts); + textWasSet = true; + } + } else if (filter.filterType == ContactListFilter.FILTER_TYPE_ACCOUNT) { + headerTextView.setText(context.getString( + R.string.listAllContactsInAccount, filter.accountName)); + textWasSet = true; + } else if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) { + headerTextView.setText(R.string.listCustomView); + textWasSet = true; + } else if (filter.filterType == ContactListFilter.FILTER_TYPE_SINGLE_CONTACT) { + headerTextView.setText(R.string.listSingleContact); + textWasSet = true; + } else { + Log.w(TAG, "Filter type \"" + filter.filterType + "\" isn't expected."); + } + } + } else { + Log.w(TAG, "Filter is null."); + } + return textWasSet; + } + + /** + * Launches account filter setting Activity using + * {@link Activity#startActivityForResult(Intent, int)}. + * + * @param activity + * @param requestCode requestCode for {@link Activity#startActivityForResult(Intent, int)} + * @param currentFilter currently-selected filter, so that it can be displayed as activated. + */ + public static void startAccountFilterActivityForResult( + Activity activity, int requestCode, ContactListFilter currentFilter) { + final Intent intent = new Intent(activity, AccountFilterActivity.class); + intent.putExtra(AccountFilterActivity.KEY_EXTRA_CURRENT_FILTER, currentFilter); + activity.startActivityForResult(intent, requestCode); + } + + /** + * Very similar to + * {@link #startAccountFilterActivityForResult(Activity, int, ContactListFilter)} + * but uses Fragment instead. + */ + public static void startAccountFilterActivityForResult( + Fragment fragment, int requestCode, ContactListFilter currentFilter) { + final Activity activity = fragment.getActivity(); + if (activity != null) { + final Intent intent = new Intent(activity, AccountFilterActivity.class); + intent.putExtra(AccountFilterActivity.KEY_EXTRA_CURRENT_FILTER, currentFilter); + fragment.startActivityForResult(intent, requestCode); + } else { + Log.w(TAG, "getActivity() returned null. Ignored"); + } + } + + /** + * Useful method to handle onActivityResult() for + * {@link #startAccountFilterActivityForResult(Activity, int)} or + * {@link #startAccountFilterActivityForResult(Fragment, int)}. + * + * This will update filter via a given ContactListFilterController. + */ + public static void handleAccountFilterResult( + ContactListFilterController filterController, int resultCode, Intent data) { + if (resultCode == Activity.RESULT_OK) { + final ContactListFilter filter = (ContactListFilter) + data.getParcelableExtra(AccountFilterActivity.KEY_EXTRA_CONTACT_LIST_FILTER); + if (filter == null) { + return; + } + if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) { + filterController.selectCustomFilter(); + } else { + filterController.setContactListFilter(filter, true); + } + } + } +} diff --git a/src/com/android/contacts/common/util/EmptyService.java b/src/com/android/contacts/common/util/EmptyService.java new file mode 100644 index 00000000..c5c36080 --- /dev/null +++ b/src/com/android/contacts/common/util/EmptyService.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.util; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +/** + * Background {@link Service} that is used to keep our process alive long enough + * for background threads to finish. Started and stopped directly by specific + * background tasks when needed. + */ +public class EmptyService extends Service { + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/src/com/android/contacts/common/util/LocalizedNameResolver.java b/src/com/android/contacts/common/util/LocalizedNameResolver.java new file mode 100644 index 00000000..f8d81511 --- /dev/null +++ b/src/com/android/contacts/common/util/LocalizedNameResolver.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.contacts.common.util; + +import android.accounts.AccountManager; +import android.accounts.AuthenticatorDescription; +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ServiceInfo; +import android.content.res.Resources; +import android.content.res.Resources.NotFoundException; +import android.content.res.TypedArray; +import android.content.res.XmlResourceParser; +import android.util.AttributeSet; +import android.util.Log; +import android.util.Xml; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; + +/** + * Retrieves localized names per account type. This allows customizing texts like + * "All Contacts" for certain account types, but e.g. "All Friends" or "All Connections" for others. + */ +public class LocalizedNameResolver { + private static final String TAG = "LocalizedNameResolver"; + + /** + * Meta-data key for the contacts configuration associated with a sync service. + */ + private static final String METADATA_CONTACTS = "android.provider.CONTACTS_STRUCTURE"; + + private static final String CONTACTS_DATA_KIND = "ContactsDataKind"; + + /** + * Returns the name for All Contacts for the specified account type. + */ + public static String getAllContactsName(Context context, String accountType) { + if (context == null) throw new IllegalArgumentException("Context must not be null"); + if (accountType == null) return null; + + return resolveAllContactsName(context, accountType); + } + + /** + * Finds "All Contacts"-Name for the specified account type. + */ + private static String resolveAllContactsName(Context context, String accountType) { + final AccountManager am = AccountManager.get(context); + + for (AuthenticatorDescription auth : am.getAuthenticatorTypes()) { + if (accountType.equals(auth.type)) { + return resolveAllContactsNameFromMetaData(context, auth.packageName); + } + } + + return null; + } + + /** + * Finds the meta-data XML containing the contacts configuration and + * reads the picture priority from that file. + */ + private static String resolveAllContactsNameFromMetaData(Context context, String packageName) { + final PackageManager pm = context.getPackageManager(); + try { + PackageInfo pi = pm.getPackageInfo(packageName, PackageManager.GET_SERVICES + | PackageManager.GET_META_DATA); + if (pi != null && pi.services != null) { + for (ServiceInfo si : pi.services) { + final XmlResourceParser parser = si.loadXmlMetaData(pm, METADATA_CONTACTS); + if (parser != null) { + return loadAllContactsNameFromXml(context, parser, packageName); + } + } + } + } catch (NameNotFoundException e) { + Log.w(TAG, "Problem loading \"All Contacts\"-name: " + e.toString()); + } + return null; + } + + private static String loadAllContactsNameFromXml(Context context, XmlPullParser parser, + String packageName) { + try { + final AttributeSet attrs = Xml.asAttributeSet(parser); + int type; + while ((type = parser.next()) != XmlPullParser.START_TAG + && type != XmlPullParser.END_DOCUMENT) { + // Drain comments and whitespace + } + + if (type != XmlPullParser.START_TAG) { + throw new IllegalStateException("No start tag found"); + } + + final int depth = parser.getDepth(); + while (((type = parser.next()) != XmlPullParser.END_TAG || parser.getDepth() > depth) + && type != XmlPullParser.END_DOCUMENT) { + String name = parser.getName(); + if (type == XmlPullParser.START_TAG && CONTACTS_DATA_KIND.equals(name)) { + final TypedArray typedArray = context.obtainStyledAttributes(attrs, + android.R.styleable.ContactsDataKind); + try { + // See if a string has been hardcoded directly into the xml + final String nonResourceString = typedArray.getNonResourceString( + android.R.styleable.ContactsDataKind_allContactsName); + if (nonResourceString != null) { + return nonResourceString; + } + + // See if a resource is referenced. We can't rely on getString + // to automatically resolve it as the resource lives in a different package + int id = typedArray.getResourceId( + android.R.styleable.ContactsDataKind_allContactsName, 0); + if (id == 0) return null; + + // Resolve the resource Id + final PackageManager packageManager = context.getPackageManager(); + final Resources resources; + try { + resources = packageManager.getResourcesForApplication(packageName); + } catch (NameNotFoundException e) { + return null; + } + try { + return resources.getString(id); + } catch (NotFoundException e) { + return null; + } + } finally { + typedArray.recycle(); + } + } + } + return null; + } catch (XmlPullParserException e) { + throw new IllegalStateException("Problem reading XML", e); + } catch (IOException e) { + throw new IllegalStateException("Problem reading XML", e); + } + } +} diff --git a/src/com/android/contacts/common/util/WeakAsyncTask.java b/src/com/android/contacts/common/util/WeakAsyncTask.java new file mode 100644 index 00000000..f46e5142 --- /dev/null +++ b/src/com/android/contacts/common/util/WeakAsyncTask.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.util; + +import android.os.AsyncTask; + +import java.lang.ref.WeakReference; + +public abstract class WeakAsyncTask<Params, Progress, Result, WeakTarget> extends + AsyncTask<Params, Progress, Result> { + protected WeakReference<WeakTarget> mTarget; + + public WeakAsyncTask(WeakTarget target) { + mTarget = new WeakReference<WeakTarget>(target); + } + + /** {@inheritDoc} */ + @Override + protected final void onPreExecute() { + final WeakTarget target = mTarget.get(); + if (target != null) { + this.onPreExecute(target); + } + } + + /** {@inheritDoc} */ + @Override + protected final Result doInBackground(Params... params) { + final WeakTarget target = mTarget.get(); + if (target != null) { + return this.doInBackground(target, params); + } else { + return null; + } + } + + /** {@inheritDoc} */ + @Override + protected final void onPostExecute(Result result) { + final WeakTarget target = mTarget.get(); + if (target != null) { + this.onPostExecute(target, result); + } + } + + protected void onPreExecute(WeakTarget target) { + // No default action + } + + protected abstract Result doInBackground(WeakTarget target, Params... params); + + protected void onPostExecute(WeakTarget target, Result result) { + // No default action + } +} |