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_PAGE_SIZE; 20 import static android.health.connect.HealthConnectException.ERROR_INTERNAL; 21 import static android.health.connect.PageTokenWrapper.EMPTY_PAGE_TOKEN; 22 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_DELETE; 23 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_UPSERT; 24 25 import static com.android.internal.util.Preconditions.checkArgument; 26 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.APP_INFO_ID_COLUMN_NAME; 27 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME; 28 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.UUID_COLUMN_NAME; 29 30 import static java.util.Objects.requireNonNull; 31 32 import android.annotation.NonNull; 33 import android.content.Context; 34 import android.database.Cursor; 35 import android.database.DatabaseUtils; 36 import android.database.sqlite.SQLiteConstraintException; 37 import android.database.sqlite.SQLiteDatabase; 38 import android.database.sqlite.SQLiteException; 39 import android.health.connect.Constants; 40 import android.health.connect.HealthConnectException; 41 import android.health.connect.MedicalResourceId; 42 import android.health.connect.PageTokenWrapper; 43 import android.health.connect.datatypes.MedicalResource; 44 import android.health.connect.internal.datatypes.MedicalResourceInternal; 45 import android.health.connect.internal.datatypes.RecordInternal; 46 import android.os.UserHandle; 47 import android.util.Pair; 48 import android.util.Slog; 49 50 import androidx.annotation.VisibleForTesting; 51 52 import com.android.server.healthconnect.HealthConnectUserContext; 53 import com.android.server.healthconnect.storage.datatypehelpers.AppInfoHelper; 54 import com.android.server.healthconnect.storage.datatypehelpers.ChangeLogsHelper; 55 import com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceHelper; 56 import com.android.server.healthconnect.storage.datatypehelpers.RecordHelper; 57 import com.android.server.healthconnect.storage.request.AggregateTableRequest; 58 import com.android.server.healthconnect.storage.request.DeleteTableRequest; 59 import com.android.server.healthconnect.storage.request.DeleteTransactionRequest; 60 import com.android.server.healthconnect.storage.request.ReadTableRequest; 61 import com.android.server.healthconnect.storage.request.ReadTransactionRequest; 62 import com.android.server.healthconnect.storage.request.UpsertTableRequest; 63 import com.android.server.healthconnect.storage.request.UpsertTransactionRequest; 64 import com.android.server.healthconnect.storage.utils.RecordHelperProvider; 65 import com.android.server.healthconnect.storage.utils.StorageUtils; 66 67 import java.io.File; 68 import java.time.Instant; 69 import java.util.ArrayList; 70 import java.util.HashMap; 71 import java.util.HashSet; 72 import java.util.List; 73 import java.util.Set; 74 import java.util.UUID; 75 import java.util.concurrent.ConcurrentHashMap; 76 import java.util.function.BiConsumer; 77 78 /** 79 * A class to handle all the DB transaction request from the clients. {@link TransactionManager} 80 * acts as a layer b/w the DB and the data type helper classes and helps perform actual operations 81 * on the DB. 82 * 83 * @hide 84 */ 85 public final class TransactionManager { 86 private static final String TAG = "HealthConnectTransactionMan"; 87 private static final ConcurrentHashMap<UserHandle, HealthConnectDatabase> 88 mUserHandleToDatabaseMap = new ConcurrentHashMap<>(); 89 90 @SuppressWarnings("NullAway.Init") // TODO(b/317029272): fix this suppression 91 private static volatile TransactionManager sTransactionManager; 92 93 private volatile HealthConnectDatabase mHealthConnectDatabase; 94 private UserHandle mUserHandle; 95 TransactionManager(@onNull HealthConnectUserContext context)96 private TransactionManager(@NonNull HealthConnectUserContext context) { 97 mHealthConnectDatabase = new HealthConnectDatabase(context); 98 mUserHandleToDatabaseMap.put(context.getCurrentUserHandle(), mHealthConnectDatabase); 99 mUserHandle = context.getCurrentUserHandle(); 100 } 101 onUserUnlocked(@onNull HealthConnectUserContext healthConnectUserContext)102 public void onUserUnlocked(@NonNull HealthConnectUserContext healthConnectUserContext) { 103 if (!mUserHandleToDatabaseMap.containsKey( 104 healthConnectUserContext.getCurrentUserHandle())) { 105 mUserHandleToDatabaseMap.put( 106 healthConnectUserContext.getCurrentUserHandle(), 107 new HealthConnectDatabase(healthConnectUserContext)); 108 } 109 110 mHealthConnectDatabase = 111 mUserHandleToDatabaseMap.get(healthConnectUserContext.getCurrentUserHandle()); 112 mUserHandle = healthConnectUserContext.getCurrentUserHandle(); 113 } 114 115 /** 116 * Upserts (insert/update) a list of {@link MedicalResource}s created based on the given list of 117 * {@link MedicalResourceInternal}s into the HealthConnect database. 118 * 119 * @param medicalResourceInternals a list of {@link MedicalResourceInternal}. 120 * @return List of {@link MedicalResource}s that were upserted into the database, in the same 121 * order as their associated {@link MedicalResourceInternal}s. 122 */ upsertMedicalResources( @onNull List<MedicalResourceInternal> medicalResourceInternals)123 public List<MedicalResource> upsertMedicalResources( 124 @NonNull List<MedicalResourceInternal> medicalResourceInternals) 125 throws SQLiteException { 126 if (Constants.DEBUG) { 127 Slog.d( 128 TAG, 129 "Upserting " 130 + medicalResourceInternals.size() 131 + " " 132 + MedicalResourceInternal.class.getSimpleName() 133 + "(s)."); 134 } 135 136 // TODO(b/337018927): Add support for change logs and access logs. 137 List<MedicalResource> upsertedMedicalResources = new ArrayList<>(); 138 SQLiteDatabase db = getWritableDb(); 139 db.beginTransaction(); 140 141 try { 142 for (MedicalResourceInternal medicalResourceInternal : medicalResourceInternals) { 143 UUID uuid = 144 StorageUtils.generateMedicalResourceUUID( 145 medicalResourceInternal.getFhirResourceId(), 146 medicalResourceInternal.getFhirResourceType(), 147 medicalResourceInternal.getDataSourceId()); 148 UpsertTableRequest upsertTableRequest = 149 MedicalResourceHelper.getUpsertTableRequest(uuid, medicalResourceInternal); 150 insertOrReplaceRecord(db, upsertTableRequest); 151 upsertedMedicalResources.add( 152 MedicalResourceHelper.buildMedicalResource(uuid, medicalResourceInternal)); 153 } 154 db.setTransactionSuccessful(); 155 } finally { 156 db.endTransaction(); 157 } 158 159 return upsertedMedicalResources; 160 } 161 162 /** 163 * Inserts all the {@link RecordInternal} in {@code request} into the HealthConnect database. 164 * 165 * @param request an insert request. 166 * @return List of uids of the inserted {@link RecordInternal}, in the same order as they 167 * presented to {@code request}. 168 */ insertAll(@onNull UpsertTransactionRequest request)169 public List<String> insertAll(@NonNull UpsertTransactionRequest request) 170 throws SQLiteException { 171 if (Constants.DEBUG) { 172 Slog.d(TAG, "Inserting " + request.getUpsertRequests().size() + " requests."); 173 } 174 175 final SQLiteDatabase db = getWritableDb(); 176 177 long currentTime = Instant.now().toEpochMilli(); 178 ChangeLogsHelper.ChangeLogs insertionChangelogs = 179 new ChangeLogsHelper.ChangeLogs(OPERATION_TYPE_UPSERT, currentTime); 180 ChangeLogsHelper.ChangeLogs modificationChangelogs = 181 new ChangeLogsHelper.ChangeLogs(OPERATION_TYPE_UPSERT, currentTime); 182 db.beginTransaction(); 183 try { 184 for (UpsertTableRequest upsertRequest : request.getUpsertRequests()) { 185 insertionChangelogs.addUUID( 186 upsertRequest.getRecordInternal().getRecordType(), 187 upsertRequest.getRecordInternal().getAppInfoId(), 188 upsertRequest.getRecordInternal().getUuid()); 189 addChangelogsForOtherModifiedRecords(upsertRequest, modificationChangelogs); 190 insertOrReplaceRecord(db, upsertRequest); 191 } 192 193 for (UpsertTableRequest insertRequestsForChangeLog : 194 insertionChangelogs.getUpsertTableRequests()) { 195 insertRecord(db, insertRequestsForChangeLog); 196 } 197 for (UpsertTableRequest modificationChangelog : 198 modificationChangelogs.getUpsertTableRequests()) { 199 insertRecord(db, modificationChangelog); 200 } 201 202 for (UpsertTableRequest insertRequestsForAccessLogs : request.getAccessLogs()) { 203 insertRecord(db, insertRequestsForAccessLogs); 204 } 205 206 db.setTransactionSuccessful(); 207 } finally { 208 db.endTransaction(); 209 } 210 211 return request.getUUIdsInOrder(); 212 } 213 214 /** 215 * Ignores if a record is already present. This does not generate changelogs and should only be 216 * used for backup and restore. 217 */ insertAll(@onNull List<UpsertTableRequest> requests)218 public void insertAll(@NonNull List<UpsertTableRequest> requests) throws SQLiteException { 219 final SQLiteDatabase db = getWritableDb(); 220 db.beginTransaction(); 221 try { 222 for (UpsertTableRequest request : requests) { 223 insertOrIgnore(db, request); 224 } 225 db.setTransactionSuccessful(); 226 } finally { 227 db.endTransaction(); 228 } 229 } 230 231 /** 232 * Inserts or replaces all the {@link UpsertTableRequest} into the HealthConnect database. 233 * 234 * @param upsertTableRequests a list of insert table requests. 235 */ insertOrReplaceAll(@onNull List<UpsertTableRequest> upsertTableRequests)236 public void insertOrReplaceAll(@NonNull List<UpsertTableRequest> upsertTableRequests) 237 throws SQLiteException { 238 insertAll(upsertTableRequests, this::insertOrReplaceRecord); 239 } 240 241 /** 242 * Inserts or ignore on conflicts all the {@link UpsertTableRequest} into the HealthConnect 243 * database. 244 * 245 * @param upsertTableRequests a list of insert table requests. 246 */ insertOrIgnoreOnConflict(@onNull List<UpsertTableRequest> upsertTableRequests)247 public void insertOrIgnoreOnConflict(@NonNull List<UpsertTableRequest> upsertTableRequests) { 248 final SQLiteDatabase db = getWritableDb(); 249 db.beginTransaction(); 250 try { 251 upsertTableRequests.forEach( 252 (upsertTableRequest) -> insertOrIgnore(db, upsertTableRequest)); 253 db.setTransactionSuccessful(); 254 } finally { 255 db.endTransaction(); 256 } 257 } 258 259 /** 260 * Deletes all the {@link RecordInternal} in {@code request} into the HealthConnect database. 261 * 262 * <p>NOTE: Please don't add logic to explicitly delete child table entries here as they should 263 * be deleted via cascade 264 * 265 * @param request a delete request. 266 */ 267 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression deleteAll(@onNull DeleteTransactionRequest request)268 public int deleteAll(@NonNull DeleteTransactionRequest request) throws SQLiteException { 269 final SQLiteDatabase db = getWritableDb(); 270 long currentTime = Instant.now().toEpochMilli(); 271 ChangeLogsHelper.ChangeLogs deletionChangelogs = 272 new ChangeLogsHelper.ChangeLogs(OPERATION_TYPE_DELETE, currentTime); 273 ChangeLogsHelper.ChangeLogs modificationChangelogs = 274 new ChangeLogsHelper.ChangeLogs(OPERATION_TYPE_UPSERT, currentTime); 275 db.beginTransaction(); 276 int numberOfRecordsDeleted = 0; 277 try { 278 for (DeleteTableRequest deleteTableRequest : request.getDeleteTableRequests()) { 279 final RecordHelper<?> recordHelper = 280 RecordHelperProvider.getRecordHelper(deleteTableRequest.getRecordType()); 281 if (deleteTableRequest.requiresRead()) { 282 /* 283 Delete request needs UUID before the entry can be 284 deleted, fetch and set it in {@code request} 285 */ 286 try (Cursor cursor = db.rawQuery(deleteTableRequest.getReadCommand(), null)) { 287 int numberOfUuidsToDelete = 0; 288 while (cursor.moveToNext()) { 289 numberOfUuidsToDelete++; 290 long appInfoId = 291 StorageUtils.getCursorLong( 292 cursor, deleteTableRequest.getPackageColumnName()); 293 if (deleteTableRequest.requiresPackageCheck()) { 294 request.enforcePackageCheck( 295 StorageUtils.getCursorUUID( 296 cursor, deleteTableRequest.getIdColumnName()), 297 appInfoId); 298 } 299 UUID deletedRecordUuid = 300 StorageUtils.getCursorUUID( 301 cursor, deleteTableRequest.getIdColumnName()); 302 deletionChangelogs.addUUID( 303 deleteTableRequest.getRecordType(), 304 appInfoId, 305 deletedRecordUuid); 306 307 // Add changelogs for affected records, e.g. a training plan being 308 // deleted will create changelogs for affected exercise sessions. 309 for (ReadTableRequest additionalChangelogUuidRequest : 310 recordHelper.getReadRequestsForRecordsModifiedByDeletion( 311 deletedRecordUuid)) { 312 Cursor cursorAdditionalUuids = read(additionalChangelogUuidRequest); 313 while (cursorAdditionalUuids.moveToNext()) { 314 modificationChangelogs.addUUID( 315 additionalChangelogUuidRequest 316 .getRecordHelper() 317 .getRecordIdentifier(), 318 StorageUtils.getCursorLong( 319 cursorAdditionalUuids, APP_INFO_ID_COLUMN_NAME), 320 StorageUtils.getCursorUUID( 321 cursorAdditionalUuids, UUID_COLUMN_NAME)); 322 } 323 cursorAdditionalUuids.close(); 324 } 325 } 326 deleteTableRequest.setNumberOfUuidsToDelete(numberOfUuidsToDelete); 327 } 328 } 329 numberOfRecordsDeleted += deleteTableRequest.getTotalNumberOfRecordsDeleted(); 330 db.execSQL(deleteTableRequest.getDeleteCommand()); 331 } 332 333 for (UpsertTableRequest insertRequestsForChangeLog : 334 deletionChangelogs.getUpsertTableRequests()) { 335 insertRecord(db, insertRequestsForChangeLog); 336 } 337 for (UpsertTableRequest modificationChangelog : 338 modificationChangelogs.getUpsertTableRequests()) { 339 insertRecord(db, modificationChangelog); 340 } 341 342 db.setTransactionSuccessful(); 343 } finally { 344 db.endTransaction(); 345 } 346 return numberOfRecordsDeleted; 347 } 348 349 /** 350 * Handles the aggregation requests for {@code aggregateTableRequest} 351 * 352 * @param aggregateTableRequest an aggregate request. 353 */ 354 @NonNull populateWithAggregation(AggregateTableRequest aggregateTableRequest)355 public void populateWithAggregation(AggregateTableRequest aggregateTableRequest) { 356 final SQLiteDatabase db = getReadableDb(); 357 if (!aggregateTableRequest.getRecordHelper().isRecordOperationsEnabled()) { 358 return; 359 } 360 try (Cursor cursor = db.rawQuery(aggregateTableRequest.getAggregationCommand(), null); 361 Cursor metaDataCursor = 362 db.rawQuery( 363 aggregateTableRequest.getCommandToFetchAggregateMetadata(), null)) { 364 aggregateTableRequest.onResultsFetched(cursor, metaDataCursor); 365 } 366 } 367 368 /** 369 * Reads the {@link MedicalResource}s stored in the HealthConnect database. 370 * 371 * @param medicalResourceIds a {@link MedicalResourceId}. 372 * @return List of {@link MedicalResource}s read from medical_resource table based on ids. 373 */ readMedicalResourcesByIds( @onNull List<MedicalResourceId> medicalResourceIds)374 public List<MedicalResource> readMedicalResourcesByIds( 375 @NonNull List<MedicalResourceId> medicalResourceIds) throws SQLiteException { 376 List<MedicalResource> medicalResources; 377 ReadTableRequest readTableRequest = 378 MedicalResourceHelper.getReadTableRequest(medicalResourceIds); 379 try (Cursor cursor = read(readTableRequest)) { 380 medicalResources = MedicalResourceHelper.getMedicalResources(cursor); 381 } 382 return medicalResources; 383 } 384 385 /** 386 * Reads the records {@link RecordInternal} stored in the HealthConnect database. 387 * 388 * @param request a read request. 389 * @return List of records read {@link RecordInternal} from table based on ids. 390 * @throws IllegalArgumentException if the {@link ReadTransactionRequest} contains pagination 391 * information, which should use {@link #readRecordsAndPageToken(ReadTransactionRequest)} 392 * instead. 393 */ readRecordsByIds(@onNull ReadTransactionRequest request)394 public List<RecordInternal<?>> readRecordsByIds(@NonNull ReadTransactionRequest request) 395 throws SQLiteException { 396 // TODO(b/308158714): Make this build time check once we have different classes. 397 checkArgument( 398 request.getPageToken() == null && request.getPageSize().isEmpty(), 399 "Expect read by id request, but request contains pagination info."); 400 List<RecordInternal<?>> recordInternals = new ArrayList<>(); 401 for (ReadTableRequest readTableRequest : request.getReadRequests()) { 402 RecordHelper<?> helper = readTableRequest.getRecordHelper(); 403 requireNonNull(helper); 404 if (helper.isRecordOperationsEnabled()) { 405 try (Cursor cursor = read(readTableRequest)) { 406 List<RecordInternal<?>> internalRecords = helper.getInternalRecords(cursor); 407 populateInternalRecordsWithExtraData(internalRecords, readTableRequest); 408 recordInternals.addAll(internalRecords); 409 } 410 } 411 } 412 return recordInternals; 413 } 414 415 /** 416 * Reads the records {@link RecordInternal} stored in the HealthConnect database and returns the 417 * next page token. 418 * 419 * @param request a read request. Only one {@link ReadTableRequest} is expected in the {@link 420 * ReadTransactionRequest request}. 421 * @return Pair containing records list read {@link RecordInternal} from the table and a page 422 * token for pagination. 423 * @throws IllegalArgumentException if the {@link ReadTransactionRequest} doesn't contain 424 * pagination information, which should use {@link 425 * #readRecordsByIds(ReadTransactionRequest)} instead. 426 */ readRecordsAndPageToken( @onNull ReadTransactionRequest request)427 public Pair<List<RecordInternal<?>>, PageTokenWrapper> readRecordsAndPageToken( 428 @NonNull ReadTransactionRequest request) throws SQLiteException { 429 // TODO(b/308158714): Make these build time checks once we have different classes. 430 checkArgument( 431 request.getPageToken() != null && request.getPageSize().isPresent(), 432 "Expect read by filter request, but request doesn't contain pagination info."); 433 checkArgument( 434 request.getReadRequests().size() == 1, 435 "Expected read by filter request, but request contains multiple read requests."); 436 ReadTableRequest readTableRequest = request.getReadRequests().get(0); 437 List<RecordInternal<?>> recordInternalList; 438 RecordHelper<?> helper = readTableRequest.getRecordHelper(); 439 requireNonNull(helper); 440 if (!helper.isRecordOperationsEnabled()) { 441 recordInternalList = new ArrayList<>(0); 442 return Pair.create(recordInternalList, EMPTY_PAGE_TOKEN); 443 } 444 445 PageTokenWrapper pageToken; 446 try (Cursor cursor = read(readTableRequest)) { 447 Pair<List<RecordInternal<?>>, PageTokenWrapper> readResult = 448 helper.getNextInternalRecordsPageAndToken( 449 cursor, 450 request.getPageSize().orElse(DEFAULT_PAGE_SIZE), 451 // pageToken is never null for read by filter requests 452 requireNonNull(request.getPageToken())); 453 recordInternalList = readResult.first; 454 pageToken = readResult.second; 455 populateInternalRecordsWithExtraData(recordInternalList, readTableRequest); 456 } 457 return Pair.create(recordInternalList, pageToken); 458 } 459 460 /** 461 * Inserts record into the table in {@code request} into the HealthConnect database. 462 * 463 * <p>NOTE: PLEASE ONLY USE THIS FUNCTION IF YOU WANT TO INSERT A SINGLE RECORD PER API. PLEASE 464 * DON'T USE THIS FUNCTION INSIDE A FOR LOOP OR REPEATEDLY: The reason is that this function 465 * tries to insert a record inside its own transaction and if you are trying to insert multiple 466 * things using this method in the same api call, they will all get inserted in their separate 467 * transactions and will be less performant. If at all, the requirement is to insert them in 468 * different transactions, as they are not related to each, then this method can be used. 469 * 470 * @param request an insert request. 471 * @return rowId of the inserted record. 472 */ insert(@onNull UpsertTableRequest request)473 public long insert(@NonNull UpsertTableRequest request) { 474 final SQLiteDatabase db = getWritableDb(); 475 return insertRecord(db, request); 476 } 477 478 /** 479 * Update record into the table in {@code request} into the HealthConnect database. 480 * 481 * <p>NOTE: PLEASE ONLY USE THIS FUNCTION IF YOU WANT TO UPDATE A SINGLE RECORD PER API. PLEASE 482 * DON'T USE THIS FUNCTION INSIDE A FOR LOOP OR REPEATEDLY: The reason is that this function 483 * tries to update a record inside its own transaction and if you are trying to insert multiple 484 * things using this method in the same api call, they will all get updates in their separate 485 * transactions and will be less performant. If at all, the requirement is to update them in 486 * different transactions, as they are not related to each, then this method can be used. 487 * 488 * @param request an update request. 489 */ update(@onNull UpsertTableRequest request)490 public void update(@NonNull UpsertTableRequest request) { 491 final SQLiteDatabase db = getWritableDb(); 492 updateRecord(db, request); 493 } 494 495 /** 496 * Inserts (or updates if the row exists) record into the table in {@code request} into the 497 * HealthConnect database. 498 * 499 * <p>NOTE: PLEASE ONLY USE THIS FUNCTION IF YOU WANT TO UPSERT A SINGLE RECORD. PLEASE DON'T 500 * USE THIS FUNCTION INSIDE A FOR LOOP OR REPEATEDLY: The reason is that this function tries to 501 * insert a record out of a transaction and if you are trying to insert a record before or after 502 * opening up a transaction please rethink if you really want to use this function. 503 * 504 * <p>NOTE: INSERT + WITH_CONFLICT_REPLACE only works on unique columns, else in case of 505 * conflict it leads to abort of the transaction. 506 * 507 * @param request an insert request. 508 * @return rowId of the inserted or updated record. 509 */ insertOrReplace(@onNull UpsertTableRequest request)510 public long insertOrReplace(@NonNull UpsertTableRequest request) { 511 final SQLiteDatabase db = getWritableDb(); 512 return insertOrReplaceRecord(db, request); 513 } 514 515 /** Note: It is the responsibility of the caller to close the returned cursor */ 516 @NonNull read(@onNull ReadTableRequest request)517 public Cursor read(@NonNull ReadTableRequest request) { 518 if (Constants.DEBUG) { 519 Slog.d(TAG, "Read query: " + request.getReadCommand()); 520 } 521 return getReadableDb().rawQuery(request.getReadCommand(), null); 522 } 523 getLastRowIdFor(String tableName)524 public long getLastRowIdFor(String tableName) { 525 final SQLiteDatabase db = getReadableDb(); 526 try (Cursor cursor = db.rawQuery(StorageUtils.getMaxPrimaryKeyQuery(tableName), null)) { 527 cursor.moveToFirst(); 528 return cursor.getLong(cursor.getColumnIndex(PRIMARY_COLUMN_NAME)); 529 } 530 } 531 532 /** 533 * Get number of entries in the given table. 534 * 535 * @param tableName Name of table 536 * @return Number of entries in the given table 537 */ getNumberOfEntriesInTheTable(@onNull String tableName)538 public long getNumberOfEntriesInTheTable(@NonNull String tableName) { 539 requireNonNull(tableName); 540 return DatabaseUtils.queryNumEntries(getReadableDb(), tableName); 541 } 542 543 /** 544 * Size of Health Connect database in bytes. 545 * 546 * @param context Context 547 * @return Size of the database 548 */ getDatabaseSize(@onNull Context context)549 public long getDatabaseSize(@NonNull Context context) { 550 requireNonNull(context); 551 return context.getDatabasePath(getReadableDb().getPath()).length(); 552 } 553 delete(DeleteTableRequest request)554 public void delete(DeleteTableRequest request) { 555 final SQLiteDatabase db = getWritableDb(); 556 db.execSQL(request.getDeleteCommand()); 557 } 558 559 /** 560 * Updates all the {@link RecordInternal} in {@code request} into the HealthConnect database. 561 * 562 * @param request an update request. 563 */ updateAll(@onNull UpsertTransactionRequest request)564 public void updateAll(@NonNull UpsertTransactionRequest request) { 565 final SQLiteDatabase db = getWritableDb(); 566 long currentTime = Instant.now().toEpochMilli(); 567 ChangeLogsHelper.ChangeLogs updateChangelogs = 568 new ChangeLogsHelper.ChangeLogs(OPERATION_TYPE_UPSERT, currentTime); 569 ChangeLogsHelper.ChangeLogs modificationChangelogs = 570 new ChangeLogsHelper.ChangeLogs(OPERATION_TYPE_UPSERT, currentTime); 571 db.beginTransaction(); 572 try { 573 for (UpsertTableRequest upsertRequest : request.getUpsertRequests()) { 574 updateChangelogs.addUUID( 575 upsertRequest.getRecordInternal().getRecordType(), 576 upsertRequest.getRecordInternal().getAppInfoId(), 577 upsertRequest.getRecordInternal().getUuid()); 578 // Add changelogs for affected records, e.g. a training plan being deleted will 579 // create changelogs for affected exercise sessions. 580 addChangelogsForOtherModifiedRecords(upsertRequest, modificationChangelogs); 581 updateRecord(db, upsertRequest); 582 } 583 584 for (UpsertTableRequest insertRequestsForChangeLog : 585 updateChangelogs.getUpsertTableRequests()) { 586 insertRecord(db, insertRequestsForChangeLog); 587 } 588 for (UpsertTableRequest modificationChangelog : 589 modificationChangelogs.getUpsertTableRequests()) { 590 insertRecord(db, modificationChangelog); 591 } 592 593 for (UpsertTableRequest insertRequestsForAccessLogs : request.getAccessLogs()) { 594 insertRecord(db, insertRequestsForAccessLogs); 595 } 596 db.setTransactionSuccessful(); 597 } finally { 598 db.endTransaction(); 599 } 600 } 601 602 /** 603 * @return list of distinct packageNames corresponding to the input table name after querying 604 * the table. 605 */ getDistinctPackageNamesForRecordsTable( Set<Integer> recordTypes)606 public HashMap<Integer, HashSet<String>> getDistinctPackageNamesForRecordsTable( 607 Set<Integer> recordTypes) throws SQLiteException { 608 final SQLiteDatabase db = getReadableDb(); 609 HashMap<Integer, HashSet<String>> packagesForRecordTypeMap = new HashMap<>(); 610 for (Integer recordType : recordTypes) { 611 RecordHelper<?> recordHelper = RecordHelperProvider.getRecordHelper(recordType); 612 HashSet<String> packageNamesForDatatype = new HashSet<>(); 613 try (Cursor cursorForDistinctPackageNames = 614 db.rawQuery( 615 /* sql query */ 616 recordHelper 617 .getReadTableRequestWithDistinctAppInfoIds() 618 .getReadCommand(), 619 /* selectionArgs */ null)) { 620 if (cursorForDistinctPackageNames.getCount() > 0) { 621 AppInfoHelper appInfoHelper = AppInfoHelper.getInstance(); 622 while (cursorForDistinctPackageNames.moveToNext()) { 623 String packageName = 624 appInfoHelper.getPackageName( 625 cursorForDistinctPackageNames.getLong( 626 cursorForDistinctPackageNames.getColumnIndex( 627 APP_INFO_ID_COLUMN_NAME))); 628 if (!packageName.isEmpty()) { 629 packageNamesForDatatype.add(packageName); 630 } 631 } 632 } 633 } 634 packagesForRecordTypeMap.put(recordType, packageNamesForDatatype); 635 } 636 return packagesForRecordTypeMap; 637 } 638 639 /** 640 * ONLY DO OPERATIONS IN A SINGLE TRANSACTION HERE 641 * 642 * <p>This is because this function is called from {@link AutoDeleteService}, and we want to 643 * make sure that either all its operation succeed or fail in a single run. 644 */ deleteWithoutChangeLogs(@onNull List<DeleteTableRequest> deleteTableRequests)645 public void deleteWithoutChangeLogs(@NonNull List<DeleteTableRequest> deleteTableRequests) { 646 requireNonNull(deleteTableRequests); 647 final SQLiteDatabase db = getWritableDb(); 648 db.beginTransaction(); 649 try { 650 for (DeleteTableRequest deleteTableRequest : deleteTableRequests) { 651 db.execSQL(deleteTableRequest.getDeleteCommand()); 652 } 653 db.setTransactionSuccessful(); 654 } finally { 655 db.endTransaction(); 656 } 657 } 658 onUserSwitching()659 public void onUserSwitching() { 660 mHealthConnectDatabase.close(); 661 } 662 insertAll( @onNull List<UpsertTableRequest> upsertTableRequests, @NonNull BiConsumer<SQLiteDatabase, UpsertTableRequest> insert)663 private void insertAll( 664 @NonNull List<UpsertTableRequest> upsertTableRequests, 665 @NonNull BiConsumer<SQLiteDatabase, UpsertTableRequest> insert) { 666 final SQLiteDatabase db = getWritableDb(); 667 db.beginTransaction(); 668 try { 669 upsertTableRequests.forEach( 670 (upsertTableRequest) -> insert.accept(db, upsertTableRequest)); 671 db.setTransactionSuccessful(); 672 } finally { 673 db.endTransaction(); 674 } 675 } 676 runAsTransaction(TransactionRunnable<E> task)677 public <E extends Throwable> void runAsTransaction(TransactionRunnable<E> task) throws E { 678 final SQLiteDatabase db = getWritableDb(); 679 db.beginTransaction(); 680 try { 681 task.run(db); 682 db.setTransactionSuccessful(); 683 } finally { 684 db.endTransaction(); 685 } 686 } 687 688 /** Assumes that caller will be closing {@code db} and handling the transaction if required */ insertRecord(@onNull SQLiteDatabase db, @NonNull UpsertTableRequest request)689 public long insertRecord(@NonNull SQLiteDatabase db, @NonNull UpsertTableRequest request) { 690 long rowId = db.insertOrThrow(request.getTable(), null, request.getContentValues()); 691 request.getChildTableRequests() 692 .forEach(childRequest -> insertRecord(db, childRequest.withParentKey(rowId))); 693 for (String postUpsertCommand : request.getPostUpsertCommands()) { 694 db.execSQL(postUpsertCommand); 695 } 696 697 return rowId; 698 } 699 700 /** 701 * Inserts the provided {@link UpsertTableRequest} into the database. 702 * 703 * <p>Assumes that caller will be closing {@code db} and handling the transaction if required. 704 * 705 * @return the row ID of the newly inserted row or <code>-1</code> if an error occurred. 706 */ insertOrIgnore(@onNull SQLiteDatabase db, @NonNull UpsertTableRequest request)707 public long insertOrIgnore(@NonNull SQLiteDatabase db, @NonNull UpsertTableRequest request) { 708 long rowId = 709 db.insertWithOnConflict( 710 request.getTable(), 711 null, 712 request.getContentValues(), 713 SQLiteDatabase.CONFLICT_IGNORE); 714 715 if (rowId != -1) { 716 request.getChildTableRequests() 717 .forEach(childRequest -> insertRecord(db, childRequest.withParentKey(rowId))); 718 for (String postUpsertCommand : request.getPostUpsertCommands()) { 719 db.execSQL(postUpsertCommand); 720 } 721 } 722 723 return rowId; 724 } 725 726 /** Note: NEVER close this DB */ 727 @NonNull getReadableDb()728 private SQLiteDatabase getReadableDb() { 729 SQLiteDatabase sqLiteDatabase = mHealthConnectDatabase.getReadableDatabase(); 730 731 if (sqLiteDatabase == null) { 732 throw new InternalError("SQLite DB not found"); 733 } 734 return sqLiteDatabase; 735 } 736 737 /** Note: NEVER close this DB */ 738 @NonNull getWritableDb()739 private SQLiteDatabase getWritableDb() { 740 SQLiteDatabase sqLiteDatabase = mHealthConnectDatabase.getWritableDatabase(); 741 742 if (sqLiteDatabase == null) { 743 throw new InternalError("SQLite DB not found"); 744 } 745 return sqLiteDatabase; 746 } 747 getDatabasePath()748 public File getDatabasePath() { 749 return mHealthConnectDatabase.getDatabasePath(); 750 } 751 updateTable(UpsertTableRequest upsertTableRequest)752 public void updateTable(UpsertTableRequest upsertTableRequest) { 753 getWritableDb() 754 .update( 755 upsertTableRequest.getTable(), 756 upsertTableRequest.getContentValues(), 757 upsertTableRequest.getUpdateWhereClauses().get(false), 758 null); 759 } 760 getDatabaseVersion()761 public int getDatabaseVersion() { 762 return getReadableDb().getVersion(); 763 } 764 updateRecord(SQLiteDatabase db, UpsertTableRequest request)765 private void updateRecord(SQLiteDatabase db, UpsertTableRequest request) { 766 // Perform an update operation where UUID and packageName (mapped by appInfoId) is same 767 // as that of the update request. 768 try { 769 long numberOfRowsUpdated = 770 db.update( 771 request.getTable(), 772 request.getContentValues(), 773 request.getUpdateWhereClauses().get(/* withWhereKeyword */ false), 774 /* WHERE args */ null); 775 for (String postUpsertCommand : request.getPostUpsertCommands()) { 776 db.execSQL(postUpsertCommand); 777 } 778 779 // throw an exception if the no row was updated, i.e. the uuid with corresponding 780 // app_id_info for this request is not found in the table. 781 if (numberOfRowsUpdated == 0) { 782 throw new IllegalArgumentException( 783 "No record found for the following input : " 784 + new StorageUtils.RecordIdentifierData( 785 request.getContentValues())); 786 } 787 } catch (SQLiteConstraintException e) { 788 try (Cursor cursor = db.rawQuery(request.getReadRequest().getReadCommand(), null)) { 789 cursor.moveToFirst(); 790 throw new IllegalArgumentException( 791 StorageUtils.getConflictErrorMessageForRecord( 792 cursor, request.getContentValues())); 793 } 794 } 795 796 if (request.getAllChildTables().isEmpty()) { 797 return; 798 } 799 800 try (Cursor cursor = 801 db.rawQuery(request.getReadRequestUsingUpdateClause().getReadCommand(), null)) { 802 if (!cursor.moveToFirst()) { 803 throw new HealthConnectException( 804 ERROR_INTERNAL, "Expected to read an entry for update, but none found"); 805 } 806 final long rowId = StorageUtils.getCursorLong(cursor, request.getRowIdColName()); 807 deleteChildTableRequest(request, rowId, db); 808 insertChildTableRequest(request, rowId, db); 809 } 810 } 811 812 /** 813 * Do extra sql requests to populate optional extra data. Used to populate {@link 814 * android.health.connect.internal.datatypes.ExerciseRouteInternal}. 815 */ populateInternalRecordsWithExtraData( List<RecordInternal<?>> records, ReadTableRequest request)816 private void populateInternalRecordsWithExtraData( 817 List<RecordInternal<?>> records, ReadTableRequest request) { 818 if (request.getExtraReadRequests() == null) { 819 return; 820 } 821 for (ReadTableRequest extraDataRequest : request.getExtraReadRequests()) { 822 Cursor cursorExtraData = read(extraDataRequest); 823 request.getRecordHelper() 824 .updateInternalRecordsWithExtraFields( 825 records, cursorExtraData, extraDataRequest.getTableName()); 826 } 827 } 828 829 /** 830 * Assumes that caller will be closing {@code db}. Returns -1 in case the update was triggered 831 * and reading the row_id was not supported on the table. 832 * 833 * <p>Note: This function updates rather than the traditional delete + insert in SQLite 834 */ insertOrReplaceRecord( @onNull SQLiteDatabase db, @NonNull UpsertTableRequest request)835 private long insertOrReplaceRecord( 836 @NonNull SQLiteDatabase db, @NonNull UpsertTableRequest request) { 837 try { 838 if (request.getUniqueColumnsCount() == 0) { 839 throw new RuntimeException( 840 "insertOrReplaceRecord should only be called with unique columns set"); 841 } 842 843 long rowId = 844 db.insertWithOnConflict( 845 request.getTable(), 846 null, 847 request.getContentValues(), 848 SQLiteDatabase.CONFLICT_FAIL); 849 insertChildTableRequest(request, rowId, db); 850 for (String postUpsertCommand : request.getPostUpsertCommands()) { 851 db.execSQL(postUpsertCommand); 852 } 853 854 return rowId; 855 } catch (SQLiteConstraintException e) { 856 try (Cursor cursor = db.rawQuery(request.getReadRequest().getReadCommand(), null)) { 857 if (!cursor.moveToFirst()) { 858 throw new HealthConnectException( 859 ERROR_INTERNAL, "Conflict found, but couldn't read the entry.", e); 860 } 861 862 long updateResult = updateEntriesIfRequired(db, request, cursor); 863 for (String postUpsertCommand : request.getPostUpsertCommands()) { 864 db.execSQL(postUpsertCommand); 865 } 866 return updateResult; 867 } 868 } 869 } 870 updateEntriesIfRequired( SQLiteDatabase db, UpsertTableRequest request, Cursor cursor)871 private long updateEntriesIfRequired( 872 SQLiteDatabase db, UpsertTableRequest request, Cursor cursor) { 873 if (!request.requiresUpdate(cursor, request)) { 874 return -1; 875 } 876 877 db.update( 878 request.getTable(), 879 request.getContentValues(), 880 request.getUpdateWhereClauses().get(/* withWhereKeyword */ false), 881 /* WHERE args */ null); 882 if (cursor.getColumnIndex(request.getRowIdColName()) == -1) { 883 // The table is not explicitly using row_ids hence returning -1 here is ok, as 884 // the rowid is of no use to this table. 885 // NOTE: Such tables in HC don't support child tables either as child tables 886 // inherently require row_ids to have support parent key. 887 return -1; 888 } 889 final long rowId = StorageUtils.getCursorLong(cursor, request.getRowIdColName()); 890 deleteChildTableRequest(request, rowId, db); 891 insertChildTableRequest(request, rowId, db); 892 893 return rowId; 894 } 895 deleteChildTableRequest( UpsertTableRequest request, long rowId, SQLiteDatabase db)896 private void deleteChildTableRequest( 897 UpsertTableRequest request, long rowId, SQLiteDatabase db) { 898 for (RecordHelper.TableColumnPair childTableAndColumn : 899 request.getChildTablesWithRowsToBeDeletedDuringUpdate()) { 900 DeleteTableRequest deleteTableRequest = 901 new DeleteTableRequest(childTableAndColumn.getTableName()) 902 .setId(childTableAndColumn.getColumnName(), String.valueOf(rowId)); 903 db.execSQL(deleteTableRequest.getDeleteCommand()); 904 } 905 } 906 insertChildTableRequest( UpsertTableRequest request, long rowId, SQLiteDatabase db)907 private void insertChildTableRequest( 908 UpsertTableRequest request, long rowId, SQLiteDatabase db) { 909 for (UpsertTableRequest childTableRequest : request.getChildTableRequests()) { 910 long childRowId = 911 db.insertOrThrow( 912 childTableRequest.withParentKey(rowId).getTable(), 913 null, 914 childTableRequest.getContentValues()); 915 insertChildTableRequest(childTableRequest, childRowId, db); 916 } 917 } 918 addChangelogsForOtherModifiedRecords( UpsertTableRequest upsertRequest, ChangeLogsHelper.ChangeLogs modificationChangelogs)919 private void addChangelogsForOtherModifiedRecords( 920 UpsertTableRequest upsertRequest, ChangeLogsHelper.ChangeLogs modificationChangelogs) { 921 // Carries out read requests provided by the record helper and uses the results to add 922 // changelogs to the transaction. 923 final RecordHelper<?> recordHelper = 924 RecordHelperProvider.getRecordHelper(upsertRequest.getRecordType()); 925 for (ReadTableRequest additionalChangelogUuidRequest : 926 recordHelper.getReadRequestsForRecordsModifiedByUpsertion( 927 upsertRequest.getRecordInternal().getUuid(), upsertRequest)) { 928 Cursor cursorAdditionalUuids = read(additionalChangelogUuidRequest); 929 while (cursorAdditionalUuids.moveToNext()) { 930 modificationChangelogs.addUUID( 931 additionalChangelogUuidRequest.getRecordHelper().getRecordIdentifier(), 932 StorageUtils.getCursorLong(cursorAdditionalUuids, APP_INFO_ID_COLUMN_NAME), 933 StorageUtils.getCursorUUID(cursorAdditionalUuids, UUID_COLUMN_NAME)); 934 } 935 cursorAdditionalUuids.close(); 936 } 937 } 938 939 public interface TransactionRunnable<E extends Throwable> { run(SQLiteDatabase db)940 void run(SQLiteDatabase db) throws E; 941 } 942 943 @NonNull getInstance( @onNull HealthConnectUserContext context)944 public static synchronized TransactionManager getInstance( 945 @NonNull HealthConnectUserContext context) { 946 if (sTransactionManager == null) { 947 sTransactionManager = new TransactionManager(context); 948 } 949 950 return sTransactionManager; 951 } 952 953 @NonNull getInitialisedInstance()954 public static TransactionManager getInitialisedInstance() { 955 requireNonNull(sTransactionManager); 956 957 return sTransactionManager; 958 } 959 960 /** Cleans up the database and this manager, so unit tests can run correctly. */ 961 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression 962 @VisibleForTesting cleanUpForTest()963 public static void cleanUpForTest() { 964 if (sTransactionManager != null) { 965 // Close the DB before we delete the DB file to avoid the exception in b/333679690. 966 sTransactionManager.getWritableDb().close(); 967 sTransactionManager.getReadableDb().close(); 968 SQLiteDatabase.deleteDatabase(sTransactionManager.getDatabasePath()); 969 sTransactionManager = null; 970 } 971 } 972 973 @NonNull getCurrentUserHandle()974 public UserHandle getCurrentUserHandle() { 975 return mUserHandle; 976 } 977 } 978