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