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