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