1 /* 2 * Copyright 2020 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.NonNull; 20 import android.annotation.Nullable; 21 import android.os.Bundle; 22 23 import com.android.internal.util.Preconditions; 24 25 import java.util.ArrayList; 26 import java.util.List; 27 import java.util.Objects; 28 29 /** 30 * This class represents one of the results obtained from an AppSearch query. 31 * 32 * <p>This allows clients to obtain: 33 * 34 * <ul> 35 * <li>The document which matched, using {@link #getGenericDocument} 36 * <li>Information about which properties in the document matched, and "snippet" information 37 * containing textual summaries of the document's matches, using {@link #getMatchInfos} 38 * </ul> 39 * 40 * <p>"Snippet" refers to a substring of text from the content of document that is returned as a 41 * part of search result. 42 * 43 * @see SearchResults 44 */ 45 public final class SearchResult { 46 static final String DOCUMENT_FIELD = "document"; 47 static final String MATCH_INFOS_FIELD = "matchInfos"; 48 static final String PACKAGE_NAME_FIELD = "packageName"; 49 static final String DATABASE_NAME_FIELD = "databaseName"; 50 static final String RANKING_SIGNAL_FIELD = "rankingSignal"; 51 52 @NonNull private final Bundle mBundle; 53 54 /** Cache of the inflated document. Comes from inflating mDocumentBundle at first use. */ 55 @Nullable private GenericDocument mDocument; 56 57 /** Cache of the inflated matches. Comes from inflating mMatchBundles at first use. */ 58 @Nullable private List<MatchInfo> mMatchInfos; 59 60 /** @hide */ SearchResult(@onNull Bundle bundle)61 public SearchResult(@NonNull Bundle bundle) { 62 mBundle = Objects.requireNonNull(bundle); 63 } 64 65 /** @hide */ 66 @NonNull getBundle()67 public Bundle getBundle() { 68 return mBundle; 69 } 70 71 /** 72 * Contains the matching {@link GenericDocument}. 73 * 74 * @return Document object which matched the query. 75 */ 76 @NonNull getGenericDocument()77 public GenericDocument getGenericDocument() { 78 if (mDocument == null) { 79 mDocument = 80 new GenericDocument(Objects.requireNonNull(mBundle.getBundle(DOCUMENT_FIELD))); 81 } 82 return mDocument; 83 } 84 85 /** 86 * Returns a list of {@link MatchInfo}s providing information about how the document in {@link 87 * #getGenericDocument} matched the query. 88 * 89 * @return List of matches based on {@link SearchSpec}. If snippeting is disabled using {@link 90 * SearchSpec.Builder#setSnippetCount} or {@link 91 * SearchSpec.Builder#setSnippetCountPerProperty}, for all results after that value, this 92 * method returns an empty list. 93 */ 94 @NonNull getMatchInfos()95 public List<MatchInfo> getMatchInfos() { 96 if (mMatchInfos == null) { 97 List<Bundle> matchBundles = 98 Objects.requireNonNull(mBundle.getParcelableArrayList(MATCH_INFOS_FIELD)); 99 mMatchInfos = new ArrayList<>(matchBundles.size()); 100 for (int i = 0; i < matchBundles.size(); i++) { 101 MatchInfo matchInfo = new MatchInfo(matchBundles.get(i), getGenericDocument()); 102 mMatchInfos.add(matchInfo); 103 } 104 } 105 return mMatchInfos; 106 } 107 108 /** 109 * Contains the package name of the app that stored the {@link GenericDocument}. 110 * 111 * @return Package name that stored the document 112 */ 113 @NonNull getPackageName()114 public String getPackageName() { 115 return Objects.requireNonNull(mBundle.getString(PACKAGE_NAME_FIELD)); 116 } 117 118 /** 119 * Contains the database name that stored the {@link GenericDocument}. 120 * 121 * @return Name of the database within which the document is stored 122 */ 123 @NonNull getDatabaseName()124 public String getDatabaseName() { 125 return Objects.requireNonNull(mBundle.getString(DATABASE_NAME_FIELD)); 126 } 127 128 /** 129 * Returns the ranking signal of the {@link GenericDocument}, according to the ranking strategy 130 * set in {@link SearchSpec.Builder#setRankingStrategy(int)}. 131 * 132 * <p>The meaning of the ranking signal and its value is determined by the selected ranking 133 * strategy: 134 * 135 * <ul> 136 * <li>{@link SearchSpec#RANKING_STRATEGY_NONE} - this value will be 0 137 * <li>{@link SearchSpec#RANKING_STRATEGY_DOCUMENT_SCORE} - the value returned by calling 138 * {@link GenericDocument#getScore()} on the document returned by {@link 139 * #getGenericDocument()} 140 * <li>{@link SearchSpec#RANKING_STRATEGY_CREATION_TIMESTAMP} - the value returned by calling 141 * {@link GenericDocument#getCreationTimestampMillis()} on the document returned by {@link 142 * #getGenericDocument()} 143 * <li>{@link SearchSpec#RANKING_STRATEGY_RELEVANCE_SCORE} - an arbitrary double value where a 144 * higher value means more relevant 145 * <li>{@link SearchSpec#RANKING_STRATEGY_USAGE_COUNT} - the number of times usage has been 146 * reported for the document returned by {@link #getGenericDocument()} 147 * <li>{@link SearchSpec#RANKING_STRATEGY_USAGE_LAST_USED_TIMESTAMP} - the timestamp of the 148 * most recent usage that has been reported for the document returned by {@link 149 * #getGenericDocument()} 150 * </ul> 151 * 152 * @return Ranking signal of the document 153 */ getRankingSignal()154 public double getRankingSignal() { 155 return mBundle.getDouble(RANKING_SIGNAL_FIELD); 156 } 157 158 /** Builder for {@link SearchResult} objects. */ 159 public static final class Builder { 160 private final String mPackageName; 161 private final String mDatabaseName; 162 private ArrayList<Bundle> mMatchInfoBundles = new ArrayList<>(); 163 private GenericDocument mGenericDocument; 164 private double mRankingSignal; 165 private boolean mBuilt = false; 166 167 /** 168 * Constructs a new builder for {@link SearchResult} objects. 169 * 170 * @param packageName the package name the matched document belongs to 171 * @param databaseName the database name the matched document belongs to. 172 */ Builder(@onNull String packageName, @NonNull String databaseName)173 public Builder(@NonNull String packageName, @NonNull String databaseName) { 174 mPackageName = Objects.requireNonNull(packageName); 175 mDatabaseName = Objects.requireNonNull(databaseName); 176 } 177 178 /** Sets the document which matched. */ 179 @NonNull setGenericDocument(@onNull GenericDocument document)180 public Builder setGenericDocument(@NonNull GenericDocument document) { 181 Objects.requireNonNull(document); 182 resetIfBuilt(); 183 mGenericDocument = document; 184 return this; 185 } 186 187 /** Adds another match to this SearchResult. */ 188 @NonNull addMatchInfo(@onNull MatchInfo matchInfo)189 public Builder addMatchInfo(@NonNull MatchInfo matchInfo) { 190 Preconditions.checkState( 191 matchInfo.mDocument == null, 192 "This MatchInfo is already associated with a SearchResult and can't be " 193 + "reassigned"); 194 resetIfBuilt(); 195 mMatchInfoBundles.add(matchInfo.mBundle); 196 return this; 197 } 198 199 /** Sets the ranking signal of the matched document in this SearchResult. */ 200 @NonNull setRankingSignal(double rankingSignal)201 public Builder setRankingSignal(double rankingSignal) { 202 resetIfBuilt(); 203 mRankingSignal = rankingSignal; 204 return this; 205 } 206 207 /** Constructs a new {@link SearchResult}. */ 208 @NonNull build()209 public SearchResult build() { 210 Bundle bundle = new Bundle(); 211 bundle.putString(PACKAGE_NAME_FIELD, mPackageName); 212 bundle.putString(DATABASE_NAME_FIELD, mDatabaseName); 213 bundle.putBundle(DOCUMENT_FIELD, mGenericDocument.getBundle()); 214 bundle.putDouble(RANKING_SIGNAL_FIELD, mRankingSignal); 215 bundle.putParcelableArrayList(MATCH_INFOS_FIELD, mMatchInfoBundles); 216 mBuilt = true; 217 return new SearchResult(bundle); 218 } 219 resetIfBuilt()220 private void resetIfBuilt() { 221 if (mBuilt) { 222 mMatchInfoBundles = new ArrayList<>(mMatchInfoBundles); 223 mBuilt = false; 224 } 225 } 226 } 227 228 /** 229 * This class represents a match objects for any Snippets that might be present in {@link 230 * SearchResults} from query. Using this class user can get the full text, exact matches and 231 * Snippets of document content for a given match. 232 * 233 * <p>Class Example 1: A document contains following text in property subject: 234 * 235 * <p>A commonly used fake word is foo. Another nonsense word that’s used a lot is bar. 236 * 237 * <p>If the queryExpression is "foo". 238 * 239 * <p>{@link MatchInfo#getPropertyPath()} returns "subject" 240 * 241 * <p>{@link MatchInfo#getFullText()} returns "A commonly used fake word is foo. Another 242 * nonsense word that’s used a lot is bar." 243 * 244 * <p>{@link MatchInfo#getExactMatchRange()} returns [29, 32] 245 * 246 * <p>{@link MatchInfo#getExactMatch()} returns "foo" 247 * 248 * <p>{@link MatchInfo#getSnippetRange()} returns [26, 33] 249 * 250 * <p>{@link MatchInfo#getSnippet()} returns "is foo." 251 * 252 * <p> 253 * 254 * <p>Class Example 2: A document contains a property name sender which contains 2 property 255 * names name and email, so we will have 2 property paths: {@code sender.name} and {@code 256 * sender.email}. 257 * 258 * <p>Let {@code sender.name = "Test Name Jr."} and {@code sender.email = 259 * "TestNameJr@gmail.com"} 260 * 261 * <p>If the queryExpression is "Test". We will have 2 matches. 262 * 263 * <p>Match-1 264 * 265 * <p>{@link MatchInfo#getPropertyPath()} returns "sender.name" 266 * 267 * <p>{@link MatchInfo#getFullText()} returns "Test Name Jr." 268 * 269 * <p>{@link MatchInfo#getExactMatchRange()} returns [0, 4] 270 * 271 * <p>{@link MatchInfo#getExactMatch()} returns "Test" 272 * 273 * <p>{@link MatchInfo#getSnippetRange()} returns [0, 9] 274 * 275 * <p>{@link MatchInfo#getSnippet()} returns "Test Name" 276 * 277 * <p>Match-2 278 * 279 * <p>{@link MatchInfo#getPropertyPath()} returns "sender.email" 280 * 281 * <p>{@link MatchInfo#getFullText()} returns "TestNameJr@gmail.com" 282 * 283 * <p>{@link MatchInfo#getExactMatchRange()} returns [0, 20] 284 * 285 * <p>{@link MatchInfo#getExactMatch()} returns "TestNameJr@gmail.com" 286 * 287 * <p>{@link MatchInfo#getSnippetRange()} returns [0, 20] 288 * 289 * <p>{@link MatchInfo#getSnippet()} returns "TestNameJr@gmail.com" 290 */ 291 public static final class MatchInfo { 292 /** The path of the matching snippet property. */ 293 private static final String PROPERTY_PATH_FIELD = "propertyPath"; 294 295 private static final String EXACT_MATCH_RANGE_LOWER_FIELD = "exactMatchRangeLower"; 296 private static final String EXACT_MATCH_RANGE_UPPER_FIELD = "exactMatchRangeUpper"; 297 private static final String SNIPPET_RANGE_LOWER_FIELD = "snippetRangeLower"; 298 private static final String SNIPPET_RANGE_UPPER_FIELD = "snippetRangeUpper"; 299 300 private final String mPropertyPath; 301 final Bundle mBundle; 302 303 /** 304 * Document which the match comes from. 305 * 306 * <p>If this is {@code null}, methods which require access to the document, like {@link 307 * #getExactMatch}, will throw {@link NullPointerException}. 308 */ 309 @Nullable final GenericDocument mDocument; 310 311 /** Full text of the matched property. Populated on first use. */ 312 @Nullable private String mFullText; 313 314 /** Range of property that exactly matched the query. Populated on first use. */ 315 @Nullable private MatchRange mExactMatchRange; 316 317 /** Range of some reasonable amount of context around the query. Populated on first use. */ 318 @Nullable private MatchRange mWindowRange; 319 MatchInfo(@onNull Bundle bundle, @Nullable GenericDocument document)320 MatchInfo(@NonNull Bundle bundle, @Nullable GenericDocument document) { 321 mBundle = Objects.requireNonNull(bundle); 322 mDocument = document; 323 mPropertyPath = Objects.requireNonNull(bundle.getString(PROPERTY_PATH_FIELD)); 324 } 325 326 /** 327 * Gets the property path corresponding to the given entry. 328 * 329 * <p>A property path is a '.' - delimited sequence of property names indicating which 330 * property in the document these snippets correspond to. 331 * 332 * <p>Example properties: 'body', 'sender.name', 'sender.emailaddress', etc. For class 333 * example 1 this returns "subject" 334 */ 335 @NonNull getPropertyPath()336 public String getPropertyPath() { 337 return mPropertyPath; 338 } 339 340 /** 341 * Gets the full text corresponding to the given entry. 342 * 343 * <p>For class example this returns "A commonly used fake word is foo. Another nonsense 344 * word that's used a lot is bar." 345 */ 346 @NonNull getFullText()347 public String getFullText() { 348 if (mFullText == null) { 349 Preconditions.checkState( 350 mDocument != null, 351 "Document has not been populated; this MatchInfo cannot be used yet"); 352 mFullText = getPropertyValues(mDocument, mPropertyPath); 353 } 354 return mFullText; 355 } 356 357 /** 358 * Gets the exact {@link MatchRange} corresponding to the given entry. 359 * 360 * <p>For class example 1 this returns [29, 32] 361 */ 362 @NonNull getExactMatchRange()363 public MatchRange getExactMatchRange() { 364 if (mExactMatchRange == null) { 365 mExactMatchRange = 366 new MatchRange( 367 mBundle.getInt(EXACT_MATCH_RANGE_LOWER_FIELD), 368 mBundle.getInt(EXACT_MATCH_RANGE_UPPER_FIELD)); 369 } 370 return mExactMatchRange; 371 } 372 373 /** 374 * Gets the {@link MatchRange} corresponding to the given entry. 375 * 376 * <p>For class example 1 this returns "foo" 377 */ 378 @NonNull getExactMatch()379 public CharSequence getExactMatch() { 380 return getSubstring(getExactMatchRange()); 381 } 382 383 /** 384 * Gets the snippet {@link MatchRange} corresponding to the given entry. 385 * 386 * <p>Only populated when set maxSnippetSize > 0 in {@link 387 * SearchSpec.Builder#setMaxSnippetSize}. 388 * 389 * <p>For class example 1 this returns [29, 41]. 390 */ 391 @NonNull getSnippetRange()392 public MatchRange getSnippetRange() { 393 if (mWindowRange == null) { 394 mWindowRange = 395 new MatchRange( 396 mBundle.getInt(SNIPPET_RANGE_LOWER_FIELD), 397 mBundle.getInt(SNIPPET_RANGE_UPPER_FIELD)); 398 } 399 return mWindowRange; 400 } 401 402 /** 403 * Gets the snippet corresponding to the given entry. 404 * 405 * <p>Snippet - Provides a subset of the content to display. Only populated when requested 406 * maxSnippetSize > 0. The size of this content can be changed by {@link 407 * SearchSpec.Builder#setMaxSnippetSize}. Windowing is centered around the middle of the 408 * matched token with content on either side clipped to token boundaries. 409 * 410 * <p>For class example 1 this returns "foo. Another" 411 */ 412 @NonNull getSnippet()413 public CharSequence getSnippet() { 414 return getSubstring(getSnippetRange()); 415 } 416 getSubstring(MatchRange range)417 private CharSequence getSubstring(MatchRange range) { 418 return getFullText().substring(range.getStart(), range.getEnd()); 419 } 420 421 /** Extracts the matching string from the document. */ getPropertyValues(GenericDocument document, String propertyName)422 private static String getPropertyValues(GenericDocument document, String propertyName) { 423 // In IcingLib snippeting is available for only 3 data types i.e String, double and 424 // long, so we need to check which of these three are requested. 425 // TODO (tytytyww): support double[] and long[]. 426 String result = document.getPropertyString(propertyName); 427 if (result == null) { 428 throw new IllegalStateException( 429 "No content found for requested property path: " + propertyName); 430 } 431 return result; 432 } 433 434 /** Builder for {@link MatchInfo} objects. */ 435 public static final class Builder { 436 private final String mPropertyPath; 437 private MatchRange mExactMatchRange = new MatchRange(0, 0); 438 private MatchRange mSnippetRange = new MatchRange(0, 0); 439 440 /** 441 * Creates a new {@link MatchInfo.Builder} reporting a match with the given property 442 * path. 443 * 444 * <p>A property path is a dot-delimited sequence of property names indicating which 445 * property in the document these snippets correspond to. 446 * 447 * <p>Example properties: 'body', 'sender.name', 'sender.emailaddress', etc. 448 * For class example 1 this returns "subject". 449 * 450 * @param propertyPath A {@code dot-delimited sequence of property names indicating 451 * which property in the document these snippets correspond to. 452 */ Builder(@onNull String propertyPath)453 public Builder(@NonNull String propertyPath) { 454 mPropertyPath = Objects.requireNonNull(propertyPath); 455 } 456 457 /** Sets the exact {@link MatchRange} corresponding to the given entry. */ 458 @NonNull setExactMatchRange(@onNull MatchRange matchRange)459 public Builder setExactMatchRange(@NonNull MatchRange matchRange) { 460 mExactMatchRange = Objects.requireNonNull(matchRange); 461 return this; 462 } 463 464 /** Sets the snippet {@link MatchRange} corresponding to the given entry. */ 465 @NonNull setSnippetRange(@onNull MatchRange matchRange)466 public Builder setSnippetRange(@NonNull MatchRange matchRange) { 467 mSnippetRange = Objects.requireNonNull(matchRange); 468 return this; 469 } 470 471 /** Constructs a new {@link MatchInfo}. */ 472 @NonNull build()473 public MatchInfo build() { 474 Bundle bundle = new Bundle(); 475 bundle.putString(SearchResult.MatchInfo.PROPERTY_PATH_FIELD, mPropertyPath); 476 bundle.putInt(MatchInfo.EXACT_MATCH_RANGE_LOWER_FIELD, mExactMatchRange.getStart()); 477 bundle.putInt(MatchInfo.EXACT_MATCH_RANGE_UPPER_FIELD, mExactMatchRange.getEnd()); 478 bundle.putInt(MatchInfo.SNIPPET_RANGE_LOWER_FIELD, mSnippetRange.getStart()); 479 bundle.putInt(MatchInfo.SNIPPET_RANGE_UPPER_FIELD, mSnippetRange.getEnd()); 480 return new MatchInfo(bundle, /*document=*/ null); 481 } 482 } 483 } 484 485 /** 486 * Class providing the position range of matching information. 487 * 488 * <p>All ranges are finite, and the left side of the range is always {@code <=} the right side 489 * of the range. 490 * 491 * <p>Example: MatchRange(0, 100) represent a hundred ints from 0 to 99." 492 */ 493 public static final class MatchRange { 494 private final int mEnd; 495 private final int mStart; 496 497 /** 498 * Creates a new immutable range. 499 * 500 * <p>The endpoints are {@code [start, end)}; that is the range is bounded. {@code start} 501 * must be lesser or equal to {@code end}. 502 * 503 * @param start The start point (inclusive) 504 * @param end The end point (exclusive) 505 */ MatchRange(int start, int end)506 public MatchRange(int start, int end) { 507 if (start > end) { 508 throw new IllegalArgumentException( 509 "Start point must be less than or equal to " + "end point"); 510 } 511 mStart = start; 512 mEnd = end; 513 } 514 515 /** Gets the start point (inclusive). */ getStart()516 public int getStart() { 517 return mStart; 518 } 519 520 /** Gets the end point (exclusive). */ getEnd()521 public int getEnd() { 522 return mEnd; 523 } 524 525 @Override equals(@ullable Object other)526 public boolean equals(@Nullable Object other) { 527 if (this == other) { 528 return true; 529 } 530 if (!(other instanceof MatchRange)) { 531 return false; 532 } 533 MatchRange otherMatchRange = (MatchRange) other; 534 return this.getStart() == otherMatchRange.getStart() 535 && this.getEnd() == otherMatchRange.getEnd(); 536 } 537 538 @Override 539 @NonNull toString()540 public String toString() { 541 return "MatchRange { start: " + mStart + " , end: " + mEnd + "}"; 542 } 543 544 @Override hashCode()545 public int hashCode() { 546 return Objects.hash(mStart, mEnd); 547 } 548 } 549 } 550