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 static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_COBALT_LOGGER_INITIALIZATION_FAILURE;
20 import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS;
21 
22 import android.annotation.NonNull;
23 import android.annotation.Nullable;
24 import android.os.Build;
25 import android.util.Pair;
26 
27 import androidx.annotation.RequiresApi;
28 
29 import com.android.adservices.LoggerFactory;
30 import com.android.adservices.cobalt.CobaltFactory;
31 import com.android.adservices.cobalt.CobaltInitializationException;
32 import com.android.adservices.cobalt.TopicsCobaltLogger;
33 import com.android.adservices.data.topics.CombinedTopic;
34 import com.android.adservices.data.topics.EncryptedTopic;
35 import com.android.adservices.data.topics.Topic;
36 import com.android.adservices.data.topics.TopicsDao;
37 import com.android.adservices.errorlogging.ErrorLogUtil;
38 import com.android.adservices.service.Flags;
39 import com.android.adservices.service.FlagsFactory;
40 import com.android.adservices.service.stats.AdServicesLogger;
41 import com.android.adservices.service.stats.AdServicesLoggerImpl;
42 import com.android.adservices.service.stats.GetTopicsReportedStats;
43 import com.android.adservices.service.stats.TopicsEncryptionGetTopicsReportedStats;
44 import com.android.adservices.shared.common.ApplicationContextSingleton;
45 import com.android.adservices.shared.util.Clock;
46 import com.android.cobalt.CobaltLogger;
47 import com.android.internal.annotations.GuardedBy;
48 import com.android.internal.annotations.VisibleForTesting;
49 
50 import com.google.common.base.Supplier;
51 import com.google.common.base.Suppliers;
52 import com.google.common.collect.ImmutableList;
53 
54 import java.io.PrintWriter;
55 import java.util.ArrayList;
56 import java.util.Collections;
57 import java.util.HashMap;
58 import java.util.HashSet;
59 import java.util.List;
60 import java.util.Locale;
61 import java.util.Map;
62 import java.util.Random;
63 import java.util.Set;
64 import java.util.concurrent.locks.ReadWriteLock;
65 import java.util.concurrent.locks.ReentrantReadWriteLock;
66 import java.util.stream.Collectors;
67 
68 import javax.annotation.concurrent.ThreadSafe;
69 
70 /**
71  * A class to manage Topics Cache.
72  *
73  * <p>This class is thread safe.
74  */
75 @RequiresApi(Build.VERSION_CODES.S)
76 @ThreadSafe
77 public class CacheManager {
78     private static final LoggerFactory.Logger sLogger = LoggerFactory.getTopicsLogger();
79     // The verbose level for dumpsys usage
80     private static final int VERBOSE = 1;
81     private static final Object SINGLETON_LOCK = new Object();
82 
83     @GuardedBy("SINGLETON_LOCK")
84     private static CacheManager sSingleton;
85     // Lock for Read and Write on the cached topics map.
86     // This allows concurrent reads but exclusive update to the cache.
87     private final ReadWriteLock mReadWriteLock = new ReentrantReadWriteLock();
88     private final TopicsDao mTopicsDao;
89     private final BlockedTopicsManager mBlockedTopicsManager;
90     private final Flags mFlags;
91     // Map<EpochId, Map<Pair<App, Sdk>, Topic>
92     private Map<Long, Map<Pair<String, String>, Topic>> mCachedTopics = new HashMap<>();
93     // Map<EpochId, Map<Pair<App, Sdk>, EncryptedTopic>
94     private Map<Long, Map<Pair<String, String>, EncryptedTopic>> mCachedEncryptedTopics =
95             new HashMap<>();
96     // TODO(b/236422354): merge hashsets to have one point of truth (Taxonomy update)
97     // HashSet<BlockedTopic>
98     private HashSet<Topic> mCachedBlockedTopics = new HashSet<>();
99     // HashSet<TopicId>
100     private HashSet<Integer> mCachedBlockedTopicIds = new HashSet<>();
101 
102     // Set containing Global Blocked Topic Ids
103     private HashSet<Integer> mCachedGlobalBlockedTopicIds;
104 
105     @Nullable
106     // Expected to be null when the topics cobalt flag is disabled.
107     private final TopicsCobaltLogger mTopicsCobaltLogger;
108 
109     private final AdServicesLogger mLogger;
110 
111     private final Clock mClock;
112 
113     @VisibleForTesting
CacheManager( TopicsDao topicsDao, Flags flags, AdServicesLogger logger, BlockedTopicsManager blockedTopicsManager, GlobalBlockedTopicsManager globalBlockedTopicsManager, TopicsCobaltLogger topicsCobaltLogger, Clock clock)114     CacheManager(
115             TopicsDao topicsDao,
116             Flags flags,
117             AdServicesLogger logger,
118             BlockedTopicsManager blockedTopicsManager,
119             GlobalBlockedTopicsManager globalBlockedTopicsManager,
120             TopicsCobaltLogger topicsCobaltLogger,
121             Clock clock) {
122         mTopicsDao = topicsDao;
123         mFlags = flags;
124         mLogger = logger;
125         mBlockedTopicsManager = blockedTopicsManager;
126         mCachedGlobalBlockedTopicIds = globalBlockedTopicsManager.getGlobalBlockedTopicIds();
127         mTopicsCobaltLogger = topicsCobaltLogger;
128         mClock = clock;
129     }
130 
131     /** Returns an instance of the CacheManager given a context. */
132     @NonNull
getInstance()133     public static CacheManager getInstance() {
134         synchronized (SINGLETON_LOCK) {
135             if (sSingleton == null) {
136                 TopicsCobaltLogger topicsCobaltLogger = null;
137                 if (FlagsFactory.getFlags().getTopicsCobaltLoggingEnabled()) {
138                     topicsCobaltLogger = new TopicsCobaltLogger(getCobaltLoggerSupplier());
139                 }
140 
141                 sSingleton =
142                         new CacheManager(
143                                 TopicsDao.getInstance(),
144                                 FlagsFactory.getFlags(),
145                                 AdServicesLoggerImpl.getInstance(),
146                                 BlockedTopicsManager.getInstance(),
147                                 GlobalBlockedTopicsManager.getInstance(),
148                                 topicsCobaltLogger,
149                                 Clock.getInstance());
150             }
151             return sSingleton;
152         }
153     }
154 
155     /**
156      * Load the cache from DB.
157      *
158      * <p>When first created, the Cache is empty. We will need to retrieve the cache from DB.
159      *
160      * @param currentEpochId current Epoch ID
161      */
loadCache(long currentEpochId)162     public void loadCache(long currentEpochId) {
163         // Retrieve the cache from DB.
164         int lookbackEpochs = mFlags.getTopicsNumberOfLookBackEpochs();
165         // Map<EpochId, Map<Pair<App, Sdk>, Topic>
166         Map<Long, Map<Pair<String, String>, Topic>> cachedTopicsFromDb =
167                 mTopicsDao.retrieveReturnedTopics(currentEpochId, lookbackEpochs + 1);
168         // Map<EpochId, Map<Pair<App, Sdk>, EncryptedTopic>
169         Map<Long, Map<Pair<String, String>, EncryptedTopic>> cachedEncryptedTopicsFromDb = Map.of();
170         int latencyOfReadingEncryptedTopicsFromDbMs = 0;
171         int countOfEncryptedTopics = 0;
172         if (mFlags.getTopicsEncryptionEnabled()) {
173             long retrieveReturnedEncryptedTopicsStartTimestamp = mClock.currentTimeMillis();
174             cachedEncryptedTopicsFromDb =
175                     mTopicsDao.retrieveReturnedEncryptedTopics(currentEpochId, lookbackEpochs + 1);
176             latencyOfReadingEncryptedTopicsFromDbMs =
177                     (int) (mClock.currentTimeMillis()
178                             - retrieveReturnedEncryptedTopicsStartTimestamp);
179             countOfEncryptedTopics =
180                     cachedEncryptedTopicsFromDb.entrySet().stream()
181                             .flatMap(entry -> entry.getValue().entrySet().stream())
182                             .collect(Collectors.toList())
183                             .size();
184             sLogger.v(
185                     "CacheManager.loadCache() loads cachedEncryptedTopics of size "
186                             + cachedEncryptedTopicsFromDb.size());
187         }
188         if (mFlags.getTopicsEncryptionMetricsEnabled()) {
189             mLogger.logTopicsEncryptionGetTopicsReportedStats(
190                     TopicsEncryptionGetTopicsReportedStats.builder()
191                             .setCountOfEncryptedTopics(countOfEncryptedTopics)
192                             .setLatencyOfReadingEncryptedTopicsFromDbMs(
193                                     latencyOfReadingEncryptedTopicsFromDbMs)
194                             .build());
195         }
196         // HashSet<BlockedTopic>
197         HashSet<Topic> blockedTopicsCacheFromDb =
198                 new HashSet<>(mBlockedTopicsManager.retrieveAllBlockedTopics());
199         HashSet<Integer> blockedTopicIdsFromDb =
200                 blockedTopicsCacheFromDb.stream()
201                         .map(Topic::getTopic)
202                         .collect(Collectors.toCollection(HashSet::new));
203 
204         sLogger.v(
205                 "CacheManager.loadCache(). CachedTopics mapping size is "
206                         + cachedTopicsFromDb.size()
207                         + ", CachedBlockedTopics mapping size is "
208                         + blockedTopicsCacheFromDb.size());
209         mReadWriteLock.writeLock().lock();
210         try {
211             mCachedTopics = cachedTopicsFromDb;
212             mCachedEncryptedTopics = cachedEncryptedTopicsFromDb;
213             mCachedBlockedTopics = blockedTopicsCacheFromDb;
214             mCachedBlockedTopicIds = blockedTopicIdsFromDb;
215         } finally {
216             mReadWriteLock.writeLock().unlock();
217         }
218     }
219 
220     /**
221      * Get list of topics for the numberOfLookBackEpochs epoch starting from [epochId -
222      * numberOfLookBackEpochs + 1, epochId] that were not blocked by the user.
223      *
224      * @param numberOfLookBackEpochs how many epochs to look back.
225      * @param currentEpochId current Epoch ID
226      * @param app the app
227      * @param sdk the sdk. In case the app calls the Topics API directly, the sdk == empty string.
228      * @param random a {@link Random} instance for shuffling
229      * @return {@link List<Topic>} a list of Topics
230      */
231     @NonNull
getTopics( int numberOfLookBackEpochs, long currentEpochId, String app, String sdk, Random random)232     public List<CombinedTopic> getTopics(
233             int numberOfLookBackEpochs,
234             long currentEpochId,
235             String app,
236             String sdk,
237             Random random) {
238         // We will need to look at the 3 historical epochs starting from last epoch.
239         long epochId = currentEpochId - 1;
240         List<Topic> topics = new ArrayList<>();
241         List<CombinedTopic> combinedTopics = new ArrayList<>();
242         List<Integer> topicIdsForLogging = new ArrayList<>();
243 
244         int duplicateTopicCount = 0, blockedTopicCount = 0;
245         mReadWriteLock.readLock().lock();
246         try {
247             for (int numEpoch = 0; numEpoch < numberOfLookBackEpochs; numEpoch++) {
248                 if (mCachedTopics.containsKey(epochId - numEpoch)) {
249                     Topic topic = mCachedTopics.get(epochId - numEpoch).get(Pair.create(app, sdk));
250                     // Get the list of real cached topics.
251                     if (topic != null) {
252                         if (topics.contains(topic)) {
253                             duplicateTopicCount++;
254                             continue;
255                         }
256                         if (isTopicIdBlocked(topic.getTopic())) {
257                             blockedTopicCount++;
258                             continue;
259                         }
260 
261                         EncryptedTopic encryptedTopic = EncryptedTopic.getDefaultInstance();
262                         if (mFlags.getTopicsEncryptionEnabled()) {
263                             // Add encrypted topic if encryption feature flag is turned on.
264                             if (mCachedEncryptedTopics.containsKey(epochId - numEpoch)) {
265                                 encryptedTopic =
266                                         mCachedEncryptedTopics
267                                                 .get(epochId - numEpoch)
268                                                 .getOrDefault(
269                                                         Pair.create(app, sdk),
270                                                         EncryptedTopic.getDefaultInstance());
271                             }
272                         }
273 
274                         topics.add(topic);
275 
276                         CombinedTopic combinedTopic = CombinedTopic.create(topic, encryptedTopic);
277                         combinedTopics.add(combinedTopic);
278                     }
279                     if (mFlags.getEnableLoggedTopic()
280                             && mTopicsDao.supportsLoggedTopicInReturnedTopicTable()) {
281                         // Get the list of logged topics.
282                         if (topic != null) {
283                             // Remove duplicate logged topics.
284                             if (topicIdsForLogging.contains(topic.getLoggedTopic())) {
285                                 continue;
286                             }
287                             if (isTopicIdBlocked(topic.getLoggedTopic())) {
288                                 continue;
289                             }
290                             topicIdsForLogging.add(topic.getLoggedTopic());
291                         }
292                     }
293                 }
294             }
295         } finally {
296             mReadWriteLock.readLock().unlock();
297         }
298 
299         Collections.shuffle(combinedTopics, random);
300 
301         // Log GetTopics stats with logged topics if flag ENABLE_LOGGED_TOPIC is true.
302         if (mFlags.getEnableLoggedTopic()
303                 && mTopicsDao.supportsLoggedTopicInReturnedTopicTable()) {
304             mLogger.logGetTopicsReportedStats(
305                     GetTopicsReportedStats.builder()
306                             .setTopicIds(topicIdsForLogging)
307                             .setDuplicateTopicCount(duplicateTopicCount)
308                             .setFilteredBlockedTopicCount(blockedTopicCount)
309                             .setTopicIdsCount(topics.size())
310                             .build());
311         } else {
312             mLogger.logGetTopicsReportedStats(
313                     GetTopicsReportedStats.builder()
314                             .setDuplicateTopicCount(duplicateTopicCount)
315                             .setFilteredBlockedTopicCount(blockedTopicCount)
316                             .setTopicIdsCount(topics.size())
317                             .build());
318         }
319 
320         if (mFlags.getTopicsCobaltLoggingEnabled()) {
321             if (mTopicsCobaltLogger != null) {
322                 mTopicsCobaltLogger.logTopicOccurrences(topics);
323             }
324         }
325 
326         return combinedTopics;
327     }
328 
329     /**
330      * Overloading getTopics() method to pass in an initialized Random object.
331      *
332      * @param numberOfLookBackEpochs how many epochs to look back.
333      * @param currentEpochId current Epoch ID
334      * @param app the app
335      * @param sdk the sdk. In case the app calls the Topics API directly, the sdk == empty string.
336      * @return {@link List<Topic>} a list of Topics
337      */
338     @NonNull
getTopics( int numberOfLookBackEpochs, long currentEpochId, String app, String sdk)339     public List<CombinedTopic> getTopics(
340             int numberOfLookBackEpochs, long currentEpochId, String app, String sdk) {
341         return getTopics(numberOfLookBackEpochs, currentEpochId, app, sdk, new Random());
342     }
343 
344     /**
345      * Get cached topics within certain epoch range. This is a helper method to get cached topics
346      * for an app-sdk caller, without considering other constraints, like UI blocking logic.
347      *
348      * @param epochLowerBound the earliest epoch to include cached topics from
349      * @param epochUpperBound the latest epoch to included cached topics to
350      * @param app the app
351      * @param sdk the sdk. In case the app calls the Topics API directly, the sdk == empty string.
352      * @return {@link List<Topic>} a list of Topics between {@code epochLowerBound} and {@code
353      *     epochUpperBound}.
354      */
355     @NonNull
getTopicsInEpochRange( long epochLowerBound, long epochUpperBound, @NonNull String app, @NonNull String sdk)356     public List<Topic> getTopicsInEpochRange(
357             long epochLowerBound, long epochUpperBound, @NonNull String app, @NonNull String sdk) {
358         List<Topic> topics = new ArrayList<>();
359         // To deduplicate returned topics
360         Set<Integer> topicsSet = new HashSet<>();
361 
362         mReadWriteLock.readLock().lock();
363         try {
364             for (long epochId = epochLowerBound; epochId <= epochUpperBound; epochId++) {
365                 if (mCachedTopics.containsKey(epochId)) {
366                     Topic topic = mCachedTopics.get(epochId).get(Pair.create(app, sdk));
367                     if (topic != null && !topicsSet.contains(topic.getTopic())) {
368                         topics.add(topic);
369                         topicsSet.add(topic.getTopic());
370                     }
371                 }
372             }
373         } finally {
374             mReadWriteLock.readLock().unlock();
375         }
376 
377         return topics;
378     }
379 
380     /**
381      * Gets a list of all topics that could be returned to the user in the last
382      * numberOfLookBackEpochs epochs. Does not include the current epoch, so range is
383      * [currentEpochId - numberOfLookBackEpochs, currentEpochId - 1].
384      *
385      * @param currentEpochId current Epoch ID
386      * @return The list of Topics.
387      */
388     @NonNull
getKnownTopicsWithConsent(long currentEpochId)389     public ImmutableList<Topic> getKnownTopicsWithConsent(long currentEpochId) {
390         // We will need to look at the 3 historical epochs starting from last epoch.
391         long epochId = currentEpochId - 1;
392         HashSet<Topic> topics = new HashSet<>();
393         mReadWriteLock.readLock().lock();
394         try {
395             for (int numEpoch = 0;
396                     numEpoch < mFlags.getTopicsNumberOfLookBackEpochs();
397                     numEpoch++) {
398                 if (mCachedTopics.containsKey(epochId - numEpoch)) {
399                     topics.addAll(
400                             mCachedTopics.get(epochId - numEpoch).values().stream()
401                                     .filter(topic -> !isTopicIdBlocked(topic.getTopic()))
402                                     .collect(Collectors.toList()));
403                 }
404             }
405         } finally {
406             mReadWriteLock.readLock().unlock();
407         }
408         return ImmutableList.copyOf(topics);
409     }
410 
411     /** Returns true if topic id is a global-blocked topic or user blocked topic. */
isTopicIdBlocked(int topicId)412     private boolean isTopicIdBlocked(int topicId) {
413         return mCachedBlockedTopicIds.contains(topicId)
414                 || mCachedGlobalBlockedTopicIds.contains(topicId);
415     }
416 
417     /**
418      * Gets a list of all cached topics that were blocked by the user.
419      *
420      * @return The list of Topics.
421      */
422     @NonNull
getTopicsWithRevokedConsent()423     public ImmutableList<Topic> getTopicsWithRevokedConsent() {
424         mReadWriteLock.readLock().lock();
425         try {
426             return ImmutableList.copyOf(mCachedBlockedTopics);
427         } finally {
428             mReadWriteLock.readLock().unlock();
429         }
430     }
431 
432     /**
433      * Delete all data generated by Topics API, except for tables in the exclusion list.
434      *
435      * @param tablesToExclude a {@link List} of tables that won't be deleted.
436      */
clearAllTopicsData(@onNull List<String> tablesToExclude)437     public void clearAllTopicsData(@NonNull List<String> tablesToExclude) {
438         mReadWriteLock.writeLock().lock();
439         try {
440             mTopicsDao.deleteAllTopicsTables(tablesToExclude);
441         } finally {
442             mReadWriteLock.writeLock().unlock();
443         }
444     }
445 
446     // Lazy loading CobaltLogger because CobaltLogger isn't needed during CacheManager
447     // initialization.
getCobaltLoggerSupplier()448     private static Supplier<CobaltLogger> getCobaltLoggerSupplier() {
449         return Suppliers.memoize(
450                 () -> {
451                     try {
452                         return CobaltFactory.getCobaltLogger(
453                                 ApplicationContextSingleton.get(), FlagsFactory.getFlags());
454                     } catch (CobaltInitializationException e) {
455                         sLogger.e(e, "Cobalt logger could not be" + " initialised.");
456                         ErrorLogUtil.e(
457                                 e,
458                                 AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_COBALT_LOGGER_INITIALIZATION_FAILURE,
459                                 AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS);
460                     }
461                     return null;
462                 });
463     }
464 
465     public void dump(@NonNull PrintWriter writer, String[] args) {
466         boolean isVerbose =
467                 args != null
468                         && args.length >= 1
469                         && Integer.parseInt(args[0].toLowerCase(Locale.ENGLISH)) == VERBOSE;
470         writer.println("==== CacheManager Dump ====");
471         writer.println(String.format("mCachedTopics size: %d", mCachedTopics.size()));
472         writer.println(String.format("mCachedBlockedTopics size: %d", mCachedBlockedTopics.size()));
473         if (isVerbose) {
474             for (Long epochId : mCachedTopics.keySet()) {
475                 writer.println(String.format("Epoch Id: %d \n", epochId));
476                 Map<Pair<String, String>, Topic> epochMapping = mCachedTopics.get(epochId);
477                 for (Pair<String, String> pair : epochMapping.keySet()) {
478                     String app = pair.first;
479                     String sdk = pair.second;
480                     Topic topic = epochMapping.get(pair);
481                     writer.println(String.format("(%s, %s): %s", app, sdk, topic.toString()));
482                 }
483             }
484         }
485     }
486 }
487