blob: cc961a36b48ff9eee804ad05b2b6eaba4360c1b4 [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.server.healthconnect.storage.utils;
import static android.health.connect.HealthDataCategory.ACTIVITY;
import static android.health.connect.HealthDataCategory.SLEEP;
import static android.health.connect.datatypes.AggregationType.SUM;
import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_BASAL_METABOLIC_RATE;
import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_HYDRATION;
import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_NUTRITION;
import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_TOTAL_CALORIES_BURNED;
import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.CLIENT_RECORD_ID_COLUMN_NAME;
import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME;
import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.UUID_COLUMN_NAME;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.ContentValues;
import android.database.Cursor;
import android.health.connect.HealthDataCategory;
import android.health.connect.RecordIdFilter;
import android.health.connect.internal.datatypes.InstantRecordInternal;
import android.health.connect.internal.datatypes.IntervalRecordInternal;
import android.health.connect.internal.datatypes.RecordInternal;
import android.health.connect.internal.datatypes.utils.RecordMapper;
import android.health.connect.internal.datatypes.utils.RecordTypeRecordCategoryMapper;
import android.text.TextUtils;
import android.util.Slog;
import com.android.server.healthconnect.storage.datatypehelpers.AppInfoHelper;
import com.android.server.healthconnect.storage.datatypehelpers.HealthDataCategoryPriorityHelper;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.time.Period;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* An util class for HC storage
*
* @hide
*/
public final class StorageUtils {
public static final String TEXT_NOT_NULL = "TEXT NOT NULL";
public static final String TEXT_NOT_NULL_UNIQUE = "TEXT NOT NULL UNIQUE";
public static final String TEXT_NULL = "TEXT";
public static final String INTEGER = "INTEGER";
public static final String INTEGER_UNIQUE = "INTEGER UNIQUE";
public static final String INTEGER_NOT_NULL_UNIQUE = "INTEGER NOT NULL UNIQUE";
public static final String INTEGER_NOT_NULL = "INTEGER NOT NULL";
public static final String REAL = "REAL";
public static final String REAL_NOT_NULL = "REAL NOT NULL";
public static final String PRIMARY_AUTOINCREMENT = "INTEGER PRIMARY KEY AUTOINCREMENT";
public static final String PRIMARY = "INTEGER PRIMARY KEY";
public static final String DELIMITER = ",";
public static final String BLOB = "BLOB";
public static final String BLOB_UNIQUE_NULL = "BLOB UNIQUE";
public static final String BLOB_UNIQUE_NON_NULL = "BLOB NOT NULL UNIQUE";
public static final String BLOB_NON_NULL = "BLOB NOT NULL";
public static final String SELECT_ALL = "SELECT * FROM ";
public static final String LIMIT_SIZE = " LIMIT ";
public static final int BOOLEAN_FALSE_VALUE = 0;
public static final int BOOLEAN_TRUE_VALUE = 1;
public static final int UUID_BYTE_SIZE = 16;
private static final String TAG = "HealthConnectUtils";
// Returns null if fetching any of the fields resulted in an error
@Nullable
public static String getConflictErrorMessageForRecord(
Cursor cursor, ContentValues contentValues) {
try {
return "Updating record with uuid: "
+ convertBytesToUUID(contentValues.getAsByteArray(UUID_COLUMN_NAME))
+ " and client record id: "
+ contentValues.getAsString(CLIENT_RECORD_ID_COLUMN_NAME)
+ " conflicts with an existing record with uuid: "
+ getCursorUUID(cursor, UUID_COLUMN_NAME)
+ " and client record id: "
+ getCursorString(cursor, CLIENT_RECORD_ID_COLUMN_NAME);
} catch (Exception exception) {
Slog.e(TAG, "", exception);
return null;
}
}
public static void addNameBasedUUIDTo(@NonNull RecordInternal<?> recordInternal) {
byte[] clientIDBlob;
if (recordInternal.getClientRecordId() == null
|| recordInternal.getClientRecordId().isEmpty()) {
clientIDBlob = UUID.randomUUID().toString().getBytes();
} else {
clientIDBlob = recordInternal.getClientRecordId().getBytes();
}
final UUID uuid =
getUUID(
recordInternal.getAppInfoId(),
clientIDBlob,
recordInternal.getRecordType());
recordInternal.setUuid(uuid);
}
/** Updates the uuid using the clientRecordID if the clientRecordId is present. */
public static void updateNameBasedUUIDIfRequired(@NonNull RecordInternal<?> recordInternal) {
byte[] clientIDBlob;
if (recordInternal.getClientRecordId() == null
|| recordInternal.getClientRecordId().isEmpty()) {
// If clientRecordID is absent, use the uuid already set in the input record and
// hence no need to modify it.
return;
}
clientIDBlob = recordInternal.getClientRecordId().getBytes();
final UUID uuid =
getUUID(
recordInternal.getAppInfoId(),
clientIDBlob,
recordInternal.getRecordType());
recordInternal.setUuid(uuid);
}
public static UUID getUUIDFor(RecordIdFilter recordIdFilter, String packageName) {
byte[] clientIDBlob;
if (recordIdFilter.getClientRecordId() == null
|| recordIdFilter.getClientRecordId().isEmpty()) {
return UUID.fromString(recordIdFilter.getId());
}
clientIDBlob = recordIdFilter.getClientRecordId().getBytes();
return getUUID(
AppInfoHelper.getInstance().getAppInfoId(packageName),
clientIDBlob,
RecordMapper.getInstance().getRecordType(recordIdFilter.getRecordType()));
}
public static void addPackageNameTo(
@NonNull RecordInternal<?> recordInternal, @NonNull String packageName) {
recordInternal.setPackageName(packageName);
}
/** Checks if the value of given column is null */
public static boolean isNullValue(Cursor cursor, String columnName) {
return cursor.isNull(cursor.getColumnIndex(columnName));
}
public static String getCursorString(Cursor cursor, String columnName) {
return cursor.getString(cursor.getColumnIndex(columnName));
}
public static UUID getCursorUUID(Cursor cursor, String columnName) {
return convertBytesToUUID(cursor.getBlob(cursor.getColumnIndex(columnName)));
}
public static int getCursorInt(Cursor cursor, String columnName) {
return cursor.getInt(cursor.getColumnIndex(columnName));
}
/** Reads integer and converts to false anything apart from 1. */
public static boolean getIntegerAndConvertToBoolean(Cursor cursor, String columnName) {
String value = cursor.getString(cursor.getColumnIndex(columnName));
if (value == null || value.isEmpty()) {
return false;
}
return Integer.parseInt(value) == BOOLEAN_TRUE_VALUE;
}
public static long getCursorLong(Cursor cursor, String columnName) {
return cursor.getLong(cursor.getColumnIndex(columnName));
}
public static double getCursorDouble(Cursor cursor, String columnName) {
return cursor.getDouble(cursor.getColumnIndex(columnName));
}
public static byte[] getCursorBlob(Cursor cursor, String columnName) {
return cursor.getBlob(cursor.getColumnIndex(columnName));
}
public static List<String> getCursorStringList(
Cursor cursor, String columnName, String delimiter) {
final String values = cursor.getString(cursor.getColumnIndex(columnName));
if (values == null || values.isEmpty()) {
return Collections.emptyList();
}
return Arrays.asList(values.split(delimiter));
}
public static List<Integer> getCursorIntegerList(
Cursor cursor, String columnName, String delimiter) {
final String stringList = cursor.getString(cursor.getColumnIndex(columnName));
if (stringList == null || stringList.isEmpty()) {
return Collections.emptyList();
}
return Arrays.stream(stringList.split(delimiter))
.mapToInt(Integer::valueOf)
.boxed()
.toList();
}
public static List<Long> getCursorLongList(Cursor cursor, String columnName, String delimiter) {
final String stringList = cursor.getString(cursor.getColumnIndex(columnName));
if (stringList == null || stringList.isEmpty()) {
return Collections.emptyList();
}
return Arrays.stream(stringList.split(delimiter)).mapToLong(Long::valueOf).boxed().toList();
}
public static String flattenIntList(List<Integer> values) {
return values.stream().map(String::valueOf).collect(Collectors.joining(DELIMITER));
}
public static String flattenLongList(List<Long> values) {
return values.stream().map(String::valueOf).collect(Collectors.joining(DELIMITER));
}
public static String flattenIntArray(int[] values) {
return Arrays.stream(values)
.mapToObj(String::valueOf)
.collect(Collectors.joining(DELIMITER));
}
@Nullable
public static String getMaxPrimaryKeyQuery(@NonNull String tableName) {
return "SELECT MAX("
+ PRIMARY_COLUMN_NAME
+ ") as "
+ PRIMARY_COLUMN_NAME
+ " FROM "
+ tableName;
}
/**
* Reads ZoneOffset using given cursor. Returns null of column name is not present in the table.
*/
public static ZoneOffset getZoneOffset(Cursor cursor, String startZoneOffsetColumnName) {
ZoneOffset zoneOffset = null;
if (cursor.getColumnIndex(startZoneOffsetColumnName) != -1) {
zoneOffset =
ZoneOffset.ofTotalSeconds(
StorageUtils.getCursorInt(cursor, startZoneOffsetColumnName));
}
return zoneOffset;
}
/** Encodes record properties participating in deduplication into a byte array. */
@Nullable
public static byte[] getDedupeByteBuffer(@NonNull RecordInternal<?> record) {
if (!TextUtils.isEmpty(record.getClientRecordId())) {
return null; // If dedupe by clientRecordId then don't dedupe by hash
}
if (record instanceof InstantRecordInternal<?>) {
return getDedupeByteBuffer((InstantRecordInternal<?>) record);
}
if (record instanceof IntervalRecordInternal<?>) {
return getDedupeByteBuffer((IntervalRecordInternal<?>) record);
}
throw new IllegalArgumentException("Unexpected record type: " + record);
}
@NonNull
private static byte[] getDedupeByteBuffer(@NonNull InstantRecordInternal<?> record) {
return ByteBuffer.allocate(Long.BYTES * 3)
.putLong(record.getAppInfoId())
.putLong(record.getDeviceInfoId())
.putLong(record.getTimeInMillis())
.array();
}
@Nullable
private static byte[] getDedupeByteBuffer(@NonNull IntervalRecordInternal<?> record) {
final int type = record.getRecordType();
if ((type == RECORD_TYPE_HYDRATION) || (type == RECORD_TYPE_NUTRITION)) {
return null; // Some records are exempt from deduplication
}
return ByteBuffer.allocate(Long.BYTES * 4)
.putLong(record.getAppInfoId())
.putLong(record.getDeviceInfoId())
.putLong(record.getStartTimeInMillis())
.putLong(record.getEndTimeInMillis())
.array();
}
/** Returns a hex string that represents a UUID */
private static UUID getUUID(long appId, byte[] clientIDBlob, int recordId) {
byte[] bytes =
ByteBuffer.allocate(Long.BYTES + Integer.BYTES + clientIDBlob.length)
.putLong(appId)
.putInt(recordId)
.put(clientIDBlob)
.array();
return UUID.nameUUIDFromBytes(bytes);
}
/**
* Returns if priority of apps needs to be considered to compute the aggregate request for the
* record type. Priority to be considered only for sleep and Activity categories.
*/
public static boolean supportsPriority(int recordType, int operationType) {
if (operationType != SUM) {
return false;
}
@HealthDataCategory.Type
int recordCategory =
RecordTypeRecordCategoryMapper.getRecordCategoryForRecordType(recordType);
return recordCategory == ACTIVITY || recordCategory == SLEEP;
}
/** Returns list of app Ids of contributing apps for the record type in the priority order */
public static List<Long> getAppIdPriorityList(int recordType) {
return HealthDataCategoryPriorityHelper.getInstance()
.getAppIdPriorityOrder(
RecordTypeRecordCategoryMapper.getRecordCategoryForRecordType(recordType));
}
/** Returns if derivation needs to be done to calculate aggregate */
public static boolean isDerivedType(int recordType) {
return recordType == RECORD_TYPE_BASAL_METABOLIC_RATE
|| recordType == RECORD_TYPE_TOTAL_CALORIES_BURNED;
}
public static UUID convertBytesToUUID(byte[] bytes) {
ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
long high = byteBuffer.getLong();
long low = byteBuffer.getLong();
return new UUID(high, low);
}
public static byte[] convertUUIDToBytes(UUID uuid) {
ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]);
byteBuffer.putLong(uuid.getMostSignificantBits());
byteBuffer.putLong(uuid.getLeastSignificantBits());
return byteBuffer.array();
}
public static String getHexString(byte[] value) {
if (value == null) {
return "";
}
final StringBuilder builder = new StringBuilder("x'");
for (byte b : value) {
builder.append(String.format("%02x", b));
}
builder.append("'");
return builder.toString();
}
public static String getHexString(UUID uuid) {
return getHexString(convertUUIDToBytes(uuid));
}
public static List<String> getListOfHexString(List<UUID> uuids) {
List<String> hexStrings = new ArrayList<>();
for (UUID uuid : uuids) {
hexStrings.add(getHexString(convertUUIDToBytes(uuid)));
}
return hexStrings;
}
public static byte[] getSingleByteArray(List<UUID> uuids) {
byte[] allByteArray = new byte[UUID_BYTE_SIZE * uuids.size()];
ByteBuffer byteBuffer = ByteBuffer.wrap(allByteArray);
for (UUID uuid : uuids) {
byteBuffer.put(convertUUIDToBytes(uuid));
}
return byteBuffer.array();
}
public static List<UUID> getCursorUUIDList(Cursor cursor, String columnName) {
byte[] bytes = cursor.getBlob(cursor.getColumnIndex(columnName));
ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
List<UUID> uuidList = new ArrayList<>();
while (byteBuffer.hasRemaining()) {
long high = byteBuffer.getLong();
long low = byteBuffer.getLong();
uuidList.add(new UUID(high, low));
}
return uuidList;
}
/**
* Returns a quoted id if {@code id} is not quoted. Following examples show the expected return
* values,
*
* <p>getNormalisedId("id") -> "'id'"
*
* <p>getNormalisedId("'id'") -> "'id'"
*
* <p>getNormalisedId("x'id'") -> "x'id'"
*/
public static String getNormalisedString(String id) {
if (!id.startsWith("'") && !id.startsWith("x'")) {
return "'" + id + "'";
}
return id;
}
/** Extracts and holds data from {@link ContentValues}. */
public static class RecordIdentifierData {
private final String mClientRecordId;
private final UUID mUuid;
public RecordIdentifierData(ContentValues contentValues) {
mClientRecordId = contentValues.getAsString(CLIENT_RECORD_ID_COLUMN_NAME);
mUuid = StorageUtils.convertBytesToUUID(contentValues.getAsByteArray(UUID_COLUMN_NAME));
}
@Nullable
public String getClientRecordId() {
return mClientRecordId;
}
@Nullable
public UUID getUuid() {
return mUuid;
}
@Override
public String toString() {
final StringBuilder builder = new StringBuilder();
if (mClientRecordId != null && !mClientRecordId.isEmpty()) {
builder.append("clientRecordID : ").append(mClientRecordId).append(" , ");
}
if (mUuid != null) {
builder.append("uuid : ").append(mUuid).append(" , ");
}
return builder.toString();
}
}
}