• 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.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