1 /* 2 * Copyright 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 android.app.appsearch; 18 19 import android.annotation.IntDef; 20 import android.annotation.IntRange; 21 import android.annotation.NonNull; 22 import android.app.appsearch.annotation.CanIgnoreReturnValue; 23 import android.app.appsearch.util.BundleUtil; 24 import android.os.Bundle; 25 import android.util.ArrayMap; 26 import android.util.ArraySet; 27 28 import com.android.internal.util.Preconditions; 29 30 import java.lang.annotation.Retention; 31 import java.lang.annotation.RetentionPolicy; 32 import java.util.ArrayList; 33 import java.util.Arrays; 34 import java.util.Collection; 35 import java.util.Collections; 36 import java.util.List; 37 import java.util.Map; 38 import java.util.Objects; 39 import java.util.Set; 40 41 /** 42 * This class represents the specification logic for AppSearch. It can be used to set the filter and 43 * settings of search a suggestions. 44 * 45 * @see AppSearchSession#searchSuggestion 46 */ 47 public final class SearchSuggestionSpec { 48 static final String NAMESPACE_FIELD = "namespace"; 49 static final String SCHEMA_FIELD = "schema"; 50 static final String PROPERTY_FIELD = "property"; 51 static final String DOCUMENT_IDS_FIELD = "documentIds"; 52 static final String MAXIMUM_RESULT_COUNT_FIELD = "maximumResultCount"; 53 static final String RANKING_STRATEGY_FIELD = "rankingStrategy"; 54 private final Bundle mBundle; 55 private final int mMaximumResultCount; 56 57 /** @hide */ SearchSuggestionSpec(@onNull Bundle bundle)58 public SearchSuggestionSpec(@NonNull Bundle bundle) { 59 Objects.requireNonNull(bundle); 60 mBundle = bundle; 61 mMaximumResultCount = bundle.getInt(MAXIMUM_RESULT_COUNT_FIELD); 62 Preconditions.checkArgument( 63 mMaximumResultCount >= 1, "MaximumResultCount must be positive."); 64 } 65 66 /** 67 * Ranking Strategy for {@link SearchSuggestionResult}. 68 * 69 * @hide 70 */ 71 @IntDef( 72 value = { 73 SUGGESTION_RANKING_STRATEGY_NONE, 74 SUGGESTION_RANKING_STRATEGY_DOCUMENT_COUNT, 75 SUGGESTION_RANKING_STRATEGY_TERM_FREQUENCY, 76 }) 77 @Retention(RetentionPolicy.SOURCE) 78 public @interface SuggestionRankingStrategy {} 79 80 /** 81 * Ranked by the document count that contains the term. 82 * 83 * <p>Suppose the following document is in the index. 84 * 85 * <pre>Doc1 contains: term1 term2 term2 term2</pre> 86 * 87 * <pre>Doc2 contains: term1</pre> 88 * 89 * <p>Then, suppose that a search suggestion for "t" is issued with the DOCUMENT_COUNT, the 90 * returned {@link SearchSuggestionResult}s will be: term1, term2. The term1 will have higher 91 * score and appear in the results first. 92 */ 93 public static final int SUGGESTION_RANKING_STRATEGY_DOCUMENT_COUNT = 0; 94 /** 95 * Ranked by the term appear frequency. 96 * 97 * <p>Suppose the following document is in the index. 98 * 99 * <pre>Doc1 contains: term1 term2 term2 term2</pre> 100 * 101 * <pre>Doc2 contains: term1</pre> 102 * 103 * <p>Then, suppose that a search suggestion for "t" is issued with the TERM_FREQUENCY, the 104 * returned {@link SearchSuggestionResult}s will be: term2, term1. The term2 will have higher 105 * score and appear in the results first. 106 */ 107 public static final int SUGGESTION_RANKING_STRATEGY_TERM_FREQUENCY = 1; 108 109 /** No Ranking, results are returned in arbitrary order. */ 110 public static final int SUGGESTION_RANKING_STRATEGY_NONE = 2; 111 112 /** 113 * Returns the {@link Bundle} populated by this builder. 114 * 115 * @hide 116 */ 117 @NonNull getBundle()118 public Bundle getBundle() { 119 return mBundle; 120 } 121 122 /** 123 * Returns the maximum number of wanted suggestion that will be returned in the result object. 124 */ getMaximumResultCount()125 public int getMaximumResultCount() { 126 return mMaximumResultCount; 127 } 128 129 /** 130 * Returns the list of namespaces to search over. 131 * 132 * <p>If empty, will search over all namespaces. 133 */ 134 @NonNull getFilterNamespaces()135 public List<String> getFilterNamespaces() { 136 List<String> namespaces = mBundle.getStringArrayList(NAMESPACE_FIELD); 137 if (namespaces == null) { 138 return Collections.emptyList(); 139 } 140 return Collections.unmodifiableList(namespaces); 141 } 142 143 /** Returns the ranking strategy. */ 144 @SuggestionRankingStrategy getRankingStrategy()145 public int getRankingStrategy() { 146 return mBundle.getInt(RANKING_STRATEGY_FIELD); 147 } 148 149 /** 150 * Returns the list of schema to search the suggestion over. 151 * 152 * <p>If empty, will search over all schemas. 153 */ 154 @NonNull getFilterSchemas()155 public List<String> getFilterSchemas() { 156 List<String> schemaTypes = mBundle.getStringArrayList(SCHEMA_FIELD); 157 if (schemaTypes == null) { 158 return Collections.emptyList(); 159 } 160 return Collections.unmodifiableList(schemaTypes); 161 } 162 163 /** 164 * Returns the map of schema and target properties to search over. 165 * 166 * <p>The keys of the returned map are schema types, and the values are the target property path 167 * in that schema to search over. 168 * 169 * <p>If {@link Builder#addFilterPropertyPaths} was never called, returns an empty map. In this 170 * case AppSearch will search over all schemas and properties. 171 * 172 * <p>Calling this function repeatedly is inefficient. Prefer to retain the Map returned by this 173 * function, rather than calling it multiple times. 174 * 175 * @hide 176 */ 177 // TODO(b/228240987) migrate this API when we support property restrict for multiple terms 178 @NonNull getFilterProperties()179 public Map<String, List<String>> getFilterProperties() { 180 Bundle typePropertyPathsBundle = Objects.requireNonNull(mBundle.getBundle(PROPERTY_FIELD)); 181 Set<String> schemas = typePropertyPathsBundle.keySet(); 182 Map<String, List<String>> typePropertyPathsMap = new ArrayMap<>(schemas.size()); 183 for (String schema : schemas) { 184 typePropertyPathsMap.put( 185 schema, 186 Objects.requireNonNull(typePropertyPathsBundle.getStringArrayList(schema))); 187 } 188 return typePropertyPathsMap; 189 } 190 191 /** 192 * Returns the map of namespace and target document ids to search over. 193 * 194 * <p>The keys of the returned map are namespaces, and the values are the target document ids in 195 * that namespace to search over. 196 * 197 * <p>If {@link Builder#addFilterDocumentIds} was never called, returns an empty map. In this 198 * case AppSearch will search over all namespace and document ids. 199 * 200 * <p>Calling this function repeatedly is inefficient. Prefer to retain the Map returned by this 201 * function, rather than calling it multiple times. 202 */ 203 @NonNull getFilterDocumentIds()204 public Map<String, List<String>> getFilterDocumentIds() { 205 Bundle documentIdsBundle = Objects.requireNonNull(mBundle.getBundle(DOCUMENT_IDS_FIELD)); 206 Set<String> namespaces = documentIdsBundle.keySet(); 207 Map<String, List<String>> documentIdsMap = new ArrayMap<>(namespaces.size()); 208 for (String namespace : namespaces) { 209 documentIdsMap.put( 210 namespace, 211 Objects.requireNonNull(documentIdsBundle.getStringArrayList(namespace))); 212 } 213 return documentIdsMap; 214 } 215 216 /** Builder for {@link SearchSuggestionSpec objects}. */ 217 public static final class Builder { 218 private ArrayList<String> mNamespaces = new ArrayList<>(); 219 private ArrayList<String> mSchemas = new ArrayList<>(); 220 private Bundle mTypePropertyFilters = new Bundle(); 221 private Bundle mDocumentIds = new Bundle(); 222 private final int mTotalResultCount; 223 224 @SuggestionRankingStrategy 225 private int mRankingStrategy = SUGGESTION_RANKING_STRATEGY_DOCUMENT_COUNT; 226 227 private boolean mBuilt = false; 228 229 /** 230 * Creates an {@link SearchSuggestionSpec.Builder} object. 231 * 232 * @param maximumResultCount Sets the maximum number of suggestion in the returned object. 233 */ Builder(@ntRangefrom = 1) int maximumResultCount)234 public Builder(@IntRange(from = 1) int maximumResultCount) { 235 Preconditions.checkArgument( 236 maximumResultCount >= 1, "maximumResultCount must be positive."); 237 mTotalResultCount = maximumResultCount; 238 } 239 240 /** 241 * Adds a namespace filter to {@link SearchSuggestionSpec} Entry. Only search for 242 * suggestions that has documents under the specified namespaces. 243 * 244 * <p>If unset, the query will search over all namespaces. 245 */ 246 @CanIgnoreReturnValue 247 @NonNull addFilterNamespaces(@onNull String... namespaces)248 public Builder addFilterNamespaces(@NonNull String... namespaces) { 249 Objects.requireNonNull(namespaces); 250 resetIfBuilt(); 251 return addFilterNamespaces(Arrays.asList(namespaces)); 252 } 253 254 /** 255 * Adds a namespace filter to {@link SearchSuggestionSpec} Entry. Only search for 256 * suggestions that has documents under the specified namespaces. 257 * 258 * <p>If unset, the query will search over all namespaces. 259 */ 260 @CanIgnoreReturnValue 261 @NonNull addFilterNamespaces(@onNull Collection<String> namespaces)262 public Builder addFilterNamespaces(@NonNull Collection<String> namespaces) { 263 Objects.requireNonNull(namespaces); 264 resetIfBuilt(); 265 mNamespaces.addAll(namespaces); 266 return this; 267 } 268 269 /** 270 * Sets ranking strategy for suggestion results. 271 * 272 * <p>The default value {@link #SUGGESTION_RANKING_STRATEGY_DOCUMENT_COUNT} will be used if 273 * this method is never called. 274 */ 275 @CanIgnoreReturnValue 276 @NonNull setRankingStrategy(@uggestionRankingStrategy int rankingStrategy)277 public Builder setRankingStrategy(@SuggestionRankingStrategy int rankingStrategy) { 278 Preconditions.checkArgumentInRange( 279 rankingStrategy, 280 SUGGESTION_RANKING_STRATEGY_DOCUMENT_COUNT, 281 SUGGESTION_RANKING_STRATEGY_NONE, 282 "Suggestion ranking strategy"); 283 resetIfBuilt(); 284 mRankingStrategy = rankingStrategy; 285 return this; 286 } 287 288 /** 289 * Adds a schema filter to {@link SearchSuggestionSpec} Entry. Only search for suggestions 290 * that has documents under the specified schema. 291 * 292 * <p>If unset, the query will search over all schema. 293 */ 294 @CanIgnoreReturnValue 295 @NonNull addFilterSchemas(@onNull String... schemaTypes)296 public Builder addFilterSchemas(@NonNull String... schemaTypes) { 297 Objects.requireNonNull(schemaTypes); 298 resetIfBuilt(); 299 return addFilterSchemas(Arrays.asList(schemaTypes)); 300 } 301 302 /** 303 * Adds a schema filter to {@link SearchSuggestionSpec} Entry. Only search for suggestions 304 * that has documents under the specified schema. 305 * 306 * <p>If unset, the query will search over all schema. 307 */ 308 @CanIgnoreReturnValue 309 @NonNull addFilterSchemas(@onNull Collection<String> schemaTypes)310 public Builder addFilterSchemas(@NonNull Collection<String> schemaTypes) { 311 Objects.requireNonNull(schemaTypes); 312 resetIfBuilt(); 313 mSchemas.addAll(schemaTypes); 314 return this; 315 } 316 317 /** 318 * Adds property paths for the specified type to the property filter of {@link 319 * SearchSuggestionSpec} Entry. Only search for suggestions that has content under the 320 * specified property. If property paths are added for a type, then only the properties 321 * referred to will be retrieved for results of that type. 322 * 323 * <p>If a property path that is specified isn't present in a result, it will be ignored for 324 * that result. Property paths cannot be null. 325 * 326 * <p>If no property paths are added for a particular type, then all properties of results 327 * of that type will be retrieved. 328 * 329 * <p>Example properties: 'body', 'sender.name', 'sender.emailaddress', etc. 330 * 331 * @param schema the {@link AppSearchSchema} that contains the target properties 332 * @param propertyPaths The String version of {@link PropertyPath}. A dot-delimited sequence 333 * of property names indicating which property in the document these snippets correspond 334 * to. 335 * @hide 336 */ 337 // TODO(b/228240987) migrate this API when we support property restrict for multiple terms 338 @NonNull addFilterProperties( @onNull String schema, @NonNull Collection<String> propertyPaths)339 public Builder addFilterProperties( 340 @NonNull String schema, @NonNull Collection<String> propertyPaths) { 341 Objects.requireNonNull(schema); 342 Objects.requireNonNull(propertyPaths); 343 resetIfBuilt(); 344 ArrayList<String> propertyPathsArrayList = new ArrayList<>(propertyPaths.size()); 345 for (String propertyPath : propertyPaths) { 346 Objects.requireNonNull(propertyPath); 347 propertyPathsArrayList.add(propertyPath); 348 } 349 mTypePropertyFilters.putStringArrayList(schema, propertyPathsArrayList); 350 return this; 351 } 352 353 /** 354 * Adds property paths for the specified type to the property filter of {@link 355 * SearchSuggestionSpec} Entry. Only search for suggestions that has content under the 356 * specified property. If property paths are added for a type, then only the properties 357 * referred to will be retrieved for results of that type. 358 * 359 * <p>If a property path that is specified isn't present in a result, it will be ignored for 360 * that result. Property paths cannot be null. 361 * 362 * <p>If no property paths are added for a particular type, then all properties of results 363 * of that type will be retrieved. 364 * 365 * @param schema the {@link AppSearchSchema} that contains the target properties 366 * @param propertyPaths The {@link PropertyPath} to search suggestion over 367 * @hide 368 */ 369 // TODO(b/228240987) migrate this API when we support property restrict for multiple terms 370 @NonNull addFilterPropertyPaths( @onNull String schema, @NonNull Collection<PropertyPath> propertyPaths)371 public Builder addFilterPropertyPaths( 372 @NonNull String schema, @NonNull Collection<PropertyPath> propertyPaths) { 373 Objects.requireNonNull(schema); 374 Objects.requireNonNull(propertyPaths); 375 ArrayList<String> propertyPathsArrayList = new ArrayList<>(propertyPaths.size()); 376 for (PropertyPath propertyPath : propertyPaths) { 377 propertyPathsArrayList.add(propertyPath.toString()); 378 } 379 return addFilterProperties(schema, propertyPathsArrayList); 380 } 381 382 /** 383 * Adds a document ID filter to {@link SearchSuggestionSpec} Entry. Only search for 384 * suggestions in the given specified documents. 385 * 386 * <p>If unset, the query will search over all documents. 387 */ 388 @CanIgnoreReturnValue 389 @NonNull addFilterDocumentIds( @onNull String namespace, @NonNull String... documentIds)390 public Builder addFilterDocumentIds( 391 @NonNull String namespace, @NonNull String... documentIds) { 392 Objects.requireNonNull(namespace); 393 Objects.requireNonNull(documentIds); 394 resetIfBuilt(); 395 return addFilterDocumentIds(namespace, Arrays.asList(documentIds)); 396 } 397 398 /** 399 * Adds a document ID filter to {@link SearchSuggestionSpec} Entry. Only search for 400 * suggestions in the given specified documents. 401 * 402 * <p>If unset, the query will search over all documents. 403 */ 404 @CanIgnoreReturnValue 405 @NonNull addFilterDocumentIds( @onNull String namespace, @NonNull Collection<String> documentIds)406 public Builder addFilterDocumentIds( 407 @NonNull String namespace, @NonNull Collection<String> documentIds) { 408 Objects.requireNonNull(namespace); 409 Objects.requireNonNull(documentIds); 410 resetIfBuilt(); 411 ArrayList<String> documentIdList = new ArrayList<>(documentIds.size()); 412 for (String documentId : documentIds) { 413 documentIdList.add(Objects.requireNonNull(documentId)); 414 } 415 mDocumentIds.putStringArrayList(namespace, documentIdList); 416 return this; 417 } 418 419 /** Constructs a new {@link SearchSpec} from the contents of this builder. */ 420 @NonNull build()421 public SearchSuggestionSpec build() { 422 Bundle bundle = new Bundle(); 423 if (!mSchemas.isEmpty()) { 424 Set<String> schemaFilter = new ArraySet<>(mSchemas); 425 for (String schema : mTypePropertyFilters.keySet()) { 426 if (!schemaFilter.contains(schema)) { 427 throw new IllegalStateException( 428 "The schema: " 429 + schema 430 + " exists in the property filter but " 431 + "doesn't exist in the schema filter."); 432 } 433 } 434 } 435 if (!mNamespaces.isEmpty()) { 436 Set<String> namespaceFilter = new ArraySet<>(mNamespaces); 437 for (String namespace : mDocumentIds.keySet()) { 438 if (!namespaceFilter.contains(namespace)) { 439 throw new IllegalStateException( 440 "The namespace: " 441 + namespace 442 + " exists in the document id " 443 + "filter but doesn't exist in the namespace filter."); 444 } 445 } 446 } 447 bundle.putStringArrayList(NAMESPACE_FIELD, mNamespaces); 448 bundle.putStringArrayList(SCHEMA_FIELD, mSchemas); 449 bundle.putBundle(PROPERTY_FIELD, mTypePropertyFilters); 450 bundle.putBundle(DOCUMENT_IDS_FIELD, mDocumentIds); 451 bundle.putInt(MAXIMUM_RESULT_COUNT_FIELD, mTotalResultCount); 452 bundle.putInt(RANKING_STRATEGY_FIELD, mRankingStrategy); 453 mBuilt = true; 454 return new SearchSuggestionSpec(bundle); 455 } 456 resetIfBuilt()457 private void resetIfBuilt() { 458 if (mBuilt) { 459 mNamespaces = new ArrayList<>(mNamespaces); 460 mSchemas = new ArrayList<>(mSchemas); 461 mTypePropertyFilters = BundleUtil.deepCopy(mTypePropertyFilters); 462 mDocumentIds = BundleUtil.deepCopy(mDocumentIds); 463 mBuilt = false; 464 } 465 } 466 } 467 } 468