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