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.MAXIMUM_ALLOWED_CURSOR_COUNT; 20 21 import static com.android.healthfitness.flags.Flags.personalHealthRecord; 22 import static com.android.server.healthconnect.storage.HealthConnectDatabase.createTable; 23 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.LAST_MODIFIED_TIME_COLUMN_NAME; 24 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME; 25 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.UUID_COLUMN_NAME; 26 import static com.android.server.healthconnect.storage.utils.StorageUtils.BLOB_UNIQUE_NON_NULL; 27 import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER; 28 import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER_NOT_NULL; 29 import static com.android.server.healthconnect.storage.utils.StorageUtils.PRIMARY_AUTOINCREMENT; 30 import static com.android.server.healthconnect.storage.utils.StorageUtils.TEXT_NOT_NULL; 31 import static com.android.server.healthconnect.storage.utils.StorageUtils.TEXT_NULL; 32 import static com.android.server.healthconnect.storage.utils.StorageUtils.generateMedicalResourceUUID; 33 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorInt; 34 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorLong; 35 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorString; 36 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorUUID; 37 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.AND; 38 39 import android.annotation.NonNull; 40 import android.content.ContentValues; 41 import android.database.Cursor; 42 import android.database.sqlite.SQLiteDatabase; 43 import android.health.connect.MedicalResourceId; 44 import android.health.connect.datatypes.MedicalResource; 45 import android.health.connect.internal.datatypes.MedicalResourceInternal; 46 import android.util.Pair; 47 48 import com.android.internal.annotations.VisibleForTesting; 49 import com.android.server.healthconnect.storage.request.CreateTableRequest; 50 import com.android.server.healthconnect.storage.request.ReadTableRequest; 51 import com.android.server.healthconnect.storage.request.UpsertTableRequest; 52 import com.android.server.healthconnect.storage.utils.StorageUtils; 53 import com.android.server.healthconnect.storage.utils.WhereClauses; 54 55 import java.util.ArrayList; 56 import java.util.HashMap; 57 import java.util.List; 58 import java.util.Locale; 59 import java.util.Map; 60 import java.util.UUID; 61 62 /** 63 * Helper class for MedicalResource table. 64 * 65 * @hide 66 */ 67 public final class MedicalResourceHelper { 68 @VisibleForTesting static final String MEDICAL_RESOURCE_TABLE_NAME = "medical_resource_table"; 69 @VisibleForTesting static final String FHIR_RESOURCE_TYPE_COLUMN_NAME = "fhir_resource_type"; 70 @VisibleForTesting static final String FHIR_DATA_COLUMN_NAME = "fhir_data"; 71 @VisibleForTesting static final String FHIR_VERSION_COLUMN_NAME = "fhir_version"; 72 @VisibleForTesting static final String DATA_SOURCE_ID_COLUMN_NAME = "data_source_id"; 73 @VisibleForTesting static final String FHIR_RESOURCE_ID_COLUMN_NAME = "fhir_resource_id"; 74 private static final List<Pair<String, Integer>> UNIQUE_COLUMNS_INFO = 75 List.of(new Pair<>(UUID_COLUMN_NAME, UpsertTableRequest.TYPE_BLOB)); 76 private static final String FHIR_RESOURCE_TYPE_IMMUNIZATION = "IMMUNIZATION"; 77 private static final int FHIR_RESOURCE_TYPE_UNKNOWN = 0; 78 private static final int FHIR_RESOURCE_TYPE_IMMUNIZATION_INT = 1; 79 // This maps the fhir_resource_type string to an integer representation. The integer 80 // representation does not necessarily match the MEDICAL_RESOURCE_TYPE. 81 // As multiple fhir_resource_type(s) could belong to a single MEDICAL_RESOURCE_TYPE. 82 private static final Map<String, Integer> FHIR_RESOURCE_TYPE_TO_INT = new HashMap<>(); 83 84 static { FHIR_RESOURCE_TYPE_TO_INT.put( FHIR_RESOURCE_TYPE_IMMUNIZATION, FHIR_RESOURCE_TYPE_IMMUNIZATION_INT)85 FHIR_RESOURCE_TYPE_TO_INT.put( 86 FHIR_RESOURCE_TYPE_IMMUNIZATION, FHIR_RESOURCE_TYPE_IMMUNIZATION_INT); 87 } 88 89 private static final Map<Integer, Integer> FHIR_RESOURCE_TYPE_TO_MEDICAL_RESOURCE_TYPE = 90 new HashMap<>(); 91 92 @NonNull getMainTableName()93 public static String getMainTableName() { 94 return MEDICAL_RESOURCE_TABLE_NAME; 95 } 96 97 @NonNull getColumnInfo()98 private static List<Pair<String, String>> getColumnInfo() { 99 return List.of( 100 Pair.create(PRIMARY_COLUMN_NAME, PRIMARY_AUTOINCREMENT), 101 Pair.create(FHIR_RESOURCE_TYPE_COLUMN_NAME, INTEGER_NOT_NULL), 102 Pair.create(FHIR_RESOURCE_ID_COLUMN_NAME, TEXT_NOT_NULL), 103 Pair.create(FHIR_DATA_COLUMN_NAME, TEXT_NOT_NULL), 104 Pair.create(FHIR_VERSION_COLUMN_NAME, TEXT_NULL), 105 Pair.create(DATA_SOURCE_ID_COLUMN_NAME, INTEGER), 106 Pair.create(UUID_COLUMN_NAME, BLOB_UNIQUE_NON_NULL), 107 Pair.create(LAST_MODIFIED_TIME_COLUMN_NAME, INTEGER)); 108 } 109 110 // TODO(b/338198993): add unit tests covering getCreateTableRequest. 111 @NonNull getCreateTableRequest()112 public static CreateTableRequest getCreateTableRequest() { 113 return new CreateTableRequest(MEDICAL_RESOURCE_TABLE_NAME, getColumnInfo()); 114 } 115 116 /** Creates the Medical Resource related tables. */ onInitialUpgrade(@onNull SQLiteDatabase db)117 public static void onInitialUpgrade(@NonNull SQLiteDatabase db) { 118 createTable(db, getCreateTableRequest()); 119 } 120 121 // TODO(b/345464102): We need to update this logic to join with indices table once we 122 // have that. 123 /** Creates {@link ReadTableRequest} for the given {@link MedicalResourceId}s. */ 124 @NonNull getReadTableRequest( @onNull List<MedicalResourceId> medicalResourceIds)125 public static ReadTableRequest getReadTableRequest( 126 @NonNull List<MedicalResourceId> medicalResourceIds) { 127 return new ReadTableRequest(getMainTableName()) 128 .setWhereClause(getReadTableWhereClause(medicalResourceIds)); 129 } 130 getReadTableWhereClause( @onNull List<MedicalResourceId> medicalResourceIds)131 private static WhereClauses getReadTableWhereClause( 132 @NonNull List<MedicalResourceId> medicalResourceIds) { 133 List<UUID> ids = 134 medicalResourceIds.stream() 135 .map( 136 medicalResourceId -> 137 generateMedicalResourceUUID( 138 medicalResourceId.getFhirResourceId(), 139 medicalResourceId.getFhirResourceType(), 140 medicalResourceId.getDataSourceId())) 141 .toList(); 142 return new WhereClauses(AND) 143 .addWhereInClauseWithoutQuotes( 144 UUID_COLUMN_NAME, StorageUtils.getListOfHexStrings(ids)); 145 } 146 147 /** Creates {@link UpsertTableRequest} for the given {@link MedicalResourceInternal}. */ 148 @NonNull getUpsertTableRequest( @onNull UUID uuid, @NonNull MedicalResourceInternal medicalResourceInternal)149 public static UpsertTableRequest getUpsertTableRequest( 150 @NonNull UUID uuid, @NonNull MedicalResourceInternal medicalResourceInternal) { 151 ContentValues contentValues = getContentValues(uuid, medicalResourceInternal); 152 return new UpsertTableRequest(getMainTableName(), contentValues, UNIQUE_COLUMNS_INFO); 153 } 154 155 // TODO(b/337020055): populate the rest of the fields. 156 @NonNull getContentValues( @onNull UUID uuid, @NonNull MedicalResourceInternal medicalResourceInternal)157 private static ContentValues getContentValues( 158 @NonNull UUID uuid, @NonNull MedicalResourceInternal medicalResourceInternal) { 159 ContentValues resourceContentValues = new ContentValues(); 160 resourceContentValues.put(UUID_COLUMN_NAME, StorageUtils.convertUUIDToBytes(uuid)); 161 resourceContentValues.put( 162 DATA_SOURCE_ID_COLUMN_NAME, 163 Long.parseLong(medicalResourceInternal.getDataSourceId())); 164 resourceContentValues.put(FHIR_DATA_COLUMN_NAME, medicalResourceInternal.getData()); 165 resourceContentValues.put( 166 FHIR_RESOURCE_TYPE_COLUMN_NAME, 167 getFhirResourceTypeInt(medicalResourceInternal.getFhirResourceType())); 168 resourceContentValues.put( 169 FHIR_RESOURCE_ID_COLUMN_NAME, medicalResourceInternal.getFhirResourceId()); 170 return resourceContentValues; 171 } 172 173 /** 174 * Creates a {@link MedicalResource} for the given {@code uuid} and {@link 175 * MedicalResourceInternal}. 176 */ buildMedicalResource( @onNull UUID uuid, @NonNull MedicalResourceInternal medicalResourceInternal)177 public static MedicalResource buildMedicalResource( 178 @NonNull UUID uuid, @NonNull MedicalResourceInternal medicalResourceInternal) { 179 return new MedicalResource.Builder( 180 uuid.toString(), 181 getMedicalResourceType(medicalResourceInternal.getFhirResourceType()), 182 medicalResourceInternal.getDataSourceId(), 183 medicalResourceInternal.getData()) 184 .build(); 185 } 186 187 /** 188 * Returns List of {@code MedicalResource}s from the cursor. If the cursor contains more than 189 * {@link MAXIMUM_ALLOWED_CURSOR_COUNT} records, it throws {@link IllegalArgumentException}. 190 */ getMedicalResources(Cursor cursor)191 public static List<MedicalResource> getMedicalResources(Cursor cursor) { 192 if (cursor.getCount() > MAXIMUM_ALLOWED_CURSOR_COUNT) { 193 throw new IllegalArgumentException( 194 "Too many resources in the cursor. Max allowed: " 195 + MAXIMUM_ALLOWED_CURSOR_COUNT); 196 } 197 List<MedicalResource> medicalResources = new ArrayList<>(); 198 if (cursor.moveToFirst()) { 199 do { 200 medicalResources.add(getMedicalResource(cursor)); 201 } while (cursor.moveToNext()); 202 } 203 cursor.close(); 204 return medicalResources; 205 } 206 207 /** 208 * Returns the {@link MedicalResource.MedicalResourceType} integer representation of the {@code 209 * fhirResourceType}. 210 */ getMedicalResourceType(@onNull String fhirResourceType)211 private static int getMedicalResourceType(@NonNull String fhirResourceType) { 212 int fhirResourceTypeInt = getFhirResourceTypeInt(fhirResourceType); 213 return getMedicalResourceType(fhirResourceTypeInt); 214 } 215 216 /** 217 * Returns the {@link MedicalResource.MedicalResourceType} integer representation of the given 218 * {@code fhirResourceTypeInt}. 219 */ getMedicalResourceType(int fhirResourceTypeInt)220 private static int getMedicalResourceType(int fhirResourceTypeInt) { 221 // TODO(b/342574702): remove the default value once we have validation and it is more 222 // clear what resources should through to the database. 223 if (personalHealthRecord()) { 224 return initIfNecessaryAndGetFhirResourceToMedicalResourceMap() 225 .getOrDefault( 226 fhirResourceTypeInt, MedicalResource.MEDICAL_RESOURCE_TYPE_UNKNOWN); 227 } 228 throw new UnsupportedOperationException( 229 "this case should never happen because we have a check at the top of the API impl" 230 + " in HealthConnectServiceImpl"); 231 } 232 233 /** Returns the integer representation of the given {@code fhirResourceType}. */ getFhirResourceTypeInt(@onNull String fhirResourceType)234 static int getFhirResourceTypeInt(@NonNull String fhirResourceType) { 235 // TODO(b/342574702): remove the default value once we have validation and it is more 236 // clear what resources should through to the database. 237 return FHIR_RESOURCE_TYPE_TO_INT.getOrDefault( 238 fhirResourceType.toUpperCase(Locale.ROOT), FHIR_RESOURCE_TYPE_UNKNOWN); 239 } 240 getMedicalResource(Cursor cursor)241 private static MedicalResource getMedicalResource(Cursor cursor) { 242 int fhirResourceTypeInt = getCursorInt(cursor, FHIR_RESOURCE_TYPE_COLUMN_NAME); 243 return new MedicalResource.Builder( 244 getCursorUUID(cursor, UUID_COLUMN_NAME).toString(), 245 getMedicalResourceType(fhirResourceTypeInt), 246 String.valueOf(getCursorLong(cursor, DATA_SOURCE_ID_COLUMN_NAME)), 247 getCursorString(cursor, FHIR_DATA_COLUMN_NAME)) 248 .build(); 249 } 250 initIfNecessaryAndGetFhirResourceToMedicalResourceMap()251 private static Map<Integer, Integer> initIfNecessaryAndGetFhirResourceToMedicalResourceMap() { 252 if (personalHealthRecord()) { 253 FHIR_RESOURCE_TYPE_TO_MEDICAL_RESOURCE_TYPE.put( 254 FHIR_RESOURCE_TYPE_IMMUNIZATION_INT, 255 MedicalResource.MEDICAL_RESOURCE_TYPE_IMMUNIZATION); 256 return FHIR_RESOURCE_TYPE_TO_MEDICAL_RESOURCE_TYPE; 257 } 258 return new HashMap<>(); 259 } 260 MedicalResourceHelper()261 private MedicalResourceHelper() {} 262 } 263