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