• 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.datatypehelpers;
18 
19 import static android.health.connect.Constants.DEFAULT_LONG;
20 import static android.health.connect.Constants.MAXIMUM_ALLOWED_CURSOR_COUNT;
21 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_DELETE;
22 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_READ;
23 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_UPSERT;
24 import static android.health.connect.datatypes.FhirVersion.parseFhirVersion;
25 
26 import static com.android.server.healthconnect.storage.HealthConnectDatabase.createTable;
27 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceHelper.getIntersectionOfResourceTypesReadAndGrantedReadPermissions;
28 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceHelper.getJoinWithIndicesTableFilterOnMedicalResourceTypes;
29 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceHelper.getReadRequestForDistinctResourceTypesBelongingToDataSourceIds;
30 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName;
31 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.LAST_MODIFIED_TIME_COLUMN_NAME;
32 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME;
33 import static com.android.server.healthconnect.storage.request.ReadTableRequest.UNION;
34 import static com.android.server.healthconnect.storage.utils.SqlJoin.INNER_QUERY_ALIAS;
35 import static com.android.server.healthconnect.storage.utils.StorageUtils.BLOB_UNIQUE_NON_NULL;
36 import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER_NOT_NULL;
37 import static com.android.server.healthconnect.storage.utils.StorageUtils.PRIMARY;
38 import static com.android.server.healthconnect.storage.utils.StorageUtils.TEXT_NOT_NULL;
39 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorInt;
40 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorLong;
41 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorString;
42 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorUUID;
43 import static com.android.server.healthconnect.storage.utils.StorageUtils.isNullValue;
44 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.AND;
45 
46 import android.annotation.Nullable;
47 import android.content.ContentValues;
48 import android.database.Cursor;
49 import android.database.sqlite.SQLiteConstraintException;
50 import android.database.sqlite.SQLiteDatabase;
51 import android.database.sqlite.SQLiteException;
52 import android.health.connect.Constants;
53 import android.health.connect.CreateMedicalDataSourceRequest;
54 import android.health.connect.datatypes.FhirVersion;
55 import android.health.connect.datatypes.MedicalDataSource;
56 import android.health.connect.datatypes.MedicalResource;
57 import android.net.Uri;
58 import android.util.Pair;
59 
60 import com.android.internal.annotations.VisibleForTesting;
61 import com.android.server.healthconnect.storage.TransactionManager;
62 import com.android.server.healthconnect.storage.request.CreateIndexRequest;
63 import com.android.server.healthconnect.storage.request.CreateTableRequest;
64 import com.android.server.healthconnect.storage.request.DeleteTableRequest;
65 import com.android.server.healthconnect.storage.request.ReadTableRequest;
66 import com.android.server.healthconnect.storage.request.UpsertTableRequest;
67 import com.android.server.healthconnect.storage.utils.SqlJoin;
68 import com.android.server.healthconnect.storage.utils.StorageUtils;
69 import com.android.server.healthconnect.storage.utils.WhereClauses;
70 import com.android.server.healthconnect.utils.TimeSource;
71 
72 import java.time.Instant;
73 import java.util.ArrayList;
74 import java.util.HashMap;
75 import java.util.HashSet;
76 import java.util.List;
77 import java.util.Map;
78 import java.util.Set;
79 import java.util.UUID;
80 import java.util.stream.Collectors;
81 
82 /**
83  * Helper class for MedicalDataSource.
84  *
85  * @hide
86  */
87 public class MedicalDataSourceHelper {
88     // The number of {@link MedicalDataSource}s that an app is allowed to create
89     @VisibleForTesting static final int MAX_ALLOWED_MEDICAL_DATA_SOURCES = 20;
90 
91     @VisibleForTesting
92     static final String MEDICAL_DATA_SOURCE_TABLE_NAME = "medical_data_source_table";
93 
94     @VisibleForTesting static final String DISPLAY_NAME_COLUMN_NAME = "display_name";
95     @VisibleForTesting static final String FHIR_BASE_URI_COLUMN_NAME = "fhir_base_uri";
96     @VisibleForTesting static final String FHIR_VERSION_COLUMN_NAME = "fhir_version";
97     @VisibleForTesting static final String DATA_SOURCE_UUID_COLUMN_NAME = "data_source_uuid";
98     private static final String APP_INFO_ID_COLUMN_NAME = "app_info_id";
99     private static final String MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME =
100             "medical_data_source_row_id";
101     private static final List<Pair<String, Integer>> UNIQUE_COLUMNS_INFO =
102             List.of(new Pair<>(DATA_SOURCE_UUID_COLUMN_NAME, UpsertTableRequest.TYPE_BLOB));
103     private static final String LAST_RESOURCES_MODIFIED_TIME_ALIAS = "last_data_update_time";
104     private static final String LAST_DATA_SOURCE_MODIFIED_TIME_ALIAS =
105             "last_data_source_update_time";
106 
107     private final TransactionManager mTransactionManager;
108     private final AppInfoHelper mAppInfoHelper;
109     private final TimeSource mTimeSource;
110     private final AccessLogsHelper mAccessLogsHelper;
111 
MedicalDataSourceHelper( TransactionManager transactionManager, AppInfoHelper appInfoHelper, TimeSource timeSource, AccessLogsHelper accessLogsHelper)112     public MedicalDataSourceHelper(
113             TransactionManager transactionManager,
114             AppInfoHelper appInfoHelper,
115             TimeSource timeSource,
116             AccessLogsHelper accessLogsHelper) {
117         mTransactionManager = transactionManager;
118         mAppInfoHelper = appInfoHelper;
119         mTimeSource = timeSource;
120         mAccessLogsHelper = accessLogsHelper;
121     }
122 
getMainTableName()123     public static String getMainTableName() {
124         return MEDICAL_DATA_SOURCE_TABLE_NAME;
125     }
126 
getPrimaryColumnName()127     public static String getPrimaryColumnName() {
128         return MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME;
129     }
130 
getDataSourceUuidColumnName()131     public static String getDataSourceUuidColumnName() {
132         return DATA_SOURCE_UUID_COLUMN_NAME;
133     }
134 
getAppInfoIdColumnName()135     public static String getAppInfoIdColumnName() {
136         return APP_INFO_ID_COLUMN_NAME;
137     }
138 
getFhirVersionColumnName()139     public static String getFhirVersionColumnName() {
140         return FHIR_VERSION_COLUMN_NAME;
141     }
142 
getColumnInfo()143     private static List<Pair<String, String>> getColumnInfo() {
144         return List.of(
145                 Pair.create(MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME, PRIMARY),
146                 Pair.create(APP_INFO_ID_COLUMN_NAME, INTEGER_NOT_NULL),
147                 Pair.create(DISPLAY_NAME_COLUMN_NAME, TEXT_NOT_NULL),
148                 Pair.create(FHIR_BASE_URI_COLUMN_NAME, TEXT_NOT_NULL),
149                 Pair.create(FHIR_VERSION_COLUMN_NAME, TEXT_NOT_NULL),
150                 Pair.create(DATA_SOURCE_UUID_COLUMN_NAME, BLOB_UNIQUE_NON_NULL),
151                 Pair.create(LAST_MODIFIED_TIME_COLUMN_NAME, INTEGER_NOT_NULL));
152     }
153 
getCreateTableRequest()154     public static CreateTableRequest getCreateTableRequest() {
155         return new CreateTableRequest(MEDICAL_DATA_SOURCE_TABLE_NAME, getColumnInfo())
156                 .addForeignKey(
157                         AppInfoHelper.TABLE_NAME,
158                         List.of(APP_INFO_ID_COLUMN_NAME),
159                         List.of(PRIMARY_COLUMN_NAME));
160     }
161 
162     /** Creates the medical_data_source table. */
onInitialUpgrade(SQLiteDatabase db)163     public static void onInitialUpgrade(SQLiteDatabase db) {
164         createTable(db, getCreateTableRequest());
165         // There's no significant difference between a unique constraint and unique index.
166         // The latter would allow us to drop or recreate it later.
167         // The combination of (display_name, app_info_id) should be unique.
168         db.execSQL(
169                 new CreateIndexRequest(
170                                 MEDICAL_DATA_SOURCE_TABLE_NAME,
171                                 MEDICAL_DATA_SOURCE_TABLE_NAME + "_display_name_idx",
172                                 /* isUnique= */ true,
173                                 List.of(DISPLAY_NAME_COLUMN_NAME, APP_INFO_ID_COLUMN_NAME))
174                         .getCommand());
175     }
176 
177     /**
178      * Creates {@link ReadTableRequest} that joins with {@link AppInfoHelper#TABLE_NAME} and filters
179      * for the given list of {@code ids}, and restricts to the given apps.
180      *
181      * @param ids the data source ids to restrict to, if empty allows all data sources
182      * @param appInfoRestriction the apps to restrict to, if null allows all apps
183      */
getReadTableRequest( List<UUID> ids, @Nullable Long appInfoRestriction)184     public static ReadTableRequest getReadTableRequest(
185             List<UUID> ids, @Nullable Long appInfoRestriction) {
186         ReadTableRequest readTableRequest = new ReadTableRequest(getMainTableName());
187         WhereClauses whereClauses = getWhereClauses(ids, appInfoRestriction);
188         return readTableRequest.setWhereClause(whereClauses);
189     }
190 
191     /**
192      * Gets a where clauses that filters the data source table by the given restrictions.
193      *
194      * @param ids the ids to include, or if empty do not filter by ids
195      * @param appInfoRestriction the app info id to restrict to, or if null do not filter by app
196      *     info
197      */
getWhereClauses(List<UUID> ids, @Nullable Long appInfoRestriction)198     public static WhereClauses getWhereClauses(List<UUID> ids, @Nullable Long appInfoRestriction) {
199         WhereClauses whereClauses;
200         if (ids.isEmpty()) {
201             whereClauses = new WhereClauses(AND);
202         } else {
203             whereClauses = getReadTableWhereClause(ids);
204         }
205         if (appInfoRestriction != null) {
206             whereClauses.addWhereInLongsClause(
207                     APP_INFO_ID_COLUMN_NAME, List.of(appInfoRestriction));
208         }
209         return whereClauses;
210     }
211 
212     /** Creates {@link ReadTableRequest} for the given list of {@code ids}. */
getReadTableRequest(List<UUID> ids)213     public static ReadTableRequest getReadTableRequest(List<UUID> ids) {
214         return new ReadTableRequest(getMainTableName())
215                 .setWhereClause(getReadTableWhereClause(ids));
216     }
217 
getJoinClauseWithAppInfoTable()218     private static SqlJoin getJoinClauseWithAppInfoTable() {
219         return new SqlJoin(
220                         MEDICAL_DATA_SOURCE_TABLE_NAME,
221                         AppInfoHelper.TABLE_NAME,
222                         APP_INFO_ID_COLUMN_NAME,
223                         PRIMARY_COLUMN_NAME)
224                 .setJoinType(SqlJoin.SQL_JOIN_INNER);
225     }
226 
getInnerJoinClauseWithMedicalResourcesTable()227     private static SqlJoin getInnerJoinClauseWithMedicalResourcesTable() {
228         return new SqlJoin(
229                         MEDICAL_DATA_SOURCE_TABLE_NAME,
230                         MedicalResourceHelper.getMainTableName(),
231                         MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME,
232                         MedicalResourceHelper.getDataSourceIdColumnName())
233                 .setJoinType(SqlJoin.SQL_JOIN_INNER);
234     }
235 
getLeftJoinClauseWithMedicalResourcesTable()236     private static SqlJoin getLeftJoinClauseWithMedicalResourcesTable() {
237         return new SqlJoin(
238                         MEDICAL_DATA_SOURCE_TABLE_NAME,
239                         MedicalResourceHelper.getMainTableName(),
240                         MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME,
241                         MedicalResourceHelper.getDataSourceIdColumnName())
242                 .setJoinType(SqlJoin.SQL_JOIN_LEFT);
243     }
244 
245     /**
246      * Returns a {@link WhereClauses} that limits to data sources with id in {@code ids}.
247      *
248      * @param ids the ids to limit to.
249      */
getReadTableWhereClause(List<UUID> ids)250     public static WhereClauses getReadTableWhereClause(List<UUID> ids) {
251         return new WhereClauses(AND)
252                 .addWhereInClauseWithoutQuotes(
253                         DATA_SOURCE_UUID_COLUMN_NAME, StorageUtils.getListOfHexStrings(ids));
254     }
255 
256     /**
257      * Returns List of {@link MedicalDataSource}s from the cursor. If the cursor contains more than
258      * {@link Constants#MAXIMUM_ALLOWED_CURSOR_COUNT} data sources, it throws {@link
259      * IllegalArgumentException}.
260      */
getMedicalDataSources(Cursor cursor)261     public static List<MedicalDataSource> getMedicalDataSources(Cursor cursor) {
262         if (cursor.getCount() > MAXIMUM_ALLOWED_CURSOR_COUNT) {
263             throw new IllegalArgumentException(
264                     "Too many data sources in the cursor. Max allowed: "
265                             + MAXIMUM_ALLOWED_CURSOR_COUNT);
266         }
267         List<MedicalDataSource> medicalDataSources = new ArrayList<>();
268         if (cursor.moveToFirst()) {
269             do {
270                 medicalDataSources.add(getMedicalDataSource(cursor));
271             } while (cursor.moveToNext());
272         }
273         return medicalDataSources;
274     }
275 
276     /**
277      * Returns List of pair of {@link MedicalDataSource}s and their associated {@link
278      * MedicalDataSourceHelper#LAST_DATA_SOURCE_MODIFIED_TIME_ALIAS} from the cursor. If the cursor
279      * contains more than {@link Constants#MAXIMUM_ALLOWED_CURSOR_COUNT} data sources, it throws
280      * {@link IllegalArgumentException}.
281      */
getMedicalDataSourcesWithTimestamps( Cursor cursor)282     public static List<Pair<MedicalDataSource, Long>> getMedicalDataSourcesWithTimestamps(
283             Cursor cursor) {
284         if (cursor.getCount() > MAXIMUM_ALLOWED_CURSOR_COUNT) {
285             throw new IllegalStateException(
286                     "Too many data sources in the cursor. Max allowed: "
287                             + MAXIMUM_ALLOWED_CURSOR_COUNT);
288         }
289         List<Pair<MedicalDataSource, Long>> medicalDataSourceAndTimestamps = new ArrayList<>();
290         if (cursor.moveToFirst()) {
291             do {
292                 long lastModifiedTimestamp =
293                         getCursorLong(cursor, LAST_DATA_SOURCE_MODIFIED_TIME_ALIAS);
294                 MedicalDataSource medicalDataSource = getMedicalDataSource(cursor);
295                 medicalDataSourceAndTimestamps.add(
296                         new Pair<>(medicalDataSource, lastModifiedTimestamp));
297             } while (cursor.moveToNext());
298         }
299         return medicalDataSourceAndTimestamps;
300     }
301 
getMedicalDataSource(Cursor cursor)302     private static MedicalDataSource getMedicalDataSource(Cursor cursor) {
303         Instant lastDataUpdateTime =
304                 isNullValue(cursor, LAST_RESOURCES_MODIFIED_TIME_ALIAS)
305                         ? null
306                         : Instant.ofEpochMilli(
307                                 getCursorLong(cursor, LAST_RESOURCES_MODIFIED_TIME_ALIAS));
308 
309         return new MedicalDataSource.Builder(
310                         /* id= */ getCursorUUID(cursor, DATA_SOURCE_UUID_COLUMN_NAME).toString(),
311                         /* packageName= */ getCursorString(
312                                 cursor, AppInfoHelper.PACKAGE_COLUMN_NAME),
313                         /* fhirBaseUri= */ Uri.parse(
314                                 getCursorString(cursor, FHIR_BASE_URI_COLUMN_NAME)),
315                         /* displayName= */ getCursorString(cursor, DISPLAY_NAME_COLUMN_NAME),
316                         /* fhirVersion= */ parseFhirVersion(
317                                 getCursorString(cursor, FHIR_VERSION_COLUMN_NAME)))
318                 .setLastDataUpdateTime(lastDataUpdateTime)
319                 .build();
320     }
321 
322     /**
323      * Inserts the {@link MedicalDataSource} created from the given {@link
324      * CreateMedicalDataSourceRequest} and {@code packageName} into the HealthConnect database.
325      *
326      * @param request a {@link CreateMedicalDataSourceRequest}.
327      * @param packageName is the package name of the application wanting to create a {@link
328      *     MedicalDataSource}.
329      * @return The {@link MedicalDataSource} created and inserted into the database.
330      */
createMedicalDataSource( CreateMedicalDataSourceRequest request, String packageName)331     public MedicalDataSource createMedicalDataSource(
332             CreateMedicalDataSourceRequest request, String packageName) {
333         try {
334             // Get the appInfoId outside the transaction
335             long appInfoId = mAppInfoHelper.getOrInsertAppInfoId(packageName);
336             return mTransactionManager.runAsTransaction(
337                     (TransactionManager.RunnableWithReturn<MedicalDataSource, RuntimeException>)
338                             db ->
339                                     createMedicalDataSourceAndAppInfoAndCheckLimits(
340                                             db,
341                                             request,
342                                             appInfoId,
343                                             packageName,
344                                             mTimeSource.getInstantNow()));
345         } catch (SQLiteConstraintException e) {
346             String exceptionMessage = e.getMessage();
347             if (exceptionMessage != null && exceptionMessage.contains(DISPLAY_NAME_COLUMN_NAME)) {
348                 throw new IllegalArgumentException("display name should be unique per calling app");
349             }
350             throw e;
351         }
352     }
353 
createMedicalDataSourceAndAppInfoAndCheckLimits( SQLiteDatabase db, CreateMedicalDataSourceRequest request, long appInfoId, String packageName, Instant instant)354     private MedicalDataSource createMedicalDataSourceAndAppInfoAndCheckLimits(
355             SQLiteDatabase db,
356             CreateMedicalDataSourceRequest request,
357             long appInfoId,
358             String packageName,
359             Instant instant) {
360 
361         if (getMedicalDataSourcesCount(appInfoId) >= MAX_ALLOWED_MEDICAL_DATA_SOURCES) {
362             throw new IllegalArgumentException(
363                     "The maximum number of data sources has been reached.");
364         }
365 
366         UUID dataSourceUuid = UUID.randomUUID();
367         UpsertTableRequest upsertTableRequest =
368                 getUpsertTableRequest(dataSourceUuid, request, appInfoId, instant);
369         mTransactionManager.insert(db, upsertTableRequest);
370         mAccessLogsHelper.addAccessLog(
371                 db,
372                 packageName,
373                 /* medicalResourceTypes= */ Set.of(),
374                 OPERATION_TYPE_UPSERT,
375                 /* accessedMedicalDataSource= */ true);
376         return buildMedicalDataSource(dataSourceUuid, request, packageName);
377     }
378 
getMedicalDataSourcesCount(long appInfoId)379     private int getMedicalDataSourcesCount(long appInfoId) {
380         ReadTableRequest readTableRequest =
381                 new ReadTableRequest(getMainTableName())
382                         .setJoinClause(getJoinClauseWithAppInfoTable());
383         readTableRequest.setWhereClause(
384                 new WhereClauses(AND)
385                         .addWhereInLongsClause(APP_INFO_ID_COLUMN_NAME, List.of(appInfoId)));
386         return mTransactionManager.count(readTableRequest);
387     }
388 
389     /** Returns the total number of medical data sources in HC database. */
getMedicalDataSourcesCount()390     public int getMedicalDataSourcesCount() {
391         ReadTableRequest readTableRequest = new ReadTableRequest(getMainTableName());
392         return mTransactionManager.count(readTableRequest);
393     }
394 
395     /**
396      * Reads the {@link MedicalDataSource}s stored in the HealthConnect database using the given
397      * list of {@code ids}.
398      *
399      * @param ids a list of {@link MedicalDataSource} ids.
400      * @return List of {@link MedicalDataSource}s read from medical_data_source table based on ids.
401      */
getMedicalDataSourcesByIdsWithoutPermissionChecks(List<UUID> ids)402     public List<MedicalDataSource> getMedicalDataSourcesByIdsWithoutPermissionChecks(List<UUID> ids)
403             throws SQLiteException {
404         String query = getReadQueryForDataSourcesFilterOnIds(ids);
405         try (Cursor cursor = mTransactionManager.rawQuery(query, /* selectionArgs= */ null)) {
406             return getMedicalDataSources(cursor);
407         }
408     }
409 
410     /**
411      * Reads the {@link MedicalDataSource}s stored in the HealthConnect database using the given
412      * list of {@code ids} based on the {@code callingPackageName}'s permissions.
413      *
414      * @return List of {@link MedicalDataSource}s read from medical_data_source table based on ids.
415      * @throws IllegalStateException if {@code hasWritePermission} is false and {@code
416      *     grantedReadMedicalResourceTypes} is empty.
417      * @throws IllegalArgumentException if {@code callingPackageName} has not written any data
418      *     sources so the appId does not exist in the {@link AppInfoHelper#TABLE_NAME} and the
419      *     {@code callingPackageName} has no read permissions either.
420      */
getMedicalDataSourcesByIdsWithPermissionChecks( List<UUID> ids, Set<Integer> grantedReadMedicalResourceTypes, String callingPackageName, boolean hasWritePermission, boolean isCalledFromBgWithoutBgRead, AppInfoHelper appInfoHelper)421     public List<MedicalDataSource> getMedicalDataSourcesByIdsWithPermissionChecks(
422             List<UUID> ids,
423             Set<Integer> grantedReadMedicalResourceTypes,
424             String callingPackageName,
425             boolean hasWritePermission,
426             boolean isCalledFromBgWithoutBgRead,
427             AppInfoHelper appInfoHelper)
428             throws SQLiteException {
429         if (!hasWritePermission && grantedReadMedicalResourceTypes.isEmpty()) {
430             throw new IllegalStateException("no read or write permission");
431         }
432 
433         long appId = appInfoHelper.getAppInfoId(callingPackageName);
434         // This is an optimization to not hit the db, when we know that the app has not
435         // created any dataSources hence appId does not exist (so no self data to read)
436         // and has no read permission, so won't be able to read dataSources written by
437         // other apps either.
438         if (appId == DEFAULT_LONG && grantedReadMedicalResourceTypes.isEmpty()) {
439             throw new IllegalArgumentException(
440                     "app has not written any data and does not have any read permission");
441         }
442         return mTransactionManager.runAsTransaction(
443                 (TransactionManager.RunnableWithReturn<List<MedicalDataSource>, RuntimeException>)
444                         db -> {
445                             String query =
446                                     getReadQueryBasedOnPermissionFilters(
447                                             ids,
448                                             grantedReadMedicalResourceTypes,
449                                             appId,
450                                             hasWritePermission,
451                                             isCalledFromBgWithoutBgRead);
452 
453                             return readMedicalDataSourcesAndAddAccessLog(
454                                     db,
455                                     query,
456                                     grantedReadMedicalResourceTypes,
457                                     callingPackageName,
458                                     isCalledFromBgWithoutBgRead);
459                         });
460     }
461 
readMedicalDataSourcesAndAddAccessLog( SQLiteDatabase db, String readQuery, Set<Integer> grantedReadMedicalResourceTypes, String callingPackageName, boolean isCalledFromBgWithoutBgRead)462     private List<MedicalDataSource> readMedicalDataSourcesAndAddAccessLog(
463             SQLiteDatabase db,
464             String readQuery,
465             Set<Integer> grantedReadMedicalResourceTypes,
466             String callingPackageName,
467             boolean isCalledFromBgWithoutBgRead) {
468         List<MedicalDataSource> medicalDataSources;
469         try (Cursor cursor = mTransactionManager.rawQuery(readQuery, /* selectionArgs= */ null)) {
470             medicalDataSources = getMedicalDataSources(cursor);
471         }
472 
473         // If the app is called from background but without background read
474         // permission, the most the app can do, is to read their own data. Same
475         // when the grantedReadMedicalResourceTypes is empty. And we don't need
476         // to add access logs when an app intends to access their own data. If
477         // medicalDataSources is empty, it means that the app hasn't read any
478         // dataSources out, so no need to add access logs either.
479         if (!isCalledFromBgWithoutBgRead
480                 && !grantedReadMedicalResourceTypes.isEmpty()
481                 && !medicalDataSources.isEmpty()) {
482             // We need to figure out from the dataSources that were read, what
483             // is the resource types relevant to those dataSources, we add
484             // access logs only if there's any intersection between read
485             // permissions and resource types's dataSources. If intersection is
486             // empty, it means that the data read was accessed through self
487             // read, hence no access log needed.
488             Set<Integer> resourceTypes =
489                     getIntersectionOfResourceTypesReadAndGrantedReadPermissions(
490                             getMedicalResourceTypesBelongingToDataSourceIds(
491                                     getUUIDsRead(medicalDataSources)),
492                             grantedReadMedicalResourceTypes);
493             if (!resourceTypes.isEmpty()) {
494                 mAccessLogsHelper.addAccessLog(
495                         db,
496                         callingPackageName,
497                         /* medicalResourceTypes= */ Set.of(),
498                         OPERATION_TYPE_READ,
499                         /* accessedMedicalDataSource= */ true);
500             }
501         }
502         return medicalDataSources;
503     }
504 
getMedicalResourceTypesBelongingToDataSourceIds(List<UUID> dataSourceIds)505     private Set<Integer> getMedicalResourceTypesBelongingToDataSourceIds(List<UUID> dataSourceIds) {
506         Set<Integer> resourceTypes = new HashSet<>();
507         ReadTableRequest readRequest =
508                 getReadRequestForDistinctResourceTypesBelongingToDataSourceIds(dataSourceIds);
509         try (Cursor cursor = mTransactionManager.read(readRequest)) {
510             if (cursor.moveToFirst()) {
511                 do {
512                     resourceTypes.add(getCursorInt(cursor, getMedicalResourceTypeColumnName()));
513                 } while (cursor.moveToNext());
514             }
515         }
516         return resourceTypes;
517     }
518 
getUUIDsRead(List<MedicalDataSource> dataSources)519     private static List<UUID> getUUIDsRead(List<MedicalDataSource> dataSources) {
520         return dataSources.stream()
521                 .map(MedicalDataSource::getId)
522                 .map(UUID::fromString)
523                 .collect(Collectors.toList());
524     }
525 
getReadQueryBasedOnPermissionFilters( List<UUID> ids, Set<Integer> grantedReadMedicalResourceTypes, long appId, boolean hasWritePermission, boolean isCalledFromBgWithoutBgRead)526     private static String getReadQueryBasedOnPermissionFilters(
527             List<UUID> ids,
528             Set<Integer> grantedReadMedicalResourceTypes,
529             long appId,
530             boolean hasWritePermission,
531             boolean isCalledFromBgWithoutBgRead) {
532         // Reading all dataSource ids that are written by the calling package.
533         String readAllIdsWrittenByCallingPackage =
534                 getReadQueryForDataSourcesFilterOnSourceIdsAndAppIds(ids, Set.of(appId));
535 
536         // App is calling the API from background without background read permission.
537         if (isCalledFromBgWithoutBgRead) {
538             // App has writePermission.
539             // App can read all dataSources they wrote themselves.
540             if (hasWritePermission) {
541                 return readAllIdsWrittenByCallingPackage;
542             }
543             // App does not have writePermission.
544             // App has normal read permission for some medicalResourceTypes.
545             // App can read the dataSources that belong to those medicalResourceTypes
546             // and were written by the app itself.
547             return getReadQueryForDataSourcesFilterOnIdsAndAppIdsAndResourceTypes(
548                     ids, Set.of(appId), grantedReadMedicalResourceTypes);
549         }
550 
551         // The request to read out all dataSource ids belonging to the medicalResourceTypes of
552         // the grantedReadMedicalResourceTypes.
553         String readIdsOfTheGrantedMedicalResourceTypes =
554                 getReadQueryForDataSourcesFilterOnSourceIdsAndResourceTypes(
555                         ids, grantedReadMedicalResourceTypes);
556 
557         // App is in background with backgroundReadPermission or in foreground.
558         // App has writePermission.
559         if (hasWritePermission) {
560             // App does not have any read permissions for any medicalResourceTypes.
561             // App can read all dataSources they wrote themselves.
562             if (grantedReadMedicalResourceTypes.isEmpty()) {
563                 return readAllIdsWrittenByCallingPackage;
564             }
565             // App has some read permissions for medicalResourceTypes.
566             // App can read all dataSources they wrote themselves and the dataSources belonging to
567             // the medicalResourceTypes they have read permission for.
568             // UNION ALL allows for duplicate values, but we want the rows to be distinct.
569             // Hence why we use normal UNION.
570             return readAllIdsWrittenByCallingPackage
571                     + UNION
572                     + readIdsOfTheGrantedMedicalResourceTypes;
573         }
574         // App is in background with background read permission or in foreground.
575         // App has some read permissions for medicalResourceTypes.
576         // App does not have write permission.
577         // App can read all dataSources belonging to the granted medicalResourceType read
578         // permissions.
579         return readIdsOfTheGrantedMedicalResourceTypes;
580     }
581 
582     /**
583      * Returns the {@link MedicalDataSource}s stored in the HealthConnect database, optionally
584      * restricted by package name.
585      *
586      * <p>If {@code packageNames} is empty, returns all dataSources, otherwise returns only
587      * dataSources belonging to the given apps.
588      *
589      * @param packageNames list of packageNames of apps to restrict to
590      */
getMedicalDataSourcesByPackageWithoutPermissionChecks( Set<String> packageNames)591     public List<MedicalDataSource> getMedicalDataSourcesByPackageWithoutPermissionChecks(
592             Set<String> packageNames) throws SQLiteException {
593         String query;
594         if (packageNames.isEmpty()) {
595             query = getReadQueryForDataSources();
596         } else {
597             List<Long> appInfoIds = mAppInfoHelper.getAppInfoIds(packageNames.stream().toList());
598             query = getReadQueryForDataSourcesFilterOnAppIds(new HashSet<>(appInfoIds));
599         }
600         try (Cursor cursor = mTransactionManager.rawQuery(query, /* selectionArgs= */ null)) {
601             return getMedicalDataSources(cursor);
602         }
603     }
604 
605     /**
606      * Returns the {@link MedicalDataSource}s stored in the HealthConnect database filtering on the
607      * given {@code packageNames} if not empty and based on the {@code callingPackageName}'s
608      * permissions.
609      *
610      * <p>If {@code packageNames} is empty, returns all dataSources, otherwise returns only
611      * dataSources belonging to the given apps.
612      *
613      * @throws IllegalArgumentException if {@code callingPackageName} has not written any data
614      *     sources so the appId does not exist in the {@link AppInfoHelper#TABLE_NAME} and the
615      *     {@code callingPackageName} has no read permissions either. Or if the app can only read
616      *     self data and the app is filtering using {@code packageNames} but the app itself is not
617      *     included in it.
618      */
getMedicalDataSourcesByPackageWithPermissionChecks( Set<String> packageNames, Set<Integer> grantedReadMedicalResourceTypes, String callingPackageName, boolean hasWritePermission, boolean isCalledFromBgWithoutBgRead)619     public List<MedicalDataSource> getMedicalDataSourcesByPackageWithPermissionChecks(
620             Set<String> packageNames,
621             Set<Integer> grantedReadMedicalResourceTypes,
622             String callingPackageName,
623             boolean hasWritePermission,
624             boolean isCalledFromBgWithoutBgRead)
625             throws SQLiteException {
626         long callingAppId = mAppInfoHelper.getAppInfoId(callingPackageName);
627         // This is an optimization to not hit the db, when we know that the app has not
628         // created any dataSources hence appId does not exist (so no self data to read)
629         // and has no read permission, so won't be able to read dataSources written by
630         // other apps either.
631         if (callingAppId == DEFAULT_LONG && grantedReadMedicalResourceTypes.isEmpty()) {
632             throw new IllegalArgumentException(
633                     "app has not written any data and does not have any read permission");
634         }
635 
636         List<Long> appIds = mAppInfoHelper.getAppInfoIds(packageNames.stream().toList());
637 
638         // App is in bg without bg read permission so the app can only read dataSources written by
639         // itself, but if the request is filtering on a set of packageNames (packageNames not empty)
640         // and the app itself is not in the packageNames, there is nothing to be read.
641         boolean intendsToReadOnlyOtherAppsData =
642                 !packageNames.isEmpty() && !packageNames.contains(callingPackageName);
643         if (isCalledFromBgWithoutBgRead && intendsToReadOnlyOtherAppsData) {
644             throw new IllegalArgumentException(
645                     "app doesn't have permission to read based on the given packages");
646         }
647 
648         // Same with if app in foreground or app in bg with bg read perm, app has write permission
649         // but no read permission, app can only read dataSource it has written itself.
650         // However, if the request is filtering on a set of packageNames (packageNames not empty)
651         // and the app itself is not in the packageNames, there is nothing to be read.
652         boolean canReadSelfDataOnly =
653                 !isCalledFromBgWithoutBgRead
654                         && hasWritePermission
655                         && grantedReadMedicalResourceTypes.isEmpty();
656         if (canReadSelfDataOnly && intendsToReadOnlyOtherAppsData) {
657             throw new IllegalArgumentException(
658                     "app doesn't have permission to read based on the given packages");
659         }
660         return mTransactionManager.runAsTransaction(
661                 (TransactionManager.RunnableWithReturn<List<MedicalDataSource>, RuntimeException>)
662                         db -> {
663                             String readQuery =
664                                     getReadQueryByPackagesWithPermissionChecks(
665                                             new HashSet<>(appIds),
666                                             grantedReadMedicalResourceTypes,
667                                             callingAppId,
668                                             hasWritePermission,
669                                             isCalledFromBgWithoutBgRead);
670 
671                             return readMedicalDataSourcesAndAddAccessLog(
672                                     db,
673                                     readQuery,
674                                     grantedReadMedicalResourceTypes,
675                                     callingPackageName,
676                                     isCalledFromBgWithoutBgRead);
677                         });
678     }
679 
680     private static String getReadQueryByPackagesWithPermissionChecks(
681             Set<Long> appIds,
682             Set<Integer> grantedReadMedicalResourceTypes,
683             long callingAppId,
684             boolean hasWritePermission,
685             boolean isCalledFromBgWithoutBgRead) {
686         // Reading all dataSources written by the calling app.
687         String readAllDataSourcesWrittenByCallingPackage =
688                 getReadQueryForDataSourcesFilterOnAppIds(Set.of(callingAppId));
689 
690         // App is calling the API from background without background read permission.
691         if (isCalledFromBgWithoutBgRead) {
692             // App has writePermission.
693             // App can read all dataSources they wrote themselves.
694             if (hasWritePermission) {
695                 return readAllDataSourcesWrittenByCallingPackage;
696             }
697             // App does not have writePermission.
698             // App has normal read permission for some medicalResourceTypes.
699             // App can read the dataSources that belong to those medicalResourceTypes
700             // and were written by the app itself.
701             return getReadQueryForDataSourcesFilterOnAppIdsAndResourceTypes(
702                     Set.of(callingAppId), grantedReadMedicalResourceTypes);
703         }
704 
705         // The request to read out all dataSources belonging to the medicalResourceTypes of
706         // the grantedReadMedicalResourceTypes and written by the given packageNames.
707         String readDataSourcesOfTheGrantedMedicalResourceTypes =
708                 getReadQueryForDataSourcesFilterOnAppIdsAndResourceTypes(
709                         appIds, grantedReadMedicalResourceTypes);
710 
711         // App is in background with backgroundReadPermission or in foreground.
712         // App has writePermission.
713         if (hasWritePermission) {
714             // App does not have any read permissions for any medicalResourceTypes.
715             // App can read all dataSources they wrote themselves.
716             if (grantedReadMedicalResourceTypes.isEmpty()) {
717                 return readAllDataSourcesWrittenByCallingPackage;
718             }
719             // If our set of appIds is not empty, means the request is filtering based on
720             // packageNames. So we don't include self data, if request is filtering based on
721             // packageNames but the callingAppId is not in the set of the given packageNames's
722             // appIds.
723             if (!appIds.isEmpty() && !appIds.contains(callingAppId)) {
724                 return readDataSourcesOfTheGrantedMedicalResourceTypes;
725             }
726             // App has some read permissions for medicalResourceTypes.
727             // App can read all dataSources they wrote themselves and the dataSources belonging to
728             // the medicalResourceTypes they have read permission for.
729             // UNION ALL allows for duplicate values, but we want the rows to be distinct.
730             // Hence why we use normal UNION.
731             return readDataSourcesOfTheGrantedMedicalResourceTypes
732                     + UNION
733                     + readAllDataSourcesWrittenByCallingPackage;
734         }
735         // App is in background with background read permission or in foreground.
736         // App has some read permissions for medicalResourceTypes.
737         // App does not have write permission.
738         // App can read all dataSources belonging to the granted medicalResourceType read
739         // permissions.
740         return readDataSourcesOfTheGrantedMedicalResourceTypes;
741     }
742 
743     private static String getReadQueryForDataSourcesFilterOnIds(List<UUID> dataSourceIds) {
744         return getReadQueryForDataSourcesFilterOnIdsAndAppIdsAndResourceTypes(
745                 dataSourceIds, null, null);
746     }
747 
748     private static String getReadQueryForDataSourcesFilterOnAppIds(Set<Long> appInfoIds) {
749         return getReadQueryForDataSourcesFilterOnIdsAndAppIdsAndResourceTypes(
750                 null, appInfoIds, null);
751     }
752 
753     private static String getReadQueryForDataSourcesFilterOnSourceIdsAndAppIds(
754             List<UUID> dataSourceIds, Set<Long> appInfoIds) {
755         return getReadQueryForDataSourcesFilterOnIdsAndAppIdsAndResourceTypes(
756                 dataSourceIds, appInfoIds, null);
757     }
758 
759     private static String getReadQueryForDataSourcesFilterOnSourceIdsAndResourceTypes(
760             List<UUID> dataSourceIds, Set<Integer> resourceTypes) {
761         return getReadQueryForDataSourcesFilterOnIdsAndAppIdsAndResourceTypes(
762                 dataSourceIds, null, resourceTypes);
763     }
764 
765     private static String getReadQueryForDataSourcesFilterOnAppIdsAndResourceTypes(
766             Set<Long> appInfoIds, Set<Integer> resourceTypes) {
767         return getReadQueryForDataSourcesFilterOnIdsAndAppIdsAndResourceTypes(
768                 null, appInfoIds, resourceTypes);
769     }
770 
771     public static String getReadQueryForDataSources() {
772         return getReadQueryForDataSourcesFilterOnIdsAndAppIdsAndResourceTypes(null, null, null);
773     }
774 
775     /**
776      * Create a {@link ReadTableRequest} to read a dataSource using the given {@code displayName}
777      * and {@code appId}.
778      */
779     public static ReadTableRequest getReadQueryForDataSourcesUsingUniqueIds(
780             String displayName, long appId) {
781         ReadTableRequest dataSourceReadUsingUniqueIds =
782                 new ReadTableRequest(getMainTableName())
783                         .setWhereClause(getReadTableWhereClause(displayName, appId))
784                         .setColumnNames(List.of(MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME));
785         return dataSourceReadUsingUniqueIds;
786     }
787 
788     private static WhereClauses getReadTableWhereClause(String displayName, long appId) {
789         return new WhereClauses(AND)
790                 .addWhereInLongsClause(APP_INFO_ID_COLUMN_NAME, List.of(appId))
791                 .addWhereInClause(DISPLAY_NAME_COLUMN_NAME, List.of(displayName));
792     }
793 
794     /**
795      * Returns the rowId of the {@link MedicalDataSource} read in the given {@link Cursor}.
796      *
797      * <p>This is only used in the DatabaseMerger code, to read the result of a query which filters
798      * out {@link MedicalDataSource}s based on a given displayName and appId. Since these two are
799      * part of the unique ID of {@link MedicalDataSource}, it throws {@link IllegalStateException}
800      * if there isn't exactly one row in the {@link Cursor}.
801      */
802     public static long readDisplayNameAndAppIdFromCursor(Cursor cursor) {
803         if (cursor.getCount() != 1) {
804             throw new IllegalStateException(
805                     "There should only exist one dataSource row with the given displayName and"
806                             + " appId.");
807         }
808         if (cursor.moveToFirst()) {
809             return getCursorLong(cursor, MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME);
810         } else {
811             throw new IllegalStateException(
812                     "No dataSources with the displayName and appId exists.");
813         }
814     }
815 
816     /**
817      * Creates a read query optionally restricted by dataSourceIds, appInfoIds and resourceTypes.
818      *
819      * <p>If {@code dataSourceIds}, {@code appInfoIds} or {@code resourceTypes} is null, no
820      * filtering occurs for that dimension. If {@code resourceTypes} are provided the returned query
821      * filters by data sources that have {@link MedicalResource} for at least one of those types.
822      *
823      * <p>If {@code resourceTypes} are provided, the query joins to the MedicalResource table to
824      * only return data source row ids, which have data for the provided {@code resourceTypes}.
825      *
826      * <p>The query joins to the AppInfoId table to get the packageName, and to the MedicalResources
827      * table to get the MAX lastDataUpdateTime for resources linked to a data source.
828      */
829     @VisibleForTesting
830     static String getReadQueryForDataSourcesFilterOnIdsAndAppIdsAndResourceTypes(
831             @Nullable List<UUID> dataSourceIds,
832             @Nullable Set<Long> appInfoIds,
833             @Nullable Set<Integer> resourceTypes) {
834         // We create a read request to read all filtered data source row ids first, which can then
835         // be used in the WHERE clause. This is needed so that if we filter data sources by resource
836         // types we can still do a join with the MedicalResource table to include all resources for
837         // calculating the last data update time.
838         WhereClauses filteredDataSourceRowIdsReadWhereClauses = new WhereClauses(AND);
839         if (dataSourceIds != null) {
840             filteredDataSourceRowIdsReadWhereClauses = getReadTableWhereClause(dataSourceIds);
841         }
842         if (appInfoIds != null) {
843             filteredDataSourceRowIdsReadWhereClauses.addWhereInLongsClause(
844                     APP_INFO_ID_COLUMN_NAME, appInfoIds);
845         }
846         ReadTableRequest filteredDataSourceRowIdsReadRequest =
847                 new ReadTableRequest(getMainTableName())
848                         .setWhereClause(filteredDataSourceRowIdsReadWhereClauses)
849                         .setColumnNames(List.of(MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME));
850         if (resourceTypes != null) {
851             SqlJoin medicalResourcesAndIndicesJoinClauseFilterOnResourceType =
852                     getInnerJoinClauseWithMedicalResourcesTable()
853                             .attachJoin(
854                                     getJoinWithIndicesTableFilterOnMedicalResourceTypes(
855                                             resourceTypes));
856             filteredDataSourceRowIdsReadRequest.setJoinClause(
857                     medicalResourcesAndIndicesJoinClauseFilterOnResourceType);
858         }
859 
860         List<String> groupByColumns =
861                 List.of(
862                         MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME,
863                         DISPLAY_NAME_COLUMN_NAME,
864                         FHIR_BASE_URI_COLUMN_NAME,
865                         FHIR_VERSION_COLUMN_NAME,
866                         DATA_SOURCE_UUID_COLUMN_NAME,
867                         AppInfoHelper.PACKAGE_COLUMN_NAME);
868         String resourcesLastModifiedTimeColumnSelect =
869                 String.format(
870                         "MAX(%1$s.%2$s) AS %3$s",
871                         MedicalResourceHelper.getMainTableName(),
872                         LAST_MODIFIED_TIME_COLUMN_NAME,
873                         LAST_RESOURCES_MODIFIED_TIME_ALIAS);
874         String dataSourceLastModifiedTime =
875                 String.format(
876                         "%1$s.%2$s AS %3$s",
877                         INNER_QUERY_ALIAS,
878                         LAST_MODIFIED_TIME_COLUMN_NAME,
879                         LAST_DATA_SOURCE_MODIFIED_TIME_ALIAS);
880         List<String> allColumns = new ArrayList<>();
881         allColumns.add(resourcesLastModifiedTimeColumnSelect);
882         allColumns.add(dataSourceLastModifiedTime);
883         allColumns.addAll(groupByColumns);
884 
885         WhereClauses dataSourceIdFilterWhereClause =
886                 new WhereClauses(AND)
887                         .addWhereInSQLRequestClause(
888                                 MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME,
889                                 filteredDataSourceRowIdsReadRequest);
890         // LEFT JOIN to the MedicalResources table to not exclude data sources that don't have any
891         // linked resources yet.
892         SqlJoin appInfoAndMedicalResourcesJoinClause =
893                 getJoinClauseWithAppInfoTable()
894                         .attachJoin(getLeftJoinClauseWithMedicalResourcesTable());
895 
896         ReadTableRequest dataSourcesReadRequest =
897                 new ReadTableRequest(getMainTableName())
898                         .setWhereClause(dataSourceIdFilterWhereClause)
899                         .setJoinClause(appInfoAndMedicalResourcesJoinClause)
900                         .setColumnNames(allColumns);
901 
902         // "GROUP BY" is not supported in ReadTableRequest and should be achieved via
903         // AggregateTableRequest. But the AggregateTableRequest is too complicated for our use case
904         // here (requiring RecordHelper), so we just build and return raw SQL query which appends
905         // the "GROUP BY" clause directly.
906         return dataSourcesReadRequest.getReadCommand()
907                 + " GROUP BY "
908                 + String.join(",", groupByColumns);
909     }
910 
911     /**
912      * Creates {@link UpsertTableRequest} for the given {@link CreateMedicalDataSourceRequest} and
913      * {@code appInfoId}.
914      */
915     public static UpsertTableRequest getUpsertTableRequest(
916             UUID uuid,
917             CreateMedicalDataSourceRequest createMedicalDataSourceRequest,
918             long appInfoId,
919             Instant instant) {
920         ContentValues contentValues =
921                 getContentValues(uuid, createMedicalDataSourceRequest, appInfoId, instant);
922         return new UpsertTableRequest(getMainTableName(), contentValues, UNIQUE_COLUMNS_INFO);
923     }
924 
925     private static DeleteTableRequest getDeleteRequestForDataSourceUuid(
926             UUID id, @Nullable Long appInfoIdRestriction) {
927         DeleteTableRequest request =
928                 new DeleteTableRequest(MEDICAL_DATA_SOURCE_TABLE_NAME)
929                         .setIds(
930                                 DATA_SOURCE_UUID_COLUMN_NAME,
931                                 StorageUtils.getListOfHexStrings(List.of(id)));
932         if (appInfoIdRestriction == null) {
933             return request;
934         }
935         return request.setPackageFilter(APP_INFO_ID_COLUMN_NAME, List.of(appInfoIdRestriction));
936     }
937 
938     /**
939      * Deletes the {@link MedicalDataSource}s stored in the HealthConnect database using the given
940      * {@code id}.
941      *
942      * <p>Note that this deletes without producing change logs, or access logs.
943      *
944      * @param id the id to delete.
945      * @throws IllegalArgumentException if the id does not exist.
946      */
947     public void deleteMedicalDataSourceWithoutPermissionChecks(UUID id) throws SQLiteException {
948         mTransactionManager.runAsTransaction(
949                 db -> {
950                     try (Cursor cursor =
951                             mTransactionManager.read(
952                                     db,
953                                     getReadTableRequest(
954                                             List.of(id), /* appInfoRestriction= */ null))) {
955                         if (cursor.getCount() != 1) {
956                             throw new IllegalArgumentException("Id " + id + " does not exist");
957                         }
958                     }
959                     // This also deletes the contained data, because they are
960                     // referenced by foreign key, and so are handled by ON DELETE
961                     // CASCADE in the db.
962                     mTransactionManager.delete(
963                             db,
964                             getDeleteRequestForDataSourceUuid(
965                                     id, /* appInfoIdRestriction= */ null));
966                 });
967     }
968 
969     /**
970      * Deletes the {@link MedicalDataSource}s stored in the HealthConnect database using the given
971      * {@code id}.
972      *
973      * <p>Note that this deletes without producing change logs.
974      *
975      * @param id the id to delete.
976      * @param callingPackageName restricts any deletions to data sources owned by the given app.
977      * @throws IllegalArgumentException if the id does not exist, or dataSource exists but it is not
978      *     owned by the {@code callingPackageName}.
979      */
980     public void deleteMedicalDataSourceWithPermissionChecks(UUID id, String callingPackageName)
981             throws SQLiteException {
982         long appId = mAppInfoHelper.getAppInfoId(callingPackageName);
983         if (appId == Constants.DEFAULT_LONG) {
984             throw new IllegalArgumentException(
985                     "Deletion not permitted as app has inserted no data.");
986         }
987         mTransactionManager.runAsTransaction(
988                 db -> {
989                     try (Cursor cursor =
990                             mTransactionManager.read(db, getReadTableRequest(List.of(id), appId))) {
991                         if (cursor.getCount() != 1) {
992                             throw new IllegalArgumentException(
993                                     "Id " + id + " does not exist or is owned by another app");
994                         }
995                     }
996 
997                     // Medical resource types that belong to this dataSource and will be deleted.
998                     Set<Integer> medicalResourceTypes =
999                             getMedicalResourceTypesBelongingToDataSourceIds(List.of(id));
1000                     // This also deletes the contained data, because they are
1001                     // referenced by foreign key, and so are handled by ON DELETE
1002                     // CASCADE in the db.
1003                     mTransactionManager.delete(db, getDeleteRequestForDataSourceUuid(id, appId));
1004                     mAccessLogsHelper.addAccessLog(
1005                             db,
1006                             callingPackageName,
1007                             medicalResourceTypes,
1008                             OPERATION_TYPE_DELETE,
1009                             /* accessedMedicalDataSource= */ true);
1010                 });
1011     }
1012 
1013     /**
1014      * Creates a {@link MedicalDataSource} for the given {@code uuid}, {@link
1015      * CreateMedicalDataSourceRequest} and the {@code packageName}.
1016      */
1017     public static MedicalDataSource buildMedicalDataSource(
1018             UUID uuid, CreateMedicalDataSourceRequest request, String packageName) {
1019         return new MedicalDataSource.Builder(
1020                         uuid.toString(),
1021                         packageName,
1022                         request.getFhirBaseUri(),
1023                         request.getDisplayName(),
1024                         request.getFhirVersion())
1025                 .build();
1026     }
1027 
1028     /**
1029      * Creates a UUID string to row ID and FHIR version map for {@link MedicalDataSource}s stored in
1030      * {@code MEDICAL_DATA_SOURCE_TABLE} that were created by the app matching the {@code *
1031      * appInfoIdRestriction}.
1032      */
1033     public Map<String, Pair<Long, FhirVersion>> getUuidToRowIdAndVersionMap(
1034             SQLiteDatabase db, long appInfoIdRestriction, List<UUID> dataSourceUuids) {
1035         Map<String, Pair<Long, FhirVersion>> uuidToRowIdAndVersion = new HashMap<>();
1036         try (Cursor cursor =
1037                 mTransactionManager.read(
1038                         db, getReadTableRequest(dataSourceUuids, appInfoIdRestriction))) {
1039             if (cursor.moveToFirst()) {
1040                 do {
1041                     UUID uuid = getCursorUUID(cursor, DATA_SOURCE_UUID_COLUMN_NAME);
1042                     long rowId = getCursorLong(cursor, MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME);
1043                     FhirVersion fhirVersion =
1044                             parseFhirVersion(getCursorString(cursor, FHIR_VERSION_COLUMN_NAME));
1045                     uuidToRowIdAndVersion.put(uuid.toString(), new Pair(rowId, fhirVersion));
1046                 } while (cursor.moveToNext());
1047             }
1048         }
1049         return uuidToRowIdAndVersion;
1050     }
1051 
1052     /**
1053      * Creates a row ID to {@link MedicalDataSource} map for all {@link MedicalDataSource}s stored
1054      * in {@code MEDICAL_DATA_SOURCE_TABLE}.
1055      */
1056     public Map<Long, MedicalDataSource> getAllRowIdToDataSourceMap(SQLiteDatabase db) {
1057         String query = getReadQueryForDataSources();
1058         Map<Long, MedicalDataSource> rowIdToDataSourceMap = new HashMap<>();
1059         try (Cursor cursor = mTransactionManager.rawQuery(query, /* selectionArgs= */ null)) {
1060             if (cursor.moveToFirst()) {
1061                 do {
1062                     long rowId = getCursorLong(cursor, MEDICAL_DATA_SOURCE_PRIMARY_COLUMN_NAME);
1063                     MedicalDataSource dataSource = getMedicalDataSource(cursor);
1064                     rowIdToDataSourceMap.put(rowId, dataSource);
1065                 } while (cursor.moveToNext());
1066             }
1067         }
1068         return rowIdToDataSourceMap;
1069     }
1070 
1071     /**
1072      * Gets all distinct app info ids from {@code APP_INFO_ID_COLUMN_NAME} for all {@link
1073      * MedicalDataSource}s stored in {@code MEDICAL_DATA_SOURCE_TABLE}.
1074      */
1075     public Set<Long> getAllContributorAppInfoIds() {
1076         ReadTableRequest readTableRequest =
1077                 new ReadTableRequest(getMainTableName())
1078                         .setDistinctClause(true)
1079                         .setColumnNames(List.of(APP_INFO_ID_COLUMN_NAME));
1080         Set<Long> appInfoIds = new HashSet<>();
1081         try (Cursor cursor = mTransactionManager.read(readTableRequest)) {
1082             if (cursor.moveToFirst()) {
1083                 do {
1084                     appInfoIds.add(getCursorLong(cursor, APP_INFO_ID_COLUMN_NAME));
1085                 } while (cursor.moveToNext());
1086             }
1087         }
1088         return appInfoIds;
1089     }
1090 
1091     /**
1092      * Create {@link ContentValues} for the given {@link MedicalDataSource}, {@code appInfoId} and
1093      * {@code lastModifiedTimestamp}.
1094      *
1095      * <p>This is only used in DatabaseMerger code, where we want to provide a lastModifiedTimestamp
1096      * from the source database rather than based on the current time.
1097      */
1098     public static ContentValues getContentValues(
1099             MedicalDataSource medicalDataSource, long appInfoId, long lastModifiedTimestamp) {
1100         ContentValues contentValues = new ContentValues();
1101         contentValues.put(
1102                 DATA_SOURCE_UUID_COLUMN_NAME,
1103                 StorageUtils.convertUUIDToBytes(UUID.fromString(medicalDataSource.getId())));
1104         contentValues.put(DISPLAY_NAME_COLUMN_NAME, medicalDataSource.getDisplayName());
1105         contentValues.put(FHIR_BASE_URI_COLUMN_NAME, medicalDataSource.getFhirBaseUri().toString());
1106         contentValues.put(FHIR_VERSION_COLUMN_NAME, medicalDataSource.getFhirVersion().toString());
1107         contentValues.put(APP_INFO_ID_COLUMN_NAME, appInfoId);
1108         contentValues.put(LAST_MODIFIED_TIME_COLUMN_NAME, lastModifiedTimestamp);
1109         return contentValues;
1110     }
1111 
1112     private static ContentValues getContentValues(
1113             UUID uuid,
1114             CreateMedicalDataSourceRequest createMedicalDataSourceRequest,
1115             long appInfoId,
1116             Instant instant) {
1117         ContentValues contentValues = new ContentValues();
1118         contentValues.put(DATA_SOURCE_UUID_COLUMN_NAME, StorageUtils.convertUUIDToBytes(uuid));
1119         contentValues.put(
1120                 DISPLAY_NAME_COLUMN_NAME, createMedicalDataSourceRequest.getDisplayName());
1121         contentValues.put(
1122                 FHIR_BASE_URI_COLUMN_NAME,
1123                 createMedicalDataSourceRequest.getFhirBaseUri().toString());
1124         contentValues.put(
1125                 FHIR_VERSION_COLUMN_NAME,
1126                 createMedicalDataSourceRequest.getFhirVersion().toString());
1127         contentValues.put(APP_INFO_ID_COLUMN_NAME, appInfoId);
1128         contentValues.put(LAST_MODIFIED_TIME_COLUMN_NAME, instant.toEpochMilli());
1129         return contentValues;
1130     }
1131 }
1132