| /* |
| * Copyright (C) 2019 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.car.telephony.common; |
| |
| import android.content.Context; |
| import android.database.Cursor; |
| import android.provider.ContactsContract; |
| import android.text.TextUtils; |
| import android.util.ArrayMap; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.lifecycle.LiveData; |
| import androidx.lifecycle.MutableLiveData; |
| import androidx.lifecycle.Observer; |
| |
| import com.android.car.apps.common.log.L; |
| |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.concurrent.Executors; |
| |
| /** |
| * A singleton statically accessible helper class which pre-loads contacts list into memory so that |
| * they can be accessed more easily and quickly. |
| */ |
| public class InMemoryPhoneBook implements Observer<List<Contact>> { |
| private static final String TAG = "CD.InMemoryPhoneBook"; |
| private static InMemoryPhoneBook sInMemoryPhoneBook; |
| |
| private final Context mContext; |
| private final AsyncQueryLiveData<List<Contact>> mContactListAsyncQueryLiveData; |
| /** |
| * A map to speed up phone number searching. |
| */ |
| private final Map<I18nPhoneNumberWrapper, Contact> mPhoneNumberContactMap = new HashMap<>(); |
| /** |
| * A map to look up contact by account name and lookup key. Each entry presents a map of lookup |
| * key to contacts for one account. |
| */ |
| private final Map<String, Map<String, Contact>> mLookupKeyContactMap = new HashMap<>(); |
| |
| /** |
| * A map which divides contacts LiveData by account. |
| */ |
| private final Map<String, MutableLiveData<List<Contact>>> mAccountContactsLiveDataMap = |
| new ArrayMap<>(); |
| private boolean mIsLoaded = false; |
| |
| /** |
| * Initialize the globally accessible {@link InMemoryPhoneBook}. Returns the existing {@link |
| * InMemoryPhoneBook} if already initialized. {@link #tearDown()} must be called before init to |
| * reinitialize. |
| */ |
| public static InMemoryPhoneBook init(Context context) { |
| if (sInMemoryPhoneBook == null) { |
| sInMemoryPhoneBook = new InMemoryPhoneBook(context); |
| sInMemoryPhoneBook.onInit(); |
| } |
| return get(); |
| } |
| |
| /** |
| * Returns if the InMemoryPhoneBook is initialized. get() won't return null or throw if this is |
| * true, but it doesn't indicate whether or not contacts are loaded yet. |
| * <p> |
| * See also: {@link #isLoaded()} |
| */ |
| public static boolean isInitialized() { |
| return sInMemoryPhoneBook != null; |
| } |
| |
| /** |
| * Get the global {@link InMemoryPhoneBook} instance. |
| */ |
| public static InMemoryPhoneBook get() { |
| if (sInMemoryPhoneBook != null) { |
| return sInMemoryPhoneBook; |
| } else { |
| throw new IllegalStateException("Call init before get InMemoryPhoneBook"); |
| } |
| } |
| |
| /** |
| * Tears down the globally accessible {@link InMemoryPhoneBook}. |
| */ |
| public static void tearDown() { |
| sInMemoryPhoneBook.onTearDown(); |
| sInMemoryPhoneBook = null; |
| } |
| |
| private InMemoryPhoneBook(Context context) { |
| mContext = context; |
| |
| QueryParam contactListQueryParam = new QueryParam( |
| ContactsContract.Data.CONTENT_URI, |
| null, |
| ContactsContract.Data.MIMETYPE + " = ? OR " |
| + ContactsContract.Data.MIMETYPE + " = ? OR " |
| + ContactsContract.Data.MIMETYPE + " = ?", |
| new String[]{ |
| ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE, |
| ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE, |
| ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE}, |
| ContactsContract.Contacts.DISPLAY_NAME + " ASC "); |
| mContactListAsyncQueryLiveData = new AsyncQueryLiveData<List<Contact>>(mContext, |
| QueryParam.of(contactListQueryParam), Executors.newSingleThreadExecutor()) { |
| @Override |
| protected List<Contact> convertToEntity(Cursor cursor) { |
| return onCursorLoaded(cursor); |
| } |
| }; |
| } |
| |
| private void onInit() { |
| mContactListAsyncQueryLiveData.observeForever(this); |
| } |
| |
| private void onTearDown() { |
| mContactListAsyncQueryLiveData.removeObserver(this); |
| } |
| |
| public boolean isLoaded() { |
| return mIsLoaded; |
| } |
| |
| /** |
| * Returns a {@link LiveData} which monitors the contact list changes. |
| * |
| * @deprecated Use {@link #getContactsLiveDataByAccount(String)} instead. |
| */ |
| @Deprecated |
| public LiveData<List<Contact>> getContactsLiveData() { |
| return mContactListAsyncQueryLiveData; |
| } |
| |
| /** |
| * Returns a LiveData that represents all contacts within an account. |
| * |
| * @param accountName the name of an account that contains all the contacts. For the contacts |
| * from a Bluetooth connected phone, the account name is equal to the |
| * Bluetooth address. |
| */ |
| public LiveData<List<Contact>> getContactsLiveDataByAccount(String accountName) { |
| if (!mAccountContactsLiveDataMap.containsKey(accountName)) { |
| mAccountContactsLiveDataMap.put(accountName, new MutableLiveData<>()); |
| } |
| return mAccountContactsLiveDataMap.get(accountName); |
| } |
| |
| /** |
| * Looks up a {@link Contact} by the given phone number. Returns null if can't find a Contact or |
| * the {@link InMemoryPhoneBook} is still loading. |
| */ |
| @Nullable |
| public Contact lookupContactEntry(String phoneNumber) { |
| L.v(TAG, String.format("lookupContactEntry: %s", TelecomUtils.piiLog(phoneNumber))); |
| if (!isLoaded()) { |
| L.w(TAG, "looking up a contact while loading."); |
| } |
| |
| if (TextUtils.isEmpty(phoneNumber)) { |
| L.w(TAG, "looking up an empty phone number."); |
| return null; |
| } |
| |
| I18nPhoneNumberWrapper i18nPhoneNumber = I18nPhoneNumberWrapper.Factory.INSTANCE.get( |
| mContext, phoneNumber); |
| return mPhoneNumberContactMap.get(i18nPhoneNumber); |
| } |
| |
| /** |
| * Looks up a {@link Contact} by the given lookup key and account name. Account name could be |
| * null for locally added contacts. Returns null if can't find the contact entry. |
| */ |
| @Nullable |
| public Contact lookupContactByKey(String lookupKey, @Nullable String accountName) { |
| if (!isLoaded()) { |
| L.w(TAG, "looking up a contact while loading."); |
| } |
| if (TextUtils.isEmpty(lookupKey)) { |
| L.w(TAG, "looking up an empty lookup key."); |
| return null; |
| } |
| if (mLookupKeyContactMap.containsKey(accountName)) { |
| return mLookupKeyContactMap.get(accountName).get(lookupKey); |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Iterates all the accounts and returns a list of contacts that match the lookup key. This API |
| * is discouraged to use whenever the account name is available where {@link |
| * #lookupContactByKey(String, String)} should be used instead. |
| */ |
| @NonNull |
| public List<Contact> lookupContactByKey(String lookupKey) { |
| if (!isLoaded()) { |
| L.w(TAG, "looking up a contact while loading."); |
| } |
| |
| if (TextUtils.isEmpty(lookupKey)) { |
| L.w(TAG, "looking up an empty lookup key."); |
| return Collections.emptyList(); |
| } |
| List<Contact> results = new ArrayList<>(); |
| // Iterate all the accounts to get all the match contacts with given lookup key. |
| for (Map<String, Contact> subMap : mLookupKeyContactMap.values()) { |
| if (subMap.containsKey(lookupKey)) { |
| results.add(subMap.get(lookupKey)); |
| } |
| } |
| |
| return results; |
| } |
| |
| private List<Contact> onCursorLoaded(Cursor cursor) { |
| Map<String, Map<String, Contact>> contactMap = new LinkedHashMap<>(); |
| List<Contact> contactList = new ArrayList<>(); |
| |
| while (cursor.moveToNext()) { |
| int accountNameColumn = cursor.getColumnIndex( |
| ContactsContract.RawContacts.ACCOUNT_NAME); |
| int lookupKeyColumn = cursor.getColumnIndex(ContactsContract.Data.LOOKUP_KEY); |
| String accountName = cursor.getString(accountNameColumn); |
| String lookupKey = cursor.getString(lookupKeyColumn); |
| |
| if (!contactMap.containsKey(accountName)) { |
| contactMap.put(accountName, new HashMap<>()); |
| } |
| |
| Map<String, Contact> subMap = contactMap.get(accountName); |
| subMap.put(lookupKey, Contact.fromCursor(mContext, cursor, subMap.get(lookupKey))); |
| } |
| |
| for (String accountName : contactMap.keySet()) { |
| Map<String, Contact> subMap = contactMap.get(accountName); |
| contactList.addAll(subMap.values()); |
| MutableLiveData<List<Contact>> accountContactsLiveData = |
| (MutableLiveData<List<Contact>>) getContactsLiveDataByAccount(accountName); |
| accountContactsLiveData.postValue(new ArrayList<>(subMap.values())); |
| } |
| |
| mLookupKeyContactMap.clear(); |
| mLookupKeyContactMap.putAll(contactMap); |
| |
| mPhoneNumberContactMap.clear(); |
| for (Contact contact : contactList) { |
| for (PhoneNumber phoneNumber : contact.getNumbers()) { |
| mPhoneNumberContactMap.put(phoneNumber.getI18nPhoneNumberWrapper(), contact); |
| } |
| } |
| return contactList; |
| } |
| |
| @Override |
| public void onChanged(List<Contact> contacts) { |
| L.d(TAG, "Contacts loaded:" + (contacts == null ? 0 : contacts.size())); |
| mIsLoaded = true; |
| } |
| } |