• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.server.healthconnect.storage.utils;
18 
19 import static android.health.connect.HealthDataCategory.ACTIVITY;
20 import static android.health.connect.HealthDataCategory.SLEEP;
21 import static android.health.connect.datatypes.AggregationType.SUM;
22 import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_BASAL_METABOLIC_RATE;
23 import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_HYDRATION;
24 import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_NUTRITION;
25 import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_TOTAL_CALORIES_BURNED;
26 import static android.text.TextUtils.isEmpty;
27 
28 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.CLIENT_RECORD_ID_COLUMN_NAME;
29 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME;
30 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.UUID_COLUMN_NAME;
31 import static com.android.server.healthconnect.storage.utils.RecordTypeForUuidMappings.getRecordTypeIdForUuid;
32 
33 import static java.util.Objects.requireNonNull;
34 
35 import android.annotation.NonNull;
36 import android.annotation.Nullable;
37 import android.content.ContentValues;
38 import android.database.Cursor;
39 import android.health.connect.HealthDataCategory;
40 import android.health.connect.RecordIdFilter;
41 import android.health.connect.internal.datatypes.InstantRecordInternal;
42 import android.health.connect.internal.datatypes.IntervalRecordInternal;
43 import android.health.connect.internal.datatypes.RecordInternal;
44 import android.health.connect.internal.datatypes.utils.RecordMapper;
45 import android.health.connect.internal.datatypes.utils.RecordTypeRecordCategoryMapper;
46 import android.util.Slog;
47 
48 import com.android.server.healthconnect.storage.datatypehelpers.HealthDataCategoryPriorityHelper;
49 
50 import java.nio.ByteBuffer;
51 import java.time.Duration;
52 import java.time.Period;
53 import java.time.ZoneOffset;
54 import java.time.temporal.ChronoUnit;
55 import java.util.ArrayList;
56 import java.util.Arrays;
57 import java.util.Collections;
58 import java.util.List;
59 import java.util.UUID;
60 import java.util.stream.Collectors;
61 
62 /**
63  * An util class for HC storage
64  *
65  * @hide
66  */
67 public final class StorageUtils {
68     public static final String TEXT_NOT_NULL = "TEXT NOT NULL";
69     public static final String TEXT_NOT_NULL_UNIQUE = "TEXT NOT NULL UNIQUE";
70     public static final String TEXT_NULL = "TEXT";
71     public static final String INTEGER = "INTEGER";
72     public static final String INTEGER_UNIQUE = "INTEGER UNIQUE";
73     public static final String INTEGER_NOT_NULL_UNIQUE = "INTEGER NOT NULL UNIQUE";
74     public static final String INTEGER_NOT_NULL = "INTEGER NOT NULL";
75     public static final String REAL = "REAL";
76     public static final String REAL_NOT_NULL = "REAL NOT NULL";
77     public static final String PRIMARY_AUTOINCREMENT = "INTEGER PRIMARY KEY AUTOINCREMENT";
78     public static final String PRIMARY = "INTEGER PRIMARY KEY";
79     public static final String DELIMITER = ",";
80     public static final String BLOB = "BLOB";
81     public static final String BLOB_UNIQUE_NULL = "BLOB UNIQUE";
82     public static final String BLOB_UNIQUE_NON_NULL = "BLOB NOT NULL UNIQUE";
83     public static final String BLOB_NON_NULL = "BLOB NOT NULL";
84     public static final String SELECT_ALL = "SELECT * FROM ";
85     public static final String LIMIT_SIZE = " LIMIT ";
86     public static final int BOOLEAN_FALSE_VALUE = 0;
87     public static final int BOOLEAN_TRUE_VALUE = 1;
88     public static final int UUID_BYTE_SIZE = 16;
89     private static final String TAG = "HealthConnectUtils";
90 
91     // Returns null if fetching any of the fields resulted in an error
92     @Nullable
getConflictErrorMessageForRecord( Cursor cursor, ContentValues contentValues)93     public static String getConflictErrorMessageForRecord(
94             Cursor cursor, ContentValues contentValues) {
95         try {
96             return "Updating record with uuid: "
97                     + convertBytesToUUID(contentValues.getAsByteArray(UUID_COLUMN_NAME))
98                     + " and client record id: "
99                     + contentValues.getAsString(CLIENT_RECORD_ID_COLUMN_NAME)
100                     + " conflicts with an existing record with uuid: "
101                     + getCursorUUID(cursor, UUID_COLUMN_NAME)
102                     + "  and client record id: "
103                     + getCursorString(cursor, CLIENT_RECORD_ID_COLUMN_NAME);
104         } catch (Exception exception) {
105             Slog.e(TAG, "", exception);
106             return null;
107         }
108     }
109 
110     /**
111      * Sets UUID for the given record. If {@link RecordInternal#getClientRecordId()} is null or
112      * empty, then the UUID is randomly generated. Otherwise, the UUID is generated as a combination
113      * of {@link RecordInternal#getPackageName()}, {@link RecordInternal#getClientRecordId()} and
114      * {@link RecordInternal#getRecordType()}.
115      */
addNameBasedUUIDTo(@onNull RecordInternal<?> recordInternal)116     public static void addNameBasedUUIDTo(@NonNull RecordInternal<?> recordInternal) {
117         final String clientRecordId = recordInternal.getClientRecordId();
118         if (isEmpty(clientRecordId)) {
119             recordInternal.setUuid(UUID.randomUUID());
120             return;
121         }
122 
123         final UUID uuid =
124                 getUUID(
125                         requireNonNull(recordInternal.getPackageName()),
126                         clientRecordId,
127                         recordInternal.getRecordType());
128         recordInternal.setUuid(uuid);
129     }
130 
131     /** Updates the uuid using the clientRecordID if the clientRecordId is present. */
updateNameBasedUUIDIfRequired(@onNull RecordInternal<?> recordInternal)132     public static void updateNameBasedUUIDIfRequired(@NonNull RecordInternal<?> recordInternal) {
133         final String clientRecordId = recordInternal.getClientRecordId();
134         if (isEmpty(clientRecordId)) {
135             // If clientRecordID is absent, use the uuid already set in the input record and
136             // hence no need to modify it.
137             return;
138         }
139 
140         final UUID uuid =
141                 getUUID(
142                         requireNonNull(recordInternal.getPackageName()),
143                         clientRecordId,
144                         recordInternal.getRecordType());
145         recordInternal.setUuid(uuid);
146     }
147 
148     /**
149      * Returns a UUID for the given {@link RecordIdFilter} and package name. If {@link
150      * RecordIdFilter#getClientRecordId()} is null or empty, then the UUID corresponds to {@link
151      * RecordIdFilter#getId()}. Otherwise, the UUID is generated as a combination of the package
152      * name, {@link RecordIdFilter#getClientRecordId()} and {@link RecordIdFilter#getRecordType()}.
153      */
getUUIDFor( @onNull RecordIdFilter recordIdFilter, @NonNull String packageName)154     public static UUID getUUIDFor(
155             @NonNull RecordIdFilter recordIdFilter, @NonNull String packageName) {
156         final String clientRecordId = recordIdFilter.getClientRecordId();
157         if (isEmpty(clientRecordId)) {
158             return UUID.fromString(recordIdFilter.getId());
159         }
160 
161         return getUUID(
162                 packageName,
163                 clientRecordId,
164                 RecordMapper.getInstance().getRecordType(recordIdFilter.getRecordType()));
165     }
166 
addPackageNameTo( @onNull RecordInternal<?> recordInternal, @NonNull String packageName)167     public static void addPackageNameTo(
168             @NonNull RecordInternal<?> recordInternal, @NonNull String packageName) {
169         recordInternal.setPackageName(packageName);
170     }
171 
172     /** Checks if the value of given column is null */
isNullValue(Cursor cursor, String columnName)173     public static boolean isNullValue(Cursor cursor, String columnName) {
174         return cursor.isNull(cursor.getColumnIndex(columnName));
175     }
176 
getCursorString(Cursor cursor, String columnName)177     public static String getCursorString(Cursor cursor, String columnName) {
178         return cursor.getString(cursor.getColumnIndex(columnName));
179     }
180 
getCursorUUID(Cursor cursor, String columnName)181     public static UUID getCursorUUID(Cursor cursor, String columnName) {
182         return convertBytesToUUID(cursor.getBlob(cursor.getColumnIndex(columnName)));
183     }
184 
getCursorInt(Cursor cursor, String columnName)185     public static int getCursorInt(Cursor cursor, String columnName) {
186         return cursor.getInt(cursor.getColumnIndex(columnName));
187     }
188 
189     /** Reads integer and converts to false anything apart from 1. */
getIntegerAndConvertToBoolean(Cursor cursor, String columnName)190     public static boolean getIntegerAndConvertToBoolean(Cursor cursor, String columnName) {
191         String value = cursor.getString(cursor.getColumnIndex(columnName));
192         if (value == null || value.isEmpty()) {
193             return false;
194         }
195         return Integer.parseInt(value) == BOOLEAN_TRUE_VALUE;
196     }
197 
getCursorLong(Cursor cursor, String columnName)198     public static long getCursorLong(Cursor cursor, String columnName) {
199         return cursor.getLong(cursor.getColumnIndex(columnName));
200     }
201 
getCursorDouble(Cursor cursor, String columnName)202     public static double getCursorDouble(Cursor cursor, String columnName) {
203         return cursor.getDouble(cursor.getColumnIndex(columnName));
204     }
205 
getCursorBlob(Cursor cursor, String columnName)206     public static byte[] getCursorBlob(Cursor cursor, String columnName) {
207         return cursor.getBlob(cursor.getColumnIndex(columnName));
208     }
209 
getCursorStringList( Cursor cursor, String columnName, String delimiter)210     public static List<String> getCursorStringList(
211             Cursor cursor, String columnName, String delimiter) {
212         final String values = cursor.getString(cursor.getColumnIndex(columnName));
213         if (values == null || values.isEmpty()) {
214             return Collections.emptyList();
215         }
216 
217         return Arrays.asList(values.split(delimiter));
218     }
219 
getCursorIntegerList( Cursor cursor, String columnName, String delimiter)220     public static List<Integer> getCursorIntegerList(
221             Cursor cursor, String columnName, String delimiter) {
222         final String stringList = cursor.getString(cursor.getColumnIndex(columnName));
223         if (stringList == null || stringList.isEmpty()) {
224             return Collections.emptyList();
225         }
226 
227         return Arrays.stream(stringList.split(delimiter))
228                 .mapToInt(Integer::valueOf)
229                 .boxed()
230                 .toList();
231     }
232 
getCursorLongList(Cursor cursor, String columnName, String delimiter)233     public static List<Long> getCursorLongList(Cursor cursor, String columnName, String delimiter) {
234         final String stringList = cursor.getString(cursor.getColumnIndex(columnName));
235         if (stringList == null || stringList.isEmpty()) {
236             return Collections.emptyList();
237         }
238 
239         return Arrays.stream(stringList.split(delimiter)).mapToLong(Long::valueOf).boxed().toList();
240     }
241 
flattenIntList(List<Integer> values)242     public static String flattenIntList(List<Integer> values) {
243         return values.stream().map(String::valueOf).collect(Collectors.joining(DELIMITER));
244     }
245 
flattenLongList(List<Long> values)246     public static String flattenLongList(List<Long> values) {
247         return values.stream().map(String::valueOf).collect(Collectors.joining(DELIMITER));
248     }
249 
flattenIntArray(int[] values)250     public static String flattenIntArray(int[] values) {
251         return Arrays.stream(values)
252                 .mapToObj(String::valueOf)
253                 .collect(Collectors.joining(DELIMITER));
254     }
255 
256     @Nullable
getMaxPrimaryKeyQuery(@onNull String tableName)257     public static String getMaxPrimaryKeyQuery(@NonNull String tableName) {
258         return "SELECT MAX("
259                 + PRIMARY_COLUMN_NAME
260                 + ") as "
261                 + PRIMARY_COLUMN_NAME
262                 + " FROM "
263                 + tableName;
264     }
265 
266     /**
267      * Reads ZoneOffset using given cursor. Returns null of column name is not present in the table.
268      */
getZoneOffset(Cursor cursor, String startZoneOffsetColumnName)269     public static ZoneOffset getZoneOffset(Cursor cursor, String startZoneOffsetColumnName) {
270         ZoneOffset zoneOffset = null;
271         if (cursor.getColumnIndex(startZoneOffsetColumnName) != -1) {
272             zoneOffset =
273                     ZoneOffset.ofTotalSeconds(
274                             StorageUtils.getCursorInt(cursor, startZoneOffsetColumnName));
275         }
276 
277         return zoneOffset;
278     }
279 
280     /** Encodes record properties participating in deduplication into a byte array. */
281     @Nullable
getDedupeByteBuffer(@onNull RecordInternal<?> record)282     public static byte[] getDedupeByteBuffer(@NonNull RecordInternal<?> record) {
283         if (!isEmpty(record.getClientRecordId())) {
284             return null; // If dedupe by clientRecordId then don't dedupe by hash
285         }
286 
287         if (record instanceof InstantRecordInternal<?>) {
288             return getDedupeByteBuffer((InstantRecordInternal<?>) record);
289         }
290 
291         if (record instanceof IntervalRecordInternal<?>) {
292             return getDedupeByteBuffer((IntervalRecordInternal<?>) record);
293         }
294 
295         throw new IllegalArgumentException("Unexpected record type: " + record);
296     }
297 
298     @NonNull
getDedupeByteBuffer(@onNull InstantRecordInternal<?> record)299     private static byte[] getDedupeByteBuffer(@NonNull InstantRecordInternal<?> record) {
300         return ByteBuffer.allocate(Long.BYTES * 3)
301                 .putLong(record.getAppInfoId())
302                 .putLong(record.getDeviceInfoId())
303                 .putLong(record.getTimeInMillis())
304                 .array();
305     }
306 
307     @Nullable
getDedupeByteBuffer(@onNull IntervalRecordInternal<?> record)308     private static byte[] getDedupeByteBuffer(@NonNull IntervalRecordInternal<?> record) {
309         final int type = record.getRecordType();
310         if ((type == RECORD_TYPE_HYDRATION) || (type == RECORD_TYPE_NUTRITION)) {
311             return null; // Some records are exempt from deduplication
312         }
313 
314         return ByteBuffer.allocate(Long.BYTES * 4)
315                 .putLong(record.getAppInfoId())
316                 .putLong(record.getDeviceInfoId())
317                 .putLong(record.getStartTimeInMillis())
318                 .putLong(record.getEndTimeInMillis())
319                 .array();
320     }
321 
322     /** Returns a UUID for the given package name, client record id and record type id. */
getUUID( @onNull String packageName, @NonNull String clientRecordId, int recordTypeId)323     private static UUID getUUID(
324             @NonNull String packageName, @NonNull String clientRecordId, int recordTypeId) {
325         final byte[] packageNameBytes = packageName.getBytes();
326         final byte[] clientRecordIdBytes = clientRecordId.getBytes();
327 
328         byte[] bytes =
329                 ByteBuffer.allocate(
330                                 packageNameBytes.length
331                                         + Integer.BYTES
332                                         + clientRecordIdBytes.length)
333                         .put(packageNameBytes)
334                         .putInt(getRecordTypeIdForUuid(recordTypeId))
335                         .put(clientRecordIdBytes)
336                         .array();
337         return UUID.nameUUIDFromBytes(bytes);
338     }
339 
340     /**
341      * Returns if priority of apps needs to be considered to compute the aggregate request for the
342      * record type. Priority to be considered only for sleep and Activity categories.
343      */
supportsPriority(int recordType, int operationType)344     public static boolean supportsPriority(int recordType, int operationType) {
345         if (operationType != SUM) {
346             return false;
347         }
348 
349         @HealthDataCategory.Type
350         int recordCategory =
351                 RecordTypeRecordCategoryMapper.getRecordCategoryForRecordType(recordType);
352         return recordCategory == ACTIVITY || recordCategory == SLEEP;
353     }
354 
355     /** Returns list of app Ids of contributing apps for the record type in the priority order */
getAppIdPriorityList(int recordType)356     public static List<Long> getAppIdPriorityList(int recordType) {
357         return HealthDataCategoryPriorityHelper.getInstance()
358                 .getAppIdPriorityOrder(
359                         RecordTypeRecordCategoryMapper.getRecordCategoryForRecordType(recordType));
360     }
361 
362     /** Returns if derivation needs to be done to calculate aggregate */
isDerivedType(int recordType)363     public static boolean isDerivedType(int recordType) {
364         return recordType == RECORD_TYPE_BASAL_METABOLIC_RATE
365                 || recordType == RECORD_TYPE_TOTAL_CALORIES_BURNED;
366     }
367 
convertBytesToUUID(byte[] bytes)368     public static UUID convertBytesToUUID(byte[] bytes) {
369         ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
370         long high = byteBuffer.getLong();
371         long low = byteBuffer.getLong();
372         return new UUID(high, low);
373     }
374 
convertUUIDToBytes(UUID uuid)375     public static byte[] convertUUIDToBytes(UUID uuid) {
376         ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]);
377         byteBuffer.putLong(uuid.getMostSignificantBits());
378         byteBuffer.putLong(uuid.getLeastSignificantBits());
379         return byteBuffer.array();
380     }
381 
getHexString(byte[] value)382     public static String getHexString(byte[] value) {
383         if (value == null) {
384             return "";
385         }
386 
387         final StringBuilder builder = new StringBuilder("x'");
388         for (byte b : value) {
389             builder.append(String.format("%02x", b));
390         }
391         builder.append("'");
392 
393         return builder.toString();
394     }
395 
getHexString(UUID uuid)396     public static String getHexString(UUID uuid) {
397         return getHexString(convertUUIDToBytes(uuid));
398     }
399 
getListOfHexString(List<UUID> uuids)400     public static List<String> getListOfHexString(List<UUID> uuids) {
401         List<String> hexStrings = new ArrayList<>();
402         for (UUID uuid : uuids) {
403             hexStrings.add(getHexString(convertUUIDToBytes(uuid)));
404         }
405 
406         return hexStrings;
407     }
408 
getSingleByteArray(List<UUID> uuids)409     public static byte[] getSingleByteArray(List<UUID> uuids) {
410         byte[] allByteArray = new byte[UUID_BYTE_SIZE * uuids.size()];
411 
412         ByteBuffer byteBuffer = ByteBuffer.wrap(allByteArray);
413         for (UUID uuid : uuids) {
414             byteBuffer.put(convertUUIDToBytes(uuid));
415         }
416 
417         return byteBuffer.array();
418     }
419 
getCursorUUIDList(Cursor cursor, String columnName)420     public static List<UUID> getCursorUUIDList(Cursor cursor, String columnName) {
421         byte[] bytes = cursor.getBlob(cursor.getColumnIndex(columnName));
422         ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
423 
424         List<UUID> uuidList = new ArrayList<>();
425         while (byteBuffer.hasRemaining()) {
426             long high = byteBuffer.getLong();
427             long low = byteBuffer.getLong();
428             uuidList.add(new UUID(high, low));
429         }
430 
431         return uuidList;
432     }
433 
434     /**
435      * Returns a quoted id if {@code id} is not quoted. Following examples show the expected return
436      * values,
437      *
438      * <p>getNormalisedId("id") -> "'id'"
439      *
440      * <p>getNormalisedId("'id'") -> "'id'"
441      *
442      * <p>getNormalisedId("x'id'") -> "x'id'"
443      */
getNormalisedString(String id)444     public static String getNormalisedString(String id) {
445         if (!id.startsWith("'") && !id.startsWith("x'")) {
446             return "'" + id + "'";
447         }
448 
449         return id;
450     }
451 
452     /** Extracts and holds data from {@link ContentValues}. */
453     public static class RecordIdentifierData {
454         private final String mClientRecordId;
455         private final UUID mUuid;
456 
RecordIdentifierData(ContentValues contentValues)457         public RecordIdentifierData(ContentValues contentValues) {
458             mClientRecordId = contentValues.getAsString(CLIENT_RECORD_ID_COLUMN_NAME);
459             mUuid = StorageUtils.convertBytesToUUID(contentValues.getAsByteArray(UUID_COLUMN_NAME));
460         }
461 
462         @Nullable
getClientRecordId()463         public String getClientRecordId() {
464             return mClientRecordId;
465         }
466 
467         @Nullable
getUuid()468         public UUID getUuid() {
469             return mUuid;
470         }
471 
472         @Override
toString()473         public String toString() {
474             final StringBuilder builder = new StringBuilder();
475             if (mClientRecordId != null && !mClientRecordId.isEmpty()) {
476                 builder.append("clientRecordID : ").append(mClientRecordId).append(" , ");
477             }
478 
479             if (mUuid != null) {
480                 builder.append("uuid : ").append(mUuid).append(" , ");
481             }
482             return builder.toString();
483         }
484     }
485 }
486