• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2024 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.MedicalDataSourceHelper.getDataSourceUuidColumnName;
28 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalDataSourceHelper.getFhirVersionColumnName;
29 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalDataSourceHelper.getReadTableWhereClause;
30 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceIndicesHelper.getCreateMedicalResourceIndicesTableRequest;
31 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName;
32 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.LAST_MODIFIED_TIME_COLUMN_NAME;
33 import static com.android.server.healthconnect.storage.utils.SqlJoin.INNER_QUERY_ALIAS;
34 import static com.android.server.healthconnect.storage.utils.SqlJoin.SQL_JOIN_INNER;
35 import static com.android.server.healthconnect.storage.utils.StorageUtils.DELIMITER;
36 import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER_NOT_NULL;
37 import static com.android.server.healthconnect.storage.utils.StorageUtils.PRIMARY_AUTOINCREMENT;
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.getCursorLongList;
42 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorString;
43 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorUUID;
44 import static com.android.server.healthconnect.storage.utils.StorageUtils.getListOfHexStrings;
45 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.AND;
46 
47 import static java.util.stream.Collectors.toMap;
48 import static java.util.stream.Collectors.toSet;
49 
50 import android.annotation.Nullable;
51 import android.content.ContentValues;
52 import android.database.Cursor;
53 import android.database.sqlite.SQLiteDatabase;
54 import android.database.sqlite.SQLiteException;
55 import android.health.connect.Constants;
56 import android.health.connect.DeleteMedicalResourcesRequest;
57 import android.health.connect.MedicalResourceId;
58 import android.health.connect.ReadMedicalResourcesInitialRequest;
59 import android.health.connect.datatypes.FhirResource;
60 import android.health.connect.datatypes.FhirVersion;
61 import android.health.connect.datatypes.MedicalDataSource;
62 import android.health.connect.datatypes.MedicalResource;
63 import android.health.connect.datatypes.MedicalResource.MedicalResourceType;
64 import android.util.Pair;
65 import android.util.Slog;
66 
67 import com.android.healthfitness.flags.Flags;
68 import com.android.internal.annotations.VisibleForTesting;
69 import com.android.server.healthconnect.fitness.aggregation.AggregateRecordRequest;
70 import com.android.server.healthconnect.phr.PhrPageTokenWrapper;
71 import com.android.server.healthconnect.phr.ReadMedicalResourcesInternalResponse;
72 import com.android.server.healthconnect.storage.TransactionManager;
73 import com.android.server.healthconnect.storage.TransactionManager.RunnableWithReturn;
74 import com.android.server.healthconnect.storage.request.CreateIndexRequest;
75 import com.android.server.healthconnect.storage.request.CreateTableRequest;
76 import com.android.server.healthconnect.storage.request.DeleteTableRequest;
77 import com.android.server.healthconnect.storage.request.ReadTableRequest;
78 import com.android.server.healthconnect.storage.request.UpsertMedicalResourceInternalRequest;
79 import com.android.server.healthconnect.storage.utils.OrderByClause;
80 import com.android.server.healthconnect.storage.utils.SqlJoin;
81 import com.android.server.healthconnect.storage.utils.StorageUtils;
82 import com.android.server.healthconnect.storage.utils.WhereClauses;
83 import com.android.server.healthconnect.utils.TimeSource;
84 
85 import java.time.Instant;
86 import java.util.ArrayList;
87 import java.util.Collections;
88 import java.util.HashMap;
89 import java.util.HashSet;
90 import java.util.List;
91 import java.util.Map;
92 import java.util.Objects;
93 import java.util.Set;
94 import java.util.UUID;
95 import java.util.stream.Collectors;
96 
97 /**
98  * Helper class for MedicalResource table.
99  *
100  * @hide
101  */
102 public final class MedicalResourceHelper {
103     private static final String TAG = "MedicalResourceHelper";
104     @VisibleForTesting static final String MEDICAL_RESOURCE_TABLE_NAME = "medical_resource_table";
105     private static final String MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME = "medical_resource_row_id";
106     @VisibleForTesting static final String FHIR_RESOURCE_TYPE_COLUMN_NAME = "fhir_resource_type";
107     @VisibleForTesting static final String FHIR_DATA_COLUMN_NAME = "fhir_data";
108 
109     @VisibleForTesting static final String DATA_SOURCE_ID_COLUMN_NAME = "data_source_id";
110     @VisibleForTesting static final String FHIR_RESOURCE_ID_COLUMN_NAME = "fhir_resource_id";
111     private static final String LAST_MODIFIED_TIMESTAMP_MEDICAL_RESOURCE_ALIAS =
112             "medical_resource_last_modified_time";
113 
114     private static final String sLastModifiedTimeInInnerQuery =
115             String.format(
116                     "%1$s.%2$s AS %3$s",
117                     INNER_QUERY_ALIAS,
118                     LAST_MODIFIED_TIME_COLUMN_NAME,
119                     LAST_MODIFIED_TIMESTAMP_MEDICAL_RESOURCE_ALIAS);
120 
121     private static final String sMedicalResourceLastModifiedTime =
122             String.format(
123                     "%1$s.%2$s AS %3$s",
124                     getMainTableName(),
125                     LAST_MODIFIED_TIME_COLUMN_NAME,
126                     LAST_MODIFIED_TIMESTAMP_MEDICAL_RESOURCE_ALIAS);
127 
128     private static final List<String> sMedicalResourceColumns =
129             List.of(
130                     MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME,
131                     FHIR_RESOURCE_TYPE_COLUMN_NAME,
132                     FHIR_RESOURCE_ID_COLUMN_NAME,
133                     FHIR_DATA_COLUMN_NAME,
134                     MedicalDataSourceHelper.getFhirVersionColumnName(),
135                     MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName(),
136                     MedicalDataSourceHelper.getDataSourceUuidColumnName());
137 
138     /**
139      * A block of SQL with a where clause to read based on the medical resource id composite key.
140      *
141      * <p>For it to be syntactically correct it needs to have the result from {@link
142      * #makeParametersAndArgs(List, Long)} appended to it. Both the resources table and the data
143      * sources table must be joined in the SELECT which uses this clause.
144      */
145     private static final String SELECT_ON_IDS_WHERE_CLAUSE =
146             "("
147                     + MedicalDataSourceHelper.getMainTableName()
148                     + "."
149                     + MedicalDataSourceHelper.getDataSourceUuidColumnName()
150                     + ","
151                     + MEDICAL_RESOURCE_TABLE_NAME
152                     + "."
153                     + FHIR_RESOURCE_TYPE_COLUMN_NAME
154                     + ","
155                     + MEDICAL_RESOURCE_TABLE_NAME
156                     + "."
157                     + FHIR_RESOURCE_ID_COLUMN_NAME
158                     + ") IN ";
159 
160     /**
161      * A block of SQL with the inner select where clause for deleting based on the medical resource
162      * id.
163      *
164      * <p>For it to be syntactically correct it needs to have the result from {@link
165      * #makeParametersAndArgs} appended to it, followed by a ")"
166      */
167     // The SQL here is made more complicated because:
168     // 1. The medical resource table has a 3 column composite primary key, on data source,
169     // resource type, and resource id.
170     // 2. Data source is a reference to another table, so a JOIN is needed
171     // 3. SQLite does not allow JOIN in the FROM clause of a delete. This means an inner
172     // SELECT is needed to reference the ids.
173     // 4. You can't bind a list to SQL to put a dynamic range of values into an "IN" list
174     // However, SQLite has a row_id value for every row which simplifies things.
175     // We end up with a select clause looking like:
176     // DELETE FROM resources WHERE medical_resource_row_id IN (
177     //   SELECT medical_resource_row_id FROM resources INNER JOIN datasources ON ...
178     //     WHERE ..key columns.. IN ( (?,?,?), (?,?,?), ...)
179     private static final String DELETE_ON_IDS_WHERE_CLAUSE =
180             MEDICAL_RESOURCE_TABLE_NAME
181                     + "."
182                     + MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME
183                     + " IN ("
184                     + "SELECT "
185                     + MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME
186                     + " FROM "
187                     + MEDICAL_RESOURCE_TABLE_NAME
188                     + " INNER JOIN "
189                     + MedicalDataSourceHelper.getMainTableName()
190                     + " ON "
191                     + MEDICAL_RESOURCE_TABLE_NAME
192                     + "."
193                     + DATA_SOURCE_ID_COLUMN_NAME
194                     + "="
195                     + MedicalDataSourceHelper.getMainTableName()
196                     + "."
197                     + MedicalDataSourceHelper.getPrimaryColumnName()
198                     + " WHERE ("
199                     + MedicalDataSourceHelper.getMainTableName()
200                     + "."
201                     + MedicalDataSourceHelper.getDataSourceUuidColumnName()
202                     + ","
203                     + FHIR_RESOURCE_TYPE_COLUMN_NAME
204                     + ","
205                     + FHIR_RESOURCE_ID_COLUMN_NAME
206                     + ") IN ";
207 
208     /**
209      * An SQL string joining the three key tables for resource information - resources, data sources
210      * and the index with resource types. Suitable for using in a FROM clause in a select.
211      */
212     private static final String RESOURCES_JOIN_DATA_SOURCES_JOIN_INDICES =
213             MEDICAL_RESOURCE_TABLE_NAME
214                     + " INNER JOIN "
215                     + MedicalResourceIndicesHelper.getTableName()
216                     + " ON "
217                     + MEDICAL_RESOURCE_TABLE_NAME
218                     + "."
219                     + MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME
220                     + " = "
221                     + MedicalResourceIndicesHelper.getTableName()
222                     + "."
223                     + MedicalResourceIndicesHelper.getParentColumnReference()
224                     + " INNER JOIN "
225                     + MedicalDataSourceHelper.getMainTableName()
226                     + " ON "
227                     + MEDICAL_RESOURCE_TABLE_NAME
228                     + "."
229                     + DATA_SOURCE_ID_COLUMN_NAME
230                     + " = "
231                     + MedicalDataSourceHelper.getMainTableName()
232                     + "."
233                     + MedicalDataSourceHelper.getPrimaryColumnName();
234 
235     private final TransactionManager mTransactionManager;
236     private final AppInfoHelper mAppInfoHelper;
237     private final MedicalDataSourceHelper mMedicalDataSourceHelper;
238     private final TimeSource mTimeSource;
239     private final AccessLogsHelper mAccessLogsHelper;
240 
MedicalResourceHelper( TransactionManager transactionManager, AppInfoHelper appInfoHelper, MedicalDataSourceHelper medicalDataSourceHelper, TimeSource timeSource, AccessLogsHelper accessLogsHelper)241     public MedicalResourceHelper(
242             TransactionManager transactionManager,
243             AppInfoHelper appInfoHelper,
244             MedicalDataSourceHelper medicalDataSourceHelper,
245             TimeSource timeSource,
246             AccessLogsHelper accessLogsHelper) {
247         mTransactionManager = transactionManager;
248         mAppInfoHelper = appInfoHelper;
249         mMedicalDataSourceHelper = medicalDataSourceHelper;
250         mTimeSource = timeSource;
251         mAccessLogsHelper = accessLogsHelper;
252     }
253 
getMainTableName()254     public static String getMainTableName() {
255         return MEDICAL_RESOURCE_TABLE_NAME;
256     }
257 
getPrimaryColumn()258     public static String getPrimaryColumn() {
259         return MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME;
260     }
261 
getDataSourceIdColumnName()262     public static String getDataSourceIdColumnName() {
263         return DATA_SOURCE_ID_COLUMN_NAME;
264     }
265 
getMedicalResourceColumns()266     private static String getMedicalResourceColumns() {
267         List<String> medicalResourceColumns = new ArrayList<>(sMedicalResourceColumns);
268         medicalResourceColumns.add(sMedicalResourceLastModifiedTime);
269         return String.join(DELIMITER, medicalResourceColumns);
270     }
271 
getColumnInfo()272     private static List<Pair<String, String>> getColumnInfo() {
273         return List.of(
274                 Pair.create(MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME, PRIMARY_AUTOINCREMENT),
275                 Pair.create(FHIR_RESOURCE_TYPE_COLUMN_NAME, INTEGER_NOT_NULL),
276                 Pair.create(FHIR_RESOURCE_ID_COLUMN_NAME, TEXT_NOT_NULL),
277                 Pair.create(FHIR_DATA_COLUMN_NAME, TEXT_NOT_NULL),
278                 Pair.create(DATA_SOURCE_ID_COLUMN_NAME, INTEGER_NOT_NULL),
279                 Pair.create(LAST_MODIFIED_TIME_COLUMN_NAME, INTEGER_NOT_NULL));
280     }
281 
282     // TODO(b/352010531): Remove the use of setChildTableRequests and upsert child table directly
283     // in {@code upsertMedicalResources} to improve readability.
284 
getCreateTableRequest()285     public static CreateTableRequest getCreateTableRequest() {
286         return new CreateTableRequest(MEDICAL_RESOURCE_TABLE_NAME, getColumnInfo())
287                 .addForeignKey(
288                         MedicalDataSourceHelper.getMainTableName(),
289                         Collections.singletonList(DATA_SOURCE_ID_COLUMN_NAME),
290                         Collections.singletonList(MedicalDataSourceHelper.getPrimaryColumnName()))
291                 .createIndexOn(LAST_MODIFIED_TIME_COLUMN_NAME)
292                 .setChildTableRequests(
293                         Collections.singletonList(getCreateMedicalResourceIndicesTableRequest()));
294     }
295 
296     /** Creates the medical_resource table. */
onInitialUpgrade(SQLiteDatabase db)297     public static void onInitialUpgrade(SQLiteDatabase db) {
298         createTable(db, getCreateTableRequest());
299         // There are 3 equivalent ways we could add the (Datasource, type, id) triple as a primary
300         // key - primary key, unique index, or unique constraint.
301         // Primary Key and unique constraints cannot be altered after table creation. Indexes can be
302         // dropped later and added to. So it seems most flexible to add as a named index.
303         db.execSQL(
304                 new CreateIndexRequest(
305                                 MEDICAL_RESOURCE_TABLE_NAME,
306                                 MEDICAL_RESOURCE_TABLE_NAME + "_fhir_idx",
307                                 /* isUnique= */ true,
308                                 List.of(
309                                         DATA_SOURCE_ID_COLUMN_NAME,
310                                         FHIR_RESOURCE_TYPE_COLUMN_NAME,
311                                         FHIR_RESOURCE_ID_COLUMN_NAME))
312                         .getCommand());
313     }
314 
315     /** Returns the total number of medical resources in HC database. */
getMedicalResourcesCount()316     public int getMedicalResourcesCount() {
317         ReadTableRequest readTableRequest = new ReadTableRequest(getMainTableName());
318         return mTransactionManager.count(readTableRequest);
319     }
320 
321     /**
322      * Reads the {@link MedicalResource}s stored in the HealthConnect database.
323      *
324      * @param medicalResourceIds a {@link MedicalResourceId}.
325      * @return List of {@link MedicalResource}s read from medical_resource table based on ids.
326      * @throws IllegalArgumentException if any of the ids has a data source id which is not valid
327      *     (not a String form of a UUID)
328      */
readMedicalResourcesByIdsWithoutPermissionChecks( List<MedicalResourceId> medicalResourceIds)329     public List<MedicalResource> readMedicalResourcesByIdsWithoutPermissionChecks(
330             List<MedicalResourceId> medicalResourceIds) throws SQLiteException {
331         if (medicalResourceIds.isEmpty()) {
332             return List.of();
333         }
334         Pair<String, String[]> paramsAndArgs =
335                 makeParametersAndArgs(medicalResourceIds, /* appId= */ null);
336         String sql =
337                 "SELECT "
338                         + getMedicalResourceColumns()
339                         + " FROM "
340                         + RESOURCES_JOIN_DATA_SOURCES_JOIN_INDICES
341                         + " WHERE "
342                         + SELECT_ON_IDS_WHERE_CLAUSE
343                         + paramsAndArgs.first;
344         List<MedicalResource> medicalResources;
345         try (Cursor cursor = mTransactionManager.rawQuery(sql, paramsAndArgs.second)) {
346             medicalResources = getMedicalResources(cursor);
347         }
348         return medicalResources;
349     }
350 
351     /**
352      * Reads the {@link MedicalResource}s stored in the HealthConnect database filtering based on
353      * the {@code callingPackageName}'s permissions.
354      *
355      * @return List of {@link MedicalResource}s read from medical_resource table based on ids.
356      * @throws IllegalStateException if {@code hasWritePermission} is false and {@code
357      *     grantedReadMedicalResourceTypes} is empty.
358      * @throws IllegalArgumentException if any of the ids has a data source id which is not valid
359      *     (not a String form of a UUID)
360      */
readMedicalResourcesByIdsWithPermissionChecks( List<MedicalResourceId> medicalResourceIds, Set<Integer> grantedReadMedicalResourceTypes, String callingPackageName, boolean hasWritePermission, boolean isCalledFromBgWithoutBgRead)361     public List<MedicalResource> readMedicalResourcesByIdsWithPermissionChecks(
362             List<MedicalResourceId> medicalResourceIds,
363             Set<Integer> grantedReadMedicalResourceTypes,
364             String callingPackageName,
365             boolean hasWritePermission,
366             boolean isCalledFromBgWithoutBgRead)
367             throws SQLiteException {
368 
369         Pair<String, String[]> sqlAndArgs =
370                 getSqlAndArgsBasedOnPermissionFilters(
371                         medicalResourceIds,
372                         grantedReadMedicalResourceTypes,
373                         callingPackageName,
374                         hasWritePermission,
375                         isCalledFromBgWithoutBgRead);
376         return mTransactionManager.runAsTransaction(
377                 db -> {
378                     List<MedicalResource> medicalResources;
379                     try (Cursor cursor = db.rawQuery(sqlAndArgs.first, sqlAndArgs.second)) {
380                         medicalResources = getMedicalResources(cursor);
381                     }
382                     // If the app is called from background but without background read permission,
383                     // the most the app can do, is to read their own data. Same when the
384                     // grantedReadMedicalResourceTypes is empty. And we don't need to add access
385                     // logs when an app intends to access their own data.
386                     // If medicalResources is empty, it means that we haven't read any resources
387                     // out, so no need to add access logs either.
388                     if (!isCalledFromBgWithoutBgRead
389                             && !grantedReadMedicalResourceTypes.isEmpty()
390                             && !medicalResources.isEmpty()) {
391                         // We do this to get resourceTypes that are read due to the calling app
392                         // having a read permission for it. If the resources returned, were read
393                         // due to selfRead only, no access logs should be created.
394                         // However if the resources read were written by the app itself, but the
395                         // app also had read permissions for those resources, we don't record
396                         // this as selfRead and access log is added.
397                         Set<Integer> resourceTypes =
398                                 getIntersectionOfResourceTypesReadAndGrantedReadPermissions(
399                                         getResourceTypesRead(medicalResources),
400                                         grantedReadMedicalResourceTypes);
401                         if (!resourceTypes.isEmpty()) {
402                             mAccessLogsHelper.addAccessLog(
403                                     db,
404                                     callingPackageName,
405                                     resourceTypes,
406                                     OPERATION_TYPE_READ,
407                                     /* accessedMedicalDataSource= */ false);
408                         }
409                     }
410                     return medicalResources;
411                 });
412     }
413 
414     /**
415      * Reads from the storage and creates a map between {@link MedicalResourceType}s and all its
416      * contributing {@link MedicalDataSource}s.
417      *
418      * <p>This map does not guarantee to contain all the valid {@link MedicalResourceType}s we
419      * support, but only contain those we have data for in the storage.
420      */
421     public Map<Integer, Set<MedicalDataSource>>
getMedicalResourceTypeToContributingDataSourcesMap()422             getMedicalResourceTypeToContributingDataSourcesMap() {
423         return mTransactionManager.runAsTransaction(
424                 db -> {
425                     Map<Long, MedicalDataSource> allRowIdToDataSourceMap =
426                             mMedicalDataSourceHelper.getAllRowIdToDataSourceMap(db);
427                     Map<Integer, List<Long>> resourceTypeToDataSourceIdsMap =
428                             getMedicalResourceTypeToDataSourceIdsMap(db);
429                     return resourceTypeToDataSourceIdsMap.keySet().stream()
430                             .collect(
431                                     toMap(
432                                             medicalResourceType -> medicalResourceType,
433                                             medicalResourceType ->
434                                                     resourceTypeToDataSourceIdsMap
435                                                             .getOrDefault(
436                                                                     medicalResourceType, List.of())
437                                                             .stream()
438                                                             .map(allRowIdToDataSourceMap::get)
439                                                             // This should not happen, but we
440                                                             // filter out nulls for extra safe.
441                                                             .filter(Objects::nonNull)
442                                                             .collect(toSet())));
443                 });
444     }
445 
446     private Map<Integer, List<Long>> getMedicalResourceTypeToDataSourceIdsMap(SQLiteDatabase db) {
447         String readMainTableQuery = getReadQueryForMedicalResourceTypeToDataSourceIdsMap();
448         Map<Integer, List<Long>> resourceTypeToDataSourceIdsMap = new HashMap<>();
449         try (Cursor cursor = db.rawQuery(readMainTableQuery, /* selectionArgs= */ null)) {
450             if (cursor.moveToFirst()) {
451                 do {
452                     int medicalResourceType =
453                             getCursorInt(cursor, getMedicalResourceTypeColumnName());
454                     List<Long> dataSourceIds =
455                             getCursorLongList(cursor, DATA_SOURCE_ID_COLUMN_NAME, DELIMITER);
456                     resourceTypeToDataSourceIdsMap.put(medicalResourceType, dataSourceIds);
457                 } while (cursor.moveToNext());
458             }
459         }
460         return resourceTypeToDataSourceIdsMap;
461     }
462 
463     static Set<Integer> getIntersectionOfResourceTypesReadAndGrantedReadPermissions(
464             Set<Integer> resourceTypesRead, Set<Integer> grantedReadPerms) {
465         Set<Integer> intersection = new HashSet<>(resourceTypesRead);
466         intersection.retainAll(grantedReadPerms);
467         return intersection;
468     }
469 
470     private static Set<Integer> getResourceTypesRead(List<MedicalResource> resources) {
471         return resources.stream().map(MedicalResource::getType).collect(Collectors.toSet());
472     }
473 
474     /**
475      * Returns an SQL query and the selection arguments for that query to get medical resources,
476      * based on permission values.
477      *
478      * @throws IllegalArgumentException if any of the ids has a data source id which is not valid
479      *     (not a String form of a UUID)
480      */
481     private Pair<String, String[]> getSqlAndArgsBasedOnPermissionFilters(
482             List<MedicalResourceId> medicalResourceIds,
483             Set<Integer> grantedReadMedicalResourceTypes,
484             String callingPackageName,
485             boolean hasWritePermission,
486             boolean isCalledFromBgWithoutBgRead) {
487         if (!hasWritePermission && grantedReadMedicalResourceTypes.isEmpty()) {
488             throw new IllegalStateException("no read or write permission");
489         }
490         long appId = mAppInfoHelper.getAppInfoId(callingPackageName);
491         // App is calling the API from background without backgroundReadPermission.
492         if (isCalledFromBgWithoutBgRead) {
493             // App has writePermission.
494             // App can read all data they wrote themselves.
495             if (hasWritePermission) {
496                 return readAllIdsWrittenByCallingPackage(medicalResourceIds, appId);
497             }
498             // App does not have writePermission.
499             // App has normal read permission for some medicalResourceTypes.
500             // App can read the ids that belong to those medicalResourceTypes and was written by the
501             // app itself.
502             return readResourcesByIdsAppIdResourceTypes(
503                     medicalResourceIds,
504                     appId,
505                     LogicalOperator.AND,
506                     grantedReadMedicalResourceTypes);
507         }
508 
509         // App is in background with backgroundReadPermission or in foreground.
510         // App has writePermission.
511         if (hasWritePermission) {
512             // App does not have any read permissions for any medicalResourceType.
513             // App can read all data they wrote themselves.
514             if (grantedReadMedicalResourceTypes.isEmpty()) {
515                 return readAllIdsWrittenByCallingPackage(medicalResourceIds, appId);
516             }
517             // App has some read permissions for medicalResourceTypes.
518             // App can read all data they wrote themselves and the medicalResourceTypes they have
519             // read permission for.
520             return readResourcesByIdsAppIdResourceTypes(
521                     medicalResourceIds, appId, LogicalOperator.OR, grantedReadMedicalResourceTypes);
522         }
523         // App is in background with backgroundReadPermission or in foreground.
524         // App has some read permissions for medicalResourceTypes.
525         // App does not have writePermission.
526         // App can read all data of the granted medicalResourceType read permissions.
527         return readResourcesByIdsAppIdResourceTypes(
528                 medicalResourceIds,
529                 /* appId= */ null,
530                 LogicalOperator.AND,
531                 grantedReadMedicalResourceTypes);
532     }
533 
534     private static Pair<String, String[]> readAllIdsWrittenByCallingPackage(
535             List<MedicalResourceId> medicalResourceIds, long appId) {
536         Pair<String, String[]> paramsAndArgs = makeParametersAndArgs(medicalResourceIds, appId);
537         return Pair.create(
538                 "SELECT "
539                         + getMedicalResourceColumns()
540                         + " FROM "
541                         + RESOURCES_JOIN_DATA_SOURCES_JOIN_INDICES
542                         + " WHERE "
543                         + SELECT_ON_IDS_WHERE_CLAUSE
544                         + paramsAndArgs.first,
545                 paramsAndArgs.second);
546     }
547 
548     /**
549      * Reads the {@link MedicalResource}s stored in the HealthConnect database by {@code request}.
550      *
551      * @param pageTokenWrapper a {@link PhrPageTokenWrapper}.
552      * @return a {@link ReadMedicalResourcesInternalResponse}.
553      */
554     public ReadMedicalResourcesInternalResponse
555             readMedicalResourcesByRequestWithoutPermissionChecks(
556                     PhrPageTokenWrapper pageTokenWrapper, int pageSize) {
557         ReadTableRequest request =
558                 getReadTableRequestUsingRequestFilters(pageTokenWrapper, pageSize);
559 
560         return mTransactionManager.runAsTransaction(
561                 (db) -> {
562                     return getMedicalResources(db, request, pageTokenWrapper, pageSize);
563                 });
564     }
565 
566     /**
567      * Reads the {@link MedicalResource}s stored in the HealthConnect database by {@code request}
568      * filtering based on {@code callingPackageName}'s permissions.
569      *
570      * @return a {@link ReadMedicalResourcesInternalResponse}.
571      */
572     // TODO(b/360352345): Add cts tests for access logs being created per API call.
573 
574     public ReadMedicalResourcesInternalResponse readMedicalResourcesByRequestWithPermissionChecks(
575             PhrPageTokenWrapper pageTokenWrapper,
576             int pageSize,
577             String callingPackageName,
578             boolean enforceSelfRead) {
579         ReadMedicalResourcesInitialRequest request = pageTokenWrapper.getRequest();
580         if (request == null) {
581             throw new IllegalStateException("The pageTokenWrapper's request can not be null.");
582         }
583         return mTransactionManager.runAsTransaction(
584                 db -> {
585                     ReadMedicalResourcesInternalResponse response;
586                     ReadTableRequest readTableRequest =
587                             getReadTableRequestUsingRequestBasedOnPermissionFilters(
588                                     pageTokenWrapper,
589                                     pageSize,
590                                     callingPackageName,
591                                     enforceSelfRead);
592                     response =
593                             getMedicalResources(db, readTableRequest, pageTokenWrapper, pageSize);
594                     if (!enforceSelfRead) {
595                         mAccessLogsHelper.addAccessLog(
596                                 db,
597                                 callingPackageName,
598                                 Set.of(request.getMedicalResourceType()),
599                                 OPERATION_TYPE_READ,
600                                 /* accessedMedicalDataSource= */ false);
601                     }
602                     return response;
603                 });
604     }
605 
606     private ReadTableRequest getReadTableRequestUsingRequestBasedOnPermissionFilters(
607             PhrPageTokenWrapper pageTokenWrapper,
608             int pageSize,
609             String callingPackageName,
610             boolean enforceSelfRead) {
611         // If this is true, app can only read its own data of the given filters set in the request.
612         if (enforceSelfRead) {
613             long appId = mAppInfoHelper.getAppInfoId(callingPackageName);
614             return getReadTableRequestUsingRequestFiltersAndAppId(
615                     pageTokenWrapper, pageSize, appId);
616         }
617         // Otherwise, app can read all data of the given filters.
618         return getReadTableRequestUsingRequestFilters(pageTokenWrapper, pageSize);
619     }
620 
621     /** Creates {@link ReadTableRequest} for the given {@link PhrPageTokenWrapper}. */
622     public static ReadTableRequest getReadTableRequestUsingRequestFilters(
623             PhrPageTokenWrapper pageTokenWrapper, int pageSize) {
624         // The INNER_QUERY_ALIAS refers to the medical_resource_table.
625         List<String> allColumns = new ArrayList<>(sMedicalResourceColumns);
626         allColumns.add(sLastModifiedTimeInInnerQuery);
627         ReadTableRequest readTableRequest =
628                 getReadTableRequestUsingPageSizeAndLastRowId(
629                                 pageSize, pageTokenWrapper.getLastRowId())
630                         .setColumnNames(allColumns);
631         ReadMedicalResourcesInitialRequest request = pageTokenWrapper.getRequest();
632         SqlJoin joinClause;
633         if (request == null) {
634             // If request is null, it means the request is to read out all the data without
635             // any filters applied. So we just join the tables without any filtering on them.
636             joinClause =
637                     joinWithMedicalResourceIndicesTable()
638                             .attachJoin(joinWithMedicalDataSourceTable());
639         } else if (request.getDataSourceIds().isEmpty()) {
640             joinClause =
641                     getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypes(
642                             Set.of(request.getMedicalResourceType()));
643         } else {
644             List<UUID> dataSourceUuids = StorageUtils.toUuids(request.getDataSourceIds());
645             joinClause =
646                     getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypesAndSourceIds(
647                             Set.of(request.getMedicalResourceType()), dataSourceUuids);
648         }
649         return readTableRequest.setJoinClause(joinClause);
650     }
651 
652     /**
653      * Creates {@link ReadTableRequest} for the given {@link PhrPageTokenWrapper} and {@code
654      * callingPackageName}.
655      */
656     private static ReadTableRequest getReadTableRequestUsingRequestFiltersAndAppId(
657             PhrPageTokenWrapper pageTokenWrapper, int pageSize, long appId) {
658         ReadMedicalResourcesInitialRequest request = pageTokenWrapper.getRequest();
659         if (request == null) {
660             throw new IllegalArgumentException("Request can't be null when doing a filtered read.");
661         }
662         List<String> allColumns = new ArrayList<>(sMedicalResourceColumns);
663         allColumns.add(sLastModifiedTimeInInnerQuery);
664         ReadTableRequest readTableRequest =
665                 getReadTableRequestUsingPageSizeAndLastRowId(
666                                 pageSize, pageTokenWrapper.getLastRowId())
667                         .setColumnNames(allColumns);
668         SqlJoin joinClause;
669         if (request.getDataSourceIds().isEmpty()) {
670             joinClause =
671                     getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypesAndAppId(
672                             Set.of(request.getMedicalResourceType()), appId);
673         } else {
674             List<UUID> dataSourceUuids = StorageUtils.toUuids(request.getDataSourceIds());
675             joinClause =
676                     getJoinWithIndicesAndDataSourceTablesFilterOnTypesAndSourceIdsAndAppId(
677                             Set.of(request.getMedicalResourceType()), dataSourceUuids, appId);
678         }
679         return readTableRequest.setJoinClause(joinClause);
680     }
681 
682     private static ReadTableRequest getReadTableRequestUsingPageSizeAndLastRowId(
683             int pageSize, long lastRowId) {
684         // The limit is set to pageSize + 1, so that we know if there are more resources
685         // than the pageSize for creating the pageToken.
686         ReadTableRequest request =
687                 new ReadTableRequest(getMainTableName())
688                         .setWhereClause(getReadByLastRowIdWhereClause(lastRowId));
689 
690         if (Flags.phrReadMedicalResourcesFixQueryLimit()) {
691             request.setFinalOrderBy(getOrderByClause()).setFinalLimit(pageSize + 1);
692         } else {
693             request.setOrderBy(getOrderByClause()).setLimit(pageSize + 1);
694         }
695 
696         return request;
697     }
698 
699     static ReadTableRequest getReadRequestForDistinctResourceTypesBelongingToDataSourceIds(
700             List<UUID> dataSourceIds) {
701         return new ReadTableRequest(getMainTableName())
702                 .setDistinctClause(true)
703                 .setColumnNames(
704                         List.of(MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName()))
705                 .setJoinClause(
706                         getJoinWithMedicalDataSourceFilterOnDataSourceIds(
707                                 dataSourceIds, joinWithMedicalResourceIndicesTable()));
708     }
709 
710     @VisibleForTesting
711     static ReadTableRequest getFilteredReadRequestForDistinctResourceTypes(
712             List<UUID> dataSourceIds, Set<Integer> medicalResourceTypes, long appId) {
713         return new ReadTableRequest(getMainTableName())
714                 .setDistinctClause(true)
715                 .setColumnNames(
716                         List.of(MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName()))
717                 .setJoinClause(
718                         getJoinWithMedicalDataSourceFilterOnDataSourceIdsAndAppId(
719                                 dataSourceIds,
720                                 appId,
721                                 getJoinWithIndicesTableFilterOnMedicalResourceTypes(
722                                         medicalResourceTypes)));
723     }
724 
725     /**
726      * Creates raw SQL query for {@link
727      * MedicalResourceHelper#getMedicalResourceTypeToDataSourceIdsMap}.
728      *
729      * <p>"GROUP BY" is not supported in {@link ReadTableRequest} and should be achieved via {@link
730      * AggregateRecordRequest}. But the {@link AggregateRecordRequest} is too complicated for our
731      * simple use case here (requiring {@link RecordHelper}). Thus we just build and return raw SQL
732      * query which appends the "GROUP BY" clause directly.
733      */
734     @VisibleForTesting
735     static String getReadQueryForMedicalResourceTypeToDataSourceIdsMap() {
736         ReadTableRequest readDistinctResourceTypeToDataSourceIdRequest =
737                 new ReadTableRequest(getMainTableName())
738                         .setDistinctClause(true)
739                         .setColumnNames(
740                                 List.of(
741                                         getMedicalResourceTypeColumnName(),
742                                         DATA_SOURCE_ID_COLUMN_NAME))
743                         .setJoinClause(joinWithMedicalResourceIndicesTable());
744 
745         return String.format(
746                 "SELECT %1$s, GROUP_CONCAT(%2$s, '%3$s') AS %4$s FROM (%5$s) GROUP BY %6$s",
747                 /* 1 */ getMedicalResourceTypeColumnName(),
748                 /* 2 */ DATA_SOURCE_ID_COLUMN_NAME,
749                 /* 3 */ DELIMITER,
750                 /* 4 */ DATA_SOURCE_ID_COLUMN_NAME,
751                 /* 5 */ readDistinctResourceTypeToDataSourceIdRequest.getReadCommand(),
752                 /* 6 */ getMedicalResourceTypeColumnName());
753     }
754 
755     /**
756      * Creates {@link SqlJoin} that is an inner join from medical_resource_table to
757      * medical_resource_indices_table followed by another inner join from medical_resource_table to
758      * medical_data_source_table.
759      */
760     private static SqlJoin getJoinWithIndicesAndDataSourceTables() {
761         return joinWithMedicalResourceIndicesTable().attachJoin(joinWithMedicalDataSourceTable());
762     }
763 
764     /**
765      * Creates {@link SqlJoin} that is an inner join from medical_resource_table to
766      * medical_resource_indices_table filtering on {@code medicalResourceTypes} followed by another
767      * inner join from medical_resource_table to medical_data_source_table.
768      */
769     private static SqlJoin getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypes(
770             Set<Integer> medicalResourceTypes) {
771         return getJoinWithIndicesTableFilterOnMedicalResourceTypes(medicalResourceTypes)
772                 .attachJoin(joinWithMedicalDataSourceTable());
773     }
774 
775     /**
776      * Creates {@link SqlJoin} that is an inner join from medical_resource_table to
777      * medical_resource_indices_table filtering on {@code medicalResourceTypes} followed by another
778      * inner join from medical_resource_table to medical_data_source_table filtering on appId.
779      */
780     private static SqlJoin
781             getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypesAndAppId(
782                     Set<Integer> medicalResourceTypes, long appId) {
783         return getJoinWithIndicesTableFilterOnMedicalResourceTypes(medicalResourceTypes)
784                 .attachJoin(joinWithMedicalDataSourceTableFilterOnAppId(appId));
785     }
786 
787     /**
788      * Creates {@link SqlJoin} that is an inner join from medical_resource_table to
789      * medical_resource_indices_table filtering on {@code medicalResourceTypes} followed by another
790      * inner join from medical_resource_table to medical_data_source_table filtering on {@code
791      * dataSourceIds}.
792      */
793     private static SqlJoin
794             getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypesAndSourceIds(
795                     Set<Integer> medicalResourceTypes, List<UUID> dataSourceUuids) {
796         return getJoinWithIndicesTableFilterOnMedicalResourceTypes(medicalResourceTypes)
797                 .attachJoin(joinWithMedicalDataSourceTableFilterOnDataSourceIds(dataSourceUuids));
798     }
799 
800     /**
801      * Creates {@link SqlJoin} that is an inner join from medical_resource_table to
802      * medical_resource_indices_table filtering on {@code medicalResourceTypes} followed by another
803      * inner join from medical_resource_table to medical_data_source_table filtering on {@code
804      * dataSourceIds} and appId.
805      */
806     private static SqlJoin getJoinWithIndicesAndDataSourceTablesFilterOnTypesAndSourceIdsAndAppId(
807             Set<Integer> medicalResourceTypes, List<UUID> dataSourceUuids, long appId) {
808         return getJoinWithIndicesTableFilterOnMedicalResourceTypes(medicalResourceTypes)
809                 .attachJoin(
810                         joinWithMedicalDataSourceTableFilterOnDataSourceIdsAndAppId(
811                                 dataSourceUuids, appId));
812     }
813 
814     /**
815      * Creates {@link SqlJoin} that is an inner join from medical_resource_table to
816      * medical_resource_indices_table filtering on {@code medicalResourceTypes} followed by {@code
817      * extraJoin} attached to it.
818      *
819      * <p>If the list of {@code medicalResourceTypes} is empty, then the {@link WhereClauses} will
820      * be empty.
821      */
822     static SqlJoin getJoinWithIndicesTableFilterOnMedicalResourceTypes(
823             Set<Integer> medicalResourceTypes) {
824         WhereClauses medicalResourceTypeWhereClause =
825                 new WhereClauses(AND)
826                         .addWhereInIntsClause(
827                                 getMedicalResourceTypeColumnName(),
828                                 new ArrayList<>(medicalResourceTypes));
829         return joinWithMedicalResourceIndicesTable()
830                 .setSecondTableWhereClause(medicalResourceTypeWhereClause);
831     }
832 
833     static SqlJoin getJoinWithMedicalDataSourceFilterOnDataSourceIdsAndAppId(
834             List<UUID> dataSourceIds, long appId, SqlJoin extraJoin) {
835         return joinWithMedicalDataSourceTable()
836                 .setSecondTableWhereClause(
837                         getDataSourceIdsAndAppIdWhereClause(dataSourceIds, appId))
838                 .attachJoin(extraJoin);
839     }
840 
841     static SqlJoin getJoinWithMedicalDataSourceFilterOnDataSourceIds(
842             List<UUID> dataSourceIds, SqlJoin extraJoin) {
843         return joinWithMedicalDataSourceTable()
844                 .setSecondTableWhereClause(getDataSourceIdsWhereClause(dataSourceIds))
845                 .attachJoin(extraJoin);
846     }
847 
848     static SqlJoin joinWithMedicalResourceIndicesTable() {
849         return new SqlJoin(
850                         MEDICAL_RESOURCE_TABLE_NAME,
851                         MedicalResourceIndicesHelper.getTableName(),
852                         MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME,
853                         MedicalResourceIndicesHelper.getParentColumnReference())
854                 .setJoinType(SQL_JOIN_INNER);
855     }
856 
857     private static SqlJoin joinWithMedicalDataSourceTable() {
858         return new SqlJoin(
859                         MEDICAL_RESOURCE_TABLE_NAME,
860                         MedicalDataSourceHelper.getMainTableName(),
861                         DATA_SOURCE_ID_COLUMN_NAME,
862                         MedicalDataSourceHelper.getPrimaryColumnName())
863                 .setJoinType(SQL_JOIN_INNER);
864     }
865 
866     private static SqlJoin joinWithMedicalDataSourceTableFilterOnAppId(long appId) {
867         SqlJoin join = joinWithMedicalDataSourceTable();
868         join.setSecondTableWhereClause(getAppIdWhereClause(appId));
869         return join;
870     }
871 
872     private static SqlJoin joinWithMedicalDataSourceTableFilterOnDataSourceIds(
873             List<UUID> dataSourceUuids) {
874         SqlJoin join = joinWithMedicalDataSourceTable();
875         join.setSecondTableWhereClause(getReadTableWhereClause(dataSourceUuids));
876         return join;
877     }
878 
879     private static SqlJoin joinWithMedicalDataSourceTableFilterOnDataSourceIdsAndAppId(
880             List<UUID> dataSourceUuids, long appId) {
881         SqlJoin join = joinWithMedicalDataSourceTable();
882         join.setSecondTableWhereClause(
883                 getReadTableWhereClause(dataSourceUuids)
884                         .addWhereEqualsClause(
885                                 MedicalDataSourceHelper.getAppInfoIdColumnName(),
886                                 String.valueOf(appId)));
887         return join;
888     }
889 
890     private static WhereClauses getAppIdWhereClause(long appId) {
891         return new WhereClauses(AND)
892                 .addWhereEqualsClause(
893                         MedicalDataSourceHelper.getAppInfoIdColumnName(), String.valueOf(appId));
894     }
895 
896     private static WhereClauses getDataSourceIdsAndAppIdWhereClause(
897             List<UUID> dataSourceIds, long appId) {
898         WhereClauses whereClauses = getAppIdWhereClause(appId);
899         whereClauses.addWhereInClauseWithoutQuotes(
900                 getDataSourceUuidColumnName(), StorageUtils.getListOfHexStrings(dataSourceIds));
901         return whereClauses;
902     }
903 
904     private static WhereClauses getDataSourceIdsWhereClause(List<UUID> dataSourceIds) {
905         return new WhereClauses(AND)
906                 .addWhereInClauseWithoutQuotes(
907                         getDataSourceUuidColumnName(), getListOfHexStrings(dataSourceIds));
908     }
909 
910     static WhereClauses getAppIdsWhereClause(Set<Long> appIds) {
911         return new WhereClauses(AND)
912                 .addWhereInLongsClause(
913                         MedicalDataSourceHelper.getAppInfoIdColumnName(), appIds.stream().toList());
914     }
915 
916     private static OrderByClause getOrderByClause() {
917         return new OrderByClause()
918                 .addOrderByClause(MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME, /* isAscending= */ true);
919     }
920 
921     private static WhereClauses getReadByLastRowIdWhereClause(long lastRowId) {
922         WhereClauses whereClauses = new WhereClauses(AND);
923 
924         if (lastRowId == DEFAULT_LONG) {
925             return whereClauses;
926         }
927 
928         whereClauses.addWhereGreaterThanClause(MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME, lastRowId);
929         return whereClauses;
930     }
931 
932     /**
933      * Upserts (insert/update) a list of {@link MedicalResource}s created based on the given list of
934      * {@link UpsertMedicalResourceInternalRequest}s into the HealthConnect database.
935      *
936      * @param upsertMedicalResourceInternalRequests a list of {@link
937      *     UpsertMedicalResourceInternalRequest}.
938      * @return List of {@link MedicalResource}s that were upserted into the database, in the same
939      *     order as their associated {@link UpsertMedicalResourceInternalRequest}s.
940      * @throws IllegalArgumentException if the data source id does not exist, or if a resource's
941      *     FHIR version does not match the data source's FHIR version.
942      */
943     public List<MedicalResource> upsertMedicalResources(
944             String callingPackageName,
945             List<UpsertMedicalResourceInternalRequest> upsertMedicalResourceInternalRequests)
946             throws SQLiteException {
947         if (Constants.DEBUG) {
948             Slog.d(
949                     TAG,
950                     "Upserting "
951                             + upsertMedicalResourceInternalRequests.size()
952                             + " "
953                             + UpsertMedicalResourceInternalRequest.class.getSimpleName()
954                             + "(s).");
955         }
956         return mTransactionManager.runAsTransaction(
957                 (RunnableWithReturn<List<MedicalResource>, RuntimeException>)
958                         db ->
959                                 readDataSourcesAndUpsertMedicalResources(
960                                         db,
961                                         callingPackageName,
962                                         upsertMedicalResourceInternalRequests));
963     }
964 
965     private List<MedicalResource> readDataSourcesAndUpsertMedicalResources(
966             SQLiteDatabase db,
967             String callingPackageName,
968             List<UpsertMedicalResourceInternalRequest> upsertRequests) {
969         List<String> dataSourceUuids =
970                 upsertRequests.stream()
971                         .map(UpsertMedicalResourceInternalRequest::getDataSourceId)
972                         .toList();
973         long appInfoIdRestriction = mAppInfoHelper.getAppInfoId(callingPackageName);
974         Map<String, Pair<Long, FhirVersion>> dataSourceUuidToRowIdAndVersion =
975                 mMedicalDataSourceHelper.getUuidToRowIdAndVersionMap(
976                         db, appInfoIdRestriction, StorageUtils.toUuids(dataSourceUuids));
977 
978         // Standard Upsert code cannot be used as it uses a query with inline values to look for
979         // existing data. The FHIR id is a user supplied string, and so vulnerable to SQL injection.
980         // The Insert itself uses ContentValues (and so is safe) but there is also a read which is
981         // not.
982         // SQLite supports UPSERT https://www.sqlite.org/lang_upsert.html with ON CONFLICT DO UPDATE
983         // This was added in SQLite version 3.24.0. This has been supported since Android API 30.
984         // https://developer.android.com/reference/android/database/sqlite/package-summary.html
985         // So we use this.
986         for (UpsertMedicalResourceInternalRequest upsertRequest : upsertRequests) {
987             Pair<Long, FhirVersion> dataSourceRowIdAndVersion =
988                     dataSourceUuidToRowIdAndVersion.get(upsertRequest.getDataSourceId());
989             if (dataSourceRowIdAndVersion == null) {
990                 throw new IllegalArgumentException(
991                         "Invalid data source id: " + upsertRequest.getDataSourceId());
992             }
993             Long dataSourceRowId = dataSourceRowIdAndVersion.first;
994             String dataSourceFhirVersion = dataSourceRowIdAndVersion.second.toString();
995             if (!upsertRequest.getFhirVersion().equals(dataSourceFhirVersion)) {
996                 throw new IllegalArgumentException(
997                         "Invalid fhir version: "
998                                 + upsertRequest.getFhirVersion()
999                                 + ". It did not match the data source's fhir version");
1000             }
1001             ContentValues contentValues =
1002                     getContentValues(dataSourceRowId, upsertRequest, mTimeSource.getInstantNow());
1003             long rowId =
1004                     db.insertWithOnConflict(
1005                             MEDICAL_RESOURCE_TABLE_NAME,
1006                             /* nullColumnHack= */ null,
1007                             contentValues,
1008                             SQLiteDatabase.CONFLICT_REPLACE);
1009             int medicalResourceType = upsertRequest.getMedicalResourceType();
1010             db.insertWithOnConflict(
1011                     MedicalResourceIndicesHelper.getTableName(),
1012                     /* nullColumnHack= */ null,
1013                     MedicalResourceIndicesHelper.getContentValues(rowId, medicalResourceType),
1014                     SQLiteDatabase.CONFLICT_REPLACE);
1015         }
1016 
1017         List<MedicalResource> upsertedMedicalResources = new ArrayList<>();
1018         Set<Integer> resourceTypes = new HashSet<>();
1019         for (UpsertMedicalResourceInternalRequest upsertMedicalResourceInternalRequest :
1020                 upsertRequests) {
1021             MedicalResource medicalResource =
1022                     buildMedicalResource(upsertMedicalResourceInternalRequest);
1023             resourceTypes.add(medicalResource.getType());
1024             upsertedMedicalResources.add(medicalResource);
1025         }
1026 
1027         mAccessLogsHelper.addAccessLog(
1028                 db,
1029                 callingPackageName,
1030                 resourceTypes,
1031                 OPERATION_TYPE_UPSERT,
1032                 /* accessedMedicalDataSource= */ false);
1033 
1034         return upsertedMedicalResources;
1035     }
1036 
1037     @VisibleForTesting
1038     static ContentValues getContentValues(
1039             long dataSourceRowId,
1040             UpsertMedicalResourceInternalRequest upsertMedicalResourceInternalRequest,
1041             Instant instant) {
1042         ContentValues resourceContentValues = new ContentValues();
1043         resourceContentValues.put(DATA_SOURCE_ID_COLUMN_NAME, dataSourceRowId);
1044         resourceContentValues.put(
1045                 FHIR_DATA_COLUMN_NAME, upsertMedicalResourceInternalRequest.getData());
1046         resourceContentValues.put(
1047                 FHIR_RESOURCE_TYPE_COLUMN_NAME,
1048                 upsertMedicalResourceInternalRequest.getFhirResourceType());
1049         resourceContentValues.put(
1050                 FHIR_RESOURCE_ID_COLUMN_NAME,
1051                 upsertMedicalResourceInternalRequest.getFhirResourceId());
1052         resourceContentValues.put(LAST_MODIFIED_TIME_COLUMN_NAME, instant.toEpochMilli());
1053         return resourceContentValues;
1054     }
1055 
1056     /**
1057      * Create {@link ContentValues} for the given {@code dataSourceRowId}, {@code lastModifiedTime},
1058      * {@code appInfoId} and {@link MedicalResource}.
1059      *
1060      * <p>This is only used in DatabaseMerger code, where we want to provide a lastModifiedTimestamp
1061      * from the source database rather than based on the current time.
1062      */
1063     public static ContentValues getContentValues(
1064             long dataSourceRowId, long lastModifiedTime, MedicalResource resource) {
1065         FhirResource fhirResource = resource.getFhirResource();
1066         ContentValues resourceContentValues = new ContentValues();
1067         resourceContentValues.put(DATA_SOURCE_ID_COLUMN_NAME, dataSourceRowId);
1068         resourceContentValues.put(FHIR_DATA_COLUMN_NAME, fhirResource.getData());
1069         resourceContentValues.put(FHIR_RESOURCE_TYPE_COLUMN_NAME, fhirResource.getType());
1070         resourceContentValues.put(FHIR_RESOURCE_ID_COLUMN_NAME, fhirResource.getId());
1071         resourceContentValues.put(LAST_MODIFIED_TIME_COLUMN_NAME, lastModifiedTime);
1072         return resourceContentValues;
1073     }
1074 
1075     /**
1076      * Creates a {@link MedicalResource} for the given {@code uuid} and {@link
1077      * UpsertMedicalResourceInternalRequest}.
1078      */
1079     private static MedicalResource buildMedicalResource(
1080             UpsertMedicalResourceInternalRequest internalRequest) {
1081         FhirResource fhirResource =
1082                 new FhirResource.Builder(
1083                                 internalRequest.getFhirResourceType(),
1084                                 internalRequest.getFhirResourceId(),
1085                                 internalRequest.getData())
1086                         .build();
1087         return new MedicalResource.Builder(
1088                         internalRequest.getMedicalResourceType(),
1089                         internalRequest.getDataSourceId(),
1090                         parseFhirVersion(internalRequest.getFhirVersion()),
1091                         fhirResource)
1092                 .build();
1093     }
1094 
1095     /**
1096      * Returns a {@link ReadMedicalResourcesInternalResponse}.
1097      *
1098      * <p>This should be run within a transaction as it does multiple requests using the db passed
1099      * for the transaction.
1100      *
1101      * @param request the specification for the rows to read
1102      * @param pageSize the number of results to return in this page
1103      * @param pageTokenWrapper the page token for the query
1104      * @throws IllegalArgumentException if the cursor contains more than @link
1105      *     MAXIMUM_ALLOWED_CURSOR_COUNT} records.
1106      */
1107     public static ReadMedicalResourcesInternalResponse getMedicalResources(
1108             SQLiteDatabase db,
1109             ReadTableRequest request,
1110             PhrPageTokenWrapper pageTokenWrapper,
1111             int pageSize) {
1112         ReadMedicalResourcesInternalResponse response;
1113         // Get the count from a requests with no limit,
1114         int totalRowCount;
1115         if (Flags.phrReadMedicalResourcesFixQueryLimit()) {
1116             Integer originalLimit = request.getFinalLimit();
1117             request.setFinalLimit(null);
1118             totalRowCount = TransactionManager.count(db, request);
1119             request.setFinalLimit(originalLimit);
1120         } else {
1121             Integer originalLimit = request.getLimit();
1122             request.setLimit(null);
1123             totalRowCount = TransactionManager.count(db, request);
1124             request.setLimit(originalLimit);
1125         }
1126         try (Cursor cursor = db.rawQuery(request.getReadCommand(), null)) {
1127             response = getMedicalResources(cursor, pageTokenWrapper, pageSize, totalRowCount);
1128         }
1129         return response;
1130     }
1131 
1132     /**
1133      * Returns a {@link ReadMedicalResourcesInternalResponse}.
1134      *
1135      * @param pageSize the number of results to return in this page
1136      * @param totalRowCount the number of rows that would have been returned if this query was
1137      *     executed with no limit
1138      * @throws IllegalArgumentException if the cursor contains more than @link
1139      *     MAXIMUM_ALLOWED_CURSOR_COUNT} records.
1140      */
1141     private static ReadMedicalResourcesInternalResponse getMedicalResources(
1142             Cursor cursor, PhrPageTokenWrapper pageTokenWrapper, int pageSize, int totalRowCount) {
1143         // TODO(b/356613483): remove these checks in the helpers and instead validate pageSize
1144         // in the service.
1145         if (cursor.getCount() > MAXIMUM_ALLOWED_CURSOR_COUNT) {
1146             throw new IllegalArgumentException(
1147                     "Too many resources in the cursor. Max allowed: "
1148                             + MAXIMUM_ALLOWED_CURSOR_COUNT);
1149         }
1150         List<MedicalResource> medicalResources = new ArrayList<>();
1151         String nextPageToken = null;
1152         long lastRowId = DEFAULT_LONG;
1153         if (cursor.moveToFirst()) {
1154             do {
1155                 if (medicalResources.size() >= pageSize) {
1156                     nextPageToken = pageTokenWrapper.cloneWithNewLastRowId(lastRowId).encode();
1157                     break;
1158                 }
1159                 medicalResources.add(getMedicalResource(cursor));
1160                 lastRowId = getCursorLong(cursor, MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME);
1161             } while (cursor.moveToNext());
1162         }
1163 
1164         int remainingCount = totalRowCount - medicalResources.size();
1165         return new ReadMedicalResourcesInternalResponse(
1166                 medicalResources, nextPageToken, remainingCount);
1167     }
1168 
1169     /**
1170      * Returns List of {@code MedicalResource}s from the cursor. If the cursor contains more than
1171      * {@link Constants#MAXIMUM_ALLOWED_CURSOR_COUNT} records, it throws {@link
1172      * IllegalArgumentException}.
1173      */
1174     private static List<MedicalResource> getMedicalResources(Cursor cursor) {
1175         if (cursor.getCount() > MAXIMUM_ALLOWED_CURSOR_COUNT) {
1176             throw new IllegalArgumentException(
1177                     "Too many resources in the cursor. Max allowed: "
1178                             + MAXIMUM_ALLOWED_CURSOR_COUNT);
1179         }
1180         List<MedicalResource> medicalResources = new ArrayList<>();
1181         if (cursor.moveToFirst()) {
1182             do {
1183                 medicalResources.add(getMedicalResource(cursor));
1184             } while (cursor.moveToNext());
1185         }
1186         cursor.close();
1187         return medicalResources;
1188     }
1189 
1190     /**
1191      * Deletes a list of {@link MedicalResource}s created based on the given list of {@link
1192      * MedicalResourceId}s into the HealthConnect database.
1193      *
1194      * @param medicalResourceIds list of {@link MedicalResourceId} to delete
1195      */
1196     public void deleteMedicalResourcesByIdsWithoutPermissionChecks(
1197             List<MedicalResourceId> medicalResourceIds) {
1198         if (medicalResourceIds.isEmpty()) {
1199             throw new IllegalArgumentException("Nothing to delete specified");
1200         }
1201         Pair<String, String[]> paramsAndArgs =
1202                 makeParametersAndArgs(medicalResourceIds, /* appId= */ null);
1203         String whereClause = DELETE_ON_IDS_WHERE_CLAUSE + paramsAndArgs.first + ")";
1204         mTransactionManager.runAsTransaction(
1205                 db -> {
1206                     db.delete(MEDICAL_RESOURCE_TABLE_NAME, whereClause, paramsAndArgs.second);
1207                 });
1208     }
1209 
1210     /**
1211      * Deletes a list of {@link MedicalResource}s created based on the given list of {@link
1212      * MedicalResourceId}s into the HealthConnect database.
1213      *
1214      * @param medicalResourceIds list of {@link MedicalResourceId} to delete
1215      * @param callingPackageName Only allows deletions of resources whose owning datasource belongs
1216      *     to the given appInfoId.
1217      * @throws IllegalArgumentException if no appId exists for the given {@code packageName} in the
1218      *     {@link AppInfoHelper#TABLE_NAME}.
1219      */
1220     public void deleteMedicalResourcesByIdsWithPermissionChecks(
1221             List<MedicalResourceId> medicalResourceIds, String callingPackageName)
1222             throws SQLiteException {
1223 
1224         long appId = mAppInfoHelper.getAppInfoId(callingPackageName);
1225         if (appId == Constants.DEFAULT_LONG) {
1226             throw new IllegalArgumentException(
1227                     "Deletion not permitted as app has inserted no data.");
1228         }
1229 
1230         Pair<String, String[]> paramsAndArgs = makeParametersAndArgs(medicalResourceIds, appId);
1231         String whereClause = DELETE_ON_IDS_WHERE_CLAUSE + paramsAndArgs.first + ")";
1232         String[] args = paramsAndArgs.second;
1233 
1234         mTransactionManager.runAsTransaction(
1235                 db -> {
1236                     // Getting the distinct resource types that will be deleted, to add
1237                     // access logs.
1238                     Set<Integer> resourcesTypes =
1239                             readMedicalResourcesTypes(db, medicalResourceIds, appId);
1240 
1241                     db.delete(MEDICAL_RESOURCE_TABLE_NAME, whereClause, args);
1242 
1243                     if (!resourcesTypes.isEmpty()) {
1244                         mAccessLogsHelper.addAccessLog(
1245                                 db,
1246                                 callingPackageName,
1247                                 resourcesTypes,
1248                                 OPERATION_TYPE_DELETE,
1249                                 /* accessedMedicalDataSource= */ false);
1250                     }
1251                 });
1252     }
1253 
1254     private Set<Integer> readMedicalResourcesTypes(
1255             SQLiteDatabase db, List<MedicalResourceId> medicalResourceIds, long appId) {
1256         Pair<String, String[]> paramsAndArgs = makeParametersAndArgs(medicalResourceIds, appId);
1257         String sql =
1258                 "SELECT DISTINCT "
1259                         + MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName()
1260                         + " FROM "
1261                         + RESOURCES_JOIN_DATA_SOURCES_JOIN_INDICES
1262                         + " WHERE "
1263                         + SELECT_ON_IDS_WHERE_CLAUSE
1264                         + paramsAndArgs.first;
1265         Set<Integer> resourceTypes = new HashSet<>();
1266         try (Cursor cursor = db.rawQuery(sql, paramsAndArgs.second)) {
1267             if (cursor.moveToFirst()) {
1268                 do {
1269                     resourceTypes.add(getCursorInt(cursor, getMedicalResourceTypeColumnName()));
1270                 } while (cursor.moveToNext());
1271             }
1272         }
1273         return resourceTypes;
1274     }
1275 
1276     /**
1277      * Deletes all {@link MedicalResource}s that are part of the given datasource.
1278      *
1279      * <p>No error occurs if any of the ids are not present because the ids are just a part of the
1280      * filters.
1281      *
1282      * @param request which resources to delete.
1283      */
1284     public void deleteMedicalResourcesByRequestWithoutPermissionChecks(
1285             DeleteMedicalResourcesRequest request) throws SQLiteException {
1286         Set<String> dataSourceIds = request.getDataSourceIds();
1287         Set<Integer> medicalResourceTypes = request.getMedicalResourceTypes();
1288         List<UUID> dataSourceUuids = StorageUtils.toUuids(dataSourceIds);
1289         if (dataSourceUuids.isEmpty() && !dataSourceIds.isEmpty()) {
1290             // The request came in with no valid UUIDs. Do nothing.
1291             return;
1292         }
1293         mTransactionManager.delete(
1294                 getFilteredDeleteRequest(dataSourceUuids, medicalResourceTypes, /* appId= */ null));
1295     }
1296 
1297     /**
1298      * Deletes all {@link MedicalResource}s that are part of the given datasource.
1299      *
1300      * <p>No error occurs if any of the ids are not present because the ids are just a part of the
1301      * filters.
1302      *
1303      * @param request which resources to delete.
1304      * @param callingPackageName only allows deletions of data sources belonging to the given app
1305      * @throws IllegalArgumentException if the {@code callingPackageName} does not exist in the
1306      *     {@link AppInfoHelper#TABLE_NAME}. This can happen if the app has never written any data
1307      *     sources.
1308      */
1309     public void deleteMedicalResourcesByRequestWithPermissionChecks(
1310             DeleteMedicalResourcesRequest request, String callingPackageName)
1311             throws SQLiteException {
1312         Set<String> dataSourceIds = request.getDataSourceIds();
1313         Set<Integer> medicalResourceTypes = request.getMedicalResourceTypes();
1314         List<UUID> dataSourceUuids = StorageUtils.toUuids(dataSourceIds);
1315         if (dataSourceUuids.isEmpty() && !dataSourceIds.isEmpty()) {
1316             // The request came in with no valid UUIDs. Do nothing.
1317             return;
1318         }
1319 
1320         long appId = mAppInfoHelper.getAppInfoId(callingPackageName);
1321         if (appId == Constants.DEFAULT_LONG) {
1322             throw new IllegalArgumentException(
1323                     "Deletion not permitted as app has inserted no data.");
1324         }
1325 
1326         mTransactionManager.runAsTransaction(
1327                 db -> {
1328                     // Getting the distinct resource types that will be deleted, to add
1329                     // access logs.
1330                     ReadTableRequest readRequest =
1331                             getFilteredReadRequestForDistinctResourceTypes(
1332                                     dataSourceUuids, medicalResourceTypes, appId);
1333                     Set<Integer> resourceTypes =
1334                             readMedicalResourcesTypesByReadRequest(db, readRequest);
1335 
1336                     mTransactionManager.delete(
1337                             db,
1338                             getFilteredDeleteRequest(dataSourceUuids, medicalResourceTypes, appId));
1339 
1340                     if (!resourceTypes.isEmpty()) {
1341                         mAccessLogsHelper.addAccessLog(
1342                                 db,
1343                                 callingPackageName,
1344                                 resourceTypes,
1345                                 OPERATION_TYPE_DELETE,
1346                                 /* accessedMedicalDataSource= */ false);
1347                     }
1348                 });
1349     }
1350 
1351     private Set<Integer> readMedicalResourcesTypesByReadRequest(
1352             SQLiteDatabase db, ReadTableRequest request) {
1353         Set<Integer> resourceTypes = new HashSet<>();
1354         try (Cursor cursor = mTransactionManager.read(db, request)) {
1355             if (cursor.moveToFirst()) {
1356                 do {
1357                     resourceTypes.add(getCursorInt(cursor, getMedicalResourceTypeColumnName()));
1358                 } while (cursor.moveToNext());
1359             }
1360         }
1361         return resourceTypes;
1362     }
1363 
1364     private DeleteTableRequest getFilteredDeleteRequest(
1365             List<UUID> dataSourceUuids, Set<Integer> medicalResourceTypes, @Nullable Long appId) {
1366         /*
1367            SQLite does not allow deletes with joins. So the following code does a select with
1368            appropriate joins, and then deletes the result. This is doing the following SQL code:
1369 
1370            DELETE FROM medical_resource_table
1371            WHERE medical_resource_row_id IN (
1372              SELECT medical_resource_row_id FROM medical_resource_table
1373              JOIN medical_indices_table ...
1374              JOIN medical_datasource_table ...
1375              WHERE data_source_uuid IN (uuid1, uuid2, ...)
1376              AND app_info_id IN (id1, id2, ...)
1377            )
1378 
1379            The ReadTableRequest does the inner select, and the DeleteTableRequest does the outer
1380            delete. The foreign key between medical_resource_table and medical_data_source_table is
1381            (datasource) PRIMARY_COLUMN_NAME = (resource) DATA_SOURCE_ID_COLUMN_NAME.
1382         */
1383 
1384         WhereClauses dataSourceWhereClauses =
1385                 MedicalDataSourceHelper.getWhereClauses(dataSourceUuids, appId);
1386         SqlJoin dataSourceJoin = joinWithMedicalDataSourceTable();
1387         dataSourceJoin.setSecondTableWhereClause(dataSourceWhereClauses);
1388 
1389         SqlJoin indexJoin =
1390                 getJoinWithIndicesTableFilterOnMedicalResourceTypes(medicalResourceTypes);
1391         indexJoin.attachJoin(dataSourceJoin);
1392 
1393         ReadTableRequest innerRead =
1394                 new ReadTableRequest(getMainTableName())
1395                         .setJoinClause(indexJoin)
1396                         .setColumnNames(List.of(getPrimaryColumn()));
1397 
1398         return new DeleteTableRequest(getMainTableName())
1399                 .addExtraWhereClauses(
1400                         new WhereClauses(AND)
1401                                 .addWhereInSQLRequestClause(getPrimaryColumn(), innerRead));
1402     }
1403 
1404     private static MedicalResource getMedicalResource(Cursor cursor) {
1405         int fhirResourceTypeInt = getCursorInt(cursor, FHIR_RESOURCE_TYPE_COLUMN_NAME);
1406         FhirResource fhirResource =
1407                 new FhirResource.Builder(
1408                                 fhirResourceTypeInt,
1409                                 getCursorString(cursor, FHIR_RESOURCE_ID_COLUMN_NAME),
1410                                 getCursorString(cursor, FHIR_DATA_COLUMN_NAME))
1411                         .build();
1412         FhirVersion fhirVersion =
1413                 parseFhirVersion(getCursorString(cursor, getFhirVersionColumnName()));
1414         long lastModifiedTimestamp =
1415                 getCursorLong(cursor, LAST_MODIFIED_TIMESTAMP_MEDICAL_RESOURCE_ALIAS);
1416         return new MedicalResource(
1417                 getCursorInt(cursor, getMedicalResourceTypeColumnName()),
1418                 getCursorUUID(cursor, getDataSourceUuidColumnName()).toString(),
1419                 fhirVersion,
1420                 fhirResource,
1421                 lastModifiedTimestamp);
1422     }
1423 
1424     /**
1425      * Creates sql and arguments suitable for appending to a WHERE clause specifying medical
1426      * resource ids.
1427      *
1428      * @param medicalResourceIds a non-empty list of ids to specify in the where clause.
1429      * @param appId if not null an app id which should be AND combined in the where clause.
1430      * @return a pair where the first element is SQL that can be appended to a relevant where clause
1431      *     with some values parameterised. The second element is the values for those parameters
1432      * @throws IllegalArgumentException if any of the ids have a data source id that is not valid
1433      *     (not a String form of a UUID)
1434      */
1435     private static Pair<String, String[]> makeParametersAndArgs(
1436             List<MedicalResourceId> medicalResourceIds, @Nullable Long appId) {
1437         if (medicalResourceIds.isEmpty()) {
1438             throw new IllegalArgumentException("No ids provided");
1439         }
1440         StringBuilder parameters = new StringBuilder();
1441         // Data source id is not passed as a parameter as the rawQuery API does not allow
1442         // BLOBs as strings. So unfortunately we inline the datasource id. This means there
1443         // are only 2 parameters per medical resource id, not 3.
1444         // One potential future improvement is to keep a Data source id map in memory as is
1445         // done for App id.
1446         String[] selectionArgs =
1447                 new String[2 * medicalResourceIds.size() + (appId == null ? 0 : 1)];
1448         int index = 0;
1449         parameters.append('(');
1450         for (MedicalResourceId id : medicalResourceIds) {
1451             index = appendMedicalResourceId(id, parameters, selectionArgs, index);
1452         }
1453         // replace a trailing comma with a )
1454         parameters.setCharAt(parameters.length() - 1, ')');
1455         if (appId != null) {
1456             parameters
1457                     .append(" AND ")
1458                     .append(MedicalDataSourceHelper.getAppInfoIdColumnName())
1459                     .append("=?");
1460             selectionArgs[index] = String.valueOf(appId);
1461         }
1462 
1463         return new Pair<>(parameters.toString(), selectionArgs);
1464     }
1465 
1466     /**
1467      * Creates SQL and selection arguments for the given {@link MedicalResourceId}s joining with
1468      * medical_resource_indices table and medical_data_source table and filtering on appId of the
1469      * {@code callingPackageName} and {@code medicalResourceTypes}.
1470      *
1471      * @param medicalResourceTypes a non-empty set of medical resource types to include.
1472      * @param appId an app id to filter to, or if null all app ids will be included
1473      * @throws IllegalArgumentException if any of the medical resource ids is not valid (has a data
1474      *     source if which is not a valid string form of a UUID)
1475      */
1476     private static Pair<String, String[]> readResourcesByIdsAppIdResourceTypes(
1477             List<MedicalResourceId> medicalResourceIds,
1478             @Nullable Long appId,
1479             LogicalOperator howToCombineAppIdAndResourceTypes,
1480             Set<Integer> medicalResourceTypes) {
1481         StringBuilder sql =
1482                 new StringBuilder(
1483                         "SELECT "
1484                                 + getMedicalResourceColumns()
1485                                 + " FROM "
1486                                 + RESOURCES_JOIN_DATA_SOURCES_JOIN_INDICES
1487                                 + " WHERE "
1488                                 + SELECT_ON_IDS_WHERE_CLAUSE);
1489 
1490         String[] selectionArgs =
1491                 new String
1492                         [2 * medicalResourceIds.size()
1493                                 + (appId == null ? 0 : 1)
1494                                 + medicalResourceTypes.size()];
1495         int index = 0;
1496         sql.append('(');
1497         for (MedicalResourceId id : medicalResourceIds) {
1498             index = appendMedicalResourceId(id, sql, selectionArgs, index);
1499         }
1500         // replace a trailing comma with a )
1501         sql.setCharAt(sql.length() - 1, ')');
1502 
1503         sql.append(" AND (");
1504         if (appId != null) {
1505             sql.append(MedicalDataSourceHelper.getAppInfoIdColumnName())
1506                     .append("=?")
1507                     .append(
1508                             howToCombineAppIdAndResourceTypes.equals(LogicalOperator.AND)
1509                                     ? " AND "
1510                                     : " OR ");
1511             selectionArgs[index++] = String.valueOf(appId);
1512         }
1513         sql.append(MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName()).append(" IN (");
1514 
1515         for (Integer type : medicalResourceTypes) {
1516             sql.append("?,");
1517             selectionArgs[index++] = String.valueOf(type);
1518         }
1519         // Replace closing comma with closing bracket for IN
1520         sql.setCharAt(sql.length() - 1, ')');
1521         sql.append(")");
1522         return new Pair<>(sql.toString(), selectionArgs);
1523     }
1524 
1525     /**
1526      * Appends a medical resource id to both an SQL {@code StringBuilder} as a parameter and to an
1527      * array of arguments.
1528      *
1529      * @param id the id to append
1530      * @param sql the SQL string being built
1531      * @param selectionArgs the array holding the arguments for the SQL parameters
1532      * @param index the index to put the argument into {@code selectionArgs}
1533      * @return the new index for the next insert
1534      * @throws IllegalArgumentException if the data source id is not a valid UUID
1535      */
1536     private static int appendMedicalResourceId(
1537             MedicalResourceId id, StringBuilder sql, String[] selectionArgs, int index) {
1538         // Data source id is not passed as a parameter as the rawQuery API does not allow
1539         // BLOBs as strings. So unfortunately we inline the datasource id.
1540         // One potential future improvement is to keep a Data source id map in memory as is
1541         // done for App id.
1542         sql.append("(")
1543                 .append(StorageUtils.getHexString(UUID.fromString(id.getDataSourceId())))
1544                 .append(",?,?),");
1545         selectionArgs[index++] = String.valueOf(id.getFhirResourceType());
1546         selectionArgs[index++] = id.getFhirResourceId();
1547         return index;
1548     }
1549 
1550     private enum LogicalOperator {
1551         AND,
1552         OR
1553     }
1554 }
1555