1 /* 2 * Copyright (C) 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.car.telephony.common; 18 19 import android.Manifest; 20 import android.content.Context; 21 import android.database.Cursor; 22 import android.provider.ContactsContract; 23 import android.provider.ContactsContract.CommonDataKinds; 24 import android.provider.ContactsContract.Data; 25 import android.text.TextUtils; 26 import android.util.ArrayMap; 27 28 import androidx.annotation.NonNull; 29 import androidx.annotation.Nullable; 30 import androidx.lifecycle.LiveData; 31 import androidx.lifecycle.Observer; 32 import androidx.lifecycle.Transformations; 33 34 import com.android.car.apps.common.log.L; 35 import com.android.car.telephony.common.QueryParam.QueryBuilder.Condition; 36 37 import java.util.ArrayList; 38 import java.util.Collections; 39 import java.util.HashMap; 40 import java.util.LinkedHashMap; 41 import java.util.List; 42 import java.util.Map; 43 44 /** 45 * A singleton statically accessible helper class which pre-loads contacts list into memory so that 46 * they can be accessed more easily and quickly. 47 */ 48 public class InMemoryPhoneBook implements Observer<List<Contact>> { 49 private static final String TAG = "CD.InMemoryPhoneBook"; 50 private static InMemoryPhoneBook sInMemoryPhoneBook; 51 52 private final Context mContext; 53 private final AsyncQueryLiveData<List<Contact>> mContactListAsyncQueryLiveData; 54 /** 55 * A map to speed up phone number searching. 56 */ 57 private final Map<I18nPhoneNumberWrapper, Contact> mPhoneNumberContactMap = new HashMap<>(); 58 /** 59 * A map to look up contact by account name and lookup key. Each entry presents a map of lookup 60 * key to contacts for one account. 61 */ 62 private final Map<String, Map<String, Contact>> mLookupKeyContactMap = new HashMap<>(); 63 64 /** 65 * A map which divides contacts by account. 66 */ 67 private final Map<String, List<Contact>> mAccountContactsMap = new ArrayMap<>(); 68 private boolean mIsLoaded = false; 69 70 /** 71 * Initialize the globally accessible {@link InMemoryPhoneBook}. Returns the existing {@link 72 * InMemoryPhoneBook} if already initialized. {@link #tearDown()} must be called before init to 73 * reinitialize. 74 */ init(Context context)75 public static InMemoryPhoneBook init(Context context) { 76 if (sInMemoryPhoneBook == null) { 77 sInMemoryPhoneBook = new InMemoryPhoneBook(context); 78 sInMemoryPhoneBook.onInit(); 79 } 80 return get(); 81 } 82 83 /** 84 * Returns if the InMemoryPhoneBook is initialized. get() won't return null or throw if this is 85 * true, but it doesn't indicate whether or not contacts are loaded yet. 86 * <p> 87 * See also: {@link #isLoaded()} 88 */ isInitialized()89 public static boolean isInitialized() { 90 return sInMemoryPhoneBook != null; 91 } 92 93 /** 94 * Get the global {@link InMemoryPhoneBook} instance. 95 */ get()96 public static InMemoryPhoneBook get() { 97 if (sInMemoryPhoneBook != null) { 98 return sInMemoryPhoneBook; 99 } else { 100 throw new IllegalStateException("Call init before get InMemoryPhoneBook"); 101 } 102 } 103 104 /** 105 * Tears down the globally accessible {@link InMemoryPhoneBook}. 106 */ tearDown()107 public static void tearDown() { 108 sInMemoryPhoneBook.onTearDown(); 109 sInMemoryPhoneBook = null; 110 } 111 InMemoryPhoneBook(Context context)112 private InMemoryPhoneBook(Context context) { 113 mContext = context; 114 QueryParam contactListQueryParam = new QueryParam.QueryBuilder(Data.CONTENT_URI) 115 .projectAll() 116 .where(Condition 117 .is(Data.MIMETYPE, "=", CommonDataKinds.Phone.CONTENT_ITEM_TYPE) 118 .or(Data.MIMETYPE, "=", CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) 119 .or(Data.MIMETYPE, "=", CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE)) 120 .orderAscBy(ContactsContract.Contacts.DISPLAY_NAME) 121 .checkPermission(Manifest.permission.READ_CONTACTS) 122 .toQueryParam(); 123 124 mContactListAsyncQueryLiveData = new AsyncQueryLiveData<List<Contact>>(mContext, 125 QueryParam.of(contactListQueryParam)) { 126 @Override 127 protected List<Contact> convertToEntity(Cursor cursor) { 128 return onCursorLoaded(cursor); 129 } 130 }; 131 } 132 onInit()133 private void onInit() { 134 mContactListAsyncQueryLiveData.observeForever(this); 135 } 136 onTearDown()137 private void onTearDown() { 138 mContactListAsyncQueryLiveData.removeObserver(this); 139 } 140 isLoaded()141 public boolean isLoaded() { 142 return mIsLoaded; 143 } 144 145 /** 146 * Returns a {@link LiveData} which monitors the contact list changes. 147 * 148 * @deprecated Use {@link #getContactsLiveDataByAccount(String)} instead. 149 */ 150 @Deprecated getContactsLiveData()151 public LiveData<List<Contact>> getContactsLiveData() { 152 return mContactListAsyncQueryLiveData; 153 } 154 155 /** 156 * Returns a LiveData that represents all contacts within an account. 157 * 158 * @param accountName the name of an account that contains all the contacts. For the contacts 159 * from a Bluetooth connected phone, the account name is equal to the 160 * Bluetooth address. 161 */ getContactsLiveDataByAccount(String accountName)162 public LiveData<List<Contact>> getContactsLiveDataByAccount(String accountName) { 163 return Transformations.map(mContactListAsyncQueryLiveData, 164 contacts -> contacts == null ? null : mAccountContactsMap.get(accountName)); 165 } 166 167 /** 168 * Looks up a {@link Contact} by the given phone number. Returns null if can't find a Contact or 169 * the {@link InMemoryPhoneBook} is still loading. 170 */ 171 @Nullable lookupContactEntry(String phoneNumber)172 public Contact lookupContactEntry(String phoneNumber) { 173 L.v(TAG, String.format("lookupContactEntry: %s", TelecomUtils.piiLog(phoneNumber))); 174 if (!isLoaded()) { 175 L.w(TAG, "looking up a contact while loading."); 176 } 177 178 if (TextUtils.isEmpty(phoneNumber)) { 179 L.w(TAG, "looking up an empty phone number."); 180 return null; 181 } 182 183 I18nPhoneNumberWrapper i18nPhoneNumber = I18nPhoneNumberWrapper.Factory.INSTANCE.get( 184 mContext, phoneNumber); 185 return mPhoneNumberContactMap.get(i18nPhoneNumber); 186 } 187 188 /** 189 * Looks up a {@link Contact} by the given lookup key and account name. Account name could be 190 * null for locally added contacts. Returns null if can't find the contact entry. 191 */ 192 @Nullable lookupContactByKey(String lookupKey, @Nullable String accountName)193 public Contact lookupContactByKey(String lookupKey, @Nullable String accountName) { 194 if (!isLoaded()) { 195 L.w(TAG, "looking up a contact while loading."); 196 } 197 if (TextUtils.isEmpty(lookupKey)) { 198 L.w(TAG, "looking up an empty lookup key."); 199 return null; 200 } 201 if (mLookupKeyContactMap.containsKey(accountName)) { 202 return mLookupKeyContactMap.get(accountName).get(lookupKey); 203 } 204 205 return null; 206 } 207 208 /** 209 * Iterates all the accounts and returns a list of contacts that match the lookup key. This API 210 * is discouraged to use whenever the account name is available where {@link 211 * #lookupContactByKey(String, String)} should be used instead. 212 */ 213 @NonNull lookupContactByKey(String lookupKey)214 public List<Contact> lookupContactByKey(String lookupKey) { 215 if (!isLoaded()) { 216 L.w(TAG, "looking up a contact while loading."); 217 } 218 219 if (TextUtils.isEmpty(lookupKey)) { 220 L.w(TAG, "looking up an empty lookup key."); 221 return Collections.emptyList(); 222 } 223 List<Contact> results = new ArrayList<>(); 224 // Iterate all the accounts to get all the match contacts with given lookup key. 225 for (Map<String, Contact> subMap : mLookupKeyContactMap.values()) { 226 if (subMap.containsKey(lookupKey)) { 227 results.add(subMap.get(lookupKey)); 228 } 229 } 230 231 return results; 232 } 233 onCursorLoaded(Cursor cursor)234 private List<Contact> onCursorLoaded(Cursor cursor) { 235 Map<String, Map<String, Contact>> contactMap = new LinkedHashMap<>(); 236 List<Contact> contactList = new ArrayList<>(); 237 238 while (cursor.moveToNext()) { 239 int accountNameColumn = cursor.getColumnIndex( 240 ContactsContract.RawContacts.ACCOUNT_NAME); 241 int lookupKeyColumn = cursor.getColumnIndex(Data.LOOKUP_KEY); 242 String accountName = cursor.getString(accountNameColumn); 243 String lookupKey = cursor.getString(lookupKeyColumn); 244 245 if (!contactMap.containsKey(accountName)) { 246 contactMap.put(accountName, new HashMap<>()); 247 } 248 249 Map<String, Contact> subMap = contactMap.get(accountName); 250 subMap.put(lookupKey, Contact.fromCursor(mContext, cursor, subMap.get(lookupKey))); 251 } 252 253 mAccountContactsMap.clear(); 254 for (String accountName : contactMap.keySet()) { 255 Map<String, Contact> subMap = contactMap.get(accountName); 256 contactList.addAll(subMap.values()); 257 List<Contact> accountContacts = new ArrayList<>(); 258 accountContacts.addAll(subMap.values()); 259 mAccountContactsMap.put(accountName, accountContacts); 260 } 261 262 mLookupKeyContactMap.clear(); 263 mLookupKeyContactMap.putAll(contactMap); 264 265 mPhoneNumberContactMap.clear(); 266 for (Contact contact : contactList) { 267 for (PhoneNumber phoneNumber : contact.getNumbers()) { 268 mPhoneNumberContactMap.put(phoneNumber.getI18nPhoneNumberWrapper(), contact); 269 } 270 } 271 return contactList; 272 } 273 274 @Override onChanged(List<Contact> contacts)275 public void onChanged(List<Contact> contacts) { 276 L.d(TAG, "Contacts loaded:" + (contacts == null ? 0 : contacts.size())); 277 mIsLoaded = true; 278 } 279 } 280