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