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