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