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.datatypehelpers; 18 19 import static android.health.connect.Constants.DEFAULT_LONG; 20 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_DELETE; 21 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_READ; 22 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_UPSERT; 23 24 import static com.android.internal.util.Preconditions.checkArgument; 25 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME; 26 import static com.android.server.healthconnect.storage.utils.StorageUtils.BOOLEAN_FALSE_VALUE; 27 import static com.android.server.healthconnect.storage.utils.StorageUtils.BOOLEAN_TRUE_VALUE; 28 import static com.android.server.healthconnect.storage.utils.StorageUtils.DELIMITER; 29 import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER; 30 import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER_NOT_NULL; 31 import static com.android.server.healthconnect.storage.utils.StorageUtils.PRIMARY_AUTOINCREMENT; 32 import static com.android.server.healthconnect.storage.utils.StorageUtils.TEXT_NOT_NULL; 33 import static com.android.server.healthconnect.storage.utils.StorageUtils.TEXT_NULL; 34 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorInt; 35 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorIntegerList; 36 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorLong; 37 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.AND; 38 39 import android.content.ContentValues; 40 import android.content.pm.PackageManager; 41 import android.database.Cursor; 42 import android.database.sqlite.SQLiteDatabase; 43 import android.health.connect.accesslog.AccessLog; 44 import android.health.connect.accesslog.AccessLog.OperationType; 45 import android.health.connect.datatypes.MedicalResource.MedicalResourceType; 46 import android.health.connect.datatypes.RecordTypeIdentifier; 47 import android.os.UserHandle; 48 import android.util.Pair; 49 import android.util.Slog; 50 51 import com.android.healthfitness.flags.AconfigFlagHelper; 52 import com.android.internal.annotations.VisibleForTesting; 53 import com.android.server.healthconnect.storage.TransactionManager; 54 import com.android.server.healthconnect.storage.request.AlterTableRequest; 55 import com.android.server.healthconnect.storage.request.CreateTableRequest; 56 import com.android.server.healthconnect.storage.request.DeleteTableRequest; 57 import com.android.server.healthconnect.storage.request.ReadTableRequest; 58 import com.android.server.healthconnect.storage.request.UpsertTableRequest; 59 import com.android.server.healthconnect.storage.utils.OrderByClause; 60 import com.android.server.healthconnect.storage.utils.WhereClauses; 61 62 import java.time.Instant; 63 import java.time.temporal.ChronoUnit; 64 import java.util.ArrayList; 65 import java.util.HashSet; 66 import java.util.List; 67 import java.util.Set; 68 import java.util.stream.Collectors; 69 70 /** 71 * A helper class to fetch and store the access logs. 72 * 73 * @hide 74 */ 75 public final class AccessLogsHelper extends DatabaseHelper { 76 public static final String TABLE_NAME = "access_logs_table"; 77 private static final String RECORD_TYPE_COLUMN_NAME = "record_type"; 78 private static final String APP_ID_COLUMN_NAME = "app_id"; 79 private static final String ACCESS_TIME_COLUMN_NAME = "access_time"; 80 private static final String OPERATION_TYPE_COLUMN_NAME = "operation_type"; 81 private static final String TAG = "AccessLogHelper"; 82 83 @VisibleForTesting 84 static final String MEDICAL_RESOURCE_TYPE_COLUMN_NAME = "medical_resource_type"; 85 86 @VisibleForTesting 87 static final String MEDICAL_DATA_SOURCE_ACCESSED_COLUMN_NAME = "medical_data_source_accessed"; 88 89 private static final int NUM_COLS = 5; 90 private static final int DEFAULT_ACCESS_LOG_TIME_PERIOD_IN_DAYS = 7; 91 92 private final TransactionManager mTransactionManager; 93 private final AppInfoHelper mAppInfoHelper; 94 private final AppOpLogsHelper mAppOpLogsHelper; 95 AccessLogsHelper( TransactionManager transactionManager, AppInfoHelper appInfoHelper, AppOpLogsHelper appOpLogsHelper, DatabaseHelpers databaseHelpers)96 public AccessLogsHelper( 97 TransactionManager transactionManager, 98 AppInfoHelper appInfoHelper, 99 AppOpLogsHelper appOpLogsHelper, 100 DatabaseHelpers databaseHelpers) { 101 super(databaseHelpers); 102 mTransactionManager = transactionManager; 103 mAppInfoHelper = appInfoHelper; 104 mAppOpLogsHelper = appOpLogsHelper; 105 } 106 getCreateTableRequest()107 public static CreateTableRequest getCreateTableRequest() { 108 return new CreateTableRequest(TABLE_NAME, getColumnInfo()); 109 } 110 111 /** 112 * @return AccessLog list 113 */ queryAccessLogs(UserHandle callingUserHandle)114 public List<AccessLog> queryAccessLogs(UserHandle callingUserHandle) { 115 final ReadTableRequest readTableRequest = new ReadTableRequest(TABLE_NAME); 116 117 List<AccessLog> accessLogsList = new ArrayList<>(); 118 try (Cursor cursor = mTransactionManager.read(readTableRequest)) { 119 while (cursor.moveToNext()) { 120 String packageName; 121 try { 122 packageName = 123 mAppInfoHelper.getPackageName( 124 getCursorLong(cursor, APP_ID_COLUMN_NAME)); 125 } catch (PackageManager.NameNotFoundException e) { 126 Slog.e(TAG, "Package name not found while query access logs", e); 127 continue; 128 } 129 @RecordTypeIdentifier.RecordType 130 List<Integer> recordTypes = 131 getCursorIntegerList(cursor, RECORD_TYPE_COLUMN_NAME, DELIMITER); 132 long accessTime = getCursorLong(cursor, ACCESS_TIME_COLUMN_NAME); 133 @OperationType.OperationTypes 134 int operationType = getCursorInt(cursor, OPERATION_TYPE_COLUMN_NAME); 135 if (!recordTypes.isEmpty()) { 136 accessLogsList.add( 137 new AccessLog(packageName, recordTypes, accessTime, operationType)); 138 } 139 if (AconfigFlagHelper.isPersonalHealthRecordEnabled()) { 140 @MedicalResourceType 141 List<Integer> medicalResourceTypes = 142 getCursorIntegerList( 143 cursor, MEDICAL_RESOURCE_TYPE_COLUMN_NAME, DELIMITER); 144 boolean isMedicalDataSource = 145 getCursorInt(cursor, MEDICAL_DATA_SOURCE_ACCESSED_COLUMN_NAME) 146 == BOOLEAN_TRUE_VALUE; 147 if (!medicalResourceTypes.isEmpty() || isMedicalDataSource) { 148 accessLogsList.add( 149 new AccessLog( 150 packageName, 151 accessTime, 152 operationType, 153 new HashSet<>(medicalResourceTypes), 154 isMedicalDataSource)); 155 } 156 } 157 } 158 } 159 160 accessLogsList.addAll(mAppOpLogsHelper.getAccessLogsFromAppOps(callingUserHandle)); 161 return accessLogsList; 162 } 163 164 /** 165 * Returns the timestamp of the latest access log and {@link Long#MIN_VALUE} if there is no 166 * access log. 167 */ 168 // TODO: b/364643016 - Should this include the AppOps data as well? getLatestUpsertOrReadOperationAccessLogTimeStamp()169 public long getLatestUpsertOrReadOperationAccessLogTimeStamp() { 170 final ReadTableRequest readTableRequest = 171 new ReadTableRequest(TABLE_NAME) 172 .setWhereClause( 173 new WhereClauses(AND) 174 .addWhereInIntsClause( 175 OPERATION_TYPE_COLUMN_NAME, 176 List.of( 177 OPERATION_TYPE_READ, 178 OPERATION_TYPE_UPSERT))) 179 .setOrderBy( 180 new OrderByClause() 181 .addOrderByClause(ACCESS_TIME_COLUMN_NAME, false)) 182 .setLimit(1); 183 184 long mostRecentAccessTime = Long.MIN_VALUE; 185 try (Cursor cursor = mTransactionManager.read(readTableRequest)) { 186 while (cursor.moveToNext()) { 187 long accessTime = getCursorLong(cursor, ACCESS_TIME_COLUMN_NAME); 188 mostRecentAccessTime = Math.max(mostRecentAccessTime, accessTime); 189 } 190 } 191 return mostRecentAccessTime; 192 } 193 194 /** 195 * Adds an entry into the {@link AccessLogsHelper#TABLE_NAME} for every insert or read operation 196 * request for record datatypes. 197 * 198 * @deprecated Use {@link #recordReadAccessLog} instead 199 */ 200 @Deprecated addAccessLog( String packageName, @RecordTypeIdentifier.RecordType List<Integer> recordTypeList, @OperationType.OperationTypes int operationType)201 public void addAccessLog( 202 String packageName, 203 @RecordTypeIdentifier.RecordType List<Integer> recordTypeList, 204 @OperationType.OperationTypes int operationType) { 205 long appInfoId = mAppInfoHelper.getAppInfoId(packageName); 206 if (appInfoId == DEFAULT_LONG) { 207 // TODO(b/371210803): Add server side log for this error 208 Slog.w(TAG, "invalid package name " + packageName + " used for access log"); 209 return; 210 } 211 UpsertTableRequest request = 212 getUpsertTableRequest(appInfoId, recordTypeList, operationType); 213 mTransactionManager.insert(request); 214 } 215 216 /** 217 * Adds an entry into the {@link AccessLogsHelper#TABLE_NAME} for every upsert/read/delete 218 * operation request for medicalResourceTypes. 219 */ addAccessLog( SQLiteDatabase db, String packageName, @MedicalResourceType Set<Integer> medicalResourceTypes, @OperationType.OperationTypes int operationType, boolean accessedMedicalDataSource)220 public void addAccessLog( 221 SQLiteDatabase db, 222 String packageName, 223 @MedicalResourceType Set<Integer> medicalResourceTypes, 224 @OperationType.OperationTypes int operationType, 225 boolean accessedMedicalDataSource) { 226 long appInfoId = mAppInfoHelper.getAppInfoId(packageName); 227 if (appInfoId == DEFAULT_LONG) { 228 // TODO(b/371210803): Add server side log for this error 229 Slog.w(TAG, "invalid package name " + packageName + " used for access log"); 230 return; 231 } 232 UpsertTableRequest request = 233 getUpsertTableRequestForPhr( 234 appInfoId, medicalResourceTypes, operationType, accessedMedicalDataSource); 235 mTransactionManager.insert(db, request); 236 } 237 getUpsertTableRequestForPhr( long appInfoId, Set<Integer> medicalResourceTypes, @OperationType.OperationTypes int operationType, boolean isMedicalDataSource)238 private static UpsertTableRequest getUpsertTableRequestForPhr( 239 long appInfoId, 240 Set<Integer> medicalResourceTypes, 241 @OperationType.OperationTypes int operationType, 242 boolean isMedicalDataSource) { 243 checkArgument(appInfoId != DEFAULT_LONG, "unknown app id"); 244 // We need to populate RECORD_TYPE_COLUMN_NAME with an empty list, as the column is set 245 // to NOT_NULL. 246 ContentValues contentValues = 247 populateCommonColumns(appInfoId, /* recordTypeList= */ List.of(), operationType); 248 contentValues.put( 249 MEDICAL_RESOURCE_TYPE_COLUMN_NAME, concatDataTypeIds(medicalResourceTypes)); 250 contentValues.put( 251 MEDICAL_DATA_SOURCE_ACCESSED_COLUMN_NAME, 252 isMedicalDataSource ? BOOLEAN_TRUE_VALUE : BOOLEAN_FALSE_VALUE); 253 return new UpsertTableRequest(TABLE_NAME, contentValues); 254 } 255 getUpsertTableRequest( long appInfoId, List<Integer> recordTypeList, @OperationType.OperationTypes int operationType)256 private static UpsertTableRequest getUpsertTableRequest( 257 long appInfoId, 258 List<Integer> recordTypeList, 259 @OperationType.OperationTypes int operationType) { 260 checkArgument(appInfoId != DEFAULT_LONG, "unknown app id"); 261 ContentValues contentValues = 262 populateCommonColumns(appInfoId, recordTypeList, operationType); 263 return new UpsertTableRequest(TABLE_NAME, contentValues); 264 } 265 266 /** Adds an entry of read type into the {@link AccessLogsHelper#TABLE_NAME} */ recordReadAccessLog( SQLiteDatabase db, String packageName, Set<Integer> recordTypeIds)267 public void recordReadAccessLog( 268 SQLiteDatabase db, String packageName, Set<Integer> recordTypeIds) { 269 recordAccessLog(db, packageName, recordTypeIds, OPERATION_TYPE_READ); 270 } 271 272 /** Adds an entry of upsert type into the {@link AccessLogsHelper#TABLE_NAME} */ recordUpsertAccessLog( SQLiteDatabase db, String packageName, Set<Integer> recordTypeIds)273 public void recordUpsertAccessLog( 274 SQLiteDatabase db, String packageName, Set<Integer> recordTypeIds) { 275 recordAccessLog(db, packageName, recordTypeIds, OPERATION_TYPE_UPSERT); 276 } 277 278 /** Adds an entry of delete type into the {@link AccessLogsHelper#TABLE_NAME} */ recordDeleteAccessLog( SQLiteDatabase db, String packageName, Set<Integer> recordTypeIds)279 public void recordDeleteAccessLog( 280 SQLiteDatabase db, String packageName, Set<Integer> recordTypeIds) { 281 recordAccessLog(db, packageName, recordTypeIds, OPERATION_TYPE_DELETE); 282 } 283 recordAccessLog( SQLiteDatabase db, String packageName, Set<Integer> recordTypeIds, @OperationType.OperationTypes int operationType)284 private void recordAccessLog( 285 SQLiteDatabase db, 286 String packageName, 287 Set<Integer> recordTypeIds, 288 @OperationType.OperationTypes int operationType) { 289 long appInfoId = mAppInfoHelper.getAppInfoId(packageName); 290 if (appInfoId == DEFAULT_LONG) { 291 // TODO(b/371210803): Add server side log for this error 292 Slog.w(TAG, "invalid package name " + packageName + " used for access log"); 293 return; 294 } 295 296 Set<Integer> filteredRecordTypeIds = new HashSet<>(recordTypeIds); 297 // Remove records for system AppOps so that we don't record them twice 298 // (they are already recorded via historical AppOps). 299 if (operationType == AccessLog.OperationType.OPERATION_TYPE_READ) { 300 Set<Integer> recordsWithSystemAppOps = mAppOpLogsHelper.getRecordsWithSystemAppOps(); 301 filteredRecordTypeIds.removeAll(recordsWithSystemAppOps); 302 } 303 if (filteredRecordTypeIds.isEmpty()) { 304 return; 305 } 306 307 ContentValues contentValues = 308 populateCommonColumns( 309 appInfoId, filteredRecordTypeIds.stream().toList(), operationType); 310 UpsertTableRequest request = new UpsertTableRequest(TABLE_NAME, contentValues); 311 mTransactionManager.insert(db, request); 312 } 313 314 @VisibleForTesting populateCommonColumns( long appInfoId, List<Integer> recordTypeList, @OperationType.OperationTypes int operationType)315 static ContentValues populateCommonColumns( 316 long appInfoId, 317 List<Integer> recordTypeList, 318 @OperationType.OperationTypes int operationType) { 319 checkArgument(appInfoId != DEFAULT_LONG, "unknown app id"); 320 321 ContentValues contentValues = new ContentValues(); 322 contentValues.put(APP_ID_COLUMN_NAME, appInfoId); 323 contentValues.put(ACCESS_TIME_COLUMN_NAME, Instant.now().toEpochMilli()); 324 contentValues.put(OPERATION_TYPE_COLUMN_NAME, operationType); 325 contentValues.put( 326 RECORD_TYPE_COLUMN_NAME, concatDataTypeIds(new HashSet<>(recordTypeList))); 327 return contentValues; 328 } 329 concatDataTypeIds(Set<Integer> dataTypes)330 private static String concatDataTypeIds(Set<Integer> dataTypes) { 331 return dataTypes.stream().map(String::valueOf).collect(Collectors.joining(",")); 332 } 333 334 /** 335 * Returns an instance of {@link DeleteTableRequest} to delete entries in access logs table 336 * older than a week. 337 */ getDeleteRequestForAutoDelete()338 public static DeleteTableRequest getDeleteRequestForAutoDelete() { 339 return new DeleteTableRequest(TABLE_NAME) 340 .setTimeFilter( 341 ACCESS_TIME_COLUMN_NAME, 342 Instant.EPOCH.toEpochMilli(), 343 Instant.now() 344 .minus(DEFAULT_ACCESS_LOG_TIME_PERIOD_IN_DAYS, ChronoUnit.DAYS) 345 .toEpochMilli()); 346 } 347 348 /** 349 * Creates an {@link AlterTableRequest} for adding PHR specific columns, {@link 350 * AccessLogsHelper#MEDICAL_RESOURCE_TYPE_COLUMN_NAME} and {@link 351 * AccessLogsHelper#MEDICAL_DATA_SOURCE_ACCESSED_COLUMN_NAME} to the access_logs_table. 352 */ getAlterTableRequestForPhrAccessLogs()353 public static AlterTableRequest getAlterTableRequestForPhrAccessLogs() { 354 return new AlterTableRequest(TABLE_NAME, getPhrAccessLogsColumnInfo()); 355 } 356 getColumnInfo()357 private static List<Pair<String, String>> getColumnInfo() { 358 List<Pair<String, String>> columnInfo = new ArrayList<>(NUM_COLS); 359 columnInfo.add(new Pair<>(PRIMARY_COLUMN_NAME, PRIMARY_AUTOINCREMENT)); 360 columnInfo.add(new Pair<>(APP_ID_COLUMN_NAME, INTEGER_NOT_NULL)); 361 columnInfo.add(new Pair<>(RECORD_TYPE_COLUMN_NAME, TEXT_NOT_NULL)); 362 columnInfo.add(new Pair<>(ACCESS_TIME_COLUMN_NAME, INTEGER_NOT_NULL)); 363 columnInfo.add(new Pair<>(OPERATION_TYPE_COLUMN_NAME, INTEGER_NOT_NULL)); 364 return columnInfo; 365 } 366 367 /** Gets the columns to add for an {@link AlterTableRequest} for adding PHR specific columns, */ getPhrAccessLogsColumnInfo()368 public static List<Pair<String, String>> getPhrAccessLogsColumnInfo() { 369 return List.of( 370 // This is list of comma separated integers that represent 371 // the medicalResourceTypes accessed. 372 Pair.create(MEDICAL_RESOURCE_TYPE_COLUMN_NAME, TEXT_NULL), 373 // This represents a boolean, which tells us whether 374 // the MedicalDataSource data is accessed. 375 Pair.create(MEDICAL_DATA_SOURCE_ACCESSED_COLUMN_NAME, INTEGER)); 376 } 377 378 @Override getMainTableName()379 protected String getMainTableName() { 380 return TABLE_NAME; 381 } 382 } 383