• 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.adservices.service.topics;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.content.Context;
22 import android.content.pm.ApplicationInfo;
23 import android.content.pm.PackageManager;
24 import android.database.sqlite.SQLiteDatabase;
25 import android.net.Uri;
26 import android.os.Build;
27 import android.util.Pair;
28 
29 import androidx.annotation.RequiresApi;
30 
31 import com.android.adservices.LoggerFactory;
32 import com.android.adservices.data.DbHelper;
33 import com.android.adservices.data.topics.Topic;
34 import com.android.adservices.data.topics.TopicsDao;
35 import com.android.adservices.data.topics.TopicsTables;
36 import com.android.adservices.service.Flags;
37 import com.android.adservices.service.FlagsFactory;
38 import com.android.adservices.service.common.compat.PackageManagerCompatUtils;
39 import com.android.internal.annotations.VisibleForTesting;
40 
41 import java.util.ArrayList;
42 import java.util.Arrays;
43 import java.util.HashSet;
44 import java.util.List;
45 import java.util.Map;
46 import java.util.Objects;
47 import java.util.Random;
48 import java.util.Set;
49 import java.util.stream.Collectors;
50 
51 /**
52  * Class to manage application update flow in Topics API.
53  *
54  * <p>It contains methods to handle app installation and uninstallation. App update will either be
55  * regarded as the combination of app installation and uninstallation, or be handled in the next
56  * epoch.
57  *
58  * <p>See go/rb-topics-app-update for details.
59  */
60 // TODO(b/269798827): Enable for R.
61 @RequiresApi(Build.VERSION_CODES.S)
62 public class AppUpdateManager {
63     private static final LoggerFactory.Logger sLogger = LoggerFactory.getTopicsLogger();
64     private static final String EMPTY_SDK = "";
65     private static AppUpdateManager sSingleton;
66 
67     // Tables that needs to be wiped out for application data
68     // and its corresponding app column name.
69     // Pair<Table Name, app Column Name>
70     private static final Pair<String, String>[] TABLE_INFO_TO_ERASE_APP_DATA =
71             new Pair[] {
72                 Pair.create(
73                         TopicsTables.AppClassificationTopicsContract.TABLE,
74                         TopicsTables.AppClassificationTopicsContract.APP),
75                 Pair.create(
76                         TopicsTables.CallerCanLearnTopicsContract.TABLE,
77                         TopicsTables.CallerCanLearnTopicsContract.CALLER),
78                 Pair.create(
79                         TopicsTables.ReturnedTopicContract.TABLE,
80                         TopicsTables.ReturnedTopicContract.APP),
81                 Pair.create(
82                         TopicsTables.UsageHistoryContract.TABLE,
83                         TopicsTables.UsageHistoryContract.APP),
84                 Pair.create(
85                         TopicsTables.AppUsageHistoryContract.TABLE,
86                         TopicsTables.AppUsageHistoryContract.APP),
87                 Pair.create(
88                         TopicsTables.TopicContributorsContract.TABLE,
89                         TopicsTables.TopicContributorsContract.APP)
90             };
91 
92     private final DbHelper mDbHelper;
93     private final TopicsDao mTopicsDao;
94     private final Random mRandom;
95     private final Flags mFlags;
96 
AppUpdateManager( @onNull DbHelper dbHelper, @NonNull TopicsDao topicsDao, @NonNull Random random, @NonNull Flags flags)97     AppUpdateManager(
98             @NonNull DbHelper dbHelper,
99             @NonNull TopicsDao topicsDao,
100             @NonNull Random random,
101             @NonNull Flags flags) {
102         mDbHelper = dbHelper;
103         mTopicsDao = topicsDao;
104         mRandom = random;
105         mFlags = flags;
106     }
107 
108     /**
109      * Returns an instance of AppUpdateManager given a context
110      *
111      * @param context the context
112      * @return an instance of AppUpdateManager
113      */
114     @NonNull
getInstance(@onNull Context context)115     public static AppUpdateManager getInstance(@NonNull Context context) {
116         synchronized (AppUpdateManager.class) {
117             if (sSingleton == null) {
118                 sSingleton =
119                         new AppUpdateManager(
120                                 DbHelper.getInstance(context),
121                                 TopicsDao.getInstance(context),
122                                 new Random(),
123                                 FlagsFactory.getFlags());
124             }
125         }
126 
127         return sSingleton;
128     }
129 
130     /**
131      * Handle application uninstallation for Topics API.
132      *
133      * <ul>
134      *   <li>Delete all derived data for an uninstalled app.
135      *   <li>When the feature is enabled, remove a topic if it has the uninstalled app as the only
136      *       contributor in an epoch.
137      * </ul>
138      *
139      * @param packageUri The {@link Uri} got from Broadcast Intent
140      * @param currentEpochId the epoch id of current Epoch
141      */
handleAppUninstallationInRealTime(@onNull Uri packageUri, long currentEpochId)142     public void handleAppUninstallationInRealTime(@NonNull Uri packageUri, long currentEpochId) {
143         String packageName = convertUriToAppName(packageUri);
144 
145         SQLiteDatabase db = mDbHelper.safeGetWritableDatabase();
146         if (db == null) {
147             sLogger.e(
148                     "Database is not available, Stop processing app uninstallation for %s!",
149                     packageName);
150             return;
151         }
152 
153         // This cross db and java boundaries multiple times, so we need to have a db transaction.
154         db.beginTransaction();
155 
156         try {
157             handleTopTopicsWithoutContributors(currentEpochId, packageName);
158 
159             deleteAppDataFromTableByApps(List.of(packageName));
160 
161             // Mark the transaction successful.
162             db.setTransactionSuccessful();
163         } finally {
164             db.endTransaction();
165             sLogger.d("End of processing app uninstallation for %s", packageName);
166         }
167     }
168 
169     /**
170      * Handle application installation for Topics API.
171      *
172      * <p>Assign topics to past epochs for the installed app.
173      *
174      * @param packageUri The {@link Uri} got from Broadcast Intent
175      * @param currentEpochId the epoch id of current Epoch
176      */
handleAppInstallationInRealTime(@onNull Uri packageUri, long currentEpochId)177     public void handleAppInstallationInRealTime(@NonNull Uri packageUri, long currentEpochId) {
178         String packageName = convertUriToAppName(packageUri);
179 
180         SQLiteDatabase db = mDbHelper.safeGetWritableDatabase();
181         if (db == null) {
182             sLogger.e(
183                     "Database is not available, Stop processing app installation for %s",
184                     packageName);
185             return;
186         }
187 
188         // This cross db and java boundaries multiple times, so we need to have a db transaction.
189         db.beginTransaction();
190 
191         try {
192             assignTopicsToNewlyInstalledApps(packageName, currentEpochId);
193 
194             // Mark the transaction successful.
195             db.setTransactionSuccessful();
196         } finally {
197             db.endTransaction();
198             sLogger.d("End of processing app installation for %s", packageName);
199         }
200     }
201 
202     /**
203      * Reconcile any mismatched data for application uninstallation.
204      *
205      * <p>Uninstallation: Wipe out data in all tables for an uninstalled application with data still
206      * persisted in database.
207      *
208      * <ul>
209      *   <li>Step 1: Get currently installed apps from Package Manager.
210      *   <li>Step 2: Apps that have either usages or returned topics but are not installed are
211      *       regarded as newly uninstalled apps.
212      *   <li>Step 3: For each newly uninstalled app, wipe out its data from database.
213      * </ul>
214      *
215      * @param context the context
216      * @param currentEpochId epoch ID of current epoch
217      */
reconcileUninstalledApps(@onNull Context context, long currentEpochId)218     public void reconcileUninstalledApps(@NonNull Context context, long currentEpochId) {
219         Set<String> currentInstalledApps = getCurrentInstalledApps(context);
220         Set<String> unhandledUninstalledApps = getUnhandledUninstalledApps(currentInstalledApps);
221         if (unhandledUninstalledApps.isEmpty()) {
222             return;
223         }
224 
225         sLogger.v(
226                 "Detect below unhandled mismatched applications: %s",
227                 unhandledUninstalledApps.toString());
228 
229         SQLiteDatabase db = mDbHelper.safeGetWritableDatabase();
230         if (db == null) {
231             sLogger.e("Database is not available, Stop reconciling app uninstallation in Topics!");
232             return;
233         }
234 
235         // This cross db and java boundaries multiple times, so we need to have a db transaction.
236         db.beginTransaction();
237 
238         try {
239             handleUninstalledAppsInReconciliation(unhandledUninstalledApps, currentEpochId);
240 
241             // Mark the transaction successful.
242             db.setTransactionSuccessful();
243         } finally {
244             db.endTransaction();
245             sLogger.v("App uninstallation reconciliation in Topics is finished!");
246         }
247     }
248 
249     /**
250      * Reconcile any mismatched data for application installation.
251      *
252      * <p>Installation: Assign a random top topic from last 3 epochs to app only.
253      *
254      * <ul>
255      *   <li>Step 1: Get currently installed apps from Package Manager.
256      *   <li>Step 2: Installed apps that don't have neither usages nor returned topics are regarded
257      *       as newly installed apps.
258      *   <li>Step 3: For each newly installed app, assign a random top topic from last epoch to it
259      *       and persist in the database.
260      * </ul>
261      *
262      * @param context the context
263      * @param currentEpochId id of current epoch
264      */
reconcileInstalledApps(@onNull Context context, long currentEpochId)265     public void reconcileInstalledApps(@NonNull Context context, long currentEpochId) {
266         Set<String> currentInstalledApps = getCurrentInstalledApps(context);
267         Set<String> unhandledInstalledApps = getUnhandledInstalledApps(currentInstalledApps);
268 
269         if (unhandledInstalledApps.isEmpty()) {
270             return;
271         }
272 
273         sLogger.v(
274                 "Detect below unhandled installed applications: %s",
275                 unhandledInstalledApps.toString());
276 
277         SQLiteDatabase db = mDbHelper.safeGetWritableDatabase();
278         if (db == null) {
279             sLogger.e("Database is not available, Stop reconciling app installation in Topics!");
280             return;
281         }
282 
283         // This cross db and java boundaries multiple times, so we need to have a db transaction.
284         db.beginTransaction();
285 
286         try {
287             handleInstalledAppsInReconciliation(unhandledInstalledApps, currentEpochId);
288 
289             // Mark the transaction successful.
290             db.setTransactionSuccessful();
291         } finally {
292             db.endTransaction();
293             sLogger.v("App installation reconciliation in Topics is finished!");
294         }
295     }
296 
297     // TODO(b/256703300): Currently we handled app-sdk topic assignments in serving flow. Move the
298     //                    logic back to app installation after we can get all SDKs when an app is
299     //                    installed.
300     /**
301      * For a newly installed app, in case SDKs that this app uses are not known when the app is
302      * installed, the returned topic for an SDK can only be assigned when user calls getTopic().
303      *
304      * <p>If an app calls Topics API via an SDK, and this app has a returned topic while SDK
305      * doesn't, assign this topic to the SDK if it can learn this topic from past observable epochs.
306      *
307      * @param app the app
308      * @param sdk the sdk. In case the app calls the Topics API directly, the sdk == empty string.
309      * @param currentEpochId the epoch id of current cycle
310      * @return A {@link Boolean} that notes whether a topic has been assigned to the sdk, so that
311      *     {@link CacheManager} needs to reload the cachedTopics
312      */
assignTopicsToSdkForAppInstallation( @onNull String app, @NonNull String sdk, long currentEpochId)313     public boolean assignTopicsToSdkForAppInstallation(
314             @NonNull String app, @NonNull String sdk, long currentEpochId) {
315         // Don't do anything if app calls getTopics directly without an SDK.
316         if (sdk.isEmpty()) {
317             return false;
318         }
319 
320         int numberOfLookBackEpochs = mFlags.getTopicsNumberOfLookBackEpochs();
321         Pair<String, String> appOnlyCaller = Pair.create(app, EMPTY_SDK);
322         Pair<String, String> appSdkCaller = Pair.create(app, sdk);
323 
324         // Get ReturnedTopics for past epochs in [currentEpochId - numberOfLookBackEpochs,
325         // currentEpochId - 1].
326         // TODO(b/237436146): Create an object class for Returned Topics.
327         Map<Long, Map<Pair<String, String>, Topic>> pastReturnedTopics =
328                 mTopicsDao.retrieveReturnedTopics(currentEpochId - 1, numberOfLookBackEpochs);
329         for (Map<Pair<String, String>, Topic> returnedTopics : pastReturnedTopics.values()) {
330             // If the SDK has a returned topic, this implies we have generated returned topics for
331             // SDKs already. Exit early.
332             if (returnedTopics.containsKey(appSdkCaller)) {
333                 return false;
334             }
335         }
336 
337         // Track whether a topic is assigned in order to know whether cache needs to be reloaded.
338         boolean isAssigned = false;
339 
340         for (long epochId = currentEpochId - 1;
341                 epochId >= currentEpochId - numberOfLookBackEpochs && epochId >= 0;
342                 epochId--) {
343             // Validate for an app-sdk pair, whether it satisfies
344             // 1) In current epoch, app as the single caller has a returned topic
345             // 2) The sdk can learn this topic from last numberOfLookBackEpochs epochs
346             // If so, the same topic should be assigned to the sdk.
347             if (pastReturnedTopics.get(epochId) != null
348                     && pastReturnedTopics.get(epochId).containsKey(appOnlyCaller)) {
349                 // This is the top Topic assigned to this app-only caller.
350                 Topic appReturnedTopic = pastReturnedTopics.get(epochId).get(appOnlyCaller);
351 
352                 // For this epochId, check whether sdk can learn this topic for past
353                 // numberOfLookBackEpochs observed epochs, i.e.
354                 // [epochId - numberOfLookBackEpochs + 1, epochId]
355                 // pastCallerCanLearnTopicsMap = Map<Topic, Set<Caller>>. Caller = App or Sdk
356                 Map<Topic, Set<String>> pastCallerCanLearnTopicsMap =
357                         mTopicsDao.retrieveCallerCanLearnTopicsMap(epochId, numberOfLookBackEpochs);
358                 List<Topic> pastTopTopic = mTopicsDao.retrieveTopTopics(epochId);
359 
360                 if (EpochManager.isTopicLearnableByCaller(
361                         appReturnedTopic,
362                         sdk,
363                         pastCallerCanLearnTopicsMap,
364                         pastTopTopic,
365                         mFlags.getTopicsNumberOfTopTopics())) {
366                     mTopicsDao.persistReturnedAppTopicsMap(
367                             epochId, Map.of(appSdkCaller, appReturnedTopic));
368                     isAssigned = true;
369                 }
370             }
371         }
372 
373         return isAssigned;
374     }
375 
376     /**
377      * Generating a random topic from given top topic list
378      *
379      * @param regularTopics a {@link List} of non-random topics in current epoch, excluding those
380      *     which have no contributors
381      * @param randomTopics a {@link List} of random top topics
382      * @param percentageForRandomTopic the probability to select random object
383      * @return a selected {@link Topic} to be assigned to newly installed app. Return null if both
384      *     lists are empty.
385      */
386     @VisibleForTesting
387     @Nullable
selectAssignedTopicFromTopTopics( @onNull List<Topic> regularTopics, @NonNull List<Topic> randomTopics, int percentageForRandomTopic)388     Topic selectAssignedTopicFromTopTopics(
389             @NonNull List<Topic> regularTopics,
390             @NonNull List<Topic> randomTopics,
391             int percentageForRandomTopic) {
392         // Return null if both lists are empty.
393         if (regularTopics.isEmpty() && randomTopics.isEmpty()) {
394             return null;
395         }
396 
397         // If one of the list is empty, select from the other list.
398         if (regularTopics.isEmpty()) {
399             return randomTopics.get(mRandom.nextInt(randomTopics.size()));
400         } else if (randomTopics.isEmpty()) {
401             return regularTopics.get(mRandom.nextInt(regularTopics.size()));
402         }
403 
404         // If both lists are not empty, make a draw to determine whether to pick a random topic.
405         // If random number is in [0, randomPercentage - 1], a random topic will be selected.
406         boolean shouldSelectRandomTopic = mRandom.nextInt(100) < percentageForRandomTopic;
407 
408         return shouldSelectRandomTopic
409                 ? randomTopics.get(mRandom.nextInt(randomTopics.size()))
410                 : regularTopics.get(mRandom.nextInt(regularTopics.size()));
411     }
412 
413     /**
414      * Delete application data for a specific application.
415      *
416      * <p>This method allows other usages besides daily maintenance job, such as real-time data
417      * wiping for an app uninstallation.
418      *
419      * @param apps a {@link List} of applications to wipe data for
420      */
421     @VisibleForTesting
422     void deleteAppDataFromTableByApps(@NonNull List<String> apps) {
423         List<Pair<String, String>> tableToEraseData =
424                 Arrays.stream(TABLE_INFO_TO_ERASE_APP_DATA).collect(Collectors.toList());
425 
426         mTopicsDao.deleteFromTableByColumn(
427                 /* tableNamesAndColumnNamePairs */ tableToEraseData, /* valuesToDelete */ apps);
428 
429         sLogger.v("Have deleted data for application " + apps);
430     }
431 
432     /**
433      * Assign a top Topic for the newly installed app. This allows SDKs in the newly installed app
434      * to get the past 3 epochs' topics if they did observe the topic in the past.
435      *
436      * <p>See more details in go/rb-topics-app-update
437      *
438      * @param app the app package name of newly installed application
439      * @param currentEpochId current epoch id
440      */
441     @VisibleForTesting
442     void assignTopicsToNewlyInstalledApps(@NonNull String app, long currentEpochId) {
443         Objects.requireNonNull(app);
444 
445         final int numberOfEpochsToAssignTopics = mFlags.getTopicsNumberOfLookBackEpochs();
446         final int numberOfTopTopics = mFlags.getTopicsNumberOfTopTopics();
447         final int topicsPercentageForRandomTopic = mFlags.getTopicsPercentageForRandomTopic();
448 
449         Pair<String, String> appOnlyCaller = Pair.create(app, EMPTY_SDK);
450 
451         // For each past epoch, assign a random topic to this newly installed app.
452         // The assigned topic should align the probability with rule to generate top topics.
453         for (long epochId = currentEpochId - 1;
454                 epochId >= currentEpochId - numberOfEpochsToAssignTopics && epochId >= 0;
455                 epochId--) {
456             List<Topic> topTopics = mTopicsDao.retrieveTopTopics(epochId);
457 
458             if (topTopics.isEmpty()) {
459                 sLogger.v(
460                         "Empty top topic list in Epoch %d, do not assign topic to App %s.",
461                         epochId, app);
462                 continue;
463             }
464 
465             // Regular Topics are placed at the beginning of top topic list.
466             List<Topic> regularTopics = topTopics.subList(0, numberOfTopTopics);
467             regularTopics = filterRegularTopicsWithoutContributors(regularTopics, epochId);
468             List<Topic> randomTopics = topTopics.subList(numberOfTopTopics, topTopics.size());
469 
470             Topic assignedTopic =
471                     selectAssignedTopicFromTopTopics(
472                             regularTopics, randomTopics, topicsPercentageForRandomTopic);
473 
474             if (assignedTopic == null) {
475                 sLogger.v(
476                         "No topic is available to assign in Epoch %d, do not assign topic to App"
477                                 + " %s.",
478                         epochId, app);
479                 continue;
480             }
481 
482             // Persist this topic to database as returned topic in this epoch
483             mTopicsDao.persistReturnedAppTopicsMap(epochId, Map.of(appOnlyCaller, assignedTopic));
484 
485             sLogger.v(
486                     "Topic %s has been assigned to newly installed App %s in Epoch %d",
487                     assignedTopic.getTopic(), app, epochId);
488         }
489     }
490 
491     /**
492      * When an app is uninstalled, we need to check whether any of its classified topics has no
493      * contributors on epoch basis for past epochs to look back. Note in an epoch, an app is a
494      * contributor to a topic if the app has called Topics API in this epoch and is classified to
495      * the topic.
496      *
497      * <p>If such topic exists, remove this topic from ReturnedTopicsTable in the epoch. This method
498      * is invoked before {@code deleteAppDataFromTableByApps}, so the uninstalled app will be
499      * cleared in TopicContributors Table there.
500      *
501      * <p>NOTE: We are only interested in the epochs which will be used for getTopics(), i.e. past
502      * numberOfLookBackEpochs epochs.
503      *
504      * @param currentEpochId the id of epoch when the method gets invoked
505      * @param uninstalledApp the newly uninstalled app
506      */
507     @VisibleForTesting
handleTopTopicsWithoutContributors(long currentEpochId, @NonNull String uninstalledApp)508     void handleTopTopicsWithoutContributors(long currentEpochId, @NonNull String uninstalledApp) {
509         // This check is on epoch basis for past epochs to look back
510         for (long epochId = currentEpochId - 1;
511                 epochId >= currentEpochId - mFlags.getTopicsNumberOfLookBackEpochs()
512                         && epochId >= 0;
513                 epochId--) {
514             Map<String, List<Topic>> appClassificationTopics =
515                     mTopicsDao.retrieveAppClassificationTopics(epochId);
516             List<Topic> topTopics = mTopicsDao.retrieveTopTopics(epochId);
517             Map<Integer, Set<String>> topTopicsToContributorsMap =
518                     mTopicsDao.retrieveTopicToContributorsMap(epochId);
519 
520             List<Topic> classifiedTopics =
521                     appClassificationTopics.getOrDefault(uninstalledApp, new ArrayList<>());
522             // Collect all top topics to delete to make only one Db Update
523             List<String> topTopicsToDelete =
524                     classifiedTopics.stream()
525                             .filter(
526                                     classifiedTopic ->
527                                             topTopics.contains(classifiedTopic)
528                                                     && topTopicsToContributorsMap.containsKey(
529                                                             classifiedTopic.getTopic())
530                                                     // Filter out the topic that has ONLY
531                                                     // the uninstalled app as a contributor
532                                                     && topTopicsToContributorsMap
533                                                                     .get(classifiedTopic.getTopic())
534                                                                     .size()
535                                                             == 1
536                                                     && topTopicsToContributorsMap
537                                                             .get(classifiedTopic.getTopic())
538                                                             .contains(uninstalledApp))
539                             .map(Topic::getTopic)
540                             .map(String::valueOf)
541                             .collect(Collectors.toList());
542 
543             if (!topTopicsToDelete.isEmpty()) {
544                 sLogger.v(
545                         "Topics %s will not have contributors at epoch %d. Delete them in"
546                                 + " epoch %d",
547                         topTopicsToDelete, epochId, epochId);
548             }
549 
550             mTopicsDao.deleteEntriesFromTableByColumnWithEqualCondition(
551                     List.of(
552                             Pair.create(
553                                     TopicsTables.ReturnedTopicContract.TABLE,
554                                     TopicsTables.ReturnedTopicContract.TOPIC)),
555                     topTopicsToDelete,
556                     TopicsTables.ReturnedTopicContract.EPOCH_ID,
557                     String.valueOf(epochId),
558                     /* isStringEqualConditionColumnValue */ false);
559         }
560     }
561 
562     /**
563      * Filter out regular topics without any contributors. Note in an epoch, an app is a contributor
564      * to a topic if the app has called Topics API in this epoch and is classified to the topic.
565      *
566      * <p>For padded Topics (Classifier randomly pads top topics if they are not enough), as we put
567      * {@link EpochManager#PADDED_TOP_TOPICS_STRING} into TopicContributors Map, padded topics
568      * actually have "contributor" PADDED_TOP_TOPICS_STRING. Therefore, they won't be filtered out.
569      *
570      * @param regularTopics non-random top topics
571      * @param epochId epochId of current epoch
572      * @return the filtered regular topics
573      */
574     @NonNull
575     @VisibleForTesting
filterRegularTopicsWithoutContributors( @onNull List<Topic> regularTopics, long epochId)576     List<Topic> filterRegularTopicsWithoutContributors(
577             @NonNull List<Topic> regularTopics, long epochId) {
578         Map<Integer, Set<String>> topicToContributorMap =
579                 mTopicsDao.retrieveTopicToContributorsMap(epochId);
580         return regularTopics.stream()
581                 .filter(
582                         regularTopic ->
583                                 topicToContributorMap.containsKey(regularTopic.getTopic())
584                                         && !topicToContributorMap
585                                                 .get(regularTopic.getTopic())
586                                                 .isEmpty())
587                 .collect(Collectors.toList());
588     }
589 
590     // An app will be regarded as an unhandled uninstalled app if it has an entry in any epoch of
591     // either usage table or returned topics table, but the app doesn't show up in package manager.
592     //
593     // This will be used in reconciliation process. See details in go/rb-topics-app-update.
594     @NonNull
595     @VisibleForTesting
getUnhandledUninstalledApps(@onNull Set<String> currentInstalledApps)596     Set<String> getUnhandledUninstalledApps(@NonNull Set<String> currentInstalledApps) {
597         Set<String> appsWithUsage =
598                 mTopicsDao.retrieveDistinctAppsFromTables(
599                         List.of(TopicsTables.AppUsageHistoryContract.TABLE),
600                         List.of(TopicsTables.AppUsageHistoryContract.APP));
601         Set<String> appsWithReturnedTopics =
602                 mTopicsDao.retrieveDistinctAppsFromTables(
603                         List.of(TopicsTables.ReturnedTopicContract.TABLE),
604                         List.of(TopicsTables.ReturnedTopicContract.APP));
605 
606         // Combine sets of apps that have usage and returned topics
607         appsWithUsage.addAll(appsWithReturnedTopics);
608 
609         // Exclude currently installed apps
610         appsWithUsage.removeAll(currentInstalledApps);
611 
612         return appsWithUsage;
613     }
614 
615     // TODO(b/234444036): Handle apps that don't have usages in last 3 epochs
616     // An app will be regarded as an unhandled installed app if it shows up in package manager,
617     // but doesn't have an entry in neither usage table or returned topic table.
618     //
619     // This will be used in reconciliation process. See details in go/rb-topics-app-update.
620     @NonNull
621     @VisibleForTesting
getUnhandledInstalledApps(@onNull Set<String> currentInstalledApps)622     Set<String> getUnhandledInstalledApps(@NonNull Set<String> currentInstalledApps) {
623         // Make a copy of installed apps
624         Set<String> installedApps = new HashSet<>(currentInstalledApps);
625 
626         // Get apps with usages or(and) returned topics
627         Set<String> appsWithUsageOrReturnedTopics =
628                 mTopicsDao.retrieveDistinctAppsFromTables(
629                         List.of(
630                                 TopicsTables.AppUsageHistoryContract.TABLE,
631                                 TopicsTables.ReturnedTopicContract.TABLE),
632                         List.of(
633                                 TopicsTables.AppUsageHistoryContract.APP,
634                                 TopicsTables.ReturnedTopicContract.APP));
635 
636         // Remove apps with usage and returned topics from currently installed apps
637         installedApps.removeAll(appsWithUsageOrReturnedTopics);
638 
639         return installedApps;
640     }
641 
642     // Get current installed applications from package manager
643     @NonNull
644     @VisibleForTesting
getCurrentInstalledApps(Context context)645     Set<String> getCurrentInstalledApps(Context context) {
646         PackageManager packageManager = context.getPackageManager();
647         List<ApplicationInfo> appInfoList =
648                 PackageManagerCompatUtils.getInstalledApplications(
649                         packageManager, PackageManager.GET_META_DATA);
650         return appInfoList.stream().map(appInfo -> appInfo.packageName).collect(Collectors.toSet());
651     }
652 
653     /**
654      * Get App Package Name from a Uri.
655      *
656      * <p>Across PPAPI, package Uri is in the form of "package:com.example.adservices.sampleapp".
657      * "package" is a scheme of Uri and "com.example.adservices.sampleapp" is the app package name.
658      * Topics API persists app package name into database so this method extracts it from a Uri.
659      *
660      * @param packageUri the {@link Uri} of a package
661      * @return the app package name
662      */
663     @VisibleForTesting
664     @NonNull
convertUriToAppName(@onNull Uri packageUri)665     String convertUriToAppName(@NonNull Uri packageUri) {
666         return packageUri.getSchemeSpecificPart();
667     }
668 
669     // Handle Uninstalled applications that still have derived data in database
670     //
671     // 1) Delete all derived data for an uninstalled app.
672     // 2) Remove a topic if it has the uninstalled app as the only contributor in an epoch. In an
673     // epoch, an app is a contributor to a topic if the app has called Topics API in this epoch and
674     // is classified to the topic.
handleUninstalledAppsInReconciliation( @onNull Set<String> newlyUninstalledApps, long currentEpochId)675     private void handleUninstalledAppsInReconciliation(
676             @NonNull Set<String> newlyUninstalledApps, long currentEpochId) {
677         for (String app : newlyUninstalledApps) {
678             handleTopTopicsWithoutContributors(currentEpochId, app);
679 
680             deleteAppDataFromTableByApps(List.of(app));
681         }
682     }
683 
684     // Handle newly installed applications
685     //
686     // Assign topics as real-time service to the app only, if the app isn't assigned with topics.
handleInstalledAppsInReconciliation( @onNull Set<String> newlyInstalledApps, long currentEpochId)687     private void handleInstalledAppsInReconciliation(
688             @NonNull Set<String> newlyInstalledApps, long currentEpochId) {
689         for (String newlyInstalledApp : newlyInstalledApps) {
690             assignTopicsToNewlyInstalledApps(newlyInstalledApp, currentEpochId);
691         }
692     }
693 }
694