blob: b9fc4686371729948a6d7e458478bc430693cbbb [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;
import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.APP_INFO_ID_COLUMN_NAME;
import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME;
import android.annotation.NonNull;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.healthconnect.Constants;
import android.healthconnect.AggregateRecordsResponse;
import android.healthconnect.datatypes.AggregationType;
import android.healthconnect.datatypes.DataOrigin;
import android.healthconnect.internal.datatypes.RecordInternal;
import android.util.Slog;
import android.util.ArrayMap;
import com.android.server.healthconnect.storage.datatypehelpers.AppInfoHelper;
import com.android.server.healthconnect.storage.datatypehelpers.RecordHelper;
import com.android.server.healthconnect.storage.request.DeleteTableRequest;
import com.android.server.healthconnect.storage.request.DeleteTransactionRequest;
import com.android.server.healthconnect.storage.datatypehelpers.AppInfoHelper;
import com.android.server.healthconnect.storage.datatypehelpers.DeviceInfoHelper;
import com.android.server.healthconnect.storage.request.AggregateTableRequest;
import com.android.server.healthconnect.storage.request.AggregateTransactionRequest;
import com.android.server.healthconnect.storage.request.ReadTableRequest;
import com.android.server.healthconnect.storage.request.ReadTransactionRequest;
import com.android.server.healthconnect.storage.request.UpsertTableRequest;
import com.android.server.healthconnect.storage.request.UpsertTransactionRequest;
import com.android.server.healthconnect.storage.utils.StorageUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* A class to handle all the DB transaction request from the clients. {@link TransactionManager}
* acts as a layer b/w the DB and the data type helper classes and helps perform actual operations
* on the DB.
*
* @hide
*/
public class TransactionManager {
private static final String TAG = "HealthConnectTransactionMan";
private static TransactionManager sTransactionManager;
private final HealthConnectDatabase mHealthConnectDatabase;
private TransactionManager(@NonNull Context context) {
mHealthConnectDatabase = new HealthConnectDatabase(context);
}
@NonNull
public static TransactionManager getInstance(@NonNull Context context) {
if (sTransactionManager == null) {
sTransactionManager = new TransactionManager(context);
}
return sTransactionManager;
}
@NonNull
public static TransactionManager getInitialisedInstance() {
Objects.requireNonNull(sTransactionManager);
return sTransactionManager;
}
/**
* Inserts all the {@link RecordInternal} in {@code request} into the HealthConnect database.
*
* @param request an insert request.
* @return List of uids of the inserted {@link RecordInternal}, in the same order as they
* presented to {@code request}.
*/
public List<String> insertAll(@NonNull UpsertTransactionRequest request)
throws SQLiteException {
try (SQLiteDatabase db = mHealthConnectDatabase.getWritableDatabase()) {
db.beginTransaction();
try {
request.getUpsertRequests()
.forEach((upsertTableRequest) -> insertRecord(db, upsertTableRequest));
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return request.getUUIdsInOrder();
}
}
/**
* Deletes all the {@link RecordInternal} in {@code request} into the HealthConnect database.
*
* <p>NOTE: Please don't add logic to explicitly delete child table entries here as they should
* be deleted via cascade
*
* @param request a delete request.
*/
public void deleteAll(@NonNull DeleteTransactionRequest request) throws SQLiteException {
try (SQLiteDatabase db = mHealthConnectDatabase.getWritableDatabase()) {
db.beginTransaction();
try {
for (DeleteTableRequest deleteTableRequest : request.getDeleteTableRequests()) {
if (deleteTableRequest.requiresRead()) {
/*
Delete request needs UUID before the entry can be
deleted, fetch and set it in {@code request}
*/
try (Cursor cursor =
db.rawQuery(deleteTableRequest.getReadCommand(), null)) {
while (cursor.moveToNext()) {
request.onUuidFetched(
deleteTableRequest.getRecordType(),
StorageUtils.getCursorString(
cursor, deleteTableRequest.getIdColumnName()));
if (deleteTableRequest.requiresPackageCheck()) {
request.enforcePackageCheck(
StorageUtils.getCursorString(
cursor, deleteTableRequest.getIdColumnName()),
StorageUtils.getCursorLong(
cursor,
deleteTableRequest.getPackageColumnName()));
}
}
}
}
db.execSQL(deleteTableRequest.getDeleteCommand());
}
request.getChangeLogUpsertRequests()
.forEach((insertRequest) -> insertRecord(db, insertRequest));
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
}
/**
* Handles the aggregation requests in {@code request}
*
* @param request an aggregate request.
* @return A map of {@link AggregationType.AggregationTypeIdentifier} -> {@link
* AggregateRecordsResponse.AggregateResult}.
*/
public Map<Integer, AggregateRecordsResponse.AggregateResult> getAggregations(
AggregateTransactionRequest request) {
SQLiteDatabase db = mHealthConnectDatabase.getReadableDatabase();
List<AggregateTableRequest> aggregateTableRequests = request.getAggregateTableRequests();
Map<Integer, AggregateRecordsResponse.AggregateResult> result =
new ArrayMap<>(aggregateTableRequests.size());
aggregateTableRequests.forEach(
(aggregateTableRequest) -> {
try (Cursor cursor =
db.rawQuery(aggregateTableRequest.getAggregationCommand(), null)) {
if (cursor.moveToFirst()) {
result.put(
aggregateTableRequest
.getDataAggregation()
.getAggregationTypeIdentifier(),
aggregateTableRequest.getAggregateResult(cursor));
}
}
});
return result;
}
/**
* Reads the records {@link RecordInternal} stored in the HealthConnect database.
*
* @param request a read request.
* @return List of records read {@link RecordInternal} from table based on ids.
*/
public List<RecordInternal<?>> readRecords(@NonNull ReadTransactionRequest request)
throws SQLiteException {
List<RecordInternal<?>> recordInternals = new ArrayList<>();
try (SQLiteDatabase db = mHealthConnectDatabase.getReadableDatabase()) {
request.getReadRequests()
.forEach(
(readTableRequest -> {
try (Cursor cursor = read(db, readTableRequest)) {
Objects.requireNonNull(readTableRequest.getRecordHelper());
recordInternals.addAll(
readTableRequest
.getRecordHelper()
.getInternalRecords(cursor));
}
}));
return recordInternals;
}
}
/**
* Inserts record into the table in {@code request} into the HealthConnect database.
*
* <p>NOTE: PLEASE ONLY USE THIS FUNCTION IF YOU WANT TO INSERT A SINGLE RECORD PER API. PLEASE
* DON'T USE THIS FUNCTION INSIDE A FOR LOOP OR REPEATEDLY: The reason is that this function
* tries to insert a record inside its own transaction and if you are trying to insert multiple
* things using this method in the same api call, they will all get inserted in their separate
* transactions and will be less performant. If at all, the requirement is to insert them in
* different transactions, as they are not related to each, then this method can be used.
*
* @param request an insert request.
* @return rowId of the inserted record.
*/
public long insert(@NonNull UpsertTableRequest request) {
try (SQLiteDatabase db = mHealthConnectDatabase.getWritableDatabase()) {
return insertRecord(db, request);
}
}
/** Note: It is the responsibility of the caller to properly manage and close {@code db} */
@NonNull
public Cursor read(@NonNull SQLiteDatabase db, @NonNull ReadTableRequest request) {
if (Constants.DEBUG) {
Slog.d(TAG, "Read query: " + request.getReadCommand());
}
return db.rawQuery(request.getReadCommand(), null);
}
public long getLastRowIdFor(String tableName) {
try (SQLiteDatabase db = mHealthConnectDatabase.getReadableDatabase();
Cursor cursor = db.rawQuery(StorageUtils.getMaxPrimaryKeyQuery(tableName), null)) {
cursor.moveToFirst();
return cursor.getLong(cursor.getColumnIndex(PRIMARY_COLUMN_NAME));
}
}
/** Note: it is the responsibility of the requester to manage and close {@code db} */
public SQLiteDatabase getReadableDb() {
return mHealthConnectDatabase.getReadableDatabase();
}
/**
* Updates all the {@link RecordInternal} in {@code request} into the HealthConnect database.
*
* @param request an update request.
*/
public void updateAll(@NonNull UpsertTransactionRequest request) throws Exception {
try (SQLiteDatabase db = mHealthConnectDatabase.getWritableDatabase()) {
db.beginTransaction();
try {
request.getUpsertRequests()
.forEach((upsertTableRequest) -> updateRecord(db, upsertTableRequest));
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
}
/** Assumes that caller will be closing {@code db} and handling the transaction if required */
private long insertRecord(@NonNull SQLiteDatabase db, @NonNull UpsertTableRequest request) {
long rowId = db.insertOrThrow(request.getTable(), null, request.getContentValues());
request.getChildTableRequests()
.forEach(childRequest -> insertRecord(db, childRequest.withParentKey(rowId)));
return rowId;
}
private void updateRecord(SQLiteDatabase db, UpsertTableRequest request) {
// perform an update operation where UUID and packageName (mapped by appInfoId) is same
// as that of the update request.
if (request.getChildTableRequests().isEmpty()) {
long numberOfRowsUpdated =
db.update(
request.getTable(),
request.getContentValues(),
request.getWhereClauses().get(/* withWhereKeyword */ false),
/* WHERE args */ null);
// throw an exception if the no row was updated, i.e. the uuid with corresponding
// app_id_info for this request is not found in the table.
if (numberOfRowsUpdated == 0) {
throw new IllegalArgumentException(
"No record found for the following input : "
+ new StorageUtils.RecordIdentifierData(
request.getContentValues()));
}
return;
}
// If the current request has connecting child tables that needs to be updated too in
// that case the entire record will be first deleted and re-inserted.
// delete the record corresponding to the provided uuid and packageName. This will
// delete child table contents in cascade.
int numberOfRowsDeleted =
db.delete(
request.getTable(),
request.getWhereClauses().get(/* withWhereKeyword */ false),
/* where args */ null);
// throw an exception if the no row was deleted, i.e. the uuid for this request is not
// found in the table.
if (numberOfRowsDeleted == 0) {
throw new IllegalArgumentException(
"No record found for the following input : "
+ new StorageUtils.RecordIdentifierData(request.getContentValues()));
} else {
// If the record was deleted successfully then re-insert the record with the
// updated contents.
insertRecord(db, request);
}
}
/**
* @return list of distinct packageNames corresponding to the input table name after querying
* the table.
*/
public ArrayList<DataOrigin> getDistinctPackageNamesForRecordTable(RecordHelper<?> recordHelper)
throws SQLiteException {
try (SQLiteDatabase db = getReadableDb()) {
ArrayList<DataOrigin> packageNamesForDatatype = new ArrayList<>();
try (Cursor cursorForDistinctPackageNames =
db.rawQuery(
/* sql query */ recordHelper
.getReadTableRequestWithDistinctAppInfoIds()
.getReadCommand(),
/* selectionArgs */ null)) {
if (cursorForDistinctPackageNames.getCount() > 0) {
AppInfoHelper appInfoHelper = AppInfoHelper.getInstance();
while (cursorForDistinctPackageNames.moveToNext()) {
String packageName =
appInfoHelper.getPackageName(
cursorForDistinctPackageNames.getLong(
cursorForDistinctPackageNames.getColumnIndex(
APP_INFO_ID_COLUMN_NAME)));
if (!packageName.isEmpty()) {
packageNamesForDatatype.add(
new DataOrigin.Builder().setPackageName(packageName).build());
}
}
}
}
return packageNamesForDatatype;
}
}
}