• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2024 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.bluetooth.pbapclient;
18 
19 import android.accounts.Account;
20 import android.bluetooth.BluetoothDevice;
21 import android.content.ContentProviderOperation;
22 import android.content.ContentResolver;
23 import android.content.ContentValues;
24 import android.content.Context;
25 import android.content.OperationApplicationException;
26 import android.database.Cursor;
27 import android.net.Uri;
28 import android.os.RemoteException;
29 import android.provider.CallLog;
30 import android.provider.CallLog.Calls;
31 import android.provider.ContactsContract;
32 import android.provider.ContactsContract.RawContacts;
33 import android.util.Log;
34 import android.util.Pair;
35 
36 import com.android.internal.annotations.VisibleForTesting;
37 import com.android.vcard.VCardEntry;
38 import com.android.vcard.VCardEntry.PhoneData;
39 
40 import java.text.ParseException;
41 import java.text.SimpleDateFormat;
42 import java.util.ArrayList;
43 import java.util.Collections;
44 import java.util.List;
45 import java.util.Locale;
46 
47 /**
48  * This class owns the interface to the contacts and call history storage mechanism, namely the
49  * Contacts DB and Contacts Provider. It also owns the list of cached metadata and facilitates the
50  * management of the AccountManagerService accounts that are required to store contacts on the
51  * device. It provides functions to allow connected devices to create and manage accounts and store
52  * and cache contacts and call logs.
53  *
54  * <p>Exactly one of these objects should exist, created by the PbapClientService at start up.
55  *
56  * <p>All contacts on Android are stored against an AccountManager Framework Account object. These
57  * Accounts should be created by devices upon connecting. This Account is used on many of the
58  * functions, in order to target the correct device's contacts.
59  */
60 class PbapClientContactsStorage {
61     private static final String TAG = PbapClientContactsStorage.class.getSimpleName();
62 
63     private static final int CONTACTS_INSERT_BATCH_SIZE = 250;
64 
65     private static final String CALL_LOG_TIMESTAMP_PROPERTY = "X-IRMC-CALL-DATETIME";
66     private static final String TIMESTAMP_FORMAT = "yyyyMMdd'T'HHmmss";
67 
68     private final Context mContext;
69     private final PbapClientAccountManager mAccountManager;
70 
71     private volatile boolean mStorageInitialized = false;
72 
73     private final List<Callback> mCallbacks = new ArrayList<Callback>();
74 
75     /** A Callback interface so clients can receive structured events about PBAP Contacts Storage */
76     interface Callback {
77         /**
78          * Invoked when storage is initialized and ready for interaction
79          *
80          * <p>Storage related functions may not work before storage is ready.
81          */
onStorageReady()82         void onStorageReady();
83 
84         /**
85          * Receive account visibility updates
86          *
87          * @param oldAccounts The list of previously available accounts
88          * @param newAccounts The list of newly available accounts
89          */
onStorageAccountsChanged(List<Account> oldAccounts, List<Account> newAccounts)90         void onStorageAccountsChanged(List<Account> oldAccounts, List<Account> newAccounts);
91     }
92 
93     class PbapClientAccountManagerCallback implements PbapClientAccountManager.Callback {
94         @Override
onAccountsChanged(List<Account> oldAccounts, List<Account> newAccounts)95         public void onAccountsChanged(List<Account> oldAccounts, List<Account> newAccounts) {
96             if (oldAccounts == null) {
97                 Log.d(TAG, "Storage accounts initialized, accounts=" + newAccounts);
98                 initialize(newAccounts);
99                 notifyStorageReady();
100                 notifyStorageAccountsChanged(
101                         Collections.emptyList(), mAccountManager.getAccounts());
102             } else if (mStorageInitialized) {
103                 Log.d(TAG, "Storage accounts changed, old=" + oldAccounts + ", new=" + newAccounts);
104                 notifyStorageAccountsChanged(oldAccounts, newAccounts);
105             } else {
106                 Log.d(TAG, "Storage not fully initialized, dropping accounts changed event");
107             }
108         }
109     }
110 
PbapClientContactsStorage(Context context)111     PbapClientContactsStorage(Context context) {
112         mContext = context;
113         mAccountManager =
114                 new PbapClientAccountManager(context, new PbapClientAccountManagerCallback());
115     }
116 
117     @VisibleForTesting
PbapClientContactsStorage(Context context, PbapClientAccountManager accountManager)118     PbapClientContactsStorage(Context context, PbapClientAccountManager accountManager) {
119         mContext = context;
120         mAccountManager = accountManager;
121     }
122 
start()123     public void start() {
124         mStorageInitialized = false;
125         mAccountManager.start();
126     }
127 
stop()128     public void stop() {
129         mAccountManager.stop();
130     }
131 
132     // *********************************************************************************************
133     // * Initialization
134     // *********************************************************************************************
135 
136     /**
137      * Determine if storage is ready or not.
138      *
139      * <p>Many storage functions won't work before storage is ready to be interacted with. Use the
140      * callback interface to be told when storage is ready if it's not ready upon calling this.
141      *
142      * @return True is storage is ready, false otherwise.
143      */
isStorageReady()144     public boolean isStorageReady() {
145         return mStorageInitialized;
146     }
147 
148     /**
149      * Initialize storage with a set of accounts.
150      *
151      * <p>This function receives a set of accounts that our PBAP Client implementation knows about
152      * and initializes our storage state based on this account list, using the following
153      * rules/steps:
154      *
155      * <p>1. CHECK ACCOUNTS: Previous accounts should not exist. Delete them and all associated data
156      *
157      * <p>These rules help ensure that we clean up accounts that might persist after an ungraceful
158      * shutdown
159      *
160      * @param accounts The list of accounts that exist following start up of the account manager
161      */
initialize(List<Account> accounts)162     private void initialize(List<Account> accounts) {
163         Log.i(TAG, "initialize(accounts=" + accounts + ")");
164         if (mStorageInitialized) {
165             Log.w(TAG, "initialize(accounts=" + accounts + "): Already initialized. Skipping");
166             return;
167         }
168 
169         for (Account account : accounts) {
170             Log.w(TAG, "initialize(): Remove pre-existing account=" + account);
171             mAccountManager.removeAccount(account);
172         }
173 
174         mStorageInitialized = true;
175     }
176 
177     // *********************************************************************************************
178     // * Storage Accounts
179     // *********************************************************************************************
180 
getStorageAccountForDevice(BluetoothDevice device)181     public Account getStorageAccountForDevice(BluetoothDevice device) {
182         return mAccountManager.getAccountForDevice(device);
183     }
184 
getStorageAccounts()185     public List<Account> getStorageAccounts() {
186         return mAccountManager.getAccounts();
187     }
188 
addAccount(Account account)189     public boolean addAccount(Account account) {
190         return mAccountManager.addAccount(account);
191     }
192 
removeAccount(Account account)193     public boolean removeAccount(Account account) {
194         return mAccountManager.removeAccount(account);
195     }
196 
197     // *********************************************************************************************
198     // * Contacts DB Operations
199     // *********************************************************************************************
200 
201     /** Insert contacts into the Contacts DB from a remote device's favorites phonebook */
insertFavorites(Account account, List<VCardEntry> contacts)202     public boolean insertFavorites(Account account, List<VCardEntry> contacts) {
203         if (contacts == null) {
204             return false;
205         }
206 
207         for (VCardEntry contact : contacts) {
208             contact.setStarred(true);
209         }
210         return insertContacts(account, PbapPhonebook.FAVORITES_PATH, contacts);
211     }
212 
213     /** Insert contacts into the Contacts DB from a remote device's local phonebook */
insertLocalContacts(Account account, List<VCardEntry> contacts)214     public boolean insertLocalContacts(Account account, List<VCardEntry> contacts) {
215         return insertContacts(account, PbapPhonebook.LOCAL_PHONEBOOK_PATH, contacts);
216     }
217 
218     /** Insert contacts into the Contacts DB from a remote device's sim local phonebook */
insertSimContacts(Account account, List<VCardEntry> contacts)219     public boolean insertSimContacts(Account account, List<VCardEntry> contacts) {
220         return insertContacts(account, PbapPhonebook.SIM_PHONEBOOK_PATH, contacts);
221     }
222 
223     /**
224      * Insert a list of contacts into the Contacts Provider/Contacts DB
225      *
226      * <p>This function also associates the phonebook metadata with the contact for easy
227      * per-phonebook cleanup operations.
228      *
229      * <p>Contacts are inserted in smaller batches so they can be loaded in chunks as opposed to
230      * shown all at once in the UI. This also prevents us from hitting the binder transaction limit.
231      *
232      * @param account The account to insert contacts against
233      * @param phonebook The phonebook these contacts belong to
234      * @param contacts The list of contacts to insert
235      */
insertContacts(Account account, String phonebook, List<VCardEntry> contacts)236     private boolean insertContacts(Account account, String phonebook, List<VCardEntry> contacts) {
237         if (!mStorageInitialized) {
238             Log.w(TAG, "insertContacts: Failed, storage not ready");
239             return false;
240         }
241 
242         if (account == null) {
243             Log.e(TAG, "insertContacts: account is null");
244             return false;
245         }
246 
247         if (contacts == null || contacts.size() == 0) {
248             Log.e(TAG, "insertContacts: contacts provided are null or empty");
249             return false;
250         }
251 
252         try {
253             Log.i(
254                     TAG,
255                     "insertContacts: inserting contacts, account="
256                             + account
257                             + ", count="
258                             + contacts.size()
259                             + ", for phonebook="
260                             + phonebook);
261 
262             ContentResolver contactsProvider = mContext.getContentResolver();
263             ArrayList<ContentProviderOperation> operations = new ArrayList<>();
264 
265             // Group insert operations together to minimize inter process communication and improve
266             // processing time.
267             for (VCardEntry contact : contacts) {
268                 if (Thread.currentThread().isInterrupted()) {
269                     Log.e(TAG, "Interrupted during insert");
270                     break;
271                 }
272 
273                 // Associate the storage account with this contact
274                 contact.setAccount(account);
275 
276                 // Append current vcard to list of insert operations.
277                 int numberOfOperations = operations.size();
278                 constructInsertOperationsForContact(contact, operations, contactsProvider);
279 
280                 if (operations.size() >= CONTACTS_INSERT_BATCH_SIZE) {
281                     Log.i(
282                             TAG,
283                             "insertContacts: batch full, operations.size()="
284                                     + operations.size()
285                                     + ", batch_size="
286                                     + CONTACTS_INSERT_BATCH_SIZE);
287 
288                     // If we have exceeded the limit to the insert operation remove the latest vcard
289                     // and submit.
290                     operations.subList(numberOfOperations, operations.size()).clear();
291 
292                     contactsProvider.applyBatch(ContactsContract.AUTHORITY, operations);
293 
294                     // Re-add the current contact operation(s) to the list
295                     operations =
296                             constructInsertOperationsForContact(contact, null, contactsProvider);
297 
298                     Log.i(
299                             TAG,
300                             "insertContacts: batch complete, operations.size()="
301                                     + operations.size());
302                 }
303             }
304 
305             // Apply any unsubmitted vcards
306             if (operations.size() > 0) {
307                 contactsProvider.applyBatch(ContactsContract.AUTHORITY, operations);
308                 operations.clear();
309             }
310             Log.i(TAG, "insertContacts: insert complete, count=" + contacts.size());
311         } catch (OperationApplicationException | RemoteException | NumberFormatException e) {
312             Log.e(TAG, "insertContacts: Exception occurred while processing phonebook pull: ", e);
313             return false;
314         }
315         return true;
316     }
317 
318     @SuppressWarnings("NonApiType") // For convenience, as applyBatch above takes an ArrayList above
constructInsertOperationsForContact( VCardEntry contact, ArrayList<ContentProviderOperation> operations, ContentResolver contactsProvider)319     private static ArrayList<ContentProviderOperation> constructInsertOperationsForContact(
320             VCardEntry contact,
321             ArrayList<ContentProviderOperation> operations,
322             ContentResolver contactsProvider) {
323         operations = contact.constructInsertOperations(contactsProvider, operations);
324         return operations;
325     }
326 
removeAllContacts(Account account)327     public boolean removeAllContacts(Account account) {
328         if (account == null) {
329             Log.e(TAG, "removeAllContacts: account is null");
330             return false;
331         }
332 
333         Log.i(TAG, "removeAllContacts: requested for account=" + account);
334         Uri contactsToDeleteUri =
335                 RawContacts.CONTENT_URI
336                         .buildUpon()
337                         .appendQueryParameter(RawContacts.ACCOUNT_NAME, account.name)
338                         .appendQueryParameter(RawContacts.ACCOUNT_TYPE, account.type)
339                         .build();
340 
341         try {
342             mContext.getContentResolver().delete(contactsToDeleteUri, null);
343         } catch (IllegalArgumentException e) {
344             Log.w(
345                     TAG,
346                     "removeAllContacts(uri="
347                             + contactsToDeleteUri
348                             + "): Contacts could not be deleted",
349                     e);
350             return false;
351         }
352         return true;
353     }
354 
355     /**
356      * Insert call logs into the incoming calls table
357      *
358      * @param account The account to insert call logs against
359      * @param history The call history to insert
360      */
insertIncomingCallHistory(Account account, List<VCardEntry> history)361     public boolean insertIncomingCallHistory(Account account, List<VCardEntry> history) {
362         return insertCallHistory(account, CallLog.Calls.INCOMING_TYPE, history);
363     }
364 
365     /**
366      * Insert call logs into the outgoing calls table
367      *
368      * @param account The account to insert call logs against
369      * @param history The call history to insert
370      */
insertOutgoingCallHistory(Account account, List<VCardEntry> history)371     public boolean insertOutgoingCallHistory(Account account, List<VCardEntry> history) {
372         return insertCallHistory(account, CallLog.Calls.OUTGOING_TYPE, history);
373     }
374 
375     /**
376      * Insert call logs into the missed calls table
377      *
378      * @param account The account to insert call logs against
379      * @param history The call history to insert
380      */
insertMissedCallHistory(Account account, List<VCardEntry> history)381     public boolean insertMissedCallHistory(Account account, List<VCardEntry> history) {
382         return insertCallHistory(account, CallLog.Calls.MISSED_TYPE, history);
383     }
384 
385     /**
386      * Insert call history entries of a given type
387      *
388      * <p>These call logs are inserted in smaller batches so they can be loaded in chunks as opposed
389      * to shown all at once in the UI. This also prevents us from hitting the binder transaction
390      * limit
391      *
392      * @param account The account to insert call logs against
393      * @param type The type of call provided
394      * @param history The list of calls to add
395      * @return True if successful, False otherwise
396      */
insertCallHistory(Account account, int type, List<VCardEntry> history)397     private boolean insertCallHistory(Account account, int type, List<VCardEntry> history) {
398         if (!mStorageInitialized) {
399             Log.w(TAG, "insertCallHistory: Failed, storage not ready");
400             return false;
401         }
402 
403         if (account == null) {
404             Log.e(TAG, "insertCallHistory: Account is null");
405             return false;
406         }
407 
408         if (history == null || history.size() == 0) {
409             Log.e(TAG, "insertCallHistory: No entries to insert");
410             return false;
411         }
412 
413         if (type != CallLog.Calls.INCOMING_TYPE
414                 && type != CallLog.Calls.OUTGOING_TYPE
415                 && type != CallLog.Calls.MISSED_TYPE) {
416             Log.e(TAG, "insertCallHistory: Unknown type=" + type);
417             return false;
418         }
419 
420         try {
421             Log.i(
422                     TAG,
423                     "insertCallHistory: inserting call history, account="
424                             + account
425                             + ", type="
426                             + type
427                             + ", count="
428                             + history.size());
429 
430             ContentResolver contactsProvider = mContext.getContentResolver();
431             ArrayList<ContentProviderOperation> operations = new ArrayList<>();
432 
433             // Group insert operations together to minimize inter process communication and improve
434             // processing time.
435             for (VCardEntry callLog : history) {
436                 if (Thread.currentThread().isInterrupted()) {
437                     Log.e(TAG, "insertCallHistory: Interrupted during insert");
438                     break;
439                 }
440 
441                 // Append current call to list of insert operations.
442                 int numberOfOperations = operations.size();
443                 constructInsertOperationsForCallLog(account, type, callLog, operations);
444 
445                 if (operations.size() >= CONTACTS_INSERT_BATCH_SIZE) {
446                     Log.i(
447                             TAG,
448                             "insertCallHistory: batch full, operations.size()="
449                                     + operations.size()
450                                     + ", batch_size="
451                                     + CONTACTS_INSERT_BATCH_SIZE);
452 
453                     // If we have exceeded the limit of the insert operations, remove the latest
454                     // call and submit.
455                     operations.subList(numberOfOperations, operations.size()).clear();
456 
457                     contactsProvider.applyBatch(CallLog.AUTHORITY, operations);
458 
459                     // Re-add the current call log operation(s) to the list
460                     operations = constructInsertOperationsForCallLog(account, type, callLog, null);
461 
462                     Log.i(
463                             TAG,
464                             "insertCallHistory: batch complete, operations.size()="
465                                     + operations.size());
466                 }
467             }
468 
469             // Apply any unsubmitted calls
470             if (operations.size() > 0) {
471                 contactsProvider.applyBatch(CallLog.AUTHORITY, operations);
472                 operations.clear();
473             }
474             Log.i(TAG, "insertCallHistory: insert complete, count=" + history.size());
475         } catch (OperationApplicationException | RemoteException | NumberFormatException e) {
476             Log.e(TAG, "insertCallHistory: Exception occurred while processing call log pull: ", e);
477             return false;
478         }
479         return true;
480     }
481 
482     // TODO: b/365629730 -- JavaUtilDate: prefer Instant or LocalDate
483     // NonApiType: For convenience, as the applyBatch API actually takes an ArrayList above
484     @SuppressWarnings({"JavaUtilDate", "NonApiType"})
constructInsertOperationsForCallLog( Account account, int type, VCardEntry call, ArrayList<ContentProviderOperation> operations)485     private static ArrayList<ContentProviderOperation> constructInsertOperationsForCallLog(
486             Account account,
487             int type,
488             VCardEntry call,
489             ArrayList<ContentProviderOperation> operations) {
490         if (operations == null) {
491             operations = new ArrayList<ContentProviderOperation>();
492         }
493 
494         ContentValues values = new ContentValues();
495         values.put(Calls.PHONE_ACCOUNT_ID, account.name);
496         values.put(CallLog.Calls.TYPE, type);
497 
498         List<PhoneData> phones = call.getPhoneList();
499         if (phones == null
500                 || phones.get(0).getNumber().equals(";")
501                 || phones.get(0).getNumber().length() == 0) {
502             values.put(CallLog.Calls.NUMBER, "");
503         } else {
504             String phoneNumber = phones.get(0).getNumber();
505             values.put(CallLog.Calls.NUMBER, phoneNumber);
506         }
507 
508         List<Pair<String, String>> irmc = call.getUnknownXData();
509         SimpleDateFormat parser = new SimpleDateFormat(TIMESTAMP_FORMAT, Locale.ROOT);
510         if (irmc != null) {
511             for (Pair<String, String> pair : irmc) {
512                 if (pair.first.startsWith(CALL_LOG_TIMESTAMP_PROPERTY)) {
513                     try {
514                         values.put(CallLog.Calls.DATE, parser.parse(pair.second).getTime());
515                     } catch (ParseException e) {
516                         Log.d(TAG, "Failed to parse date, value=" + pair.second);
517                     }
518                 }
519             }
520         }
521 
522         operations.add(
523                 ContentProviderOperation.newInsert(CallLog.Calls.CONTENT_URI)
524                         .withValues(values)
525                         .withYieldAllowed(true)
526                         .build());
527 
528         return operations;
529     }
530 
531     /**
532      * Remove all call history associated with this client's account
533      *
534      * @param account The account to remove call history on behalf of
535      */
removeCallHistory(Account account)536     public boolean removeCallHistory(Account account) {
537         if (account == null) {
538             Log.e(TAG, "removeCallHistory: account is null");
539             return false;
540         }
541 
542         Log.i(TAG, "removeCallHistory: requested for account=" + account);
543         try {
544             mContext.getContentResolver()
545                     .delete(
546                             CallLog.Calls.CONTENT_URI,
547                             CallLog.Calls.PHONE_ACCOUNT_ID + "=?",
548                             new String[] {account.name});
549         } catch (IllegalArgumentException e) {
550             Log.w(TAG, "Call Logs could not be deleted, they may not exist yet.", e);
551             return false;
552         }
553         return true;
554     }
555 
556     // *********************************************************************************************
557     // * Callbacks
558     // *********************************************************************************************
559 
registerCallback(Callback callback)560     public void registerCallback(Callback callback) {
561         synchronized (mCallbacks) {
562             mCallbacks.add(callback);
563         }
564     }
565 
unregisterCallback(Callback callback)566     public void unregisterCallback(Callback callback) {
567         synchronized (mCallbacks) {
568             mCallbacks.remove(callback);
569         }
570     }
571 
572     /** Notify all client callbacks that the set of storage accounts has changed */
notifyStorageReady()573     private void notifyStorageReady() {
574         Log.d(TAG, "notifyStorageReady");
575         synchronized (mCallbacks) {
576             for (Callback callback : mCallbacks) {
577                 callback.onStorageReady();
578             }
579         }
580     }
581 
582     /** Notify all client callbacks that the set of storage accounts has changed */
notifyStorageAccountsChanged( List<Account> oldAccounts, List<Account> newAccounts)583     private void notifyStorageAccountsChanged(
584             List<Account> oldAccounts, List<Account> newAccounts) {
585         Log.d(TAG, "notifyAccountsChanged, old=" + oldAccounts + ", new=" + newAccounts);
586         synchronized (mCallbacks) {
587             for (Callback callback : mCallbacks) {
588                 callback.onStorageAccountsChanged(oldAccounts, newAccounts);
589             }
590         }
591     }
592 
593     // *********************************************************************************************
594     // * Debug and Dump Output
595     // *********************************************************************************************
596 
597     @Override
toString()598     public String toString() {
599         return "<" + TAG + " ready=" + isStorageReady() + ">";
600     }
601 
602     /**
603      * Get a summary of the total number of contacts stored for a given account
604      *
605      * <p>Query the Contacts Provider Data table for raw contact ids that below to a given account
606      * type and name.
607      *
608      * @return a formatted string with the number of contacts stored for a given account
609      */
dumpContactsSummary(Account account)610     private String dumpContactsSummary(Account account) {
611         StringBuilder sb = new StringBuilder();
612         List<Long> rawContactIds = new ArrayList<>();
613         try (Cursor cursor =
614                 mContext.getContentResolver()
615                         .query(
616                                 ContactsContract.Data.CONTENT_URI,
617                                 new String[] {ContactsContract.Data.RAW_CONTACT_ID},
618                                 ContactsContract.RawContacts.ACCOUNT_TYPE
619                                         + " = ? AND "
620                                         + ContactsContract.RawContacts.ACCOUNT_NAME
621                                         + " = ?",
622                                 new String[] {account.type, account.name},
623                                 null)) {
624 
625             if (cursor.moveToFirst()) {
626                 int rawContactIdIndex = cursor.getColumnIndex(ContactsContract.Data.RAW_CONTACT_ID);
627                 do {
628                     long rawContactId = cursor.getLong(rawContactIdIndex);
629                     rawContactIds.add(rawContactId);
630                 } while (cursor.moveToNext());
631             }
632         }
633 
634         sb.append("            ").append(rawContactIds.size()).append(" contacts\n");
635         return sb.toString();
636     }
637 
dump()638     public String dump() {
639         StringBuilder sb = new StringBuilder();
640         sb.append(TAG + ":\n");
641         sb.append("    Storage Ready: ").append(mStorageInitialized).append("\n\n");
642         sb.append("    ").append(mAccountManager.dump()).append("\n");
643 
644         sb.append("\n    Database:\n");
645         for (Account account : mAccountManager.getAccounts()) {
646             sb.append("        Account ").append(account.name).append(":\n");
647             sb.append(dumpContactsSummary(account));
648         }
649 
650         return sb.toString();
651     }
652 }
653