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