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.DEBUG;
20 import static android.health.connect.Constants.DEFAULT_LONG;
21 
22 import static com.android.server.healthconnect.storage.request.UpsertTableRequest.TYPE_STRING;
23 import static com.android.server.healthconnect.storage.utils.StorageUtils.BLOB;
24 import static com.android.server.healthconnect.storage.utils.StorageUtils.PRIMARY;
25 import static com.android.server.healthconnect.storage.utils.StorageUtils.TEXT_NOT_NULL_UNIQUE;
26 import static com.android.server.healthconnect.storage.utils.StorageUtils.TEXT_NULL;
27 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorBlob;
28 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorLong;
29 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorString;
30 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.AND;
31 
32 import static java.util.Objects.requireNonNull;
33 
34 import android.annotation.NonNull;
35 import android.annotation.Nullable;
36 import android.annotation.SuppressLint;
37 import android.content.ContentValues;
38 import android.content.Context;
39 import android.content.pm.ApplicationInfo;
40 import android.content.pm.PackageManager;
41 import android.content.pm.PackageManager.ApplicationInfoFlags;
42 import android.content.pm.PackageManager.NameNotFoundException;
43 import android.database.Cursor;
44 import android.graphics.Bitmap;
45 import android.graphics.BitmapFactory;
46 import android.graphics.Canvas;
47 import android.graphics.drawable.Drawable;
48 import android.health.connect.Constants;
49 import android.health.connect.datatypes.AppInfo;
50 import android.health.connect.internal.datatypes.AppInfoInternal;
51 import android.health.connect.internal.datatypes.RecordInternal;
52 import android.health.connect.internal.datatypes.utils.RecordMapper;
53 import android.util.Log;
54 import android.util.Pair;
55 import android.util.Slog;
56 
57 import com.android.server.healthconnect.storage.TransactionManager;
58 import com.android.server.healthconnect.storage.request.CreateTableRequest;
59 import com.android.server.healthconnect.storage.request.ReadTableRequest;
60 import com.android.server.healthconnect.storage.request.UpsertTableRequest;
61 import com.android.server.healthconnect.storage.utils.WhereClauses;
62 
63 import java.io.ByteArrayOutputStream;
64 import java.io.IOException;
65 import java.util.ArrayList;
66 import java.util.Arrays;
67 import java.util.Collections;
68 import java.util.HashMap;
69 import java.util.HashSet;
70 import java.util.List;
71 import java.util.Map;
72 import java.util.Objects;
73 import java.util.Set;
74 import java.util.concurrent.ConcurrentHashMap;
75 import java.util.stream.Collectors;
76 
77 /**
78  * A class to help with the DB transaction for storing Application Info. {@link AppInfoHelper} acts
79  * as a layer b/w the application_igenfo_table stored in the DB and helps perform insert and read
80  * operations on the table
81  *
82  * @hide
83  */
84 public final class AppInfoHelper extends DatabaseHelper {
85     public static final String TABLE_NAME = "application_info_table";
86     public static final String APPLICATION_COLUMN_NAME = "app_name";
87     public static final String PACKAGE_COLUMN_NAME = "package_name";
88     public static final List<Pair<String, Integer>> UNIQUE_COLUMN_INFO =
89             Collections.singletonList(new Pair<>(PACKAGE_COLUMN_NAME, TYPE_STRING));
90     public static final String APP_ICON_COLUMN_NAME = "app_icon";
91     private static final String TAG = "HealthConnectAppInfoHelper";
92     private static final String RECORD_TYPES_USED_COLUMN_NAME = "record_types_used";
93     private static final int COMPRESS_FACTOR = 100;
94 
95     @SuppressWarnings("NullAway.Init") // TODO(b/317029272): fix this suppression
96     private static volatile AppInfoHelper sAppInfoHelper;
97 
98     /**
99      * Map to store appInfoId -> packageName mapping for populating record for read
100      *
101      * <p>TO HAVE THREAD SAFETY DON'T USE THESE VARIABLES DIRECTLY, INSTEAD USE ITS GETTER
102      */
103     private volatile ConcurrentHashMap<Long, String> mIdPackageNameMap;
104 
105     /**
106      * Map to store application package-name -> AppInfo mapping (such as packageName -> appName,
107      * icon, rowId in the DB etc.)
108      *
109      * <p>TO HAVE THREAD SAFETY DON'T USE THESE VARIABLES DIRECTLY, INSTEAD USE ITS GETTER
110      */
111     private volatile ConcurrentHashMap<String, AppInfoInternal> mAppInfoMap;
112 
113     @SuppressWarnings("NullAway.Init") // TODO(b/317029272): fix this suppression
AppInfoHelper()114     private AppInfoHelper() {}
115 
116     @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
117     @Override
clearCache()118     public synchronized void clearCache() {
119         mAppInfoMap = null;
120         mIdPackageNameMap = null;
121     }
122 
123     @Override
getMainTableName()124     protected String getMainTableName() {
125         return TABLE_NAME;
126     }
127 
128     /**
129      * Returns a requests representing the tables that should be created corresponding to this
130      * helper
131      */
132     @NonNull
getCreateTableRequest()133     public static CreateTableRequest getCreateTableRequest() {
134         return new CreateTableRequest(TABLE_NAME, getColumnInfo());
135     }
136 
137     /** Populates record with appInfoId */
populateAppInfoId( @onNull RecordInternal<?> record, @NonNull Context context, boolean requireAllFields)138     public void populateAppInfoId(
139             @NonNull RecordInternal<?> record, @NonNull Context context, boolean requireAllFields) {
140         final String packageName = requireNonNull(record.getPackageName());
141         AppInfoInternal appInfo = getAppInfoMap().get(packageName);
142 
143         if (appInfo == null) {
144             try {
145                 appInfo = getAppInfo(packageName, context);
146             } catch (NameNotFoundException e) {
147                 if (requireAllFields) {
148                     throw new IllegalArgumentException("Could not find package info", e);
149                 }
150 
151                 appInfo =
152                         new AppInfoInternal(
153                                 DEFAULT_LONG, packageName, record.getAppName(), null, null);
154             }
155 
156             insertIfNotPresent(packageName, appInfo);
157         }
158 
159         record.setAppInfoId(appInfo.getId());
160         record.setPackageName(appInfo.getPackageName());
161     }
162 
163     /**
164      * Inserts or replaces (based on the passed param onlyUpdate) the application info of the
165      * specified {@code packageName} with the specified {@code name} and {@code icon}, only if the
166      * corresponding application is not currently installed.
167      *
168      * <p>If onlyUpdate is true then only replace the exiting AppInfo; no new insertion. If
169      * onlyUpdate is false then only insert a new AppInfo entry; no replacement.
170      */
addOrUpdateAppInfoIfNotInstalled( @onNull Context context, @NonNull String packageName, @Nullable String name, @Nullable byte[] icon, boolean onlyUpdate)171     public void addOrUpdateAppInfoIfNotInstalled(
172             @NonNull Context context,
173             @NonNull String packageName,
174             @Nullable String name,
175             @Nullable byte[] icon,
176             boolean onlyUpdate) {
177         if (!isAppInstalled(context, packageName)) {
178             // using pre-existing value of recordTypesUsed.
179             @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
180             var recordTypesUsed =
181                     containsAppInfo(packageName)
182                             ? mAppInfoMap.get(packageName).getRecordTypesUsed()
183                             : null;
184             AppInfoInternal appInfoInternal =
185                     new AppInfoInternal(
186                             DEFAULT_LONG, packageName, name, decodeBitmap(icon), recordTypesUsed);
187             if (onlyUpdate) {
188                 updateIfPresent(packageName, appInfoInternal);
189             } else {
190                 insertIfNotPresent(packageName, appInfoInternal);
191             }
192         }
193     }
194 
isAppInstalled(@onNull Context context, @NonNull String packageName)195     private boolean isAppInstalled(@NonNull Context context, @NonNull String packageName) {
196         try {
197             context.getPackageManager().getApplicationInfo(packageName, ApplicationInfoFlags.of(0));
198             return true;
199         } catch (NameNotFoundException e) {
200             return false;
201         }
202     }
203 
204     /**
205      * @return id of {@code packageName} or {@link Constants#DEFAULT_LONG} if the id is not found
206      */
getAppInfoId(String packageName)207     public long getAppInfoId(String packageName) {
208         if (packageName == null) {
209             return DEFAULT_LONG;
210         }
211 
212         AppInfoInternal appInfo = getAppInfoMap().getOrDefault(packageName, null);
213 
214         if (appInfo == null) {
215             return DEFAULT_LONG;
216         }
217         return appInfo.getId();
218     }
219 
containsAppInfo(String packageName)220     private boolean containsAppInfo(String packageName) {
221         return getAppInfoMap().containsKey(packageName);
222     }
223 
224     /**
225      * @param packageNames List of package names
226      * @return A list of appinfo ids from the application_info_table.
227      */
getAppInfoIds(List<String> packageNames)228     public List<Long> getAppInfoIds(List<String> packageNames) {
229         if (DEBUG) {
230             Slog.d(TAG, "App info map: " + mAppInfoMap);
231         }
232         if (packageNames == null || packageNames.isEmpty()) {
233             return Collections.emptyList();
234         }
235 
236         List<Long> result = new ArrayList<>(packageNames.size());
237         packageNames.forEach(packageName -> result.add(getAppInfoId(packageName)));
238 
239         return result;
240     }
241 
242     /** Gets the package name corresponding to the {@code packageId}. */
243     @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
244     @NonNull
getPackageName(long packageId)245     public String getPackageName(long packageId) {
246         return getIdPackageNameMap().get(packageId);
247     }
248 
249     @NonNull
getPackageNames(List<Long> packageIds)250     public List<String> getPackageNames(List<Long> packageIds) {
251         if (packageIds == null || packageIds.isEmpty()) {
252             return Collections.emptyList();
253         }
254 
255         List<String> packageNames = new ArrayList<>();
256         packageIds.forEach(
257                 (packageId) -> {
258                     String packageName = getPackageName(packageId);
259                     requireNonNull(packageName);
260 
261                     packageNames.add(packageName);
262                 });
263 
264         return packageNames;
265     }
266 
267     /** Returns a list of AppInfo objects which are contributing data to some recordType. */
getApplicationInfosWithRecordTypes()268     public List<AppInfo> getApplicationInfosWithRecordTypes() {
269         return getAppInfoMap().values().stream()
270                 .filter(
271                         (appInfo) ->
272                                 (appInfo.getRecordTypesUsed() != null
273                                         && !appInfo.getRecordTypesUsed().isEmpty()))
274                 .map(AppInfoInternal::toExternal)
275                 .collect(Collectors.toList());
276     }
277 
278     /** Returns AppInfo id for the provided {@code packageName}, creating it if needed. */
getOrInsertAppInfoId(@onNull String packageName, @NonNull Context context)279     public long getOrInsertAppInfoId(@NonNull String packageName, @NonNull Context context) {
280         AppInfoInternal appInfoInternal = getAppInfoMap().get(packageName);
281 
282         if (appInfoInternal == null) {
283             try {
284                 appInfoInternal = getAppInfo(packageName, context);
285             } catch (NameNotFoundException e) {
286                 throw new IllegalArgumentException("Could not find package info for package", e);
287             }
288 
289             insertIfNotPresent(packageName, appInfoInternal);
290         }
291 
292         return appInfoInternal.getId();
293     }
294 
populateAppInfoMap()295     private synchronized void populateAppInfoMap() {
296         if (mAppInfoMap != null) {
297             return;
298         }
299         ConcurrentHashMap<String, AppInfoInternal> appInfoMap = new ConcurrentHashMap<>();
300         ConcurrentHashMap<Long, String> idPackageNameMap = new ConcurrentHashMap<>();
301         final TransactionManager transactionManager = TransactionManager.getInitialisedInstance();
302         try (Cursor cursor = transactionManager.read(new ReadTableRequest(TABLE_NAME))) {
303             while (cursor.moveToNext()) {
304                 long rowId = getCursorLong(cursor, RecordHelper.PRIMARY_COLUMN_NAME);
305                 String packageName = getCursorString(cursor, PACKAGE_COLUMN_NAME);
306                 String appName = getCursorString(cursor, APPLICATION_COLUMN_NAME);
307                 byte[] icon = getCursorBlob(cursor, APP_ICON_COLUMN_NAME);
308                 Bitmap bitmap = decodeBitmap(icon);
309                 String recordTypesUsed = getCursorString(cursor, RECORD_TYPES_USED_COLUMN_NAME);
310 
311                 Set<Integer> recordTypesListAsSet = getRecordTypesAsSet(recordTypesUsed);
312 
313                 appInfoMap.put(
314                         packageName,
315                         new AppInfoInternal(
316                                 rowId, packageName, appName, bitmap, recordTypesListAsSet));
317                 idPackageNameMap.put(rowId, packageName);
318             }
319         }
320         mAppInfoMap = appInfoMap;
321         mIdPackageNameMap = idPackageNameMap;
322     }
323 
324     @Nullable
getRecordTypesAsSet(String recordTypesUsed)325     private Set<Integer> getRecordTypesAsSet(String recordTypesUsed) {
326         if (recordTypesUsed != null && !recordTypesUsed.isEmpty()) {
327             return Arrays.stream(recordTypesUsed.split(","))
328                     .map(Integer::parseInt)
329                     .collect(Collectors.toSet());
330         }
331         return null;
332     }
333 
334     /**
335      * Updates recordTypesUsed for the {@code packageName} in app info table.
336      *
337      * <p><b>NOTE:</b> This method should only be used for insert operation on recordType tables.
338      * Should not be called elsewhere.
339      *
340      * <p>see {@link AppInfoHelper#syncAppInfoMapRecordTypesUsed(Map)}} for updating this table
341      * during delete operations on recordTypes.
342      *
343      * @param recordTypes The record types that needs to be inserted.
344      * @param packageName The package for which the records need to be inserted.
345      */
346     @SuppressLint("LongLogTag")
updateAppInfoRecordTypesUsedOnInsert( Set<Integer> recordTypes, String packageName)347     public synchronized void updateAppInfoRecordTypesUsedOnInsert(
348             Set<Integer> recordTypes, String packageName) {
349         AppInfoInternal appInfo = getAppInfoMap().get(packageName);
350         if (appInfo == null) {
351             Log.e(
352                     TAG,
353                     "AppInfo for the current package: "
354                             + packageName
355                             + " does not exist. "
356                             + "Hence recordTypesUsed is not getting updated.");
357 
358             return;
359         }
360 
361         if (recordTypes == null || recordTypes.isEmpty()) {
362             return;
363         }
364         Set<Integer> updatedRecordTypes = new HashSet<>(recordTypes);
365         if (appInfo.getRecordTypesUsed() != null) {
366             updatedRecordTypes.addAll(appInfo.getRecordTypesUsed());
367         }
368         if (!updatedRecordTypes.equals(appInfo.getRecordTypesUsed())) {
369             updateAppInfoRecordTypesUsedSync(packageName, appInfo, updatedRecordTypes);
370         }
371     }
372 
373     /**
374      * Updates recordTypesUsed by for all packages in app info table.
375      *
376      * <p><b>NOTE:</b> This method should only be used for delete operation on recordType tables.
377      * Should not be called elsewhere.
378      *
379      * <p>Use this method to update the table for passed recordTypes, not passing any record will
380      * update all recordTypes.
381      *
382      * <p>see {@link AppInfoHelper#updateAppInfoRecordTypesUsedOnInsert(Set, String)} for updating
383      * this table during insert operations on recordTypes.
384      */
syncAppInfoRecordTypesUsed()385     public synchronized void syncAppInfoRecordTypesUsed() {
386         syncAppInfoRecordTypesUsed(null);
387     }
388 
389     /**
390      * Updates recordTypesUsed by for all packages in app info table.
391      *
392      * <p><b>NOTE:</b> This method should only be used for delete operation on recordType tables.
393      * Should not be called elsewhere.
394      *
395      * <p>Use this method to update the table for passed {@code recordTypesToBeSynced}, not passing
396      * any record will update all recordTypes.
397      *
398      * <p>see {@link AppInfoHelper#updateAppInfoRecordTypesUsedOnInsert(Set, String)} for updating
399      * this table during insert operations on recordTypes.
400      */
syncAppInfoRecordTypesUsed( @ullable Set<Integer> recordTypesToBeSynced)401     public synchronized void syncAppInfoRecordTypesUsed(
402             @Nullable Set<Integer> recordTypesToBeSynced) {
403         Set<Integer> recordTypesToBeUpdated =
404                 Objects.requireNonNullElseGet(
405                         recordTypesToBeSynced,
406                         () ->
407                                 RecordMapper.getInstance()
408                                         .getRecordIdToExternalRecordClassMap()
409                                         .keySet());
410 
411         HashMap<Integer, HashSet<String>> recordTypeToContributingPackagesMap =
412                 TransactionManager.getInitialisedInstance()
413                         .getDistinctPackageNamesForRecordsTable(recordTypesToBeUpdated);
414 
415         if (recordTypesToBeSynced == null) {
416             syncAppInfoMapRecordTypesUsed(recordTypeToContributingPackagesMap);
417         } else {
418             getAppInfoMap()
419                     .keySet()
420                     .forEach(
421                             (packageName) -> {
422                                 deleteRecordTypesForPackagesIfRequiredInternal(
423                                         recordTypesToBeUpdated,
424                                         recordTypeToContributingPackagesMap,
425                                         packageName);
426                             });
427         }
428     }
429 
430     /**
431      * This method updates recordTypesUsed for all packages and hence is a heavy operation. This
432      * method is used during AutoDeleteService and is run once per day.
433      */
434     @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
435     @SuppressLint("LongLogTag")
syncAppInfoMapRecordTypesUsed( @onNull Map<Integer, HashSet<String>> recordTypeToContributingPackagesMap)436     private synchronized void syncAppInfoMapRecordTypesUsed(
437             @NonNull Map<Integer, HashSet<String>> recordTypeToContributingPackagesMap) {
438         HashMap<String, List<Integer>> packageToRecordTypesMap =
439                 getPackageToRecordTypesMap(recordTypeToContributingPackagesMap);
440         getAppInfoMap()
441                 .forEach(
442                         (packageName, appInfo) -> {
443                             if (packageToRecordTypesMap.containsKey(packageName)) {
444                                 updateAppInfoRecordTypesUsedSync(
445                                         packageName,
446                                         appInfo,
447                                         new HashSet<>(packageToRecordTypesMap.get(packageName)));
448                             } else {
449                                 updateAppInfoRecordTypesUsedSync(
450                                         packageName, appInfo, /* recordTypesUsed */ null);
451                             }
452                             if (DEBUG) {
453                                 Log.d(
454                                         TAG,
455                                         "Syncing packages and corresponding recordTypesUsed for"
456                                                 + " package : "
457                                                 + packageName
458                                                 + ", recordTypesUsed : "
459                                                 + appInfo.getRecordTypesUsed());
460                             }
461                         });
462     }
463 
getPackageToRecordTypesMap( @onNull Map<Integer, HashSet<String>> recordTypeToContributingPackagesMap)464     private HashMap<String, List<Integer>> getPackageToRecordTypesMap(
465             @NonNull Map<Integer, HashSet<String>> recordTypeToContributingPackagesMap) {
466         HashMap<String, List<Integer>> packageToRecordTypesMap = new HashMap<>();
467         recordTypeToContributingPackagesMap.forEach(
468                 (recordType, packageList) -> {
469                     packageList.forEach(
470                             (packageName) -> {
471                                 if (packageToRecordTypesMap.containsKey(packageName)) {
472                                     packageToRecordTypesMap.get(packageName).add(recordType);
473                                 } else {
474                                     packageToRecordTypesMap.put(
475                                             packageName,
476                                             new ArrayList<>() {
477                                                 {
478                                                     add(recordType);
479                                                 }
480                                             });
481                                 }
482                             });
483                 });
484         return packageToRecordTypesMap;
485     }
486 
487     /**
488      * Checks and deletes record types in app info table for which the package is no longer
489      * contributing data. This is done after delete records operation has been performed.
490      */
491     @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
492     @SuppressLint("LongLogTag")
deleteRecordTypesForPackagesIfRequiredInternal( Set<Integer> recordTypesToBeDeleted, HashMap<Integer, HashSet<String>> currentRecordTypePackageMap, String packageName)493     private synchronized void deleteRecordTypesForPackagesIfRequiredInternal(
494             Set<Integer> recordTypesToBeDeleted,
495             HashMap<Integer, HashSet<String>> currentRecordTypePackageMap,
496             String packageName) {
497         AppInfoInternal appInfo = getAppInfoMap().get(packageName);
498         if (appInfo == null) {
499             Log.e(
500                     TAG,
501                     "AppInfo for the current package: "
502                             + packageName
503                             + " does not exist. "
504                             + "Hence recordTypesUsed is not getting updated.");
505 
506             return;
507         }
508         if (appInfo.getRecordTypesUsed() == null || appInfo.getRecordTypesUsed().isEmpty()) {
509             // return since this package is not contributing to any recordType and hence there
510             // is nothing to delete.
511             return;
512         }
513         Set<Integer> updatedRecordTypesUsed = new HashSet<>(appInfo.getRecordTypesUsed());
514         for (Integer recordType : recordTypesToBeDeleted) {
515             // get the distinct packages used by the record after the deletion process, check if
516             // the recordType does not have the current package then remove record type from
517             // the package's app info record.
518             if (!currentRecordTypePackageMap.get(recordType).contains(packageName)) {
519                 updatedRecordTypesUsed.remove(recordType);
520             }
521         }
522         if (updatedRecordTypesUsed.equals(appInfo.getRecordTypesUsed())) {
523             return;
524         }
525         if (updatedRecordTypesUsed.isEmpty()) {
526             updatedRecordTypesUsed = null;
527         }
528         updateAppInfoRecordTypesUsedSync(packageName, appInfo, updatedRecordTypesUsed);
529     }
530 
531     @SuppressLint("LongLogTag")
updateAppInfoRecordTypesUsedSync( @onNull String packageName, @NonNull AppInfoInternal appInfo, Set<Integer> recordTypesUsed)532     private synchronized void updateAppInfoRecordTypesUsedSync(
533             @NonNull String packageName,
534             @NonNull AppInfoInternal appInfo,
535             Set<Integer> recordTypesUsed) {
536         appInfo.setRecordTypesUsed(recordTypesUsed);
537         // create upsert table request to modify app info table, keyed by packages name.
538         WhereClauses whereClauseForAppInfoTableUpdate = new WhereClauses(AND);
539         whereClauseForAppInfoTableUpdate.addWhereEqualsClause(
540                 PACKAGE_COLUMN_NAME, appInfo.getPackageName());
541         UpsertTableRequest upsertRequestForAppInfoUpdate =
542                 new UpsertTableRequest(
543                         TABLE_NAME, getContentValues(packageName, appInfo), UNIQUE_COLUMN_INFO);
544         TransactionManager.getInitialisedInstance().update(upsertRequestForAppInfoUpdate);
545 
546         // update locally stored maps to keep data in sync.
547         getAppInfoMap().put(packageName, appInfo);
548         getIdPackageNameMap().put(appInfo.getId(), packageName);
549         if (DEBUG) {
550             Log.d(
551                     TAG,
552                     "Updated app info table. PackageName : "
553                             + packageName
554                             + " , RecordTypesUsed : "
555                             + appInfo.getRecordTypesUsed()
556                             + ".");
557         }
558     }
559 
560     /** Returns a map for recordTypes and their contributing packages. */
getRecordTypesToContributingPackagesMap()561     public Map<Integer, Set<String>> getRecordTypesToContributingPackagesMap() {
562         Map<Integer, Set<String>> recordTypeContributingPackagesMap = new HashMap<>();
563         Map<String, AppInfoInternal> appInfoMap = getAppInfoMap();
564         appInfoMap.forEach(
565                 (packageName, appInfo) -> {
566                     Set<Integer> recordTypesUsed = appInfo.getRecordTypesUsed();
567                     if (recordTypesUsed != null) {
568                         recordTypesUsed.forEach(
569                                 (recordType) -> {
570                                     if (recordTypeContributingPackagesMap.containsKey(recordType)) {
571                                         recordTypeContributingPackagesMap
572                                                 .get(recordType)
573                                                 .add(packageName);
574                                     } else {
575                                         recordTypeContributingPackagesMap.put(
576                                                 recordType,
577                                                 new HashSet<>(Collections.singleton(packageName)));
578                                     }
579                                 });
580                     }
581                 });
582         return recordTypeContributingPackagesMap;
583     }
584 
getAppInfoMap()585     private Map<String, AppInfoInternal> getAppInfoMap() {
586         if (Objects.isNull(mAppInfoMap)) {
587             populateAppInfoMap();
588         }
589 
590         return mAppInfoMap;
591     }
592 
getIdPackageNameMap()593     private Map<Long, String> getIdPackageNameMap() {
594         if (mIdPackageNameMap == null) {
595             populateAppInfoMap();
596         }
597 
598         return mIdPackageNameMap;
599     }
600 
getAppInfo(@onNull String packageName, @NonNull Context context)601     private AppInfoInternal getAppInfo(@NonNull String packageName, @NonNull Context context)
602             throws NameNotFoundException {
603         PackageManager packageManager = context.getPackageManager();
604         ApplicationInfo info =
605                 packageManager.getApplicationInfo(
606                         packageName, PackageManager.ApplicationInfoFlags.of(0));
607         String appName = packageManager.getApplicationLabel(info).toString();
608         Drawable icon = packageManager.getApplicationIcon(info);
609         Bitmap bitmap = getBitmapFromDrawable(icon);
610         return new AppInfoInternal(DEFAULT_LONG, packageName, appName, bitmap, null);
611     }
612 
insertIfNotPresent( @onNull String packageName, @NonNull AppInfoInternal appInfo)613     private synchronized void insertIfNotPresent(
614             @NonNull String packageName, @NonNull AppInfoInternal appInfo) {
615         if (getAppInfoMap().containsKey(packageName)) {
616             return;
617         }
618 
619         long rowId =
620                 TransactionManager.getInitialisedInstance()
621                         .insert(
622                                 new UpsertTableRequest(
623                                         TABLE_NAME,
624                                         getContentValues(packageName, appInfo),
625                                         UNIQUE_COLUMN_INFO));
626         appInfo.setId(rowId);
627         getAppInfoMap().put(packageName, appInfo);
628         getIdPackageNameMap().put(appInfo.getId(), packageName);
629     }
630 
updateIfPresent(String packageName, AppInfoInternal appInfoInternal)631     private synchronized void updateIfPresent(String packageName, AppInfoInternal appInfoInternal) {
632         if (!getAppInfoMap().containsKey(packageName)) {
633             return;
634         }
635 
636         UpsertTableRequest upsertTableRequest =
637                 new UpsertTableRequest(
638                         TABLE_NAME,
639                         getContentValues(packageName, appInfoInternal),
640                         UNIQUE_COLUMN_INFO);
641 
642         TransactionManager.getInitialisedInstance().updateTable(upsertTableRequest);
643         getAppInfoMap().put(packageName, appInfoInternal);
644     }
645 
646     @NonNull
getContentValues(String packageName, AppInfoInternal appInfo)647     private ContentValues getContentValues(String packageName, AppInfoInternal appInfo) {
648         ContentValues contentValues = new ContentValues();
649         contentValues.put(PACKAGE_COLUMN_NAME, packageName);
650         contentValues.put(APPLICATION_COLUMN_NAME, appInfo.getName());
651         contentValues.put(APP_ICON_COLUMN_NAME, encodeBitmap(appInfo.getIcon()));
652         String recordTypesUsedAsString = null;
653         // Since a list of recordTypeIds cannot be saved directly in the database, record types IDs
654         // are concatenated using ',' and are saved as a string.
655         if (appInfo.getRecordTypesUsed() != null) {
656             recordTypesUsedAsString =
657                     appInfo.getRecordTypesUsed().stream()
658                             .map(String::valueOf)
659                             .collect(Collectors.joining(","));
660         }
661         contentValues.put(RECORD_TYPES_USED_COLUMN_NAME, recordTypesUsedAsString);
662 
663         return contentValues;
664     }
665 
666     /**
667      * This implementation should return the column names with which the table should be created.
668      *
669      * <p>NOTE: New columns can only be added via onUpgrade. Why? Consider what happens if a table
670      * already exists on the device
671      *
672      * <p>PLEASE DON'T USE THIS METHOD TO ADD NEW COLUMNS
673      */
674     @NonNull
getColumnInfo()675     private static List<Pair<String, String>> getColumnInfo() {
676         ArrayList<Pair<String, String>> columnInfo = new ArrayList<>();
677         columnInfo.add(new Pair<>(RecordHelper.PRIMARY_COLUMN_NAME, PRIMARY));
678         columnInfo.add(new Pair<>(PACKAGE_COLUMN_NAME, TEXT_NOT_NULL_UNIQUE));
679         columnInfo.add(new Pair<>(APPLICATION_COLUMN_NAME, TEXT_NULL));
680         columnInfo.add(new Pair<>(APP_ICON_COLUMN_NAME, BLOB));
681         columnInfo.add(new Pair<>(RECORD_TYPES_USED_COLUMN_NAME, TEXT_NULL));
682 
683         return columnInfo;
684     }
685 
getInstance()686     public static synchronized AppInfoHelper getInstance() {
687         if (sAppInfoHelper == null) {
688             sAppInfoHelper = new AppInfoHelper();
689         }
690 
691         return sAppInfoHelper;
692     }
693 
694     @Nullable
encodeBitmap(@ullable Bitmap bitmap)695     private static byte[] encodeBitmap(@Nullable Bitmap bitmap) {
696         if (bitmap == null) {
697             return null;
698         }
699 
700         try (ByteArrayOutputStream stream = new ByteArrayOutputStream()) {
701             bitmap.compress(Bitmap.CompressFormat.PNG, COMPRESS_FACTOR, stream);
702             return stream.toByteArray();
703         } catch (IOException exception) {
704             throw new IllegalArgumentException(exception);
705         }
706     }
707 
708     @Nullable
decodeBitmap(@ullable byte[] bytes)709     private static Bitmap decodeBitmap(@Nullable byte[] bytes) {
710         return bytes != null ? BitmapFactory.decodeByteArray(bytes, 0, bytes.length) : null;
711     }
712 
713     @NonNull
getBitmapFromDrawable(@onNull Drawable drawable)714     private static Bitmap getBitmapFromDrawable(@NonNull Drawable drawable) {
715         final Bitmap bmp =
716                 Bitmap.createBitmap(
717                         drawable.getIntrinsicWidth(),
718                         drawable.getIntrinsicHeight(),
719                         Bitmap.Config.ARGB_8888);
720         final Canvas canvas = new Canvas(bmp);
721         drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
722         drawable.draw(canvas);
723         return bmp;
724     }
725 }
726