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