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