1 /* 2 * Copyright (C) 2022 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.server.healthconnect.storage; 18 19 import static android.health.connect.Constants.DEFAULT_LONG; 20 import static android.health.connect.Constants.DEFAULT_PAGE_SIZE; 21 import static android.health.connect.Constants.PARENT_KEY; 22 import static android.health.connect.HealthConnectException.ERROR_INTERNAL; 23 24 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.APP_INFO_ID_COLUMN_NAME; 25 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME; 26 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorLong; 27 28 import android.annotation.NonNull; 29 import android.content.Context; 30 import android.database.Cursor; 31 import android.database.DatabaseUtils; 32 import android.database.sqlite.SQLiteConstraintException; 33 import android.database.sqlite.SQLiteDatabase; 34 import android.database.sqlite.SQLiteException; 35 import android.health.connect.Constants; 36 import android.health.connect.HealthConnectException; 37 import android.health.connect.internal.datatypes.RecordInternal; 38 import android.os.UserHandle; 39 import android.util.Pair; 40 import android.util.Slog; 41 42 import com.android.server.healthconnect.HealthConnectUserContext; 43 import com.android.server.healthconnect.storage.datatypehelpers.AppInfoHelper; 44 import com.android.server.healthconnect.storage.datatypehelpers.RecordHelper; 45 import com.android.server.healthconnect.storage.request.AggregateTableRequest; 46 import com.android.server.healthconnect.storage.request.DeleteTableRequest; 47 import com.android.server.healthconnect.storage.request.DeleteTransactionRequest; 48 import com.android.server.healthconnect.storage.request.ReadTableRequest; 49 import com.android.server.healthconnect.storage.request.ReadTransactionRequest; 50 import com.android.server.healthconnect.storage.request.UpsertTableRequest; 51 import com.android.server.healthconnect.storage.request.UpsertTransactionRequest; 52 import com.android.server.healthconnect.storage.utils.RecordHelperProvider; 53 import com.android.server.healthconnect.storage.utils.StorageUtils; 54 55 import java.io.File; 56 import java.util.ArrayList; 57 import java.util.HashMap; 58 import java.util.HashSet; 59 import java.util.List; 60 import java.util.Objects; 61 import java.util.Set; 62 import java.util.concurrent.ConcurrentHashMap; 63 import java.util.function.BiConsumer; 64 65 /** 66 * A class to handle all the DB transaction request from the clients. {@link TransactionManager} 67 * acts as a layer b/w the DB and the data type helper classes and helps perform actual operations 68 * on the DB. 69 * 70 * @hide 71 */ 72 public final class TransactionManager { 73 private static final String TAG = "HealthConnectTransactionMan"; 74 private static final ConcurrentHashMap<UserHandle, HealthConnectDatabase> 75 mUserHandleToDatabaseMap = new ConcurrentHashMap<>(); 76 private static volatile TransactionManager sTransactionManager; 77 private volatile HealthConnectDatabase mHealthConnectDatabase; 78 TransactionManager(@onNull HealthConnectUserContext context)79 private TransactionManager(@NonNull HealthConnectUserContext context) { 80 mHealthConnectDatabase = new HealthConnectDatabase(context); 81 mUserHandleToDatabaseMap.put(context.getCurrentUserHandle(), mHealthConnectDatabase); 82 } 83 onUserUnlocked(@onNull HealthConnectUserContext healthConnectUserContext)84 public void onUserUnlocked(@NonNull HealthConnectUserContext healthConnectUserContext) { 85 if (!mUserHandleToDatabaseMap.containsKey( 86 healthConnectUserContext.getCurrentUserHandle())) { 87 mUserHandleToDatabaseMap.put( 88 healthConnectUserContext.getCurrentUserHandle(), 89 new HealthConnectDatabase(healthConnectUserContext)); 90 } 91 92 mHealthConnectDatabase = 93 mUserHandleToDatabaseMap.get(healthConnectUserContext.getCurrentUserHandle()); 94 } 95 96 /** 97 * Inserts all the {@link RecordInternal} in {@code request} into the HealthConnect database. 98 * 99 * @param request an insert request. 100 * @return List of uids of the inserted {@link RecordInternal}, in the same order as they 101 * presented to {@code request}. 102 */ insertAll(@onNull UpsertTransactionRequest request)103 public List<String> insertAll(@NonNull UpsertTransactionRequest request) 104 throws SQLiteException { 105 if (Constants.DEBUG) { 106 Slog.d(TAG, "Inserting " + request.getUpsertRequests().size() + " requests."); 107 } 108 109 final SQLiteDatabase db = getWritableDb(); 110 db.beginTransaction(); 111 try { 112 for (UpsertTableRequest upsertRequest : request.getUpsertRequests()) { 113 insertOrReplaceRecord(db, upsertRequest); 114 } 115 for (UpsertTableRequest insertRequestsForChangeLog : 116 request.getInsertRequestsForChangeLogs()) { 117 insertRecord(db, insertRequestsForChangeLog); 118 } 119 120 for (UpsertTableRequest insertRequestsForAccessLogs : request.getAccessLogs()) { 121 insertRecord(db, insertRequestsForAccessLogs); 122 } 123 124 db.setTransactionSuccessful(); 125 } finally { 126 db.endTransaction(); 127 } 128 129 return request.getUUIdsInOrder(); 130 } 131 132 /** Ignores if a record is already present. */ insertAll(@onNull List<UpsertTableRequest> requests)133 public void insertAll(@NonNull List<UpsertTableRequest> requests) throws SQLiteException { 134 final SQLiteDatabase db = getWritableDb(); 135 db.beginTransaction(); 136 try { 137 for (UpsertTableRequest request : requests) { 138 insertOrIgnore(db, request); 139 } 140 db.setTransactionSuccessful(); 141 } finally { 142 db.endTransaction(); 143 } 144 } 145 146 /** 147 * Inserts or replaces all the {@link UpsertTableRequest} into the HealthConnect database. 148 * 149 * @param upsertTableRequests a list of insert table requests. 150 */ insertOrReplaceAll(@onNull List<UpsertTableRequest> upsertTableRequests)151 public void insertOrReplaceAll(@NonNull List<UpsertTableRequest> upsertTableRequests) 152 throws SQLiteException { 153 insertAll(upsertTableRequests, this::insertOrReplaceRecord); 154 } 155 156 /** 157 * Inserts or ignore on conflicts all the {@link UpsertTableRequest} into the HealthConnect 158 * database. 159 * 160 * @param upsertTableRequests a list of insert table requests. 161 */ insertOrIgnoreOnConflict(@onNull List<UpsertTableRequest> upsertTableRequests)162 public void insertOrIgnoreOnConflict(@NonNull List<UpsertTableRequest> upsertTableRequests) { 163 final SQLiteDatabase db = getWritableDb(); 164 db.beginTransaction(); 165 try { 166 upsertTableRequests.forEach( 167 (upsertTableRequest) -> insertOrIgnore(db, upsertTableRequest)); 168 db.setTransactionSuccessful(); 169 } finally { 170 db.endTransaction(); 171 } 172 } 173 174 /** 175 * Deletes all the {@link RecordInternal} in {@code request} into the HealthConnect database. 176 * 177 * <p>NOTE: Please don't add logic to explicitly delete child table entries here as they should 178 * be deleted via cascade 179 * 180 * @param request a delete request. 181 */ deleteAll(@onNull DeleteTransactionRequest request)182 public int deleteAll(@NonNull DeleteTransactionRequest request) throws SQLiteException { 183 final SQLiteDatabase db = getWritableDb(); 184 db.beginTransaction(); 185 int numberOfRecordsDeleted = 0; 186 try { 187 for (DeleteTableRequest deleteTableRequest : request.getDeleteTableRequests()) { 188 if (deleteTableRequest.requiresRead()) { 189 /* 190 Delete request needs UUID before the entry can be 191 deleted, fetch and set it in {@code request} 192 */ 193 try (Cursor cursor = db.rawQuery(deleteTableRequest.getReadCommand(), null)) { 194 int numberOfUuidsToDelete = 0; 195 while (cursor.moveToNext()) { 196 numberOfUuidsToDelete++; 197 if (deleteTableRequest.requiresPackageCheck()) { 198 request.enforcePackageCheck( 199 StorageUtils.getCursorUUID( 200 cursor, deleteTableRequest.getIdColumnName()), 201 StorageUtils.getCursorLong( 202 cursor, deleteTableRequest.getPackageColumnName())); 203 } 204 request.onRecordFetched( 205 deleteTableRequest.getRecordType(), 206 StorageUtils.getCursorLong( 207 cursor, deleteTableRequest.getPackageColumnName()), 208 StorageUtils.getCursorUUID( 209 cursor, deleteTableRequest.getIdColumnName())); 210 } 211 deleteTableRequest.setNumberOfUuidsToDelete(numberOfUuidsToDelete); 212 } 213 } 214 numberOfRecordsDeleted += deleteTableRequest.getTotalNumberOfRecordsDeleted(); 215 db.execSQL(deleteTableRequest.getDeleteCommand()); 216 } 217 218 request.getChangeLogUpsertRequests() 219 .forEach((insertRequest) -> insertRecord(db, insertRequest)); 220 221 db.setTransactionSuccessful(); 222 } finally { 223 db.endTransaction(); 224 } 225 return numberOfRecordsDeleted; 226 } 227 228 /** 229 * Handles the aggregation requests for {@code aggregateTableRequest} 230 * 231 * @param aggregateTableRequest an aggregate request. 232 */ 233 @NonNull populateWithAggregation(AggregateTableRequest aggregateTableRequest)234 public void populateWithAggregation(AggregateTableRequest aggregateTableRequest) { 235 final SQLiteDatabase db = getReadableDb(); 236 if (!aggregateTableRequest.getRecordHelper().isRecordOperationsEnabled()) { 237 return; 238 } 239 try (Cursor cursor = db.rawQuery(aggregateTableRequest.getAggregationCommand(), null); 240 Cursor metaDataCursor = 241 db.rawQuery( 242 aggregateTableRequest.getCommandToFetchAggregateMetadata(), null)) { 243 aggregateTableRequest.onResultsFetched(cursor, metaDataCursor); 244 } 245 } 246 247 /** 248 * Reads the records {@link RecordInternal} stored in the HealthConnect database. 249 * 250 * @param request a read request. 251 * @return List of records read {@link RecordInternal} from table based on ids. 252 */ readRecords(@onNull ReadTransactionRequest request)253 public List<RecordInternal<?>> readRecords(@NonNull ReadTransactionRequest request) 254 throws SQLiteException { 255 List<RecordInternal<?>> recordInternals = new ArrayList<>(); 256 request.getReadRequests() 257 .forEach( 258 (readTableRequest -> { 259 if (readTableRequest.getRecordHelper().isRecordOperationsEnabled()) { 260 try (Cursor cursor = read(readTableRequest)) { 261 Objects.requireNonNull(readTableRequest.getRecordHelper()); 262 List<RecordInternal<?>> internalRecords = 263 readTableRequest 264 .getRecordHelper() 265 .getInternalRecords(cursor, DEFAULT_PAGE_SIZE); 266 267 populateInternalRecordsWithExtraData( 268 internalRecords, readTableRequest); 269 270 recordInternals.addAll(internalRecords); 271 } 272 } 273 })); 274 return recordInternals; 275 } 276 277 /** 278 * Reads the records {@link RecordInternal} stored in the HealthConnect database and returns the 279 * max row_id as next page token. 280 * 281 * @param request a read request. 282 * @return Pair containing records list read {@link RecordInternal} from the table and a next 283 * page token for pagination 284 */ readRecordsAndGetNextToken( @onNull ReadTransactionRequest request)285 public Pair<List<RecordInternal<?>>, Long> readRecordsAndGetNextToken( 286 @NonNull ReadTransactionRequest request) throws SQLiteException { 287 // throw an exception if read requested is not for a single record type 288 // i.e. size of read table request is not equal to 1. 289 if (request.getReadRequests().size() != 1) { 290 throw new IllegalArgumentException("Read requested is not for a single record type"); 291 } 292 List<RecordInternal<?>> recordInternalList; 293 long token = DEFAULT_LONG; 294 ReadTableRequest readTableRequest = request.getReadRequests().get(0); 295 RecordHelper<?> helper = readTableRequest.getRecordHelper(); 296 Objects.requireNonNull(helper); 297 if (!helper.isRecordOperationsEnabled()) { 298 recordInternalList = new ArrayList<>(0); 299 return Pair.create(recordInternalList, token); 300 } 301 302 try (Cursor cursor = read(readTableRequest)) { 303 recordInternalList = helper.getInternalRecords(cursor, readTableRequest.getPageSize()); 304 String startTimeColumnName = helper.getStartTimeColumnName(); 305 306 populateInternalRecordsWithExtraData(recordInternalList, readTableRequest); 307 if (cursor.moveToNext()) { 308 token = getCursorLong(cursor, startTimeColumnName); 309 } 310 } 311 return Pair.create(recordInternalList, token); 312 } 313 314 /** 315 * Inserts record into the table in {@code request} into the HealthConnect database. 316 * 317 * <p>NOTE: PLEASE ONLY USE THIS FUNCTION IF YOU WANT TO INSERT A SINGLE RECORD PER API. PLEASE 318 * DON'T USE THIS FUNCTION INSIDE A FOR LOOP OR REPEATEDLY: The reason is that this function 319 * tries to insert a record inside its own transaction and if you are trying to insert multiple 320 * things using this method in the same api call, they will all get inserted in their separate 321 * transactions and will be less performant. If at all, the requirement is to insert them in 322 * different transactions, as they are not related to each, then this method can be used. 323 * 324 * @param request an insert request. 325 * @return rowId of the inserted record. 326 */ insert(@onNull UpsertTableRequest request)327 public long insert(@NonNull UpsertTableRequest request) { 328 final SQLiteDatabase db = getWritableDb(); 329 return insertRecord(db, request); 330 } 331 332 /** 333 * Update record into the table in {@code request} into the HealthConnect database. 334 * 335 * <p>NOTE: PLEASE ONLY USE THIS FUNCTION IF YOU WANT TO UPDATE A SINGLE RECORD PER API. PLEASE 336 * DON'T USE THIS FUNCTION INSIDE A FOR LOOP OR REPEATEDLY: The reason is that this function 337 * tries to update a record inside its own transaction and if you are trying to insert multiple 338 * things using this method in the same api call, they will all get updates in their separate 339 * transactions and will be less performant. If at all, the requirement is to update them in 340 * different transactions, as they are not related to each, then this method can be used. 341 * 342 * @param request an update request. 343 */ update(@onNull UpsertTableRequest request)344 public void update(@NonNull UpsertTableRequest request) { 345 final SQLiteDatabase db = getWritableDb(); 346 updateRecord(db, request); 347 } 348 349 /** 350 * Inserts (or updates if the row exists) record into the table in {@code request} into the 351 * HealthConnect database. 352 * 353 * <p>NOTE: PLEASE ONLY USE THIS FUNCTION IF YOU WANT TO UPSERT A SINGLE RECORD. PLEASE DON'T 354 * USE THIS FUNCTION INSIDE A FOR LOOP OR REPEATEDLY: The reason is that this function tries to 355 * insert a record out of a transaction and if you are trying to insert a record before or after 356 * opening up a transaction please rethink if you really want to use this function. 357 * 358 * <p>NOTE: INSERT + WITH_CONFLICT_REPLACE only works on unique columns, else in case of 359 * conflict it leads to abort of the transaction. 360 * 361 * @param request an insert request. 362 * @return rowId of the inserted or updated record. 363 */ insertOrReplace(@onNull UpsertTableRequest request)364 public long insertOrReplace(@NonNull UpsertTableRequest request) { 365 final SQLiteDatabase db = getWritableDb(); 366 return insertOrReplaceRecord(db, request); 367 } 368 369 /** Note: It is the responsibility of the caller to close the returned cursor */ 370 @NonNull read(@onNull ReadTableRequest request)371 public Cursor read(@NonNull ReadTableRequest request) { 372 if (Constants.DEBUG) { 373 Slog.d(TAG, "Read query: " + request.getReadCommand()); 374 } 375 return getReadableDb().rawQuery(request.getReadCommand(), null); 376 } 377 getLastRowIdFor(String tableName)378 public long getLastRowIdFor(String tableName) { 379 final SQLiteDatabase db = getReadableDb(); 380 try (Cursor cursor = db.rawQuery(StorageUtils.getMaxPrimaryKeyQuery(tableName), null)) { 381 cursor.moveToFirst(); 382 return cursor.getLong(cursor.getColumnIndex(PRIMARY_COLUMN_NAME)); 383 } 384 } 385 386 /** 387 * Get number of entries in the given table. 388 * 389 * @param tableName Name of table 390 * @return Number of entries in the given table 391 */ getNumberOfEntriesInTheTable(@onNull String tableName)392 public long getNumberOfEntriesInTheTable(@NonNull String tableName) { 393 Objects.requireNonNull(tableName); 394 return DatabaseUtils.queryNumEntries(getReadableDb(), tableName); 395 } 396 397 /** 398 * Size of Health Connect database in bytes. 399 * 400 * @param context Context 401 * @return Size of the database 402 */ getDatabaseSize(@onNull Context context)403 public long getDatabaseSize(@NonNull Context context) { 404 Objects.requireNonNull(context); 405 return context.getDatabasePath(getReadableDb().getPath()).length(); 406 } 407 delete(DeleteTableRequest request)408 public void delete(DeleteTableRequest request) { 409 final SQLiteDatabase db = getWritableDb(); 410 db.execSQL(request.getDeleteCommand()); 411 } 412 413 /** 414 * Updates all the {@link RecordInternal} in {@code request} into the HealthConnect database. 415 * 416 * @param request an update request. 417 */ updateAll(@onNull UpsertTransactionRequest request)418 public void updateAll(@NonNull UpsertTransactionRequest request) { 419 final SQLiteDatabase db = getWritableDb(); 420 db.beginTransaction(); 421 try { 422 for (UpsertTableRequest upsertRequest : request.getUpsertRequests()) { 423 updateRecord(db, upsertRequest); 424 } 425 for (UpsertTableRequest insertRequestsForChangeLog : 426 request.getInsertRequestsForChangeLogs()) { 427 insertRecord(db, insertRequestsForChangeLog); 428 } 429 for (UpsertTableRequest insertRequestsForAccessLogs : request.getAccessLogs()) { 430 insertRecord(db, insertRequestsForAccessLogs); 431 } 432 db.setTransactionSuccessful(); 433 } finally { 434 db.endTransaction(); 435 } 436 } 437 438 /** 439 * @return list of distinct packageNames corresponding to the input table name after querying 440 * the table. 441 */ getDistinctPackageNamesForRecordsTable( Set<Integer> recordTypes)442 public HashMap<Integer, HashSet<String>> getDistinctPackageNamesForRecordsTable( 443 Set<Integer> recordTypes) throws SQLiteException { 444 final SQLiteDatabase db = getReadableDb(); 445 HashMap<Integer, HashSet<String>> packagesForRecordTypeMap = new HashMap<>(); 446 for (Integer recordType : recordTypes) { 447 RecordHelper<?> recordHelper = 448 RecordHelperProvider.getInstance().getRecordHelper(recordType); 449 HashSet<String> packageNamesForDatatype = new HashSet<>(); 450 try (Cursor cursorForDistinctPackageNames = 451 db.rawQuery( 452 /* sql query */ 453 recordHelper 454 .getReadTableRequestWithDistinctAppInfoIds() 455 .getReadCommand(), 456 /* selectionArgs */ null)) { 457 if (cursorForDistinctPackageNames.getCount() > 0) { 458 AppInfoHelper appInfoHelper = AppInfoHelper.getInstance(); 459 while (cursorForDistinctPackageNames.moveToNext()) { 460 String packageName = 461 appInfoHelper.getPackageName( 462 cursorForDistinctPackageNames.getLong( 463 cursorForDistinctPackageNames.getColumnIndex( 464 APP_INFO_ID_COLUMN_NAME))); 465 if (!packageName.isEmpty()) { 466 packageNamesForDatatype.add(packageName); 467 } 468 } 469 } 470 } 471 packagesForRecordTypeMap.put(recordType, packageNamesForDatatype); 472 } 473 return packagesForRecordTypeMap; 474 } 475 476 /** 477 * ONLY DO OPERATIONS IN A SINGLE TRANSACTION HERE 478 * 479 * <p>This is because this function is called from {@link AutoDeleteService}, and we want to 480 * make sure that either all its operation succeed or fail in a single run. 481 */ deleteWithoutChangeLogs(@onNull List<DeleteTableRequest> deleteTableRequests)482 public void deleteWithoutChangeLogs(@NonNull List<DeleteTableRequest> deleteTableRequests) { 483 Objects.requireNonNull(deleteTableRequests); 484 final SQLiteDatabase db = getWritableDb(); 485 db.beginTransaction(); 486 try { 487 for (DeleteTableRequest deleteTableRequest : deleteTableRequests) { 488 db.execSQL(deleteTableRequest.getDeleteCommand()); 489 } 490 db.setTransactionSuccessful(); 491 } finally { 492 db.endTransaction(); 493 } 494 } 495 onUserSwitching()496 public void onUserSwitching() { 497 mHealthConnectDatabase.close(); 498 } 499 insertAll( @onNull List<UpsertTableRequest> upsertTableRequests, @NonNull BiConsumer<SQLiteDatabase, UpsertTableRequest> insert)500 private void insertAll( 501 @NonNull List<UpsertTableRequest> upsertTableRequests, 502 @NonNull BiConsumer<SQLiteDatabase, UpsertTableRequest> insert) { 503 final SQLiteDatabase db = getWritableDb(); 504 db.beginTransaction(); 505 try { 506 upsertTableRequests.forEach( 507 (upsertTableRequest) -> insert.accept(db, upsertTableRequest)); 508 db.setTransactionSuccessful(); 509 } finally { 510 db.endTransaction(); 511 } 512 } 513 runAsTransaction(TransactionRunnable<E> task)514 public <E extends Throwable> void runAsTransaction(TransactionRunnable<E> task) throws E { 515 final SQLiteDatabase db = getWritableDb(); 516 db.beginTransaction(); 517 try { 518 task.run(db); 519 db.setTransactionSuccessful(); 520 } finally { 521 db.endTransaction(); 522 } 523 } 524 525 /** Assumes that caller will be closing {@code db} and handling the transaction if required */ insertRecord(@onNull SQLiteDatabase db, @NonNull UpsertTableRequest request)526 public long insertRecord(@NonNull SQLiteDatabase db, @NonNull UpsertTableRequest request) { 527 long rowId = db.insertOrThrow(request.getTable(), null, request.getContentValues()); 528 request.getChildTableRequests() 529 .forEach(childRequest -> insertRecord(db, childRequest.withParentKey(rowId))); 530 531 return rowId; 532 } 533 534 /** 535 * Inserts the provided {@link UpsertTableRequest} into the database. 536 * 537 * <p>Assumes that caller will be closing {@code db} and handling the transaction if required. 538 * 539 * @return the row ID of the newly inserted row or <code>-1</code> if an error occurred. 540 */ insertOrIgnore(@onNull SQLiteDatabase db, @NonNull UpsertTableRequest request)541 public long insertOrIgnore(@NonNull SQLiteDatabase db, @NonNull UpsertTableRequest request) { 542 long rowId = 543 db.insertWithOnConflict( 544 request.getTable(), 545 null, 546 request.getContentValues(), 547 SQLiteDatabase.CONFLICT_IGNORE); 548 549 if (rowId != -1) { 550 request.getChildTableRequests() 551 .forEach(childRequest -> insertRecord(db, childRequest.withParentKey(rowId))); 552 } 553 554 return rowId; 555 } 556 557 /** Note: NEVER close this DB */ 558 @NonNull getReadableDb()559 private SQLiteDatabase getReadableDb() { 560 SQLiteDatabase sqLiteDatabase = mHealthConnectDatabase.getReadableDatabase(); 561 562 if (sqLiteDatabase == null) { 563 throw new InternalError("SQLite DB not found"); 564 } 565 return sqLiteDatabase; 566 } 567 568 /** Note: NEVER close this DB */ 569 @NonNull getWritableDb()570 private SQLiteDatabase getWritableDb() { 571 SQLiteDatabase sqLiteDatabase = mHealthConnectDatabase.getWritableDatabase(); 572 573 if (sqLiteDatabase == null) { 574 throw new InternalError("SQLite DB not found"); 575 } 576 return sqLiteDatabase; 577 } 578 getDatabasePath()579 public File getDatabasePath() { 580 return mHealthConnectDatabase.getDatabasePath(); 581 } 582 updateTable(UpsertTableRequest upsertTableRequest)583 public void updateTable(UpsertTableRequest upsertTableRequest) { 584 getWritableDb() 585 .update( 586 upsertTableRequest.getTable(), 587 upsertTableRequest.getContentValues(), 588 upsertTableRequest.getUpdateWhereClauses().get(false), 589 null); 590 } 591 getDatabaseVersion()592 public int getDatabaseVersion() { 593 return getReadableDb().getVersion(); 594 } 595 updateRecord(SQLiteDatabase db, UpsertTableRequest request)596 private void updateRecord(SQLiteDatabase db, UpsertTableRequest request) { 597 // Perform an update operation where UUID and packageName (mapped by appInfoId) is same 598 // as that of the update request. 599 try { 600 long numberOfRowsUpdated = 601 db.update( 602 request.getTable(), 603 request.getContentValues(), 604 request.getUpdateWhereClauses().get(/* withWhereKeyword */ false), 605 /* WHERE args */ null); 606 607 // throw an exception if the no row was updated, i.e. the uuid with corresponding 608 // app_id_info for this request is not found in the table. 609 if (numberOfRowsUpdated == 0) { 610 throw new IllegalArgumentException( 611 "No record found for the following input : " 612 + new StorageUtils.RecordIdentifierData( 613 request.getContentValues())); 614 } 615 } catch (SQLiteConstraintException e) { 616 try (Cursor cursor = db.rawQuery(request.getReadRequest().getReadCommand(), null)) { 617 cursor.moveToFirst(); 618 throw new IllegalArgumentException( 619 StorageUtils.getConflictErrorMessageForRecord( 620 cursor, request.getContentValues())); 621 } 622 } 623 624 if (request.getAllChildTables().isEmpty()) { 625 return; 626 } 627 628 try (Cursor cursor = 629 db.rawQuery(request.getReadRequestUsingUpdateClause().getReadCommand(), null)) { 630 if (!cursor.moveToFirst()) { 631 throw new HealthConnectException( 632 ERROR_INTERNAL, "Expected to read an entry for update, but none found"); 633 } 634 final long rowId = StorageUtils.getCursorLong(cursor, request.getRowIdColName()); 635 deleteChildTableRequest(request, rowId, db); 636 insertChildTableRequest(request, rowId, db); 637 } 638 } 639 640 /** 641 * Do extra sql requests to populate optional extra data. Used to populate {@link 642 * android.health.connect.internal.datatypes.ExerciseRouteInternal}. 643 */ populateInternalRecordsWithExtraData( List<RecordInternal<?>> records, ReadTableRequest request)644 private void populateInternalRecordsWithExtraData( 645 List<RecordInternal<?>> records, ReadTableRequest request) { 646 if (request.getExtraReadRequests() == null) { 647 return; 648 } 649 for (ReadTableRequest extraDataRequest : request.getExtraReadRequests()) { 650 Cursor cursorExtraData = read(extraDataRequest); 651 request.getRecordHelper() 652 .updateInternalRecordsWithExtraFields( 653 records, cursorExtraData, extraDataRequest.getTableName()); 654 } 655 } 656 657 /** 658 * Assumes that caller will be closing {@code db}. Returns -1 in case the update was triggered 659 * and reading the row_id was not supported on the table. 660 * 661 * <p>Note: This function updates rather than the traditional delete + insert in SQLite 662 */ insertOrReplaceRecord( @onNull SQLiteDatabase db, @NonNull UpsertTableRequest request)663 private long insertOrReplaceRecord( 664 @NonNull SQLiteDatabase db, @NonNull UpsertTableRequest request) { 665 try { 666 if (request.getUniqueColumnsCount() == 0) { 667 throw new RuntimeException( 668 "insertOrReplaceRecord should only be called with unique columns set"); 669 } 670 671 long rowId = 672 db.insertWithOnConflict( 673 request.getTable(), 674 null, 675 request.getContentValues(), 676 SQLiteDatabase.CONFLICT_FAIL); 677 insertChildTableRequest(request, rowId, db); 678 return rowId; 679 } catch (SQLiteConstraintException e) { 680 try (Cursor cursor = db.rawQuery(request.getReadRequest().getReadCommand(), null)) { 681 if (!cursor.moveToFirst()) { 682 throw new HealthConnectException( 683 ERROR_INTERNAL, "Conflict found, but couldn't read the entry."); 684 } 685 686 return updateEntriesIfRequired(db, request, cursor); 687 } 688 } 689 } 690 updateEntriesIfRequired( SQLiteDatabase db, UpsertTableRequest request, Cursor cursor)691 private long updateEntriesIfRequired( 692 SQLiteDatabase db, UpsertTableRequest request, Cursor cursor) { 693 if (!request.requiresUpdate(cursor, request)) { 694 return -1; 695 } 696 697 db.update( 698 request.getTable(), 699 request.getContentValues(), 700 request.getUpdateWhereClauses().get(/* withWhereKeyword */ false), 701 /* WHERE args */ null); 702 if (cursor.getColumnIndex(request.getRowIdColName()) == -1) { 703 // The table is not explicitly using row_ids hence returning -1 here is ok, as 704 // the rowid is of no use to this table. 705 // NOTE: Such tables in HC don't support child tables either as child tables 706 // inherently require row_ids to have support parent key. 707 return -1; 708 } 709 final long rowId = StorageUtils.getCursorLong(cursor, request.getRowIdColName()); 710 deleteChildTableRequest(request, rowId, db); 711 insertChildTableRequest(request, rowId, db); 712 713 return rowId; 714 } 715 deleteChildTableRequest( UpsertTableRequest request, long rowId, SQLiteDatabase db)716 private void deleteChildTableRequest( 717 UpsertTableRequest request, long rowId, SQLiteDatabase db) { 718 for (String childTable : request.getAllChildTablesToDelete()) { 719 DeleteTableRequest deleteTableRequest = 720 new DeleteTableRequest(childTable).setId(PARENT_KEY, String.valueOf(rowId)); 721 db.execSQL(deleteTableRequest.getDeleteCommand()); 722 } 723 } 724 insertChildTableRequest( UpsertTableRequest request, long rowId, SQLiteDatabase db)725 private void insertChildTableRequest( 726 UpsertTableRequest request, long rowId, SQLiteDatabase db) { 727 for (UpsertTableRequest childTableRequest : request.getChildTableRequests()) { 728 db.insertOrThrow( 729 childTableRequest.withParentKey(rowId).getTable(), 730 null, 731 childTableRequest.getContentValues()); 732 } 733 } 734 735 public interface TransactionRunnable<E extends Throwable> { run(SQLiteDatabase db)736 void run(SQLiteDatabase db) throws E; 737 } 738 739 @NonNull getInstance( @onNull HealthConnectUserContext context)740 public static synchronized TransactionManager getInstance( 741 @NonNull HealthConnectUserContext context) { 742 if (sTransactionManager == null) { 743 sTransactionManager = new TransactionManager(context); 744 } 745 746 return sTransactionManager; 747 } 748 749 @NonNull getInitialisedInstance()750 public static TransactionManager getInitialisedInstance() { 751 Objects.requireNonNull(sTransactionManager); 752 753 return sTransactionManager; 754 } 755 } 756