1 /* 2 * Copyright (C) 2023 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.appsearch; 18 19 import android.annotation.NonNull; 20 import android.annotation.Nullable; 21 import android.os.Build; 22 23 import androidx.annotation.RequiresApi; 24 import androidx.appsearch.annotation.Document; 25 import androidx.appsearch.app.AppSearchSchema.StringPropertyConfig; 26 import androidx.appsearch.app.GlobalSearchSession; 27 28 import com.android.adservices.LogUtil; 29 import com.android.adservices.data.topics.Topic; 30 31 import com.google.common.util.concurrent.ListenableFuture; 32 33 import java.util.ArrayList; 34 import java.util.Arrays; 35 import java.util.List; 36 import java.util.Objects; 37 import java.util.concurrent.Executor; 38 39 /** 40 * This class represents the data access object for the Topics that the user opts out of. By default 41 * all topics are opted in. 42 */ 43 @RequiresApi(Build.VERSION_CODES.S) 44 @Document 45 class AppSearchTopicsConsentDao extends AppSearchDao { 46 /** 47 * Identifier of the Consent Document; must be unique within the Document's `namespace`. This is 48 * the row ID for consent data. It is the same as userId, but we store userId separately so that 49 * this DAO can be extended if needed in the future. 50 */ 51 @Document.Id private final String mId; 52 53 @Document.StringProperty(indexingType = StringPropertyConfig.INDEXING_TYPE_EXACT_TERMS) 54 private final String mUserId; 55 56 /** Namespace of the Topics Document. Used to group documents during querying or deletion. */ 57 @Document.Namespace private final String mNamespace; 58 59 /** List of Topics that the user has opted out of. */ 60 @Document.LongProperty private List<Integer> mBlockedTopics = new ArrayList<>(); 61 62 /** The taxonomy versions of Topics that the user has opted out of. */ 63 @Document.LongProperty private List<Long> mBlockedTopicsTaxonomyVersions = new ArrayList<>(); 64 65 /** The model versions of Topics that the user has opted out of. */ 66 @Document.LongProperty private List<Long> mBlockedTopicsModelVersions = new ArrayList<>(); 67 68 // Column name used for preparing the query string, are not part of the @Document. 69 private static final String USER_ID_COLNAME = "userId"; 70 public static final String NAMESPACE = "blockedTopics"; 71 72 /** 73 * Create an AppSearchTopicsConsentDao instance. 74 * 75 * @param id is the user ID for this user 76 * @param userId is the user ID for this user 77 * @param namespace (required by AppSearch) 78 * @param blockedTopics list of blockedTopics by ID 79 * @param blockedTopicsTaxonomyVersions list of taxonomy versions for the blocked topics 80 * @param blockedTopicsModelVersions list of model versions for the blocked topics 81 */ AppSearchTopicsConsentDao( @onNull String id, @NonNull String userId, @NonNull String namespace, @Nullable List<Integer> blockedTopics, @Nullable List<Long> blockedTopicsTaxonomyVersions, @Nullable List<Long> blockedTopicsModelVersions)82 AppSearchTopicsConsentDao( 83 @NonNull String id, 84 @NonNull String userId, 85 @NonNull String namespace, 86 @Nullable List<Integer> blockedTopics, 87 @Nullable List<Long> blockedTopicsTaxonomyVersions, 88 @Nullable List<Long> blockedTopicsModelVersions) { 89 this.mId = id; 90 this.mUserId = userId; 91 this.mNamespace = namespace; 92 mBlockedTopics.addAll(blockedTopics != null ? blockedTopics : List.of()); 93 mBlockedTopicsTaxonomyVersions.addAll( 94 blockedTopicsTaxonomyVersions != null ? blockedTopicsTaxonomyVersions : List.of()); 95 mBlockedTopicsModelVersions.addAll( 96 blockedTopicsModelVersions != null ? blockedTopicsModelVersions : List.of()); 97 } 98 99 /** 100 * Get the row ID for this row. 101 * 102 * @return ID 103 */ 104 @NonNull getId()105 public String getId() { 106 return mId; 107 } 108 109 /** 110 * Get the user ID for this row. 111 * 112 * @return user ID 113 */ 114 @NonNull getUserId()115 public String getUserId() { 116 return mUserId; 117 } 118 119 /** 120 * Get the namespace for this row. 121 * 122 * @return nameespace 123 */ 124 @NonNull getNamespace()125 public String getNamespace() { 126 return mNamespace; 127 } 128 129 /** 130 * Get the list of blocked topics (by topic ID). 131 * 132 * @return blockedTopics 133 */ 134 @NonNull getBlockedTopics()135 public List<Integer> getBlockedTopics() { 136 return mBlockedTopics; 137 } 138 139 /** 140 * Get the list of taxonomy versions for blocked topics. 141 * 142 * @return blockedTopicsTaxonomyVersions 143 */ 144 @NonNull getBlockedTopicsTaxonomyVersions()145 public List<Long> getBlockedTopicsTaxonomyVersions() { 146 return mBlockedTopicsTaxonomyVersions; 147 } 148 149 /** 150 * Get the list of model versions for blocked topics. 151 * 152 * @return blockedTopicsModelVersions 153 */ 154 @NonNull getBlockedTopicsModelVersions()155 public List<Long> getBlockedTopicsModelVersions() { 156 return mBlockedTopicsModelVersions; 157 } 158 159 /** Returns the row ID that should be unique for the consent namespace. */ 160 @NonNull getRowId(@onNull String uid)161 public static String getRowId(@NonNull String uid) { 162 return uid; 163 } 164 165 /** 166 * Converts the DAO to a string. 167 * 168 * @return string representing the DAO. 169 */ 170 @NonNull toString()171 public String toString() { 172 String blockedTopics = Arrays.toString(mBlockedTopics.toArray()); 173 String blockedTopicsTaxonomyVersions = 174 Arrays.toString(mBlockedTopicsTaxonomyVersions.toArray()); 175 String blockedTopicsModelVersions = Arrays.toString(mBlockedTopicsModelVersions.toArray()); 176 return "id=" 177 + mId 178 + "; userId=" 179 + mUserId 180 + "; namespace=" 181 + mNamespace 182 + "; blockedTopics=" 183 + blockedTopics 184 + "; blockedTopicsTaxonomyVersions=" 185 + blockedTopicsTaxonomyVersions 186 + "; blockedTopicsModelVersions=" 187 + blockedTopicsModelVersions; 188 } 189 190 @Override hashCode()191 public int hashCode() { 192 return Objects.hash( 193 mId, 194 mUserId, 195 mNamespace, 196 mBlockedTopics, 197 mBlockedTopicsModelVersions, 198 mBlockedTopicsModelVersions); 199 } 200 201 @Override equals(Object o)202 public boolean equals(Object o) { 203 if (this == o) return true; 204 if (!(o instanceof AppSearchTopicsConsentDao)) return false; 205 AppSearchTopicsConsentDao obj = (AppSearchTopicsConsentDao) o; 206 return (Objects.equals(this.mId, obj.getId())) 207 && (Objects.equals(this.mUserId, obj.getUserId())) 208 && (Objects.equals(this.getBlockedTopics(), obj.getBlockedTopics())) 209 && (Objects.equals(this.mNamespace, obj.getNamespace())) 210 && (Objects.equals( 211 this.getBlockedTopicsTaxonomyVersions(), 212 obj.getBlockedTopicsTaxonomyVersions())) 213 && (Objects.equals( 214 this.getBlockedTopicsModelVersions(), obj.getBlockedTopicsModelVersions())); 215 } 216 217 /** Reads the topics consent data for this user. */ readConsentData( @onNull ListenableFuture<GlobalSearchSession> searchSession, @NonNull Executor executor, @NonNull String userId, @NonNull String adServicesPackageName)218 public static AppSearchTopicsConsentDao readConsentData( 219 @NonNull ListenableFuture<GlobalSearchSession> searchSession, 220 @NonNull Executor executor, 221 @NonNull String userId, 222 @NonNull String adServicesPackageName) { 223 Objects.requireNonNull(searchSession); 224 Objects.requireNonNull(executor); 225 Objects.requireNonNull(userId); 226 return readConsentData( 227 AppSearchTopicsConsentDao.class, 228 searchSession, 229 executor, 230 NAMESPACE, 231 getQuery(userId), 232 adServicesPackageName); 233 } 234 235 /** Adds a blocked topic to the list of blocked topics. */ addBlockedTopic(@onNull Topic topic)236 public void addBlockedTopic(@NonNull Topic topic) { 237 Objects.requireNonNull(topic); 238 239 // Only add the topic if it doesn't exist. 240 for (int i = 0; i < mBlockedTopics.size(); i++) { 241 if (mBlockedTopics.get(i).equals(topic.getTopic()) 242 && mBlockedTopicsTaxonomyVersions.get(i).equals(topic.getTaxonomyVersion()) 243 && mBlockedTopicsModelVersions.get(i).equals(topic.getModelVersion())) { 244 return; 245 } 246 } 247 mBlockedTopics.add(topic.getTopic()); 248 mBlockedTopicsTaxonomyVersions.add(topic.getTaxonomyVersion()); 249 mBlockedTopicsModelVersions.add(topic.getModelVersion()); 250 } 251 252 /** Removes a blocked topic from the list of blocked topics. */ removeBlockedTopic(@onNull Topic topic)253 public void removeBlockedTopic(@NonNull Topic topic) { 254 Objects.requireNonNull(topic); 255 mBlockedTopics = mBlockedTopics == null ? List.of() : mBlockedTopics; 256 if (!mBlockedTopics.contains(topic.getTopic())) { 257 return; 258 } 259 mBlockedTopicsTaxonomyVersions = 260 mBlockedTopicsTaxonomyVersions == null ? List.of() : mBlockedTopicsTaxonomyVersions; 261 mBlockedTopicsModelVersions = 262 mBlockedTopicsModelVersions == null ? List.of() : mBlockedTopicsModelVersions; 263 // AppSearch does not support Maps, so we associate the IDs via the index of each element. 264 // We delete the entries in the stored lists that correspond to the given topic along all 265 // three dimensions - topic ID, taxonomy version and model version because it is safer to 266 // not assume that topic IDs will not repeat with new taxonomy or model versions. 267 int indexToRemove = -1; 268 for (int i = 0; i < mBlockedTopics.size(); i++) { 269 if (mBlockedTopics.get(i).equals(topic.getTopic()) 270 && mBlockedTopicsTaxonomyVersions.get(i).equals(topic.getTaxonomyVersion()) 271 && mBlockedTopicsModelVersions.get(i).equals(topic.getModelVersion())) { 272 indexToRemove = i; 273 } 274 } 275 mBlockedTopics.remove(indexToRemove); 276 mBlockedTopicsTaxonomyVersions.remove(indexToRemove); 277 mBlockedTopicsModelVersions.remove(indexToRemove); 278 } 279 280 /** 281 * Read the Topics consent data from AppSearch. 282 * 283 * @param searchSession we use GlobalSearchSession here to allow AdServices to read. 284 * @param executor the Executor to use. 285 * @param userId the user ID for the query. 286 * @return list of blocked topics. 287 */ getBlockedTopics( @onNull ListenableFuture<GlobalSearchSession> searchSession, @NonNull Executor executor, @NonNull String userId, @NonNull String adServicesPackageName)288 public static List<Topic> getBlockedTopics( 289 @NonNull ListenableFuture<GlobalSearchSession> searchSession, 290 @NonNull Executor executor, 291 @NonNull String userId, 292 @NonNull String adServicesPackageName) { 293 Objects.requireNonNull(searchSession); 294 Objects.requireNonNull(executor); 295 Objects.requireNonNull(userId); 296 297 String query = getQuery(userId); 298 AppSearchTopicsConsentDao dao = 299 AppSearchDao.readConsentData( 300 AppSearchTopicsConsentDao.class, 301 searchSession, 302 executor, 303 NAMESPACE, 304 query, 305 adServicesPackageName); 306 LogUtil.d("AppSearch topics data read: " + dao + " [ query: " + query + "]"); 307 if (dao == null) { 308 return List.of(); 309 } 310 return convertToTopics(dao); 311 } 312 313 @NonNull convertToTopics(AppSearchTopicsConsentDao dao)314 private static List<Topic> convertToTopics(AppSearchTopicsConsentDao dao) { 315 if (dao == null || dao.getBlockedTopics() == null) { 316 return List.of(); 317 } 318 if (dao.getBlockedTopics().size() != dao.getBlockedTopicsTaxonomyVersions().size() 319 || dao.getBlockedTopics().size() != dao.getBlockedTopicsModelVersions().size()) { 320 LogUtil.e("Incorrect blocked topics data stored in AppSearch"); 321 return List.of(); 322 } 323 List<Topic> result = new ArrayList<>(); 324 for (int i = 0; i < dao.getBlockedTopics().size(); ++i) { 325 result.add( 326 Topic.create( 327 dao.getBlockedTopics().get(i), 328 dao.getBlockedTopicsTaxonomyVersions().get(i), 329 dao.getBlockedTopicsModelVersions().get(i))); 330 } 331 return result; 332 } 333 334 // Get the search query for AppSearch. Format specified at http://shortn/_RwVKmB74f3. 335 // Note: AND as an operator is not supported by AppSearch on S or T. getQuery(String userId)336 static String getQuery(String userId) { 337 return USER_ID_COLNAME + ":" + userId; 338 } 339 } 340