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.HealthConnectException.ERROR_INTERNAL; 20 21 import static com.android.internal.util.Preconditions.checkArgument; 22 23 import android.annotation.Nullable; 24 import android.content.ContentValues; 25 import android.database.Cursor; 26 import android.database.DatabaseUtils; 27 import android.database.sqlite.SQLiteConstraintException; 28 import android.database.sqlite.SQLiteDatabase; 29 import android.database.sqlite.SQLiteException; 30 import android.health.connect.Constants; 31 import android.health.connect.HealthConnectException; 32 import android.util.Slog; 33 34 import com.android.server.healthconnect.storage.request.DeleteTableRequest; 35 import com.android.server.healthconnect.storage.request.ReadTableRequest; 36 import com.android.server.healthconnect.storage.request.UpsertTableRequest; 37 import com.android.server.healthconnect.storage.utils.InternalHealthConnectMappings; 38 import com.android.server.healthconnect.storage.utils.StorageUtils; 39 import com.android.server.healthconnect.storage.utils.TableColumnPair; 40 41 import java.io.File; 42 import java.util.List; 43 44 /** 45 * A class to handle all the DB transaction request from the clients. {@link TransactionManager} 46 * acts as a layer b/w the DB and the data type helper classes and helps perform actual operations 47 * on the DB. 48 * 49 * @hide 50 */ 51 public final class TransactionManager { 52 private static final String TAG = "HealthConnectTransactionMan"; 53 54 private volatile HealthConnectDatabase mHealthConnectDatabase; 55 private final InternalHealthConnectMappings mInternalHealthConnectMappings; 56 57 /** Create for the given context */ create( HealthConnectContext hcContext, InternalHealthConnectMappings internalHealthConnectMappings)58 public static TransactionManager create( 59 HealthConnectContext hcContext, 60 InternalHealthConnectMappings internalHealthConnectMappings) { 61 return new TransactionManager( 62 new HealthConnectDatabase(hcContext), internalHealthConnectMappings); 63 } 64 65 /** Create for a staged database, used in import and d2d restore */ forStagedDatabase( HealthConnectDatabase stagedDatabase, InternalHealthConnectMappings internalHealthConnectMappings)66 public static TransactionManager forStagedDatabase( 67 HealthConnectDatabase stagedDatabase, 68 InternalHealthConnectMappings internalHealthConnectMappings) { 69 return new TransactionManager(stagedDatabase, internalHealthConnectMappings); 70 } 71 TransactionManager( HealthConnectDatabase hcDatabase, InternalHealthConnectMappings internalHealthConnectMappings)72 private TransactionManager( 73 HealthConnectDatabase hcDatabase, 74 InternalHealthConnectMappings internalHealthConnectMappings) { 75 mHealthConnectDatabase = hcDatabase; 76 mInternalHealthConnectMappings = internalHealthConnectMappings; 77 } 78 79 /** Called when we are switching from the current user. */ shutDownCurrentUser()80 public void shutDownCurrentUser() { 81 mHealthConnectDatabase.close(); 82 } 83 84 /** Setup the transaction manager for the new user. */ setupForUser(HealthConnectContext hcContext)85 public void setupForUser(HealthConnectContext hcContext) { 86 mHealthConnectDatabase = new HealthConnectDatabase(hcContext); 87 } 88 89 /** 90 * Inserts record into the table in {@code request} into the HealthConnect database. 91 * 92 * <p>NOTE: PLEASE ONLY USE THIS FUNCTION IF YOU WANT TO INSERT A SINGLE RECORD PER API. PLEASE 93 * DON'T USE THIS FUNCTION INSIDE A FOR LOOP OR REPEATEDLY: The reason is that this function 94 * tries to insert a record inside its own transaction and if you are trying to insert multiple 95 * things using this method in the same api call, they will all get inserted in their separate 96 * transactions and will be less performant. If at all, the requirement is to insert them in 97 * different transactions, as they are not related to each, then this method can be used. 98 * 99 * @param request an insert request. 100 * @return rowId of the inserted record. 101 */ insert(UpsertTableRequest request)102 public long insert(UpsertTableRequest request) { 103 final SQLiteDatabase db = getWritableDb(); 104 return insert(db, request); 105 } 106 107 /** 108 * Inserts record into the table in {@code request} into the HealthConnect database using the 109 * given {@link SQLiteDatabase}. 110 * 111 * <p>Assumes that caller will be closing {@code db} and handling the transaction if required. 112 * 113 * <p>NOTE: PLEASE ONLY USE THIS FUNCTION IF YOU WANT TO INSERT A SINGLE RECORD PER API. PLEASE 114 * DON'T USE THIS FUNCTION INSIDE A FOR LOOP OR REPEATEDLY: The reason is that this function 115 * tries to insert a record inside its own transaction and if you are trying to insert multiple 116 * things using this method in the same api call, they will all get inserted in their separate 117 * transactions and will be less performant. If at all, the requirement is to insert them in 118 * different transactions, as they are not related to each, then this method can be used. 119 * 120 * @param db a {@link SQLiteDatabase}. 121 * @param request an insert request. 122 * @return rowId of the inserted record. 123 */ insert(SQLiteDatabase db, UpsertTableRequest request)124 public long insert(SQLiteDatabase db, UpsertTableRequest request) { 125 long rowId = db.insertOrThrow(request.getTable(), null, request.getContentValues()); 126 request.getChildTableRequests() 127 .forEach(childRequest -> insert(db, childRequest.withParentKey(rowId))); 128 for (String postUpsertCommand : request.getPostUpsertCommands()) { 129 db.execSQL(postUpsertCommand); 130 } 131 132 return rowId; 133 } 134 135 /** 136 * Inserts or replaces all the {@link UpsertTableRequest} into the HealthConnect database. 137 * 138 * @param upsertTableRequests a list of insert table requests. 139 */ insertOrReplaceAllOnConflict(List<UpsertTableRequest> upsertTableRequests)140 public void insertOrReplaceAllOnConflict(List<UpsertTableRequest> upsertTableRequests) 141 throws SQLiteException { 142 runAsTransaction( 143 db -> { 144 upsertTableRequests.forEach(request -> insertOrReplaceOnConflict(db, request)); 145 }); 146 } 147 148 /** 149 * Inserts (or updates if the row exists) record into the table in {@code request} into the 150 * HealthConnect database. 151 * 152 * <p>NOTE: PLEASE ONLY USE THIS FUNCTION IF YOU WANT TO UPSERT A SINGLE RECORD. PLEASE DON'T 153 * USE THIS FUNCTION INSIDE A FOR LOOP OR REPEATEDLY: The reason is that this function tries to 154 * insert a record out of a transaction and if you are trying to insert a record before or after 155 * opening up a transaction please rethink if you really want to use this function. 156 * 157 * <p>NOTE: INSERT + WITH_CONFLICT_REPLACE only works on unique columns, else in case of 158 * conflict it leads to abort of the transaction. 159 * 160 * @param request an insert request. 161 * @return rowId of the inserted or updated record. 162 */ insertOrReplaceOnConflict(UpsertTableRequest request)163 public long insertOrReplaceOnConflict(UpsertTableRequest request) { 164 final SQLiteDatabase db = getWritableDb(); 165 return insertOrReplaceOnConflict(db, request); 166 } 167 168 /** 169 * Assumes that caller will be closing {@code db}. Returns -1 in case the update was triggered 170 * and reading the row_id was not supported on the table. 171 * 172 * <p>Note: This function updates rather than the traditional delete + insert in SQLite 173 */ insertOrReplaceOnConflict(SQLiteDatabase db, UpsertTableRequest request)174 public long insertOrReplaceOnConflict(SQLiteDatabase db, UpsertTableRequest request) { 175 try { 176 if (request.getUniqueColumnsCount() == 0) { 177 throw new RuntimeException( 178 "insertOrReplaceRecord should only be called with unique columns set"); 179 } 180 181 long rowId = 182 db.insertWithOnConflict( 183 request.getTable(), 184 null, 185 request.getContentValues(), 186 SQLiteDatabase.CONFLICT_FAIL); 187 insertChildTableRequest(request, rowId, db); 188 for (String postUpsertCommand : request.getPostUpsertCommands()) { 189 db.execSQL(postUpsertCommand); 190 } 191 192 return rowId; 193 } catch (SQLiteConstraintException e) { 194 try (Cursor cursor = db.rawQuery(request.getReadRequest().getReadCommand(), null)) { 195 if (!cursor.moveToFirst()) { 196 throw new HealthConnectException( 197 ERROR_INTERNAL, "Conflict found, but couldn't read the entry.", e); 198 } 199 200 long updateResult = updateEntriesIfRequired(db, request, cursor); 201 for (String postUpsertCommand : request.getPostUpsertCommands()) { 202 db.execSQL(postUpsertCommand); 203 } 204 return updateResult; 205 } 206 } 207 } 208 209 /** 210 * Inserts or ignore on conflicts all the {@link UpsertTableRequest} into the HealthConnect 211 * database. 212 */ insertOrIgnoreAllOnConflict(List<UpsertTableRequest> upsertTableRequests)213 public void insertOrIgnoreAllOnConflict(List<UpsertTableRequest> upsertTableRequests) { 214 runAsTransaction( 215 db -> { 216 upsertTableRequests.forEach(request -> insertOrIgnoreOnConflict(db, request)); 217 }); 218 } 219 220 /** 221 * Inserts the provided {@link UpsertTableRequest} into the database. 222 * 223 * <p>Assumes that caller will be closing {@code db} and handling the transaction if required. 224 * 225 * @return the row ID of the newly inserted row or <code>-1</code> if an error occurred. 226 */ insertOrIgnoreOnConflict(SQLiteDatabase db, UpsertTableRequest request)227 public long insertOrIgnoreOnConflict(SQLiteDatabase db, UpsertTableRequest request) { 228 long rowId = 229 db.insertWithOnConflict( 230 request.getTable(), 231 null, 232 request.getContentValues(), 233 SQLiteDatabase.CONFLICT_IGNORE); 234 235 if (rowId != -1) { 236 request.getChildTableRequests() 237 .forEach(childRequest -> insert(db, childRequest.withParentKey(rowId))); 238 for (String postUpsertCommand : request.getPostUpsertCommands()) { 239 db.execSQL(postUpsertCommand); 240 } 241 } 242 243 return rowId; 244 } 245 246 /** Updates data for the given request. */ update(UpsertTableRequest request)247 public void update(UpsertTableRequest request) { 248 final SQLiteDatabase db = getWritableDb(); 249 update(db, request); 250 } 251 update(SQLiteDatabase db, UpsertTableRequest request)252 private void update(SQLiteDatabase db, UpsertTableRequest request) { 253 // Perform an update operation where UUID and packageName (mapped by appInfoId) is same 254 // as that of the update request. 255 try { 256 long numberOfRowsUpdated = 257 db.update( 258 request.getTable(), 259 request.getContentValues(), 260 request.getUpdateWhereClauses().get(/* withWhereKeyword */ false), 261 /* WHERE args */ null); 262 for (String postUpsertCommand : request.getPostUpsertCommands()) { 263 db.execSQL(postUpsertCommand); 264 } 265 266 // throw an exception if the no row was updated, i.e. the uuid with corresponding 267 // app_id_info for this request is not found in the table. 268 if (numberOfRowsUpdated == 0) { 269 throw new IllegalArgumentException( 270 "No record found for the following input : " 271 + new StorageUtils.RecordIdentifierData( 272 request.getContentValues())); 273 } 274 } catch (SQLiteConstraintException e) { 275 try (Cursor cursor = db.rawQuery(request.getReadRequest().getReadCommand(), null)) { 276 cursor.moveToFirst(); 277 throw new IllegalArgumentException( 278 StorageUtils.getConflictErrorMessageForRecord( 279 cursor, request.getContentValues())); 280 } 281 } 282 283 if (request.getAllChildTables().isEmpty()) { 284 return; 285 } 286 287 try (Cursor cursor = 288 db.rawQuery(request.getReadRequestUsingUpdateClause().getReadCommand(), null)) { 289 if (!cursor.moveToFirst()) { 290 throw new HealthConnectException( 291 ERROR_INTERNAL, "Expected to read an entry for update, but none found"); 292 } 293 final long rowId = StorageUtils.getCursorLong(cursor, request.getRowIdColName()); 294 deleteChildTableRequest(request, rowId, db); 295 insertChildTableRequest(request, rowId, db); 296 } 297 } 298 299 /** Deletes all data for the list of requests into the database in a single transaction. */ deleteAll(List<DeleteTableRequest> deleteTableRequests)300 public void deleteAll(List<DeleteTableRequest> deleteTableRequests) { 301 runAsTransaction( 302 db -> { 303 deleteTableRequests.forEach(request -> delete(db, request)); 304 }); 305 } 306 307 /** Delete data for the given request. */ delete(DeleteTableRequest request)308 public void delete(DeleteTableRequest request) { 309 delete(getWritableDb(), request); 310 } 311 312 /** Delete data for the given request, from the given db. */ delete(SQLiteDatabase db, DeleteTableRequest request)313 public void delete(SQLiteDatabase db, DeleteTableRequest request) { 314 db.execSQL(request.getDeleteCommand()); 315 } 316 317 /** Note: It is the responsibility of the caller to close the returned cursor */ read(ReadTableRequest request)318 public Cursor read(ReadTableRequest request) { 319 if (Constants.DEBUG) { 320 Slog.d(TAG, "Read query: " + request.getReadCommand()); 321 } 322 return getReadableDb().rawQuery(request.getReadCommand(), null); 323 } 324 325 /** 326 * Reads the given {@link SQLiteDatabase} using the given {@link ReadTableRequest}. 327 * 328 * <p>Note: It is the responsibility of the caller to close the returned cursor. 329 */ read(SQLiteDatabase db, ReadTableRequest request)330 public Cursor read(SQLiteDatabase db, ReadTableRequest request) { 331 if (Constants.DEBUG) { 332 Slog.d(TAG, "Read query: " + request.getReadCommand()); 333 } 334 return db.rawQuery(request.getReadCommand(), null); 335 } 336 337 /** 338 * Do a read using {@link SQLiteDatabase#rawQuery(String, String[])}. This method should be used 339 * in preference to {@link ReadTableRequest} when it is necessary to read using a query with 340 * untrusted user input, to prevent SQL injection attacks. 341 * 342 * <p>Note: It is the responsibility of the caller to close the returned cursor 343 */ rawQuery(String sql, @Nullable String[] selectionArgs)344 public Cursor rawQuery(String sql, @Nullable String[] selectionArgs) { 345 return getReadableDb().rawQuery(sql, selectionArgs); 346 } 347 348 /** Returns the count of rows that would be returned by the given request. */ count(ReadTableRequest request)349 public int count(ReadTableRequest request) { 350 return count(getReadableDb(), request); 351 } 352 353 /** 354 * Returns the count of rows that would be returned by the given request. 355 * 356 * <p>Use {@link #count(ReadTableRequest)} unless you already have the database from a 357 * transaction. 358 */ count(SQLiteDatabase db, ReadTableRequest request)359 public static int count(SQLiteDatabase db, ReadTableRequest request) { 360 String countSql = request.getCountCommand(); 361 if (Constants.DEBUG) { 362 Slog.d(TAG, "Count query: " + countSql); 363 } 364 try (Cursor cursor = db.rawQuery(countSql, null)) { 365 if (cursor.moveToFirst()) { 366 return cursor.getInt(0); 367 } else { 368 throw new RuntimeException("Bad count SQL:" + countSql); 369 } 370 } 371 } 372 373 /** Check if a table exists. */ checkTableExists(String tableName)374 public boolean checkTableExists(String tableName) { 375 return StorageUtils.checkTableExists(getReadableDb(), tableName); 376 } 377 378 /** Get number of entries in the given table. */ queryNumEntries(String tableName)379 public long queryNumEntries(String tableName) { 380 return DatabaseUtils.queryNumEntries(getReadableDb(), tableName); 381 } 382 383 /** Size of Health Connect database in bytes. */ getDatabaseSize()384 public long getDatabaseSize() { 385 return mHealthConnectDatabase.getDatabasePath().length(); 386 } 387 getDatabasePath()388 public File getDatabasePath() { 389 return mHealthConnectDatabase.getDatabasePath(); 390 } 391 getDatabaseVersion()392 public int getDatabaseVersion() { 393 return getReadableDb().getVersion(); 394 } 395 396 /** 397 * Runs a {@link Runnable} task in a Transaction. Using the given request on the provided DB. 398 * 399 * <p>Note that the provided DB can not be read-only. 400 */ runAsTransaction(SQLiteDatabase db, Runnable<E> task)401 public static <E extends Throwable> void runAsTransaction(SQLiteDatabase db, Runnable<E> task) 402 throws E { 403 checkArgument(!db.isReadOnly(), "db is read only"); 404 db.beginTransaction(); 405 try { 406 task.run(db); 407 db.setTransactionSuccessful(); 408 } finally { 409 db.endTransaction(); 410 } 411 } 412 413 /** Runs a {@link Runnable} task in a Transaction. */ runAsTransaction(Runnable<E> task)414 public <E extends Throwable> void runAsTransaction(Runnable<E> task) throws E { 415 final SQLiteDatabase db = getWritableDb(); 416 db.beginTransaction(); 417 try { 418 task.run(db); 419 db.setTransactionSuccessful(); 420 } finally { 421 db.endTransaction(); 422 } 423 } 424 425 /** Runs a {@link Runnable} task without a transaction. */ runWithoutTransaction(Runnable<E> task)426 public <E extends Throwable> void runWithoutTransaction(Runnable<E> task) throws E { 427 final SQLiteDatabase db = getWritableDb(); 428 task.run(db); 429 } 430 431 /** 432 * Runnable interface where run method throws Throwable or its subclasses. 433 * 434 * @param <E> Throwable or its subclass. 435 */ 436 public interface Runnable<E extends Throwable> { 437 /** Task to be executed that throws throwable of type E. */ run(SQLiteDatabase db)438 void run(SQLiteDatabase db) throws E; 439 } 440 441 /** 442 * Runs a {@link RunnableWithReturn} task in a Transaction. 443 * 444 * @param task is a {@link RunnableWithReturn}. 445 * @param <R> is the return type of the {@code task}. 446 * @param <E> is the exception thrown by the {@code task}. 447 */ runAsTransaction(RunnableWithReturn<R, E> task)448 public <R, E extends Throwable> R runAsTransaction(RunnableWithReturn<R, E> task) throws E { 449 final SQLiteDatabase db = getWritableDb(); 450 db.beginTransaction(); 451 try { 452 R result = task.run(db); 453 db.setTransactionSuccessful(); 454 return result; 455 } finally { 456 db.endTransaction(); 457 } 458 } 459 460 /** 461 * Runs a {@link RunnableWithReturn} task without a transaction. 462 * 463 * @param task is a {@link RunnableWithReturn}. 464 * @param <R> is the return type of the {@code task}. 465 * @param <E> is the exception thrown by the {@code task}. 466 */ runWithoutTransaction(RunnableWithReturn<R, E> task)467 public <R, E extends Throwable> R runWithoutTransaction(RunnableWithReturn<R, E> task) 468 throws E { 469 final SQLiteDatabase db = getWritableDb(); 470 return task.run(db); 471 } 472 473 /** 474 * Runnable interface where run method throws Throwable or its subclasses and returns any data 475 * type R. 476 * 477 * @param <E> Throwable or its subclass. 478 * @param <R> any data type. 479 */ 480 public interface RunnableWithReturn<R, E extends Throwable> { 481 /** Task to be executed that throws throwable of type E and returns type R. */ run(SQLiteDatabase db)482 R run(SQLiteDatabase db) throws E; 483 } 484 485 /** Note: NEVER close this DB */ getReadableDb()486 private SQLiteDatabase getReadableDb() { 487 SQLiteDatabase sqLiteDatabase = mHealthConnectDatabase.getReadableDatabase(); 488 489 if (sqLiteDatabase == null) { 490 throw new InternalError("SQLite DB not found"); 491 } 492 return sqLiteDatabase; 493 } 494 495 /** Note: NEVER close this DB */ getWritableDb()496 private SQLiteDatabase getWritableDb() { 497 SQLiteDatabase sqLiteDatabase = mHealthConnectDatabase.getWritableDatabase(); 498 499 if (sqLiteDatabase == null) { 500 throw new InternalError("SQLite DB not found"); 501 } 502 return sqLiteDatabase; 503 } 504 updateEntriesIfRequired( SQLiteDatabase db, UpsertTableRequest request, Cursor cursor)505 private long updateEntriesIfRequired( 506 SQLiteDatabase db, UpsertTableRequest request, Cursor cursor) { 507 if (!request.requiresUpdate(cursor, request)) { 508 return -1; 509 } 510 db.update( 511 request.getTable(), 512 request.getContentValues(), 513 request.getUpdateWhereClauses().get(/* withWhereKeyword */ false), 514 /* WHERE args */ null); 515 if (cursor.getColumnIndex(request.getRowIdColName()) == -1) { 516 // The table is not explicitly using row_ids hence returning -1 here is ok, as 517 // the rowid is of no use to this table. 518 // NOTE: Such tables in HC don't support child tables either as child tables 519 // inherently require row_ids to have support parent key. 520 return -1; 521 } 522 final long rowId = StorageUtils.getCursorLong(cursor, request.getRowIdColName()); 523 deleteChildTableRequest(request, rowId, db); 524 insertChildTableRequest(request, rowId, db); 525 526 return rowId; 527 } 528 deleteChildTableRequest( UpsertTableRequest request, long rowId, SQLiteDatabase db)529 private void deleteChildTableRequest( 530 UpsertTableRequest request, long rowId, SQLiteDatabase db) { 531 for (TableColumnPair childTableAndColumn : 532 request.getChildTablesWithRowsToBeDeletedDuringUpdate()) { 533 DeleteTableRequest deleteTableRequest = 534 new DeleteTableRequest(childTableAndColumn.getTableName()) 535 .setId(childTableAndColumn.getColumnName(), String.valueOf(rowId)); 536 db.execSQL(deleteTableRequest.getDeleteCommand()); 537 } 538 } 539 insertChildTableRequest( UpsertTableRequest request, long rowId, SQLiteDatabase db)540 private void insertChildTableRequest( 541 UpsertTableRequest request, long rowId, SQLiteDatabase db) { 542 for (UpsertTableRequest childTableRequest : request.getChildTableRequests()) { 543 String tableName = childTableRequest.getTable(); 544 ContentValues contentValues = childTableRequest.withParentKey(rowId).getContentValues(); 545 long childRowId = db.insertOrThrow(tableName, null, contentValues); 546 insertChildTableRequest(childTableRequest, childRowId, db); 547 } 548 } 549 } 550