content: /*
   * Copyright (C) 2015-2023 Emanuel Moecklin
   *
   * 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.onegravity.contactpicker.core;
  
  import android.Manifest;
  import android.app.Activity;
  import android.content.Intent;
  import android.content.pm.ActivityInfo;
  import android.content.pm.PackageManager;
  import android.content.res.Resources;
  import android.database.Cursor;
  import android.net.Uri;
  import android.os.Bundle;
  import android.provider.ContactsContract;
  import android.util.Log;
  import android.util.TypedValue;
  import android.view.Menu;
  import android.view.MenuItem;
  import android.widget.Toast;
  
  import com.google.android.material.tabs.TabLayout;
  import com.onegravity.contactpicker.OnContactCheckedListener;
  import com.onegravity.contactpicker.R;
  import com.onegravity.contactpicker.contact.Contact;
  import com.onegravity.contactpicker.contact.ContactDescription;
  import com.onegravity.contactpicker.contact.ContactSelectionChanged;
  import com.onegravity.contactpicker.contact.ContactSortOrder;
  import com.onegravity.contactpicker.contact.ContactsLoaded;
  import com.onegravity.contactpicker.group.Group;
  import com.onegravity.contactpicker.group.GroupsLoaded;
  import com.onegravity.contactpicker.picture.ContactPictureType;
  
  import org.greenrobot.eventbus.EventBus;
  import org.greenrobot.eventbus.Subscribe;
  import org.greenrobot.eventbus.ThreadMode;
  
  import java.io.Serializable;
  import java.lang.reflect.Field;
  import java.util.ArrayList;
  import java.util.Collection;
  import java.util.Collections;
  import java.util.Comparator;
  import java.util.HashMap;
  import java.util.HashSet;
  import java.util.List;
  import java.util.Map;
  
  import static android.provider.ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS;
  
  import androidx.appcompat.app.AppCompatActivity;
  import androidx.loader.app.LoaderManager;
  import androidx.loader.content.CursorLoader;
  import androidx.loader.content.Loader;
  import androidx.viewpager.widget.ViewPager;
  
  public class ContactPickerActivity extends AppCompatActivity implements
          LoaderManager.LoaderCallbacks<Cursor> {
  
      /**
       * Use this parameter to set the Theme for this activity.
       * It's the theme's resource id (like R.style.YourFancyTheme)..
       * Please note that the theme needs to define the attributes defined in attrs.xml or the
       * Activity will crash. You can either extend the library's theme(s) or define the attributes in
       * your custom theme.
       */
      public static final String EXTRA_THEME = "EXTRA_THEME";
  
      /**
       * Use this parameter to determine whether the contact picture shows a contact badge and if yes
       * what type (round, square).
       *
       * {@link com.onegravity.contactpicker.picture.ContactPictureType}
       */
      public static final String EXTRA_CONTACT_BADGE_TYPE = "EXTRA_CONTACT_BADGE_TYPE";
  
      /**
       * Use this to define what contact information is used for the description field (second line).
       *
       * {@link com.onegravity.contactpicker.contact.ContactDescription}
       */
      public static final String EXTRA_CONTACT_DESCRIPTION = "EXTRA_CONTACT_DESCRIPTION";
  
      /**
       * This parameter sets limit of the amount of contacts the user can select on each intent.
       * By default, the retrieved value is 0
       */
      public static final String EXTRA_SELECT_CONTACTS_LIMIT = "EXTRA_SELECT_CONTACTS_LIMIT";
  
      /**
       * This parameter sets messages seen in a toast when the selection limit has been reached.
       */
      public static final String EXTRA_LIMIT_REACHED_MESSAGE = "EXTRA_LIMIT_REACHED_MESSAGE";
  
      /**
       * This parameter sets the boolean which decides whether to show the check all menu button.
       * By default, the retrieved value is true
       */
      public static final String EXTRA_SHOW_CHECK_ALL = "EXTRA_SHOW_CHECK_ALL";
  
      /**
       * This parameter sets a boolean to filter out contacts that do not have phone numbers.
       * By default, the retrieved value is false
       */
      public static final String EXTRA_ONLY_CONTACTS_WITH_PHONE = "EXTRA_ONLY_CONTACTS_WITH_PHONE";
  
      /**
       * This parameter sets a boolean to only show tab contacts or both (contacts and group).
       * By default, the retrieved value is true
       */
      public static final String EXTRA_WITH_GROUP_TAB = "EXTRA_WITH_GROUP_TAB";
  
      /**
       * This defines which type is shown in the description. It refines the EXTRA_CONTACT_DESCRIPTION
       * parameter and uses the android.provider.ContactsContract.CommonDataKinds values
       *
       * If the description is PHONE then this parameter needs to match one of the
       * ContactsContract.CommonDataKinds.Email.ContactsContract.CommonDataKinds.Phone.TYPE values:
       *
       * - TYPE_CUSTOM, TYPE_HOME, TYPE_MOBILE, TYPE_WORK, TYPE_FAX_WORK, TYPE_FAX_HOME, TYPE_PAGER
       * - TYPE_OTHER, TYPE_CALLBACK, TYPE_CAR, TYPE_COMPANY_MAIN, TYPE_ISDN, TYPE_MAIN
       * - TYPE_OTHER_FAX, TYPE_RADIO, TYPE_TELEX, TYPE_TTY_TDD, TYPE_WORK_MOBILE, TYPE_WORK_PAGER
       * - TYPE_ASSISTANT, TYPE_MMS
       *
       * (https://developer.android.com/reference/android/provider/ContactsContract.CommonDataKinds.Phone.html)
       *
       * If the description is EMAIL then this parameter needs to match one of the
       * ContactsContract.CommonDataKinds.Email.TYPE values:
       *
       * - TYPE_CUSTOM
       * - TYPE_HOME
       * - TYPE_WORK
       * - TYPE_OTHER
       * - TYPE_MOBILE
       *
       * (https://developer.android.com/reference/android/provider/ContactsContract.CommonDataKinds.Email.html)
       *
       * If the description is ADDRESS then this parameter needs to match one of the
       * ContactsContract.CommonDataKinds.StructuredPostal.TYPE values:
       *
       * - TYPE_CUSTOM
       * - TYPE_HOME
       * - TYPE_WORK
       * - TYPE_OTHER
       *
       * (https://developer.android.com/reference/android/provider/ContactsContract.CommonDataKinds.StructuredPostal.html)
       */
      public static final String EXTRA_CONTACT_DESCRIPTION_TYPE = "EXTRA_CONTACT_DESCRIPTION_TYPE";
  
      /**
       * Use this to define the sorting order for contacts
       *
       * {@link com.onegravity.contactpicker.contact.ContactSortOrder}
       */
      public static final String EXTRA_CONTACT_SORT_ORDER = "EXTRA_CONTACT_SORT_ORDER";
  
      /**
       * This parameter contains a serializable collection of contact IDs. The contacts matching these IDs are
       * preselected when entering the activity.
       *
       * {@link com.onegravity.contactpicker.ContactElement}
       */
      public static final String EXTRA_PRESELECTED_CONTACTS = "EXTRA_PRESELECTED_CONTACTS";
  
      /**
       * This parameter contains a serializable collection of group IDs. The groups matching these IDs are
       * preselected when entering the activity.
       *
       * {@link com.onegravity.contactpicker.ContactElement}
       */
      public static final String EXTRA_PRESELECTED_GROUPS = "EXTRA_PRESELECTED_GROUPS";
  
      /**
       * We put the resulting contact list into the Intent as extra data with this key.
       */
      public static final String RESULT_CONTACT_DATA = "RESULT_CONTACT_DATA";
  
      /**
       * We put the resulting group list into the Intent as extra data with this key.
       */
      public static final String RESULT_GROUP_DATA = "RESULT_GROUP_DATA";
  
      private int mThemeResId;
  
      private ContactPictureType mBadgeType = ContactPictureType.ROUND;
  
      private int mDescriptionType = ContactsContract.CommonDataKinds.StructuredPostal.TYPE_HOME;
      private ContactDescription mDescription = ContactDescription.ADDRESS;
  
      private ContactSortOrder mSortOrder = ContactSortOrder.AUTOMATIC;
  
      private PagerAdapter mAdapter;
  
      private String mDefaultTitle;
  
      private Boolean mShowCheckAll = true;
  
      // update the adapter after a certain amount of contacts has loaded
      private static final int BATCH_SIZE = 50;
  
      /*
       * The selected ids are saved in onSaveInstanceState, restored in onCreate and then applied to
       * the contacts and groups in onLoadFinished.
       */
      private static final String CONTACT_IDS = "CONTACT_IDS";
      private HashSet<Long> mSelectedContactIds = new HashSet<>();
  
      private static final String GROUP_IDS = "GROUP_IDS";
      private HashSet<Long> mSelectedGroupIds = new HashSet<>();
  
      private String mLimitReachedMessage;
      private int mSelectContactsLimit = 0;
      private Boolean mOnlyWithPhoneNumbers = false;
      private Boolean mWithGroupTab = true;
  
      // ****************************************** Lifecycle Methods *******************************************
  
      @Override
      protected void onCreate(Bundle savedInstanceState) {
          super.onCreate(savedInstanceState);
  
          /*
           * Check if we have the READ_CONTACTS permission, if not --> terminate.
           */
          try {
              int pid = android.os.Process.myPid();
              PackageManager pckMgr = getPackageManager();
              int uid = pckMgr.getApplicationInfo(getComponentName().getPackageName(), PackageManager.GET_META_DATA).uid;
              enforcePermission(Manifest.permission.READ_CONTACTS, pid, uid, "Contact permission hasn't been granted to this app, terminating.");
          }
          catch (PackageManager.NameNotFoundException | SecurityException e) {
              Log.e(getClass().getSimpleName(), e.getMessage());
              finish();
              return;
          }
  
          Intent intent = getIntent();
          if (savedInstanceState == null) {
              /*
               * Retrieve default title used if no contacts are selected.
               */
              try {
                  PackageManager pkMgr = getPackageManager();
                  ActivityInfo activityInfo = pkMgr.getActivityInfo(getComponentName(), PackageManager.GET_META_DATA);
                  mDefaultTitle = activityInfo.loadLabel(pkMgr).toString();
              }
              catch (PackageManager.NameNotFoundException ignore) {
                  mDefaultTitle = getTitle().toString();
              }
  
              if(intent.hasExtra(EXTRA_PRESELECTED_CONTACTS)) {
                  Collection<Long> preselectedContacts = (Collection<Long>) intent.getSerializableExtra( EXTRA_PRESELECTED_CONTACTS );
                  mSelectedContactIds.addAll(preselectedContacts);
              }
  
              if(intent.hasExtra(EXTRA_PRESELECTED_GROUPS)) {
                  Collection<Long> preselectedGroups = (Collection<Long>) intent.getSerializableExtra( EXTRA_PRESELECTED_GROUPS );
                  mSelectedGroupIds.addAll(preselectedGroups);
              }
  
              mThemeResId = intent.getIntExtra(EXTRA_THEME, R.style.ContactPicker_Theme_Light);
          }
          else {
              mDefaultTitle = savedInstanceState.getString("mDefaultTitle");
  
              mThemeResId = savedInstanceState.getInt("mThemeResId");
  
              // Retrieve selected contact and group ids.
              try {
                  mSelectedContactIds = (HashSet<Long>) savedInstanceState.getSerializable(CONTACT_IDS);
                  mSelectedGroupIds = (HashSet<Long>) savedInstanceState.getSerializable(GROUP_IDS);
              }
              catch (ClassCastException ignore) {}
          }
  
          /*
           * Retrieve ContactPictureType.
           */
          String enumName = intent.getStringExtra(EXTRA_CONTACT_BADGE_TYPE);
          mBadgeType = ContactPictureType.lookup(enumName);
  
          /*
           * Retrieve SelectContactsLimit.
           */
          mSelectContactsLimit = intent.getIntExtra(EXTRA_SELECT_CONTACTS_LIMIT, 0);
  
          /*
           * Retrieve ShowCheckAll.
           */
          mShowCheckAll = mSelectContactsLimit <= 0 && intent.getBooleanExtra(EXTRA_SHOW_CHECK_ALL, true);
  
          /*
           * Retrieve OnlyWithPhoneNumbers.
           */
          mOnlyWithPhoneNumbers = intent.getBooleanExtra(EXTRA_ONLY_CONTACTS_WITH_PHONE, false);
  
          /*
           * Retrieve mWithGroupTab.
           */
          mWithGroupTab = intent.getBooleanExtra(EXTRA_WITH_GROUP_TAB, true);
  
          /*
           * Retrieve LimitReachedMessage.
           */
          String limitMsg = intent.getStringExtra(EXTRA_LIMIT_REACHED_MESSAGE);
          if(limitMsg != null){
              mLimitReachedMessage = limitMsg;
          }
          else {
              mLimitReachedMessage = getString(R.string.cp_limit_reached, mSelectContactsLimit);
          }
  
          /*
           * Retrieve ContactDescription.
           */
          enumName = intent.getStringExtra(EXTRA_CONTACT_DESCRIPTION);
          mDescription = ContactDescription.lookup(enumName);
          mDescriptionType = intent.getIntExtra(EXTRA_CONTACT_DESCRIPTION_TYPE, ContactsContract.CommonDataKinds.StructuredPostal.TYPE_HOME);
  
          /*
           * Retrieve ContactSortOrder.
           */
          enumName = intent.getStringExtra(EXTRA_CONTACT_SORT_ORDER);
          mSortOrder = ContactSortOrder.lookup(enumName);
  
          setTheme(mThemeResId);
  
          // check if all custom attributes are defined
          if (! checkTheming()) {
              finish();
              return;
          }
  
          setContentView(R.layout.cp_contact_tab_layout);
  
          // initialize TabLayout
          TabLayout tabLayout = (TabLayout)findViewById(R.id.tabContent);
          tabLayout.setTabMode(TabLayout.MODE_FIXED);
          tabLayout.setTabGravity(TabLayout.GRAVITY_FILL);
  
          TabLayout.Tab tabContacts = tabLayout.newTab();
          tabContacts.setText(R.string.cp_contact_tab_title);
          tabLayout.addTab(tabContacts);
  
          if (mWithGroupTab) {
              TabLayout.Tab tabGroups = tabLayout.newTab();
              tabGroups.setText(R.string.cp_group_tab_title);
              tabLayout.addTab(tabGroups);
          }
  
          // initialize ViewPager
          final ViewPager viewPager = (ViewPager) findViewById(R.id.tabPager);
          mAdapter = new PagerAdapter(getSupportFragmentManager(), tabLayout.getTabCount(),
                  mSortOrder, mBadgeType, mDescription, mDescriptionType);
          viewPager.setAdapter(mAdapter);
          viewPager.addOnPageChangeListener(new TabLayout.TabLayoutOnPageChangeListener(tabLayout));
  
          tabLayout.setOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
              @Override
              public void onTabSelected(TabLayout.Tab tab) {
                  viewPager.setCurrentItem(tab.getPosition());
              }
              @Override
              public void onTabUnselected(TabLayout.Tab tab) {}
              @Override
              public void onTabReselected(TabLayout.Tab tab) {}
          });
      }
  
      @Override
      protected void onResume() {
          super.onResume();
  
          EventBus.getDefault().register(this);
  
          getSupportLoaderManager().initLoader(CONTACTS_LOADER_ID, null, this);
          getSupportLoaderManager().initLoader(GROUPS_LOADER_ID, null, this);
      }
  
      @Override
      protected void onPause() {
          super.onPause();
  
          EventBus.getDefault().unregister(this);
      }
  
      @Override
      protected void onSaveInstanceState(Bundle outState) {
          super.onSaveInstanceState(outState);
  
          outState.putString("mDefaultTitle", mDefaultTitle);
  
          outState.putInt("mThemeResId", mThemeResId);
  
          mSelectedContactIds.clear();
          for (Contact contact : mContacts) {
              if (contact.isChecked()) {
                  mSelectedContactIds.add( contact.getId() );
              }
          }
          outState.putSerializable(CONTACT_IDS, mSelectedContactIds);
  
          mSelectedGroupIds.clear();
          for (Group group : mGroups) {
              if (group.isChecked()) {
                  mSelectedGroupIds.add( group.getId() );
              }
          }
          outState.putSerializable(GROUP_IDS, mSelectedGroupIds);
      }
  
      private void updateTitle() {
          if (mNrOfSelectedContacts == 0) {
              setTitle(mDefaultTitle);
          }
          else {
              String title = getString(R.string.cp_actionmode_selected, mNrOfSelectedContacts);
              setTitle(title);
          }
      }
  
      // ****************************************** Option Menu *******************************************
  
      @Override
      public boolean onCreateOptionsMenu(Menu menu) {
          getMenuInflater().inflate(R.menu.cp_contact_picker, menu);
          if(!mShowCheckAll){
              MenuItem checkAllBtn = menu.findItem(R.id.action_check_all);
              checkAllBtn.setVisible(mShowCheckAll);
          }
          return true;
      }
  
      @Override
      public boolean onOptionsItemSelected(MenuItem item) {
          int id = item.getItemId();
          if( id == android.R.id.home) {
              setResult(Activity.RESULT_CANCELED, null);
              finish();
              return true;
          }
          else if( id == R.id.action_done) {
              onDone();
              return true;
          }
  
          return super.onOptionsItemSelected(item);
      }
  
      private void onDone() {
          // return only checked contacts
          List<Contact> contacts = new ArrayList<>();
          if (mContacts != null) {
              for (Contact contact : mContacts) {
                  if (contact.isChecked()) {
                      contacts.add(contact);
                  }
              }
          }
  
          // return only checked groups
          List<Group> groups = new ArrayList<>();
          if (mGroups != null) {
              for (Group group : mGroups) {
                  if (group.isChecked()) {
                      groups.add(group);
                  }
              }
          }
  
          Intent data = new Intent();
          data.putExtra(RESULT_CONTACT_DATA, (Serializable) contacts);
          data.putExtra(RESULT_GROUP_DATA, (Serializable) groups);
          setResult(Activity.RESULT_OK, data);
          finish();
      }
  
      // ****************************************** Loader Methods *******************************************
  
      /*
       * Loader configuration contacts
       */
      private static final int CONTACTS_LOADER_ID = 0;
      private static final Uri CONTACTS_URI = ContactsContract.Contacts.CONTENT_URI;
      private static final String[] CONTACTS_PROJECTION = new String[] {
              ContactsContract.Contacts._ID,
              ContactsContract.Contacts.LOOKUP_KEY,
              ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
              ContactsContract.Contacts.PHOTO_THUMBNAIL_URI};
      private static final String CONTACTS_SORT = ContactsContract.Contacts.DISPLAY_NAME_PRIMARY + " COLLATE LOCALIZED ASC";
  
      /*
       * Loader configuration contacts details
       */
      private static final int CONTACT_DETAILS_LOADER_ID = 1;
      private static final Uri CONTACT_DETAILS_URI = ContactsContract.Data.CONTENT_URI;
      private static final String[] CONTACT_DETAILS_PROJECTION = {
              ContactsContract.Data.LOOKUP_KEY,
              ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
              ContactsContract.Data.MIMETYPE,
              FORMATTED_ADDRESS,
              ContactsContract.CommonDataKinds.StructuredPostal.TYPE,
              ContactsContract.CommonDataKinds.Phone.NUMBER,
              ContactsContract.CommonDataKinds.Phone.TYPE,
              ContactsContract.CommonDataKinds.Email.ADDRESS,
              ContactsContract.CommonDataKinds.Email.TYPE,
              ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
              ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
              ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID,
      };
  
      /*
       * Loader configuration groups
       */
      private static final int GROUPS_LOADER_ID = 2;
      private static final Uri GROUPS_URI = ContactsContract.Groups.CONTENT_URI;
      private static final String[] GROUPS_PROJECTION = new String[] {
              ContactsContract.Groups._ID,
              ContactsContract.Groups.TITLE};
      private static final String GROUPS_SELECTION = ContactsContract.Groups.DELETED + " = 0";
      private static final String GROUPS_SORT = ContactsContract.Groups.TITLE + " COLLATE LOCALIZED ASC";
  
      @Override
      public Loader<Cursor> onCreateLoader(int id, Bundle args) {
          String selection = "";
          if(mOnlyWithPhoneNumbers){
              selection = ContactsContract.Contacts.HAS_PHONE_NUMBER;
          }
          switch(id) {
              case CONTACTS_LOADER_ID:
                  return new CursorLoader(this, CONTACTS_URI, CONTACTS_PROJECTION,
                          selection, null, CONTACTS_SORT);
              case CONTACT_DETAILS_LOADER_ID:
                  return new CursorLoader(this, CONTACT_DETAILS_URI, CONTACT_DETAILS_PROJECTION,
                          selection, null, null);
              case GROUPS_LOADER_ID:
                  return new CursorLoader(this, GROUPS_URI, GROUPS_PROJECTION, GROUPS_SELECTION, null, GROUPS_SORT);
          }
          return null;
      }
  
      @Override
      public void onLoaderReset(Loader<Cursor> loader) {
          ContactsLoaded.post(null);
          GroupsLoaded.post(null);
      }
  
      @Override
      public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
          switch(loader.getId()) {
              case CONTACTS_LOADER_ID:
                  readContacts(cursor);
                  // contacts loaded --> load the contact details
                  getSupportLoaderManager().initLoader(CONTACT_DETAILS_LOADER_ID, null, this);
                  break;
  
              case CONTACT_DETAILS_LOADER_ID:
                  readContactDetails(cursor);
                  break;
  
              case GROUPS_LOADER_ID: {
                  readGroups(cursor);
                  break;
              }
          }
      }
  
      // ****************************************** Contact Methods *******************************************
  
      /*
       * List of all contacts.
       */
      private List<ContactImpl> mContacts = new ArrayList<>();
  
      /*
       * Map of all contacts by lookup key (ContactsContract.Contacts.LOOKUP_KEY).
       * We use this to find the contacts when the contact details are loaded.
       */
      private Map<String, ContactImpl> mContactsByLookupKey = new HashMap<>();
  
      /*
       * Number of selected contacts.
       * Selected groups are reflected in this too.
       */
      private int mNrOfSelectedContacts = 0;
  
      private Comparator<ContactImpl> mContactComparator = new Comparator<ContactImpl>() {
          @Override
          public int compare(ContactImpl lhs, ContactImpl rhs) {
              switch(mSortOrder) {
                  case FIRST_NAME: return lhs.getFirstName().compareToIgnoreCase(rhs.getFirstName());
                  case LAST_NAME: return lhs.getLastName().compareToIgnoreCase(rhs.getLastName());
                  default: return lhs.getDisplayName().compareToIgnoreCase(rhs.getDisplayName());
              }
          }
      };
  
      private void readContacts(Cursor cursor) {
          mContacts.clear();
          mContactsByLookupKey.clear();
          mNrOfSelectedContacts = 0;
  
          int count = 0;
          if (cursor.moveToFirst()) {
              cursor.moveToPrevious();
              while (cursor.moveToNext()) {
                  ContactImpl contact = ContactImpl.fromCursor(cursor);
                  mContacts.add(contact);
  
                  // LOOKUP_KEY is the one we use to retrieve the contact when the contact details are loaded
                  String lookupKey = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY));
                  mContactsByLookupKey.put(lookupKey, contact);
  
                  boolean isChecked = mSelectedContactIds.contains( contact.getId() );
                  contact.setChecked(isChecked, true);
                  mNrOfSelectedContacts += isChecked ? 1 : 0;
  
                  contact.addOnContactCheckedListener(mContactListener);
  
                  // update the ui once some contacts have loaded
                  if (++count >= BATCH_SIZE) {
                      sortAndPostCopy(mContacts);
                      count = 0;
                  }
              }
          }
  
          if (count > 0) {
              sortAndPostCopy(mContacts);
          }
  
          updateTitle();
      }
  
      /**
       * For concurrency reasons we create a copy of the contacts list before it's sorted and sent to
       * the ContactFragment. Note: this affects only the list itself, individual contacts are still
       * shared between the Activity and the Fragment (and its adapter).
       */
      private void sortAndPostCopy(List<ContactImpl> contacts) {
          List<ContactImpl> copy = new ArrayList<>();
          copy.addAll(contacts);
          Collections.sort(copy, mContactComparator);
          ContactsLoaded.post(copy);
      }
  
      private void readContactDetails(Cursor cursor) {
          if (cursor != null && cursor.moveToFirst()) {
              cursor.moveToPrevious();
              while (cursor.moveToNext()) {
                  String lookupKey = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.LOOKUP_KEY));
                  ContactImpl contact = mContactsByLookupKey.get(lookupKey);
  
                  if (contact != null) {
                      readContactDetails(cursor, contact);
                  }
              }
          }
  
          sortAndPostCopy(mContacts);
          joinContactsAndGroups(mContacts);
      }
  
      private void readContactDetails(Cursor cursor, ContactImpl contact) {
          String mime = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.MIMETYPE));
          if (mime.equals(ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)) {
              String email = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS));
              int type = cursor.getInt(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.TYPE));
              if (email != null) {
                  contact.setEmail(type, email);
              }
          }
          else if (mime.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)) {
              String phone = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
              int type = cursor.getInt(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE));
              if (phone != null) {
                  contact.setPhone(type, phone);
              }
          }
          else if (mime.equals(ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE)) {
              String address = cursor.getString(cursor.getColumnIndex(FORMATTED_ADDRESS));
              int type = cursor.getInt(cursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredPostal.TYPE));
              if (address != null) {
                  contact.setAddress(type, address.replaceAll("\\n", ", "));
              }
          }
          else if (mime.equals(ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)) {
              String firstName = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME));
              String lastName = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME));
              if (firstName != null) contact.setFirstName(firstName);
              if (lastName != null) contact.setLastName(lastName);
          }
          else if (mime.equals(ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE)) {
              int groupId = cursor.getInt(cursor.getColumnIndex(ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID));
              contact.addGroupId(groupId);
          }
      }
  
      // ****************************************** Group Methods *******************************************
  
      /*
       * List of all groups.
       */
      private List<GroupImpl> mGroups = new ArrayList<>();
  
      /*
       * Map of all groups by id (ContactsContract.Groups._ID).
       * We use this to find the group when joining contacts and groups.
       */
      private Map<Long, GroupImpl> mGroupsById = new HashMap<>();
  
      /*
       * List of all visible groups.
       * Only groups with contacts will be shown / visible.
       */
      private List<GroupImpl> mVisibleGroups = new ArrayList<>();
  
      private void readGroups(Cursor cursor) {
          mGroups.clear();
          mGroupsById.clear();
          mVisibleGroups.clear();
  
          if (cursor.moveToFirst()) {
              cursor.moveToPrevious();
              while (cursor.moveToNext()) {
                  GroupImpl group = GroupImpl.fromCursor(cursor);
  
                  mGroups.add(group);
                  mGroupsById.put(group.getId(), group);
  
                  boolean isChecked = mSelectedGroupIds.contains( group.getId() );
                  group.setChecked(isChecked, true);
  
                  group.addOnContactCheckedListener(mGroupListener);
              }
          }
  
          GroupsLoaded.post(mVisibleGroups);
          joinContactsAndGroups(mContacts);
      }
  
      // ****************************************** Process Contacts / Groups *******************************************
  
      /**
       * Join contacts and groups.
       * This can happen once the contact details and the groups have loaded.
       */
      private synchronized void joinContactsAndGroups(List<? extends Contact> contacts) {
          if (contacts == null || contacts.isEmpty()) return;
          if (mGroupsById == null || mGroupsById.isEmpty()) return;
  
          // map contacts to groups
          for (Contact contact : contacts) {
              for (Long groupId : contact.getGroupIds()) {
                  GroupImpl group = mGroupsById.get(groupId);
                  if (group != null) {
                      if (! group.hasContacts()) {
                          mVisibleGroups.add(group);
                      }
                      group.addContact(contact);
                  }
              }
          }
  
          Collections.sort(mVisibleGroups, new Comparator<GroupImpl>() {
              @Override
              public int compare(GroupImpl lhs, GroupImpl rhs) {
                  return lhs.getDisplayName().compareTo(rhs.getDisplayName());
              }
          });
  
          GroupsLoaded.post(mVisibleGroups);
      }
  
      /**
       * Listening to onContactChecked for contacts because we need to update the title to reflect
       * the number of selected contacts and we also want to un-check groups if none of their contacts
       * are checked any more.
       */
      private OnContactCheckedListener<Contact> mContactListener = new OnContactCheckedListener<Contact>() {
          @Override
          public void onContactChecked(Contact contact, boolean wasChecked, boolean isChecked) {
              if (isAboveContactLimit(1, wasChecked, isChecked)){
                  contact.setChecked(false, true);
                  ContactsLoaded.post(mContacts);
                  Toast.makeText(ContactPickerActivity.this, mLimitReachedMessage,
                          Toast.LENGTH_LONG).show();
              } else {
                  updateSelectContacts();
  
                  if (!isChecked) {
                      processGroupSelection();
                  }
              }
          }
      };
  
      /**
       * Check/un-check a group's contacts if the user checks/un-checks a group.
       */
      private OnContactCheckedListener<Group> mGroupListener = new OnContactCheckedListener<Group>() {
          @Override
          public void onContactChecked(Group group, boolean wasChecked, boolean isChecked) {
              if (isAboveContactLimit(group.getContacts().size(), wasChecked, isChecked)){
                  group.setChecked(false, true);
                  GroupsLoaded.post(mVisibleGroups);
                  Toast.makeText(ContactPickerActivity.this, mLimitReachedMessage,
                          Toast.LENGTH_LONG).show();
              } else {
                  // check/un-check the group's contacts
                  processContactSelection(group, isChecked);
                  // check if we need to deselect some groups
                  processGroupSelection();
              }
  
  
          }
      };
  
      private boolean isAboveContactLimit(int contactsSelection, boolean wasChecked, boolean isChecked) {
          return !wasChecked && isChecked && mSelectContactsLimit > 0 &&
                  mNrOfSelectedContacts + contactsSelection > mSelectContactsLimit;
      }
  
      @Subscribe(threadMode = ThreadMode.MAIN)
      public void onEventMainThread(ContactSelectionChanged event) {
          // all has changed -> calculate the number of selected contacts and update the title
          updateSelectContacts();
  
          // check if we need to deselect some groups
          processGroupSelection();
      }
  
      /**
       * Check/un-check contacts for a group that has been selected/deselected.
       * Call this when a group has been selected/deselected or after a ContactSelectionChanged event.
       */
      private void processContactSelection(Group group, boolean isChecked) {
          if (group == null || mContacts == null) return;
  
          updateSelectedGroupIds(mSelectedGroupIds, group.getId(), isChecked);
  
          // check/un-check contacts
          boolean hasChanged = false;
          for (Contact contact : group.getContacts()) {
              if (contact.isChecked() != isChecked) {
                  contact.setChecked(isChecked, true);
                  hasChanged = true;
              }
          }
  
          if (hasChanged) {
              updateSelectContacts();
              ContactsLoaded.post(mContacts);
          }
      }
  
      private void updateSelectedGroupIds(HashSet<Long> selectIds, long contactId, boolean isChecked) {
          if (isChecked) {
              selectIds.add(contactId);
          } else {
              selectIds.remove(contactId);
          }
      }
  
      /**
       * Calculate the number or selected contacts.
       * Call this when a group has been selected/deselected or after a ContactSelectionChanged event.
       */
      private void updateSelectContacts() {
          if (mContacts == null) return;
  
          mNrOfSelectedContacts = 0;
          for (Contact contact : mContacts) {
              if (contact.isChecked()) {
                  mNrOfSelectedContacts++;
                  mSelectedContactIds.add(contact.getId());
              } else {
                  mSelectedContactIds.remove(contact.getId());
              }
          }
  
          updateTitle();
      }
  
      /**
       * Check if a group needs to be deselected because none of its contacts is selected.
       * Call this when a contact or group has been deselected or after a ContactSelectionChanged event.
       */
      private void processGroupSelection() {
          if (mGroups == null) return;
  
          boolean hasChanged = false;
          for (Group theGroup : mGroups) {
              if (deselectGroup(theGroup)) {
                  hasChanged = true;
              }
          }
  
          if (hasChanged) {
              GroupsLoaded.post(mVisibleGroups);
          }
      }
  
      private boolean deselectGroup(Group group) {
          if (group == null) return false;
  
          // check if the group's contacts are all deselected
          boolean isSelected = false;
          for (Contact groupContact : group.getContacts()) {
              if (groupContact.isChecked()) {
                  isSelected = true;
                  break;
              }
          }
  
          if (! isSelected && group.isChecked()) {
              // no contact selected
              group.setChecked(false, true);
              return true;
          }
  
          return false;
      }
  
      // ****************************************** Misc Method *******************************************
  
      private static boolean sThemingChecked;
  
      /**
       * This method makes sure that all mandatory style attributes are defined for the current theme.
       */
      private boolean checkTheming() {
          if (sThemingChecked) {
              return true;
          }
  
          Resources.Theme theme = getTheme();
          Resources res = getResources();
          TypedValue typedValue = new TypedValue();
  
          int[] resIds =  getStyleableAttributes("ContactPicker");
          if (resIds != null) {
              for (int resId : resIds) {
                  String resName = res.getResourceEntryName(resId);
                  boolean exists = theme.resolveAttribute(resId, typedValue, true);
                  if (! exists) {
                      themeFailure(resName);
                      return false;
                  }
              }
          }
  
          sThemingChecked = true;
          return true;
      }
  
      private int[] getStyleableAttributes(String name) {
          Field[] allFields = R.styleable.class.getDeclaredFields();
          for (Field field : allFields) {
              if (name.equals(field.getName())) {
                  try {
                      return (int[]) field.get(R.styleable.class);
                  } catch (IllegalAccessException ignore) {}
              }
          }
  
          return null;
      }
  
      private void themeFailure(String resName) {
          Toast.makeText(this, "Attribute undefined: \"" + resName + "\". " +
                  "Did you apply the correct theme?", Toast.LENGTH_LONG).show();
          finish();
      }
  
  }
  