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