• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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