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.content.Context; 21 import android.os.Build; 22 import android.util.Pair; 23 24 import androidx.annotation.RequiresApi; 25 26 import com.android.adservices.LoggerFactory; 27 import com.android.adservices.data.topics.Topic; 28 import com.android.adservices.data.topics.TopicsDao; 29 import com.android.adservices.service.Flags; 30 import com.android.adservices.service.FlagsFactory; 31 import com.android.adservices.service.stats.AdServicesLogger; 32 import com.android.adservices.service.stats.AdServicesLoggerImpl; 33 import com.android.adservices.service.stats.GetTopicsReportedStats; 34 import com.android.internal.annotations.GuardedBy; 35 import com.android.internal.annotations.VisibleForTesting; 36 37 import com.google.common.collect.ImmutableList; 38 39 import java.io.PrintWriter; 40 import java.util.ArrayList; 41 import java.util.Collections; 42 import java.util.HashMap; 43 import java.util.HashSet; 44 import java.util.List; 45 import java.util.Map; 46 import java.util.Random; 47 import java.util.Set; 48 import java.util.concurrent.locks.ReadWriteLock; 49 import java.util.concurrent.locks.ReentrantReadWriteLock; 50 import java.util.stream.Collectors; 51 52 import javax.annotation.concurrent.ThreadSafe; 53 54 /** 55 * A class to manage Topics Cache. 56 * 57 * <p>This class is thread safe. 58 */ 59 // TODO(b/269798827): Enable for R. 60 @RequiresApi(Build.VERSION_CODES.S) 61 @ThreadSafe 62 public class CacheManager { 63 private static final LoggerFactory.Logger sLogger = LoggerFactory.getTopicsLogger(); 64 // The verbose level for dumpsys usage 65 private static final int VERBOSE = 1; 66 private static final Object SINGLETON_LOCK = new Object(); 67 68 @GuardedBy("SINGLETON_LOCK") 69 private static CacheManager sSingleton; 70 // Lock for Read and Write on the cached topics map. 71 // This allows concurrent reads but exclusive update to the cache. 72 private final ReadWriteLock mReadWriteLock = new ReentrantReadWriteLock(); 73 private final TopicsDao mTopicsDao; 74 private final BlockedTopicsManager mBlockedTopicsManager; 75 private final Flags mFlags; 76 // Map<EpochId, Map<Pair<App, Sdk>, Topic> 77 private Map<Long, Map<Pair<String, String>, Topic>> mCachedTopics = new HashMap<>(); 78 // TODO(b/236422354): merge hashsets to have one point of truth (Taxonomy update) 79 // HashSet<BlockedTopic> 80 private HashSet<Topic> mCachedBlockedTopics = new HashSet<>(); 81 // HashSet<TopicId> 82 private HashSet<Integer> mCachedBlockedTopicIds = new HashSet<>(); 83 84 // Set containing Global Blocked Topic Ids 85 private HashSet<Integer> mCachedGlobalBlockedTopicIds; 86 private final AdServicesLogger mLogger; 87 88 @VisibleForTesting CacheManager( TopicsDao topicsDao, Flags flags, AdServicesLogger logger, BlockedTopicsManager blockedTopicsManager, GlobalBlockedTopicsManager globalBlockedTopicsManager)89 CacheManager( 90 TopicsDao topicsDao, 91 Flags flags, 92 AdServicesLogger logger, 93 BlockedTopicsManager blockedTopicsManager, 94 GlobalBlockedTopicsManager globalBlockedTopicsManager) { 95 mTopicsDao = topicsDao; 96 mFlags = flags; 97 mLogger = logger; 98 mBlockedTopicsManager = blockedTopicsManager; 99 mCachedGlobalBlockedTopicIds = globalBlockedTopicsManager.getGlobalBlockedTopicIds(); 100 } 101 102 /** Returns an instance of the CacheManager given a context. */ 103 @NonNull getInstance(Context context)104 public static CacheManager getInstance(Context context) { 105 synchronized (SINGLETON_LOCK) { 106 if (sSingleton == null) { 107 sSingleton = 108 new CacheManager( 109 TopicsDao.getInstance(context), 110 FlagsFactory.getFlags(), 111 AdServicesLoggerImpl.getInstance(), 112 BlockedTopicsManager.getInstance(context), 113 GlobalBlockedTopicsManager.getInstance()); 114 } 115 return sSingleton; 116 } 117 } 118 119 /** 120 * Load the cache from DB. 121 * 122 * <p>When first created, the Cache is empty. We will need to retrieve the cache from DB. 123 * 124 * @param currentEpochId current Epoch ID 125 */ loadCache(long currentEpochId)126 public void loadCache(long currentEpochId) { 127 // Retrieve the cache from DB. 128 int lookbackEpochs = mFlags.getTopicsNumberOfLookBackEpochs(); 129 // Map<EpochId, Map<Pair<App, Sdk>, Topic> 130 Map<Long, Map<Pair<String, String>, Topic>> cacheFromDb = 131 mTopicsDao.retrieveReturnedTopics(currentEpochId, lookbackEpochs + 1); 132 // HashSet<BlockedTopic> 133 HashSet<Topic> blockedTopicsCacheFromDb = 134 new HashSet<>(mBlockedTopicsManager.retrieveAllBlockedTopics()); 135 HashSet<Integer> blockedTopicIdsFromDb = 136 blockedTopicsCacheFromDb.stream() 137 .map(Topic::getTopic) 138 .collect(Collectors.toCollection(HashSet::new)); 139 140 sLogger.v( 141 "CacheManager.loadCache(). CachedTopics mapping size is " 142 + cacheFromDb.size() 143 + ", CachedBlockedTopics mapping size is " 144 + blockedTopicsCacheFromDb.size()); 145 mReadWriteLock.writeLock().lock(); 146 try { 147 mCachedTopics = cacheFromDb; 148 mCachedBlockedTopics = blockedTopicsCacheFromDb; 149 mCachedBlockedTopicIds = blockedTopicIdsFromDb; 150 } finally { 151 mReadWriteLock.writeLock().unlock(); 152 } 153 } 154 155 /** 156 * Get list of topics for the numberOfLookBackEpochs epoch starting from [epochId - 157 * numberOfLookBackEpochs + 1, epochId] that were not blocked by the user. 158 * 159 * @param numberOfLookBackEpochs how many epochs to look back. 160 * @param currentEpochId current Epoch ID 161 * @param app the app 162 * @param sdk the sdk. In case the app calls the Topics API directly, the sdk == empty string. 163 * @param random a {@link Random} instance for shuffling 164 * @return {@link List<Topic>} a list of Topics 165 */ 166 @NonNull getTopics( int numberOfLookBackEpochs, long currentEpochId, String app, String sdk, Random random)167 public List<Topic> getTopics( 168 int numberOfLookBackEpochs, 169 long currentEpochId, 170 String app, 171 String sdk, 172 Random random) { 173 // We will need to look at the 3 historical epochs starting from last epoch. 174 long epochId = currentEpochId - 1; 175 List<Topic> topics = new ArrayList<>(); 176 // To deduplicate returned topics 177 Set<Integer> topicsSet = new HashSet<>(); 178 179 int duplicateTopicCount = 0, blockedTopicCount = 0; 180 mReadWriteLock.readLock().lock(); 181 try { 182 for (int numEpoch = 0; numEpoch < numberOfLookBackEpochs; numEpoch++) { 183 if (mCachedTopics.containsKey(epochId - numEpoch)) { 184 Topic topic = mCachedTopics.get(epochId - numEpoch).get(Pair.create(app, sdk)); 185 if (topic != null) { 186 if (topicsSet.contains(topic.getTopic())) { 187 duplicateTopicCount++; 188 continue; 189 } 190 if (isTopicIdBlocked(topic.getTopic())) { 191 blockedTopicCount++; 192 continue; 193 } 194 topics.add(topic); 195 topicsSet.add(topic.getTopic()); 196 } 197 } 198 } 199 } finally { 200 mReadWriteLock.readLock().unlock(); 201 } 202 203 Collections.shuffle(topics, random); 204 205 // Log GetTopics stats. 206 ImmutableList.Builder<Integer> topicIds = ImmutableList.builder(); 207 for (Topic topic : topics) { 208 topicIds.add(topic.getTopic()); 209 } 210 mLogger.logGetTopicsReportedStats( 211 GetTopicsReportedStats.builder() 212 .setDuplicateTopicCount(duplicateTopicCount) 213 .setFilteredBlockedTopicCount(blockedTopicCount) 214 .setTopicIdsCount(topics.size()) 215 .build()); 216 217 return topics; 218 } 219 220 /** 221 * Overloading getTopics() method to pass in an initialized Random object. 222 * 223 * @param numberOfLookBackEpochs how many epochs to look back. 224 * @param currentEpochId current Epoch ID 225 * @param app the app 226 * @param sdk the sdk. In case the app calls the Topics API directly, the sdk == empty string. 227 * @return {@link List<Topic>} a list of Topics 228 */ 229 @NonNull getTopics( int numberOfLookBackEpochs, long currentEpochId, String app, String sdk)230 public List<Topic> getTopics( 231 int numberOfLookBackEpochs, long currentEpochId, String app, String sdk) { 232 return getTopics(numberOfLookBackEpochs, currentEpochId, app, sdk, new Random()); 233 } 234 235 /** 236 * Get cached topics within certain epoch range. This is a helper method to get cached topics 237 * for an app-sdk caller, without considering other constraints, like UI blocking logic. 238 * 239 * @param epochLowerBound the earliest epoch to include cached topics from 240 * @param epochUpperBound the latest epoch to included cached topics to 241 * @param app the app 242 * @param sdk the sdk. In case the app calls the Topics API directly, the sdk == empty string. 243 * @return {@link List<Topic>} a list of Topics between {@code epochLowerBound} and {@code 244 * epochUpperBound}. 245 */ 246 @NonNull getTopicsInEpochRange( long epochLowerBound, long epochUpperBound, @NonNull String app, @NonNull String sdk)247 public List<Topic> getTopicsInEpochRange( 248 long epochLowerBound, long epochUpperBound, @NonNull String app, @NonNull String sdk) { 249 List<Topic> topics = new ArrayList<>(); 250 // To deduplicate returned topics 251 Set<Integer> topicsSet = new HashSet<>(); 252 253 mReadWriteLock.readLock().lock(); 254 try { 255 for (long epochId = epochLowerBound; epochId <= epochUpperBound; epochId++) { 256 if (mCachedTopics.containsKey(epochId)) { 257 Topic topic = mCachedTopics.get(epochId).get(Pair.create(app, sdk)); 258 if (topic != null && !topicsSet.contains(topic.getTopic())) { 259 topics.add(topic); 260 topicsSet.add(topic.getTopic()); 261 } 262 } 263 } 264 } finally { 265 mReadWriteLock.readLock().unlock(); 266 } 267 268 return topics; 269 } 270 271 /** 272 * Gets a list of all topics that could be returned to the user in the last 273 * numberOfLookBackEpochs epochs. Does not include the current epoch, so range is 274 * [currentEpochId - numberOfLookBackEpochs, currentEpochId - 1]. 275 * 276 * @param currentEpochId current Epoch ID 277 * @return The list of Topics. 278 */ 279 @NonNull getKnownTopicsWithConsent(long currentEpochId)280 public ImmutableList<Topic> getKnownTopicsWithConsent(long currentEpochId) { 281 // We will need to look at the 3 historical epochs starting from last epoch. 282 long epochId = currentEpochId - 1; 283 HashSet<Topic> topics = new HashSet<>(); 284 mReadWriteLock.readLock().lock(); 285 try { 286 for (int numEpoch = 0; 287 numEpoch < mFlags.getTopicsNumberOfLookBackEpochs(); 288 numEpoch++) { 289 if (mCachedTopics.containsKey(epochId - numEpoch)) { 290 topics.addAll( 291 mCachedTopics.get(epochId - numEpoch).values().stream() 292 .filter(topic -> !isTopicIdBlocked(topic.getTopic())) 293 .collect(Collectors.toList())); 294 } 295 } 296 } finally { 297 mReadWriteLock.readLock().unlock(); 298 } 299 return ImmutableList.copyOf(topics); 300 } 301 302 /** Returns true if topic id is a global blocked topic or user blocked topic. */ isTopicIdBlocked(int topicId)303 private boolean isTopicIdBlocked(int topicId) { 304 return mCachedBlockedTopicIds.contains(topicId) 305 || mCachedGlobalBlockedTopicIds.contains(topicId); 306 } 307 308 /** 309 * Gets a list of all cached topics that were blocked by the user. 310 * 311 * @return The list of Topics. 312 */ 313 @NonNull getTopicsWithRevokedConsent()314 public ImmutableList<Topic> getTopicsWithRevokedConsent() { 315 mReadWriteLock.readLock().lock(); 316 try { 317 return ImmutableList.copyOf(mCachedBlockedTopics); 318 } finally { 319 mReadWriteLock.readLock().unlock(); 320 } 321 } 322 323 /** 324 * Delete all data generated by Topics API, except for tables in the exclusion list. 325 * 326 * @param tablesToExclude a {@link List} of tables that won't be deleted. 327 */ clearAllTopicsData(@onNull List<String> tablesToExclude)328 public void clearAllTopicsData(@NonNull List<String> tablesToExclude) { 329 mReadWriteLock.writeLock().lock(); 330 try { 331 mTopicsDao.deleteAllTopicsTables(tablesToExclude); 332 } finally { 333 mReadWriteLock.writeLock().unlock(); 334 } 335 } 336 dump(@onNull PrintWriter writer, String[] args)337 public void dump(@NonNull PrintWriter writer, String[] args) { 338 boolean isVerbose = 339 args != null 340 && args.length >= 1 341 && Integer.parseInt(args[0].toLowerCase()) == VERBOSE; 342 writer.println("==== CacheManager Dump ===="); 343 writer.println(String.format("mCachedTopics size: %d", mCachedTopics.size())); 344 writer.println(String.format("mCachedBlockedTopics size: %d", mCachedBlockedTopics.size())); 345 if (isVerbose) { 346 for (Long epochId : mCachedTopics.keySet()) { 347 writer.println(String.format("Epoch Id: %d \n", epochId)); 348 Map<Pair<String, String>, Topic> epochMapping = mCachedTopics.get(epochId); 349 for (Pair<String, String> pair : epochMapping.keySet()) { 350 String app = pair.first; 351 String sdk = pair.second; 352 Topic topic = epochMapping.get(pair); 353 writer.println(String.format("(%s, %s): %s", app, sdk, topic.toString())); 354 } 355 } 356 } 357 } 358 } 359