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.HealthPermissions.getDataCategoriesWithWritePermissionsForPackage; 20 import static android.health.connect.HealthPermissions.getPackageHasWriteHealthPermissionsForCategory; 21 import static android.health.connect.internal.datatypes.utils.RecordTypeRecordCategoryMapper.getRecordCategoryForRecordType; 22 23 import static com.android.server.healthconnect.storage.request.UpsertTableRequest.TYPE_STRING; 24 import static com.android.server.healthconnect.storage.utils.StorageUtils.DELIMITER; 25 import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER_UNIQUE; 26 import static com.android.server.healthconnect.storage.utils.StorageUtils.PRIMARY; 27 import static com.android.server.healthconnect.storage.utils.StorageUtils.TEXT_NOT_NULL; 28 29 import android.annotation.NonNull; 30 import android.content.ContentValues; 31 import android.content.Context; 32 import android.content.pm.PackageInfo; 33 import android.content.res.Resources; 34 import android.database.Cursor; 35 import android.health.connect.HealthDataCategory; 36 import android.health.connect.HealthPermissions; 37 import android.os.UserHandle; 38 import android.util.Pair; 39 import android.util.Slog; 40 41 import com.android.internal.annotations.VisibleForTesting; 42 import com.android.server.healthconnect.HealthConnectDeviceConfigManager; 43 import com.android.server.healthconnect.permission.HealthConnectPermissionHelper; 44 import com.android.server.healthconnect.permission.PackageInfoUtils; 45 import com.android.server.healthconnect.storage.TransactionManager; 46 import com.android.server.healthconnect.storage.request.CreateTableRequest; 47 import com.android.server.healthconnect.storage.request.DeleteTableRequest; 48 import com.android.server.healthconnect.storage.request.ReadTableRequest; 49 import com.android.server.healthconnect.storage.request.UpsertTableRequest; 50 import com.android.server.healthconnect.storage.utils.StorageUtils; 51 52 import java.util.ArrayList; 53 import java.util.Collections; 54 import java.util.HashMap; 55 import java.util.HashSet; 56 import java.util.List; 57 import java.util.Map; 58 import java.util.Objects; 59 import java.util.Set; 60 import java.util.concurrent.ConcurrentHashMap; 61 import java.util.stream.Collectors; 62 63 /** 64 * Helper class to get priority of the apps for each {@link HealthDataCategory} 65 * 66 * @hide 67 */ 68 public class HealthDataCategoryPriorityHelper extends DatabaseHelper { 69 private static final String TABLE_NAME = "health_data_category_priority_table"; 70 private static final String HEALTH_DATA_CATEGORY_COLUMN_NAME = "health_data_category"; 71 public static final List<Pair<String, Integer>> UNIQUE_COLUMN_INFO = 72 Collections.singletonList(new Pair<>(HEALTH_DATA_CATEGORY_COLUMN_NAME, TYPE_STRING)); 73 private static final String APP_ID_PRIORITY_ORDER_COLUMN_NAME = "app_id_priority_order"; 74 private static final String TAG = "HealthConnectPrioHelper"; 75 private static final String DEFAULT_APP_RESOURCE_NAME = 76 "android:string/config_defaultHealthConnectApp"; 77 78 public static final String INACTIVE_APPS_ADDED = "inactive_apps_added"; 79 80 @SuppressWarnings("NullAway.Init") // TODO(b/317029272): fix this suppression 81 private static volatile HealthDataCategoryPriorityHelper sHealthDataCategoryPriorityHelper; 82 83 /** 84 * map of {@link HealthDataCategory} to list of app ids from {@link AppInfoHelper}, in the order 85 * of their priority 86 */ 87 private volatile ConcurrentHashMap<Integer, List<Long>> mHealthDataCategoryToAppIdPriorityMap; 88 89 @SuppressWarnings("NullAway.Init") // TODO(b/317029272): fix this suppression HealthDataCategoryPriorityHelper()90 private HealthDataCategoryPriorityHelper() {} 91 92 /** 93 * Returns a requests representing the tables that should be created corresponding to this 94 * helper 95 */ 96 @NonNull getCreateTableRequest()97 public static CreateTableRequest getCreateTableRequest() { 98 return new CreateTableRequest(TABLE_NAME, getColumnInfo()); 99 } 100 101 /** 102 * Appends a packageName to the priority list for this category when an app gets write 103 * permissions or during the one-time operation to add inactive apps. 104 * 105 * <p>Inactive apps are added at the bottom of the priority list even if they are the default 106 * app. 107 */ 108 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression appendToPriorityList( @onNull String packageName, @HealthDataCategory.Type int dataCategory, Context context, boolean isInactiveApp)109 public synchronized void appendToPriorityList( 110 @NonNull String packageName, 111 @HealthDataCategory.Type int dataCategory, 112 Context context, 113 boolean isInactiveApp) { 114 List<Long> newPriorityOrder; 115 getHealthDataCategoryToAppIdPriorityMap().putIfAbsent(dataCategory, new ArrayList<>()); 116 long appInfoId = AppInfoHelper.getInstance().getOrInsertAppInfoId(packageName, context); 117 if (getHealthDataCategoryToAppIdPriorityMap().get(dataCategory).contains(appInfoId)) { 118 return; 119 } 120 newPriorityOrder = 121 new ArrayList<>(getHealthDataCategoryToAppIdPriorityMap().get(dataCategory)); 122 123 if (isDefaultApp(packageName, context) && !isInactiveApp) { 124 newPriorityOrder.add(0, appInfoId); 125 } else { 126 newPriorityOrder.add(appInfoId); 127 } 128 safelyUpdateDBAndUpdateCache( 129 new UpsertTableRequest( 130 TABLE_NAME, 131 getContentValuesFor(dataCategory, newPriorityOrder), 132 UNIQUE_COLUMN_INFO), 133 dataCategory, 134 newPriorityOrder); 135 } 136 137 @VisibleForTesting isDefaultApp(@onNull String packageName, @NonNull Context context)138 boolean isDefaultApp(@NonNull String packageName, @NonNull Context context) { 139 String defaultApp = 140 context.getResources() 141 .getString( 142 Resources.getSystem() 143 .getIdentifier(DEFAULT_APP_RESOURCE_NAME, null, null)); 144 145 return Objects.equals(packageName, defaultApp); 146 } 147 148 /** 149 * Removes a packageName from the priority list of a particular category if the package name 150 * does not have any granted write permissions. In the new aggregation source control, the 151 * package name is not removed if it has data in this category. 152 */ maybeRemoveAppFromPriorityList( @onNull String packageName, @HealthDataCategory.Type int dataCategory, HealthConnectPermissionHelper permissionHelper, UserHandle userHandle)153 public synchronized void maybeRemoveAppFromPriorityList( 154 @NonNull String packageName, 155 @HealthDataCategory.Type int dataCategory, 156 HealthConnectPermissionHelper permissionHelper, 157 UserHandle userHandle) { 158 159 final List<String> grantedPermissions = 160 permissionHelper.getGrantedHealthPermissions(packageName, userHandle); 161 for (String permission : HealthPermissions.getWriteHealthPermissionsFor(dataCategory)) { 162 if (grantedPermissions.contains(permission)) { 163 return; 164 } 165 } 166 167 maybeRemoveAppFromPriorityListInternal(dataCategory, packageName); 168 } 169 170 /** 171 * Removes apps from the priority list if they no longer hold write permissions to the category 172 * and have no data for that category. 173 * 174 * <p>If the new aggregation source control flag is off, apps that don't have write permissions 175 * are removed regardless of whether they hold data in that category. 176 */ 177 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression updateHealthDataPriority( @onNull String[] packageNames, @NonNull UserHandle user, @NonNull Context context)178 public synchronized void updateHealthDataPriority( 179 @NonNull String[] packageNames, @NonNull UserHandle user, @NonNull Context context) { 180 Objects.requireNonNull(packageNames); 181 Objects.requireNonNull(user); 182 Objects.requireNonNull(context); 183 PackageInfoUtils packageInfoUtils = PackageInfoUtils.getInstance(); 184 for (String packageName : packageNames) { 185 PackageInfo packageInfo = 186 packageInfoUtils.getPackageInfoWithPermissionsAsUser( 187 packageName, user, context); 188 189 Set<Integer> dataCategoriesWithWritePermission = 190 getDataCategoriesWithWritePermissionsForPackage(packageInfo, context); 191 192 for (int category : getHealthDataCategoryToAppIdPriorityMap().keySet()) { 193 if (!dataCategoriesWithWritePermission.contains(category)) { 194 maybeRemoveAppFromPriorityListInternal(category, packageInfo.packageName); 195 } 196 } 197 } 198 } 199 200 /** 201 * Removes app from priorityList for all HealthData Categories if the package is uninstalled or 202 * if it has no health permissions. In the new aggregation source behaviour, the package name is 203 * not removed if it still has health data in a category. 204 */ maybeRemoveAppWithoutWritePermissionsFromPriorityList( @onNull String packageName)205 public synchronized void maybeRemoveAppWithoutWritePermissionsFromPriorityList( 206 @NonNull String packageName) { 207 Objects.requireNonNull(packageName); 208 for (Integer dataCategory : getHealthDataCategoryToAppIdPriorityMap().keySet()) { 209 maybeRemoveAppFromPriorityListInternal(dataCategory, packageName); 210 } 211 } 212 213 /** Returns list of package names based on priority for the input {@link HealthDataCategory} */ 214 @NonNull getPriorityOrder( @ealthDataCategory.Type int type, @NonNull Context context)215 public List<String> getPriorityOrder( 216 @HealthDataCategory.Type int type, @NonNull Context context) { 217 boolean newAggregationSourceControl = 218 HealthConnectDeviceConfigManager.getInitialisedInstance() 219 .isAggregationSourceControlsEnabled(); 220 if (newAggregationSourceControl) { 221 reSyncHealthDataPriorityTable(context); 222 } 223 return AppInfoHelper.getInstance().getPackageNames(getAppIdPriorityOrder(type)); 224 } 225 226 /** Returns list of App ids based on priority for the input {@link HealthDataCategory} */ 227 @NonNull getAppIdPriorityOrder(@ealthDataCategory.Type int type)228 public List<Long> getAppIdPriorityOrder(@HealthDataCategory.Type int type) { 229 List<Long> packageIds = getHealthDataCategoryToAppIdPriorityMap().get(type); 230 if (packageIds == null) { 231 return Collections.emptyList(); 232 } 233 234 return packageIds; 235 } 236 237 /** 238 * Sets a new priority order for the given category, and allows adding and removing packages 239 * from the priority list. 240 * 241 * <p>In the old behaviour it is not allowed to add or remove packages so the new priority order 242 * needs to be sanitised before applying the operation. 243 */ setPriorityOrder(int dataCategory, @NonNull List<String> packagePriorityOrder)244 public void setPriorityOrder(int dataCategory, @NonNull List<String> packagePriorityOrder) { 245 boolean newAggregationSourceControl = 246 HealthConnectDeviceConfigManager.getInitialisedInstance() 247 .isAggregationSourceControlsEnabled(); 248 249 List<Long> newPriorityOrder = 250 AppInfoHelper.getInstance().getAppInfoIds(packagePriorityOrder); 251 252 if (!newAggregationSourceControl) { 253 newPriorityOrder = sanitizePriorityOder(dataCategory, newPriorityOrder); 254 } 255 256 safelyUpdateDBAndUpdateCache( 257 new UpsertTableRequest( 258 TABLE_NAME, 259 getContentValuesFor(dataCategory, newPriorityOrder), 260 UNIQUE_COLUMN_INFO), 261 dataCategory, 262 newPriorityOrder); 263 } 264 265 /** 266 * Sanitizes the new priority order by ensuring it contains the same elements as the old 267 * priority order, for the old behaviour of aggregation source control. 268 */ sanitizePriorityOder(int dataCategory, List<Long> newPriorityOrder)269 private List<Long> sanitizePriorityOder(int dataCategory, List<Long> newPriorityOrder) { 270 271 List<Long> currentPriorityOrder = 272 getHealthDataCategoryToAppIdPriorityMap() 273 .getOrDefault(dataCategory, Collections.emptyList()); 274 275 // Remove appId from the priority order if it is not part of the current priority order, 276 // this is because in the time app tried to update the order an app permission might 277 // have been removed, and we only store priority order of apps with permission. 278 newPriorityOrder.removeIf(priorityOrder -> !currentPriorityOrder.contains(priorityOrder)); 279 280 // Make sure we don't remove any new entries. So append old priority in new priority and 281 // remove duplicates 282 newPriorityOrder.addAll(currentPriorityOrder); 283 newPriorityOrder = newPriorityOrder.stream().distinct().collect(Collectors.toList()); 284 285 return newPriorityOrder; 286 } 287 288 @Override clearData(@onNull TransactionManager transactionManager)289 protected synchronized void clearData(@NonNull TransactionManager transactionManager) { 290 clearCache(); 291 super.clearData(transactionManager); 292 } 293 294 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression 295 @Override clearCache()296 public synchronized void clearCache() { 297 mHealthDataCategoryToAppIdPriorityMap = null; 298 } 299 300 @Override getMainTableName()301 protected String getMainTableName() { 302 return TABLE_NAME; 303 } 304 getHealthDataCategoryToAppIdPriorityMap()305 private Map<Integer, List<Long>> getHealthDataCategoryToAppIdPriorityMap() { 306 if (mHealthDataCategoryToAppIdPriorityMap == null) { 307 populateDataCategoryToAppIdPriorityMap(); 308 } 309 310 return mHealthDataCategoryToAppIdPriorityMap; 311 } 312 313 /** Returns an immutable map of data categories along with their priority order. */ getHealthDataCategoryToAppIdPriorityMapImmutable()314 public Map<Integer, List<Long>> getHealthDataCategoryToAppIdPriorityMapImmutable() { 315 return Collections.unmodifiableMap(getHealthDataCategoryToAppIdPriorityMap()); 316 } 317 populateDataCategoryToAppIdPriorityMap()318 private synchronized void populateDataCategoryToAppIdPriorityMap() { 319 if (mHealthDataCategoryToAppIdPriorityMap != null) { 320 return; 321 } 322 323 ConcurrentHashMap<Integer, List<Long>> healthDataCategoryToAppIdPriorityMap = 324 new ConcurrentHashMap<>(); 325 final TransactionManager transactionManager = TransactionManager.getInitialisedInstance(); 326 try (Cursor cursor = transactionManager.read(new ReadTableRequest(TABLE_NAME))) { 327 while (cursor.moveToNext()) { 328 int dataCategory = 329 cursor.getInt(cursor.getColumnIndex(HEALTH_DATA_CATEGORY_COLUMN_NAME)); 330 List<Long> appIdsInOrder = 331 StorageUtils.getCursorLongList( 332 cursor, APP_ID_PRIORITY_ORDER_COLUMN_NAME, DELIMITER); 333 334 healthDataCategoryToAppIdPriorityMap.put(dataCategory, appIdsInOrder); 335 } 336 } 337 338 mHealthDataCategoryToAppIdPriorityMap = healthDataCategoryToAppIdPriorityMap; 339 } 340 safelyUpdateDBAndUpdateCache( UpsertTableRequest request, @HealthDataCategory.Type int dataCategory, List<Long> newList)341 private synchronized void safelyUpdateDBAndUpdateCache( 342 UpsertTableRequest request, 343 @HealthDataCategory.Type int dataCategory, 344 List<Long> newList) { 345 try { 346 TransactionManager.getInitialisedInstance().insertOrReplace(request); 347 getHealthDataCategoryToAppIdPriorityMap().put(dataCategory, newList); 348 } catch (Exception e) { 349 Slog.e(TAG, "Priority update failed", e); 350 throw e; 351 } 352 } 353 safelyUpdateDBAndUpdateCache( DeleteTableRequest request, @HealthDataCategory.Type int dataCategory)354 private synchronized void safelyUpdateDBAndUpdateCache( 355 DeleteTableRequest request, @HealthDataCategory.Type int dataCategory) { 356 try { 357 TransactionManager.getInitialisedInstance().delete(request); 358 getHealthDataCategoryToAppIdPriorityMap().remove(dataCategory); 359 } catch (Exception e) { 360 Slog.e(TAG, "Delete from priority DB failed: ", e); 361 throw e; 362 } 363 } 364 getContentValuesFor( @ealthDataCategory.Type int dataCategory, List<Long> priorityList)365 private ContentValues getContentValuesFor( 366 @HealthDataCategory.Type int dataCategory, List<Long> priorityList) { 367 ContentValues contentValues = new ContentValues(); 368 contentValues.put(HEALTH_DATA_CATEGORY_COLUMN_NAME, dataCategory); 369 contentValues.put( 370 APP_ID_PRIORITY_ORDER_COLUMN_NAME, StorageUtils.flattenLongList(priorityList)); 371 372 return contentValues; 373 } 374 375 /** 376 * This implementation should return the column names with which the table should be created. 377 * 378 * <p>NOTE: New columns can only be added via onUpgrade. Why? Consider what happens if a table 379 * already exists on the device 380 * 381 * <p>PLEASE DON'T USE THIS METHOD TO ADD NEW COLUMNS 382 */ 383 @NonNull getColumnInfo()384 private static List<Pair<String, String>> getColumnInfo() { 385 ArrayList<Pair<String, String>> columnInfo = new ArrayList<>(); 386 columnInfo.add(new Pair<>(RecordHelper.PRIMARY_COLUMN_NAME, PRIMARY)); 387 columnInfo.add(new Pair<>(HEALTH_DATA_CATEGORY_COLUMN_NAME, INTEGER_UNIQUE)); 388 columnInfo.add(new Pair<>(APP_ID_PRIORITY_ORDER_COLUMN_NAME, TEXT_NOT_NULL)); 389 390 return columnInfo; 391 } 392 393 @NonNull getInstance()394 public static synchronized HealthDataCategoryPriorityHelper getInstance() { 395 if (sHealthDataCategoryPriorityHelper == null) { 396 sHealthDataCategoryPriorityHelper = new HealthDataCategoryPriorityHelper(); 397 } 398 399 return sHealthDataCategoryPriorityHelper; 400 } 401 402 /** Syncs priority table with the permissions and data. */ reSyncHealthDataPriorityTable(@onNull Context context)403 public synchronized void reSyncHealthDataPriorityTable(@NonNull Context context) { 404 Objects.requireNonNull(context); 405 boolean newAggregationSourceControl = 406 HealthConnectDeviceConfigManager.getInitialisedInstance() 407 .isAggregationSourceControlsEnabled(); 408 // Candidates to be added to the priority list 409 Map<Integer, List<Long>> dataCategoryToAppIdMapHavingPermission = 410 getHealthDataCategoryToAppIdPriorityMap().entrySet().stream() 411 .collect( 412 Collectors.toMap( 413 Map.Entry::getKey, e -> new ArrayList<>(e.getValue()))); 414 // Candidates to be removed from the priority list 415 Map<Integer, Set<Long>> dataCategoryToAppIdMapWithoutPermission = 416 getHealthDataCategoryToAppIdPriorityMap().entrySet().stream() 417 .collect( 418 Collectors.toMap( 419 Map.Entry::getKey, e -> new HashSet<>(e.getValue()))); 420 421 List<PackageInfo> validHealthApps = getValidHealthApps(context); 422 AppInfoHelper appInfoHelper = AppInfoHelper.getInstance(); 423 for (PackageInfo packageInfo : validHealthApps) { 424 Set<Integer> dataCategoriesWithWritePermissionsForThisPackage = 425 getDataCategoriesWithWritePermissionsForPackage(packageInfo, context); 426 long appInfoId = appInfoHelper.getOrInsertAppInfoId(packageInfo.packageName, context); 427 428 for (int dataCategory : dataCategoriesWithWritePermissionsForThisPackage) { 429 List<Long> appIdsHavingPermission = 430 dataCategoryToAppIdMapHavingPermission.getOrDefault( 431 dataCategory, new ArrayList<>()); 432 if (!appIdsHavingPermission.contains(appInfoId) 433 && appIdsHavingPermission.add(appInfoId)) { 434 dataCategoryToAppIdMapHavingPermission.put( 435 dataCategory, appIdsHavingPermission); 436 } 437 438 Set<Long> appIdsWithoutPermission = 439 dataCategoryToAppIdMapWithoutPermission.getOrDefault( 440 dataCategory, new HashSet<>()); 441 if (appIdsWithoutPermission.remove(appInfoId)) { 442 dataCategoryToAppIdMapWithoutPermission.put( 443 dataCategory, appIdsWithoutPermission); 444 } 445 } 446 } 447 448 // The new behaviour does not automatically add to the priority list if there is 449 // a write permission for a package name 450 if (!newAggregationSourceControl) { 451 updateTableWithNewPriorityList(dataCategoryToAppIdMapHavingPermission); 452 } 453 maybeRemoveAppsFromPriorityList(dataCategoryToAppIdMapWithoutPermission); 454 maybeAddContributingAppsIfEmpty(context); 455 } 456 457 /** Returns a list of PackageInfos holding health permissions for this user. */ getValidHealthApps(@onNull Context context)458 private List<PackageInfo> getValidHealthApps(@NonNull Context context) { 459 UserHandle user = TransactionManager.getInitialisedInstance().getCurrentUserHandle(); 460 Context currentUserContext = context.createContextAsUser(user, /*flags*/ 0); 461 return PackageInfoUtils.getInstance() 462 .getPackagesHoldingHealthPermissions(user, currentUserContext); 463 } 464 465 /** 466 * Removes a packageName from the priority list of a category. For the new aggregation source 467 * control, the package name is not removed if it has data in that category. 468 */ maybeRemoveAppFromPriorityListInternal( @ealthDataCategory.Type int dataCategory, @NonNull String packageName)469 private synchronized void maybeRemoveAppFromPriorityListInternal( 470 @HealthDataCategory.Type int dataCategory, @NonNull String packageName) { 471 boolean newAggregationSourceControl = 472 HealthConnectDeviceConfigManager.getInitialisedInstance() 473 .isAggregationSourceControlsEnabled(); 474 boolean dataExistsForPackageName = appHasDataInCategory(packageName, dataCategory); 475 if (newAggregationSourceControl && dataExistsForPackageName) { 476 // Do not remove if data exists for packageName in the new aggregation 477 return; 478 } 479 480 List<Long> newPriorityList = 481 new ArrayList<>( 482 getHealthDataCategoryToAppIdPriorityMap() 483 .getOrDefault(dataCategory, Collections.emptyList())); 484 if (newPriorityList.isEmpty()) { 485 return; 486 } 487 488 newPriorityList.remove(AppInfoHelper.getInstance().getAppInfoId(packageName)); 489 if (newPriorityList.isEmpty()) { 490 safelyUpdateDBAndUpdateCache( 491 new DeleteTableRequest(TABLE_NAME) 492 .setId(HEALTH_DATA_CATEGORY_COLUMN_NAME, String.valueOf(dataCategory)), 493 dataCategory); 494 return; 495 } 496 497 safelyUpdateDBAndUpdateCache( 498 new UpsertTableRequest( 499 TABLE_NAME, 500 getContentValuesFor(dataCategory, newPriorityList), 501 UNIQUE_COLUMN_INFO), 502 dataCategory, 503 newPriorityList); 504 } 505 506 /** 507 * Removes apps without permissions for these categories from the priority list. In the new 508 * aggregation source control, the packages are not removed if they still have data in these 509 * categories. 510 */ 511 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression maybeRemoveAppsFromPriorityList( Map<Integer, Set<Long>> dataCategoryToAppIdsWithoutPermissions)512 private synchronized void maybeRemoveAppsFromPriorityList( 513 Map<Integer, Set<Long>> dataCategoryToAppIdsWithoutPermissions) { 514 for (int dataCategory : dataCategoryToAppIdsWithoutPermissions.keySet()) { 515 for (Long appInfoId : dataCategoryToAppIdsWithoutPermissions.get(dataCategory)) { 516 maybeRemoveAppFromPriorityListInternal( 517 dataCategory, AppInfoHelper.getInstance().getPackageName(appInfoId)); 518 } 519 } 520 } 521 522 /** 523 * If the priority list is empty for a {@link HealthDataCategory}, add the contributing apps. 524 * 525 * <p>This is necessary because the priority list should never be empty if there are 526 * contributing apps present. 527 */ maybeAddContributingAppsIfEmpty(@onNull Context context)528 private synchronized void maybeAddContributingAppsIfEmpty(@NonNull Context context) { 529 List.of( 530 HealthDataCategory.ACTIVITY, 531 HealthDataCategory.BODY_MEASUREMENTS, 532 HealthDataCategory.CYCLE_TRACKING, 533 HealthDataCategory.NUTRITION, 534 HealthDataCategory.SLEEP, 535 HealthDataCategory.VITALS) 536 .forEach( 537 (category) -> 538 getHealthDataCategoryToAppIdPriorityMap() 539 .putIfAbsent(category, new ArrayList<>())); 540 Map<Integer, List<Long>> healthDataCategoryToAppIdPriorityMap = 541 getHealthDataCategoryToAppIdPriorityMap(); 542 for (int dataCategory : healthDataCategoryToAppIdPriorityMap.keySet()) { 543 List<Long> appIdsInPriorityOrder = 544 healthDataCategoryToAppIdPriorityMap.getOrDefault(dataCategory, List.of()); 545 if (appIdsInPriorityOrder.isEmpty()) { 546 getAllContributorApps().getOrDefault(dataCategory, new HashSet<>()).stream() 547 .sorted() 548 .forEach( 549 (contributingApp) -> 550 appendToPriorityList( 551 contributingApp, 552 dataCategory, 553 context, 554 isInactiveApp( 555 dataCategory, contributingApp, context))); 556 } 557 } 558 } 559 updateTableWithNewPriorityList( Map<Integer, List<Long>> healthDataCategoryToAppIdPriorityMap)560 private synchronized void updateTableWithNewPriorityList( 561 Map<Integer, List<Long>> healthDataCategoryToAppIdPriorityMap) { 562 for (int dataCategory : healthDataCategoryToAppIdPriorityMap.keySet()) { 563 List<Long> appInfoIdList = 564 List.copyOf(healthDataCategoryToAppIdPriorityMap.get(dataCategory)); 565 if (!appInfoIdList.equals( 566 getHealthDataCategoryToAppIdPriorityMap().get(dataCategory))) { 567 safelyUpdateDBAndUpdateCache( 568 new UpsertTableRequest( 569 TABLE_NAME, 570 getContentValuesFor(dataCategory, appInfoIdList), 571 UNIQUE_COLUMN_INFO), 572 dataCategory, 573 appInfoIdList); 574 } 575 } 576 } 577 578 /** 579 * A one-time operation which adds contributing apps to the priority list if the new aggregation 580 * source controls are available. 581 * 582 * <p>The contributing apps are added in ascending order of their package names. 583 * 584 * <p>Originally only inactive apps were added, extending this to all contributing apps is a 585 * workaround for the case when the device to device transfer empties the priority list. 586 */ maybeAddContributingAppsToPriorityList(Context context)587 public void maybeAddContributingAppsToPriorityList(Context context) { 588 if (!shouldAddContributingApps()) { 589 return; 590 } 591 592 Map<Integer, Set<String>> contributingApps = getAllContributorApps(); 593 594 for (Map.Entry<Integer, Set<String>> entry : contributingApps.entrySet()) { 595 int category = entry.getKey(); 596 entry.getValue().stream() 597 .sorted() 598 .forEach( 599 packageName -> 600 appendToPriorityList( 601 packageName, 602 category, 603 context, 604 isInactiveApp(category, packageName, context))); 605 } 606 607 PreferenceHelper.getInstance() 608 .insertOrReplacePreference(INACTIVE_APPS_ADDED, String.valueOf(true)); 609 } 610 isInactiveApp( @ealthDataCategory.Type int dataCategory, @NonNull String packageName, @NonNull Context context)611 private boolean isInactiveApp( 612 @HealthDataCategory.Type int dataCategory, 613 @NonNull String packageName, 614 @NonNull Context context) { 615 Map<Integer, Set<String>> inactiveApps = getAllInactiveApps(context); 616 return inactiveApps.getOrDefault(dataCategory, new HashSet<>()).contains(packageName); 617 } 618 shouldAddContributingApps()619 private boolean shouldAddContributingApps() { 620 boolean newAggregationSourceControl = 621 HealthConnectDeviceConfigManager.getInitialisedInstance() 622 .isAggregationSourceControlsEnabled(); 623 624 if (!newAggregationSourceControl) { 625 return false; 626 } 627 628 String haveInactiveAppsBeenAddedString = 629 PreferenceHelper.getInstance().getPreference(INACTIVE_APPS_ADDED); 630 631 // No-op if this operation has already been completed 632 if (haveInactiveAppsBeenAddedString != null 633 && Boolean.parseBoolean(haveInactiveAppsBeenAddedString)) { 634 return false; 635 } 636 637 return true; 638 } 639 640 @VisibleForTesting appHasDataInCategory(String packageName, int category)641 boolean appHasDataInCategory(String packageName, int category) { 642 return getDataCategoriesWithDataForPackage(packageName).contains(category); 643 } 644 645 @VisibleForTesting getDataCategoriesWithDataForPackage(String packageName)646 Set<Integer> getDataCategoriesWithDataForPackage(String packageName) { 647 Map<Integer, Set<String>> recordTypeToContributingPackages = 648 AppInfoHelper.getInstance().getRecordTypesToContributingPackagesMap(); 649 Set<Integer> dataCategoriesWithData = new HashSet<>(); 650 651 for (Map.Entry<Integer, Set<String>> entry : recordTypeToContributingPackages.entrySet()) { 652 Integer recordType = entry.getKey(); 653 Set<String> contributingPackages = entry.getValue(); 654 int recordCategory = getRecordCategoryForRecordType(recordType); 655 boolean isPackageNameContributor = contributingPackages.contains(packageName); 656 if (isPackageNameContributor) { 657 dataCategoriesWithData.add(recordCategory); 658 } 659 } 660 return dataCategoriesWithData; 661 } 662 663 /** 664 * Returns a set of contributing apps for each dataCategory. If a dataCategory does not have any 665 * data it will not be present in the map. 666 */ 667 @VisibleForTesting getAllContributorApps()668 Map<Integer, Set<String>> getAllContributorApps() { 669 Map<Integer, Set<String>> recordTypeToContributingPackages = 670 AppInfoHelper.getInstance().getRecordTypesToContributingPackagesMap(); 671 672 Map<Integer, Set<String>> allContributorApps = new HashMap<>(); 673 674 for (Map.Entry<Integer, Set<String>> entry : recordTypeToContributingPackages.entrySet()) { 675 int recordCategory = getRecordCategoryForRecordType(entry.getKey()); 676 Set<String> contributingPackages = entry.getValue(); 677 678 Set<String> currentPackages = 679 allContributorApps.getOrDefault(recordCategory, new HashSet<>()); 680 currentPackages.addAll(contributingPackages); 681 allContributorApps.put(recordCategory, currentPackages); 682 } 683 684 return allContributorApps; 685 } 686 687 /** 688 * Returns a map of dataCategory to sets of packageNames that are inactive. 689 * 690 * <p>An inactive app is one that has data for the dataCategory but no write permissions. 691 */ 692 @VisibleForTesting getAllInactiveApps(Context context)693 Map<Integer, Set<String>> getAllInactiveApps(Context context) { 694 Map<Integer, Set<String>> allContributorApps = getAllContributorApps(); 695 Map<Integer, Set<String>> inactiveApps = new HashMap<>(); 696 697 for (Map.Entry<Integer, Set<String>> entry : allContributorApps.entrySet()) { 698 int category = entry.getKey(); 699 Set<String> contributorApps = entry.getValue(); 700 701 for (String app : contributorApps) { 702 if (!appHasWriteHealthPermissionsForCategory(app, category, context)) { 703 Set<String> currentPackages = 704 inactiveApps.getOrDefault(category, new HashSet<>()); 705 if (currentPackages.add(app)) { 706 inactiveApps.put(category, currentPackages); 707 } 708 } 709 } 710 } 711 712 return inactiveApps; 713 } 714 715 /** 716 * Returns true if this packageName has at least one granted WRITE permission for this 717 * dataCategory. 718 */ 719 @VisibleForTesting appHasWriteHealthPermissionsForCategory( @onNull String packageName, @HealthDataCategory.Type int dataCategory, @NonNull Context context)720 boolean appHasWriteHealthPermissionsForCategory( 721 @NonNull String packageName, 722 @HealthDataCategory.Type int dataCategory, 723 @NonNull Context context) { 724 725 List<PackageInfo> validHealthApps = getValidHealthApps(context); 726 727 for (PackageInfo validHealthApp : validHealthApps) { 728 if (Objects.equals(validHealthApp.packageName, packageName)) { 729 if (getPackageHasWriteHealthPermissionsForCategory( 730 validHealthApp, dataCategory, context)) { 731 return true; 732 } 733 } 734 } 735 736 return false; 737 } 738 } 739