/* * Copyright 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.app.appsearch; import android.annotation.CurrentTimeMillisLong; import android.annotation.IntRange; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.SuppressLint; import android.app.appsearch.PropertyPath.PathSegment; import android.app.appsearch.annotation.CanIgnoreReturnValue; import android.app.appsearch.util.BundleUtil; import android.app.appsearch.util.IndentingStringBuilder; import android.os.Bundle; import android.os.Parcelable; import android.util.Log; import java.lang.reflect.Array; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Set; /** * Represents a document unit. * *

Documents contain structured data conforming to their {@link AppSearchSchema} type. Each * document is uniquely identified by a namespace and a String ID within that namespace. * *

Documents are constructed by using the {@link GenericDocument.Builder}. * * @see AppSearchSession#put * @see AppSearchSession#getByDocumentId * @see AppSearchSession#search */ public class GenericDocument { private static final String TAG = "AppSearchGenericDocumen"; /** The maximum number of indexed properties a document can have. */ private static final int MAX_INDEXED_PROPERTIES = 16; /** The default score of document. */ private static final int DEFAULT_SCORE = 0; /** The default time-to-live in millisecond of a document, which is infinity. */ private static final long DEFAULT_TTL_MILLIS = 0L; private static final String PROPERTIES_FIELD = "properties"; private static final String BYTE_ARRAY_FIELD = "byteArray"; private static final String SCHEMA_TYPE_FIELD = "schemaType"; private static final String ID_FIELD = "id"; private static final String SCORE_FIELD = "score"; private static final String TTL_MILLIS_FIELD = "ttlMillis"; private static final String CREATION_TIMESTAMP_MILLIS_FIELD = "creationTimestampMillis"; private static final String NAMESPACE_FIELD = "namespace"; /** * The maximum number of indexed properties a document can have. * *

Indexed properties are properties which are strings where the {@link * AppSearchSchema.StringPropertyConfig#getIndexingType} value is anything other than {@link * AppSearchSchema.StringPropertyConfig#INDEXING_TYPE_NONE}. */ public static int getMaxIndexedProperties() { return MAX_INDEXED_PROPERTIES; } /** * Contains all {@link GenericDocument} information in a packaged format. * *

Keys are the {@code *_FIELD} constants in this class. */ @NonNull final Bundle mBundle; /** Contains all properties in {@link GenericDocument} to support getting properties via name */ @NonNull private final Bundle mProperties; @NonNull private final String mId; @NonNull private final String mSchemaType; private final long mCreationTimestampMillis; @Nullable private Integer mHashCode; /** * Rebuilds a {@link GenericDocument} from a bundle. * * @param bundle Packaged {@link GenericDocument} data, such as the result of {@link * #getBundle}. * @hide */ @SuppressWarnings("deprecation") public GenericDocument(@NonNull Bundle bundle) { Objects.requireNonNull(bundle); mBundle = bundle; mProperties = Objects.requireNonNull(bundle.getParcelable(PROPERTIES_FIELD)); mId = Objects.requireNonNull(mBundle.getString(ID_FIELD)); mSchemaType = Objects.requireNonNull(mBundle.getString(SCHEMA_TYPE_FIELD)); mCreationTimestampMillis = mBundle.getLong(CREATION_TIMESTAMP_MILLIS_FIELD, System.currentTimeMillis()); } /** * Creates a new {@link GenericDocument} from an existing instance. * *

This method should be only used by constructor of a subclass. */ protected GenericDocument(@NonNull GenericDocument document) { this(document.mBundle); } /** * Returns the {@link Bundle} populated by this builder. * * @hide */ @NonNull public Bundle getBundle() { return mBundle; } /** Returns the unique identifier of the {@link GenericDocument}. */ @NonNull public String getId() { return mId; } /** Returns the namespace of the {@link GenericDocument}. */ @NonNull public String getNamespace() { return mBundle.getString(NAMESPACE_FIELD, /*defaultValue=*/ ""); } /** Returns the {@link AppSearchSchema} type of the {@link GenericDocument}. */ @NonNull public String getSchemaType() { return mSchemaType; } /** * Returns the creation timestamp of the {@link GenericDocument}, in milliseconds. * *

The value is in the {@link System#currentTimeMillis} time base. */ @CurrentTimeMillisLong public long getCreationTimestampMillis() { return mCreationTimestampMillis; } /** * Returns the TTL (time-to-live) of the {@link GenericDocument}, in milliseconds. * *

The TTL is measured against {@link #getCreationTimestampMillis}. At the timestamp of * {@code creationTimestampMillis + ttlMillis}, measured in the {@link System#currentTimeMillis} * time base, the document will be auto-deleted. * *

The default value is 0, which means the document is permanent and won't be auto-deleted * until the app is uninstalled or {@link AppSearchSession#remove} is called. */ public long getTtlMillis() { return mBundle.getLong(TTL_MILLIS_FIELD, DEFAULT_TTL_MILLIS); } /** * Returns the score of the {@link GenericDocument}. * *

The score is a query-independent measure of the document's quality, relative to other * {@link GenericDocument} objects of the same {@link AppSearchSchema} type. * *

Results may be sorted by score using {@link SearchSpec.Builder#setRankingStrategy}. * Documents with higher scores are considered better than documents with lower scores. * *

Any non-negative integer can be used a score. */ public int getScore() { return mBundle.getInt(SCORE_FIELD, DEFAULT_SCORE); } /** Returns the names of all properties defined in this document. */ @NonNull public Set getPropertyNames() { return Collections.unmodifiableSet(mProperties.keySet()); } /** * Retrieves the property value with the given path as {@link Object}. * *

A path can be a simple property name, such as those returned by {@link #getPropertyNames}. * It may also be a dot-delimited path through the nested document hierarchy, with nested {@link * GenericDocument} properties accessed via {@code '.'} and repeated properties optionally * indexed into via {@code [n]}. * *

For example, given the following {@link GenericDocument}: * *

     *     (Message) {
     *         from: "sender@example.com"
     *         to: [{
     *             name: "Albert Einstein"
     *             email: "einstein@example.com"
     *           }, {
     *             name: "Marie Curie"
     *             email: "curie@example.com"
     *           }]
     *         tags: ["important", "inbox"]
     *         subject: "Hello"
     *     }
     * 
* *

Here are some example paths and their results: * *

* *

If you know the expected type of the property you are retrieving, it is recommended to use * one of the typed versions of this method instead, such as {@link #getPropertyString} or * {@link #getPropertyStringArray}. * *

If the property was assigned as an empty array using one of the {@code * Builder#setProperty} functions, this method will return an empty array. If no such property * exists at all, this method returns {@code null}. * *

Note: If the property is an empty {@link GenericDocument}[] or {@code byte[][]}, this * method will return a {@code null} value in versions of Android prior to {@link * android.os.Build.VERSION_CODES#TIRAMISU Android T}. Starting in Android T it will return an * empty array if the property has been set as an empty array, matching the behavior of other * property types. * * @param path The path to look for. * @return The entry with the given path as an object or {@code null} if there is no such path. * The returned object will be one of the following types: {@code String[]}, {@code long[]}, * {@code double[]}, {@code boolean[]}, {@code byte[][]}, {@code GenericDocument[]}. */ @Nullable public Object getProperty(@NonNull String path) { Objects.requireNonNull(path); Object rawValue = getRawPropertyFromRawDocument(new PropertyPath(path), /*pathIndex=*/ 0, mBundle); // Unpack the raw value into the types the user expects, if required. if (rawValue instanceof Bundle) { // getRawPropertyFromRawDocument may return a document as a bare Bundle as a performance // optimization for lookups. GenericDocument document = new GenericDocument((Bundle) rawValue); return new GenericDocument[] {document}; } if (rawValue instanceof List) { // byte[][] fields are packed into List where each Bundle contains just a single // entry: BYTE_ARRAY_FIELD -> byte[]. @SuppressWarnings("unchecked") List bundles = (List) rawValue; byte[][] bytes = new byte[bundles.size()][]; for (int i = 0; i < bundles.size(); i++) { Bundle bundle = bundles.get(i); if (bundle == null) { Log.e(TAG, "The inner bundle is null at " + i + ", for path: " + path); continue; } byte[] innerBytes = bundle.getByteArray(BYTE_ARRAY_FIELD); if (innerBytes == null) { Log.e(TAG, "The bundle at " + i + " contains a null byte[]."); continue; } bytes[i] = innerBytes; } return bytes; } if (rawValue instanceof Parcelable[]) { // The underlying Bundle of nested GenericDocuments is packed into a Parcelable array. // We must unpack it into GenericDocument instances. Parcelable[] bundles = (Parcelable[]) rawValue; GenericDocument[] documents = new GenericDocument[bundles.length]; for (int i = 0; i < bundles.length; i++) { if (bundles[i] == null) { Log.e(TAG, "The inner bundle is null at " + i + ", for path: " + path); continue; } if (!(bundles[i] instanceof Bundle)) { Log.e( TAG, "The inner element at " + i + " is a " + bundles[i].getClass() + ", not a Bundle for path: " + path); continue; } documents[i] = new GenericDocument((Bundle) bundles[i]); } return documents; } // Otherwise the raw property is the same as the final property and needs no transformation. return rawValue; } /** * Looks up a property path within the given document bundle. * *

The return value may be any of GenericDocument's internal repeated storage types * (String[], long[], double[], boolean[], ArrayList<Bundle>, Parcelable[]). * *

Usually, this method takes a path and loops over it to get a property from the bundle. But * in the case where we collect documents across repeated nested documents, we need to recurse * back into this method, and so we also keep track of the index into the path. * * @param path the PropertyPath object representing the path * @param pathIndex the index into the path we start at * @param documentBundle the bundle that contains the path we are looking up * @return the raw property */ @Nullable @SuppressWarnings("deprecation") private static Object getRawPropertyFromRawDocument( @NonNull PropertyPath path, int pathIndex, @NonNull Bundle documentBundle) { Objects.requireNonNull(path); Objects.requireNonNull(documentBundle); Bundle properties = Objects.requireNonNull(documentBundle.getBundle(PROPERTIES_FIELD)); for (int i = pathIndex; i < path.size(); i++) { PathSegment segment = path.get(i); Object currentElementValue = properties.get(segment.getPropertyName()); if (currentElementValue == null) { return null; } // If the current PathSegment has an index, we now need to update currentElementValue to // contain the value of the indexed property. For example, for a path segment like // "recipients[0]", currentElementValue now contains the value of "recipients" while we // need the value of "recipients[0]". int index = segment.getPropertyIndex(); if (index != PathSegment.NON_REPEATED_CARDINALITY) { // Extract the right array element Object extractedValue = null; if (currentElementValue instanceof String[]) { String[] stringValues = (String[]) currentElementValue; if (index < stringValues.length) { extractedValue = Arrays.copyOfRange(stringValues, index, index + 1); } } else if (currentElementValue instanceof long[]) { long[] longValues = (long[]) currentElementValue; if (index < longValues.length) { extractedValue = Arrays.copyOfRange(longValues, index, index + 1); } } else if (currentElementValue instanceof double[]) { double[] doubleValues = (double[]) currentElementValue; if (index < doubleValues.length) { extractedValue = Arrays.copyOfRange(doubleValues, index, index + 1); } } else if (currentElementValue instanceof boolean[]) { boolean[] booleanValues = (boolean[]) currentElementValue; if (index < booleanValues.length) { extractedValue = Arrays.copyOfRange(booleanValues, index, index + 1); } } else if (currentElementValue instanceof List) { @SuppressWarnings("unchecked") List bundles = (List) currentElementValue; if (index < bundles.size()) { extractedValue = bundles.subList(index, index + 1); } } else if (currentElementValue instanceof Parcelable[]) { // Special optimization: to avoid creating new singleton arrays for traversing // paths we return the bare document Bundle in this particular case. Parcelable[] bundles = (Parcelable[]) currentElementValue; if (index < bundles.length) { extractedValue = bundles[index]; } } else { throw new IllegalStateException( "Unsupported value type: " + currentElementValue); } currentElementValue = extractedValue; } // at the end of the path, either something like "...foo" or "...foo[1]" if (currentElementValue == null || i == path.size() - 1) { return currentElementValue; } // currentElementValue is now a Bundle or Parcelable[], we can continue down the path if (currentElementValue instanceof Bundle) { properties = ((Bundle) currentElementValue).getBundle(PROPERTIES_FIELD); } else if (currentElementValue instanceof Parcelable[]) { Parcelable[] parcelables = (Parcelable[]) currentElementValue; if (parcelables.length == 1) { properties = ((Bundle) parcelables[0]).getBundle(PROPERTIES_FIELD); continue; } // Slowest path: we're collecting values across repeated nested docs. (Example: // given a path like recipient.name, where recipient is a repeated field, we return // a string array where each recipient's name is an array element). // // Performance note: Suppose that we have a property path "a.b.c" where the "a" // property has N document values and each containing a "b" property with M document // values and each of those containing a "c" property with an int array. // // We'll allocate a new ArrayList for each of the "b" properties, add the M int // arrays from the "c" properties to it and then we'll allocate an int array in // flattenAccumulator before returning that (1 + M allocation per "b" property). // // When we're on the "a" properties, we'll allocate an ArrayList and add the N // flattened int arrays returned from the "b" properties to the list. Then we'll // allocate an int array in flattenAccumulator (1 + N ("b" allocs) allocations per // "a"). // So this implementation could incur 1 + N + NM allocs. // // However, we expect the vast majority of getProperty calls to be either for direct // property names (not paths) or else property paths returned from snippetting, // which always refer to exactly one property value and don't aggregate across // repeated values. The implementation is optimized for these two cases, requiring // no additional allocations. So we've decided that the above performance // characteristics are OK for the less used path. List accumulator = new ArrayList<>(parcelables.length); for (Parcelable parcelable : parcelables) { // recurse as we need to branch Object value = getRawPropertyFromRawDocument( path, /*pathIndex=*/ i + 1, (Bundle) parcelable); if (value != null) { accumulator.add(value); } } // Break the path traversing loop return flattenAccumulator(accumulator); } else { Log.e(TAG, "Failed to apply path to document; no nested value found: " + path); return null; } } // Only way to get here is with an empty path list return null; } /** * Combines accumulated repeated properties from multiple documents into a single array. * * @param accumulator List containing objects of the following types: {@code String[]}, {@code * long[]}, {@code double[]}, {@code boolean[]}, {@code List}, or {@code * Parcelable[]}. * @return The result of concatenating each individual list element into a larger array/list of * the same type. */ @Nullable private static Object flattenAccumulator(@NonNull List accumulator) { if (accumulator.isEmpty()) { return null; } Object first = accumulator.get(0); if (first instanceof String[]) { int length = 0; for (int i = 0; i < accumulator.size(); i++) { length += ((String[]) accumulator.get(i)).length; } String[] result = new String[length]; int total = 0; for (int i = 0; i < accumulator.size(); i++) { String[] castValue = (String[]) accumulator.get(i); System.arraycopy(castValue, 0, result, total, castValue.length); total += castValue.length; } return result; } if (first instanceof long[]) { int length = 0; for (int i = 0; i < accumulator.size(); i++) { length += ((long[]) accumulator.get(i)).length; } long[] result = new long[length]; int total = 0; for (int i = 0; i < accumulator.size(); i++) { long[] castValue = (long[]) accumulator.get(i); System.arraycopy(castValue, 0, result, total, castValue.length); total += castValue.length; } return result; } if (first instanceof double[]) { int length = 0; for (int i = 0; i < accumulator.size(); i++) { length += ((double[]) accumulator.get(i)).length; } double[] result = new double[length]; int total = 0; for (int i = 0; i < accumulator.size(); i++) { double[] castValue = (double[]) accumulator.get(i); System.arraycopy(castValue, 0, result, total, castValue.length); total += castValue.length; } return result; } if (first instanceof boolean[]) { int length = 0; for (int i = 0; i < accumulator.size(); i++) { length += ((boolean[]) accumulator.get(i)).length; } boolean[] result = new boolean[length]; int total = 0; for (int i = 0; i < accumulator.size(); i++) { boolean[] castValue = (boolean[]) accumulator.get(i); System.arraycopy(castValue, 0, result, total, castValue.length); total += castValue.length; } return result; } if (first instanceof List) { int length = 0; for (int i = 0; i < accumulator.size(); i++) { length += ((List) accumulator.get(i)).size(); } List result = new ArrayList<>(length); for (int i = 0; i < accumulator.size(); i++) { @SuppressWarnings("unchecked") List castValue = (List) accumulator.get(i); result.addAll(castValue); } return result; } if (first instanceof Parcelable[]) { int length = 0; for (int i = 0; i < accumulator.size(); i++) { length += ((Parcelable[]) accumulator.get(i)).length; } Parcelable[] result = new Parcelable[length]; int total = 0; for (int i = 0; i < accumulator.size(); i++) { Parcelable[] castValue = (Parcelable[]) accumulator.get(i); System.arraycopy(castValue, 0, result, total, castValue.length); total += castValue.length; } return result; } throw new IllegalStateException("Unexpected property type: " + first); } /** * Retrieves a {@link String} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * * @param path The path to look for. * @return The first {@link String} associated with the given path or {@code null} if there is * no such value or the value is of a different type. */ @Nullable public String getPropertyString(@NonNull String path) { Objects.requireNonNull(path); String[] propertyArray = getPropertyStringArray(path); if (propertyArray == null || propertyArray.length == 0) { return null; } warnIfSinglePropertyTooLong("String", path, propertyArray.length); return propertyArray[0]; } /** * Retrieves a {@code long} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * * @param path The path to look for. * @return The first {@code long} associated with the given path or default value {@code 0} if * there is no such value or the value is of a different type. */ public long getPropertyLong(@NonNull String path) { Objects.requireNonNull(path); long[] propertyArray = getPropertyLongArray(path); if (propertyArray == null || propertyArray.length == 0) { return 0; } warnIfSinglePropertyTooLong("Long", path, propertyArray.length); return propertyArray[0]; } /** * Retrieves a {@code double} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * * @param path The path to look for. * @return The first {@code double} associated with the given path or default value {@code 0.0} * if there is no such value or the value is of a different type. */ public double getPropertyDouble(@NonNull String path) { Objects.requireNonNull(path); double[] propertyArray = getPropertyDoubleArray(path); if (propertyArray == null || propertyArray.length == 0) { return 0.0; } warnIfSinglePropertyTooLong("Double", path, propertyArray.length); return propertyArray[0]; } /** * Retrieves a {@code boolean} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * * @param path The path to look for. * @return The first {@code boolean} associated with the given path or default value {@code * false} if there is no such value or the value is of a different type. */ public boolean getPropertyBoolean(@NonNull String path) { Objects.requireNonNull(path); boolean[] propertyArray = getPropertyBooleanArray(path); if (propertyArray == null || propertyArray.length == 0) { return false; } warnIfSinglePropertyTooLong("Boolean", path, propertyArray.length); return propertyArray[0]; } /** * Retrieves a {@code byte[]} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * * @param path The path to look for. * @return The first {@code byte[]} associated with the given path or {@code null} if there is * no such value or the value is of a different type. */ @Nullable public byte[] getPropertyBytes(@NonNull String path) { Objects.requireNonNull(path); byte[][] propertyArray = getPropertyBytesArray(path); if (propertyArray == null || propertyArray.length == 0) { return null; } warnIfSinglePropertyTooLong("ByteArray", path, propertyArray.length); return propertyArray[0]; } /** * Retrieves a {@link GenericDocument} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * * @param path The path to look for. * @return The first {@link GenericDocument} associated with the given path or {@code null} if * there is no such value or the value is of a different type. */ @Nullable public GenericDocument getPropertyDocument(@NonNull String path) { Objects.requireNonNull(path); GenericDocument[] propertyArray = getPropertyDocumentArray(path); if (propertyArray == null || propertyArray.length == 0) { return null; } warnIfSinglePropertyTooLong("Document", path, propertyArray.length); return propertyArray[0]; } /** Prints a warning to logcat if the given propertyLength is greater than 1. */ private static void warnIfSinglePropertyTooLong( @NonNull String propertyType, @NonNull String path, int propertyLength) { if (propertyLength > 1) { Log.w( TAG, "The value for \"" + path + "\" contains " + propertyLength + " elements. Only the first one will be returned from " + "getProperty" + propertyType + "(). Try getProperty" + propertyType + "Array()."); } } /** * Retrieves a repeated {@code String} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * *

If the property has not been set via {@link Builder#setPropertyString}, this method * returns {@code null}. * *

If it has been set via {@link Builder#setPropertyString} to an empty {@code String[]}, * this method returns an empty {@code String[]}. * * @param path The path to look for. * @return The {@code String[]} associated with the given path, or {@code null} if no value is * set or the value is of a different type. */ @Nullable public String[] getPropertyStringArray(@NonNull String path) { Objects.requireNonNull(path); Object value = getProperty(path); return safeCastProperty(path, value, String[].class); } /** * Retrieves a repeated {@code long[]} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * *

If the property has not been set via {@link Builder#setPropertyLong}, this method returns * {@code null}. * *

If it has been set via {@link Builder#setPropertyLong} to an empty {@code long[]}, this * method returns an empty {@code long[]}. * * @param path The path to look for. * @return The {@code long[]} associated with the given path, or {@code null} if no value is set * or the value is of a different type. */ @Nullable public long[] getPropertyLongArray(@NonNull String path) { Objects.requireNonNull(path); Object value = getProperty(path); return safeCastProperty(path, value, long[].class); } /** * Retrieves a repeated {@code double} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * *

If the property has not been set via {@link Builder#setPropertyDouble}, this method * returns {@code null}. * *

If it has been set via {@link Builder#setPropertyDouble} to an empty {@code double[]}, * this method returns an empty {@code double[]}. * * @param path The path to look for. * @return The {@code double[]} associated with the given path, or {@code null} if no value is * set or the value is of a different type. */ @Nullable public double[] getPropertyDoubleArray(@NonNull String path) { Objects.requireNonNull(path); Object value = getProperty(path); return safeCastProperty(path, value, double[].class); } /** * Retrieves a repeated {@code boolean} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * *

If the property has not been set via {@link Builder#setPropertyBoolean}, this method * returns {@code null}. * *

If it has been set via {@link Builder#setPropertyBoolean} to an empty {@code boolean[]}, * this method returns an empty {@code boolean[]}. * * @param path The path to look for. * @return The {@code boolean[]} associated with the given path, or {@code null} if no value is * set or the value is of a different type. */ @Nullable public boolean[] getPropertyBooleanArray(@NonNull String path) { Objects.requireNonNull(path); Object value = getProperty(path); return safeCastProperty(path, value, boolean[].class); } /** * Retrieves a {@code byte[][]} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * *

If the property has not been set via {@link Builder#setPropertyBytes}, this method returns * {@code null}. * *

If it has been set via {@link Builder#setPropertyBytes} to an empty {@code byte[][]}, this * method returns an empty {@code byte[][]} starting in {@link * android.os.Build.VERSION_CODES#TIRAMISU Android T} and {@code null} in earlier versions of * Android. * * @param path The path to look for. * @return The {@code byte[][]} associated with the given path, or {@code null} if no value is * set or the value is of a different type. */ @SuppressLint("ArrayReturn") @Nullable public byte[][] getPropertyBytesArray(@NonNull String path) { Objects.requireNonNull(path); Object value = getProperty(path); return safeCastProperty(path, value, byte[][].class); } /** * Retrieves a repeated {@link GenericDocument} property by path. * *

See {@link #getProperty} for a detailed description of the path syntax. * *

If the property has not been set via {@link Builder#setPropertyDocument}, this method * returns {@code null}. * *

If it has been set via {@link Builder#setPropertyDocument} to an empty {@code * GenericDocument[]}, this method returns an empty {@code GenericDocument[]} starting in {@link * android.os.Build.VERSION_CODES#TIRAMISU Android T} and {@code null} in earlier versions of * Android. * * @param path The path to look for. * @return The {@link GenericDocument}[] associated with the given path, or {@code null} if no * value is set or the value is of a different type. */ @SuppressLint("ArrayReturn") @Nullable public GenericDocument[] getPropertyDocumentArray(@NonNull String path) { Objects.requireNonNull(path); Object value = getProperty(path); return safeCastProperty(path, value, GenericDocument[].class); } /** * Casts a repeated property to the provided type, logging an error and returning {@code null} * if the cast fails. * * @param path Path to the property within the document. Used for logging. * @param value Value of the property * @param tClass Class to cast the value into */ @Nullable private static T safeCastProperty( @NonNull String path, @Nullable Object value, @NonNull Class tClass) { if (value == null) { return null; } try { return tClass.cast(value); } catch (ClassCastException e) { Log.w(TAG, "Error casting to requested type for path \"" + path + "\"", e); return null; } } /** * Copies the contents of this {@link GenericDocument} into a new {@link * GenericDocument.Builder}. * *

The returned builder is a deep copy whose data is separate from this document. * * @hide */ // TODO(b/171882200): Expose this API in Android T @NonNull public GenericDocument.Builder> toBuilder() { Bundle clonedBundle = BundleUtil.deepCopy(mBundle); return new GenericDocument.Builder<>(clonedBundle); } @Override public boolean equals(@Nullable Object other) { if (this == other) { return true; } if (!(other instanceof GenericDocument)) { return false; } GenericDocument otherDocument = (GenericDocument) other; return BundleUtil.deepEquals(this.mBundle, otherDocument.mBundle); } @Override public int hashCode() { if (mHashCode == null) { mHashCode = BundleUtil.deepHashCode(mBundle); } return mHashCode; } @Override @NonNull public String toString() { IndentingStringBuilder stringBuilder = new IndentingStringBuilder(); appendGenericDocumentString(stringBuilder); return stringBuilder.toString(); } /** * Appends a debug string for the {@link GenericDocument} instance to the given string builder. * * @param builder the builder to append to. */ void appendGenericDocumentString(@NonNull IndentingStringBuilder builder) { Objects.requireNonNull(builder); builder.append("{\n"); builder.increaseIndentLevel(); builder.append("namespace: \"").append(getNamespace()).append("\",\n"); builder.append("id: \"").append(getId()).append("\",\n"); builder.append("score: ").append(getScore()).append(",\n"); builder.append("schemaType: \"").append(getSchemaType()).append("\",\n"); builder.append("creationTimestampMillis: ") .append(getCreationTimestampMillis()) .append(",\n"); builder.append("timeToLiveMillis: ").append(getTtlMillis()).append(",\n"); builder.append("properties: {\n"); String[] sortedProperties = getPropertyNames().toArray(new String[0]); Arrays.sort(sortedProperties); for (int i = 0; i < sortedProperties.length; i++) { Object property = Objects.requireNonNull(getProperty(sortedProperties[i])); builder.increaseIndentLevel(); appendPropertyString(sortedProperties[i], property, builder); if (i != sortedProperties.length - 1) { builder.append(",\n"); } builder.decreaseIndentLevel(); } builder.append("\n"); builder.append("}"); builder.decreaseIndentLevel(); builder.append("\n"); builder.append("}"); } /** * Appends a debug string for the given document property to the given string builder. * * @param propertyName name of property to create string for. * @param property property object to create string for. * @param builder the builder to append to. */ private void appendPropertyString( @NonNull String propertyName, @NonNull Object property, @NonNull IndentingStringBuilder builder) { Objects.requireNonNull(propertyName); Objects.requireNonNull(property); Objects.requireNonNull(builder); builder.append("\"").append(propertyName).append("\": ["); if (property instanceof GenericDocument[]) { GenericDocument[] documentValues = (GenericDocument[]) property; for (int i = 0; i < documentValues.length; ++i) { builder.append("\n"); builder.increaseIndentLevel(); documentValues[i].appendGenericDocumentString(builder); if (i != documentValues.length - 1) { builder.append(","); } builder.append("\n"); builder.decreaseIndentLevel(); } builder.append("]"); } else { int propertyArrLength = Array.getLength(property); for (int i = 0; i < propertyArrLength; i++) { Object propertyElement = Array.get(property, i); if (propertyElement instanceof String) { builder.append("\"").append((String) propertyElement).append("\""); } else if (propertyElement instanceof byte[]) { builder.append(Arrays.toString((byte[]) propertyElement)); } else { builder.append(propertyElement.toString()); } if (i != propertyArrLength - 1) { builder.append(", "); } else { builder.append("]"); } } } } /** * The builder class for {@link GenericDocument}. * * @param Type of subclass who extends this. */ // This builder is specifically designed to be extended by classes deriving from // GenericDocument. @SuppressLint("StaticFinalBuilder") public static class Builder { private Bundle mBundle; private Bundle mProperties; private final BuilderType mBuilderTypeInstance; private boolean mBuilt = false; /** * Creates a new {@link GenericDocument.Builder}. * *

Document IDs are unique within a namespace. * *

The number of namespaces per app should be kept small for efficiency reasons. * * @param namespace the namespace to set for the {@link GenericDocument}. * @param id the unique identifier for the {@link GenericDocument} in its namespace. * @param schemaType the {@link AppSearchSchema} type of the {@link GenericDocument}. The * provided {@code schemaType} must be defined using {@link AppSearchSession#setSchema} * prior to inserting a document of this {@code schemaType} into the AppSearch index * using {@link AppSearchSession#put}. Otherwise, the document will be rejected by * {@link AppSearchSession#put} with result code {@link * AppSearchResult#RESULT_NOT_FOUND}. */ @SuppressWarnings("unchecked") public Builder(@NonNull String namespace, @NonNull String id, @NonNull String schemaType) { Objects.requireNonNull(namespace); Objects.requireNonNull(id); Objects.requireNonNull(schemaType); mBundle = new Bundle(); mBuilderTypeInstance = (BuilderType) this; mBundle.putString(GenericDocument.NAMESPACE_FIELD, namespace); mBundle.putString(GenericDocument.ID_FIELD, id); mBundle.putString(GenericDocument.SCHEMA_TYPE_FIELD, schemaType); mBundle.putLong(GenericDocument.TTL_MILLIS_FIELD, DEFAULT_TTL_MILLIS); mBundle.putInt(GenericDocument.SCORE_FIELD, DEFAULT_SCORE); mProperties = new Bundle(); mBundle.putBundle(PROPERTIES_FIELD, mProperties); } /** * Creates a new {@link GenericDocument.Builder} from the given Bundle. * *

The bundle is NOT copied. */ @SuppressWarnings("unchecked") Builder(@NonNull Bundle bundle) { mBundle = Objects.requireNonNull(bundle); // mProperties is NonNull and initialized to empty Bundle() in builder. mProperties = Objects.requireNonNull(mBundle.getBundle(PROPERTIES_FIELD)); mBuilderTypeInstance = (BuilderType) this; } /** * Sets the app-defined namespace this document resides in, changing the value provided in * the constructor. No special values are reserved or understood by the infrastructure. * *

Document IDs are unique within a namespace. * *

The number of namespaces per app should be kept small for efficiency reasons. * * @hide */ @CanIgnoreReturnValue @NonNull public BuilderType setNamespace(@NonNull String namespace) { Objects.requireNonNull(namespace); resetIfBuilt(); mBundle.putString(GenericDocument.NAMESPACE_FIELD, namespace); return mBuilderTypeInstance; } /** * Sets the ID of this document, changing the value provided in the constructor. No special * values are reserved or understood by the infrastructure. * *

Document IDs are unique within a namespace. * * @hide */ @CanIgnoreReturnValue @NonNull public BuilderType setId(@NonNull String id) { Objects.requireNonNull(id); resetIfBuilt(); mBundle.putString(GenericDocument.ID_FIELD, id); return mBuilderTypeInstance; } /** * Sets the schema type of this document, changing the value provided in the constructor. * *

To successfully index a document, the schema type must match the name of an {@link * AppSearchSchema} object previously provided to {@link AppSearchSession#setSchema}. * * @hide */ @CanIgnoreReturnValue @NonNull public BuilderType setSchemaType(@NonNull String schemaType) { Objects.requireNonNull(schemaType); resetIfBuilt(); mBundle.putString(GenericDocument.SCHEMA_TYPE_FIELD, schemaType); return mBuilderTypeInstance; } /** * Sets the score of the {@link GenericDocument}. * *

The score is a query-independent measure of the document's quality, relative to other * {@link GenericDocument} objects of the same {@link AppSearchSchema} type. * *

Results may be sorted by score using {@link SearchSpec.Builder#setRankingStrategy}. * Documents with higher scores are considered better than documents with lower scores. * *

Any non-negative integer can be used a score. By default, scores are set to 0. * * @param score any non-negative {@code int} representing the document's score. * @throws IllegalArgumentException if the score is negative. */ @CanIgnoreReturnValue @NonNull public BuilderType setScore(@IntRange(from = 0, to = Integer.MAX_VALUE) int score) { if (score < 0) { throw new IllegalArgumentException("Document score cannot be negative."); } resetIfBuilt(); mBundle.putInt(GenericDocument.SCORE_FIELD, score); return mBuilderTypeInstance; } /** * Sets the creation timestamp of the {@link GenericDocument}, in milliseconds. * *

This should be set using a value obtained from the {@link System#currentTimeMillis} * time base. * *

If this method is not called, this will be set to the time the object is built. * * @param creationTimestampMillis a creation timestamp in milliseconds. */ @CanIgnoreReturnValue @NonNull public BuilderType setCreationTimestampMillis( @CurrentTimeMillisLong long creationTimestampMillis) { resetIfBuilt(); mBundle.putLong( GenericDocument.CREATION_TIMESTAMP_MILLIS_FIELD, creationTimestampMillis); return mBuilderTypeInstance; } /** * Sets the TTL (time-to-live) of the {@link GenericDocument}, in milliseconds. * *

The TTL is measured against {@link #getCreationTimestampMillis}. At the timestamp of * {@code creationTimestampMillis + ttlMillis}, measured in the {@link * System#currentTimeMillis} time base, the document will be auto-deleted. * *

The default value is 0, which means the document is permanent and won't be * auto-deleted until the app is uninstalled or {@link AppSearchSession#remove} is called. * * @param ttlMillis a non-negative duration in milliseconds. * @throws IllegalArgumentException if ttlMillis is negative. */ @CanIgnoreReturnValue @NonNull public BuilderType setTtlMillis(long ttlMillis) { if (ttlMillis < 0) { throw new IllegalArgumentException("Document ttlMillis cannot be negative."); } resetIfBuilt(); mBundle.putLong(GenericDocument.TTL_MILLIS_FIELD, ttlMillis); return mBuilderTypeInstance; } /** * Sets one or multiple {@code String} values for a property, replacing its previous values. * * @param name the name associated with the {@code values}. Must match the name for this * property as given in {@link AppSearchSchema.PropertyConfig#getName}. * @param values the {@code String} values of the property. * @throws IllegalArgumentException if no values are provided, or if a passed in {@code * String} is {@code null} or "". */ @CanIgnoreReturnValue @NonNull public BuilderType setPropertyString(@NonNull String name, @NonNull String... values) { Objects.requireNonNull(name); Objects.requireNonNull(values); resetIfBuilt(); putInPropertyBundle(name, values); return mBuilderTypeInstance; } /** * Sets one or multiple {@code boolean} values for a property, replacing its previous * values. * * @param name the name associated with the {@code values}. Must match the name for this * property as given in {@link AppSearchSchema.PropertyConfig#getName}. * @param values the {@code boolean} values of the property. * @throws IllegalArgumentException if the name is empty or {@code null}. */ @CanIgnoreReturnValue @NonNull public BuilderType setPropertyBoolean(@NonNull String name, @NonNull boolean... values) { Objects.requireNonNull(name); Objects.requireNonNull(values); resetIfBuilt(); putInPropertyBundle(name, values); return mBuilderTypeInstance; } /** * Sets one or multiple {@code long} values for a property, replacing its previous values. * * @param name the name associated with the {@code values}. Must match the name for this * property as given in {@link AppSearchSchema.PropertyConfig#getName}. * @param values the {@code long} values of the property. * @throws IllegalArgumentException if the name is empty or {@code null}. */ @CanIgnoreReturnValue @NonNull public BuilderType setPropertyLong(@NonNull String name, @NonNull long... values) { Objects.requireNonNull(name); Objects.requireNonNull(values); resetIfBuilt(); putInPropertyBundle(name, values); return mBuilderTypeInstance; } /** * Sets one or multiple {@code double} values for a property, replacing its previous values. * * @param name the name associated with the {@code values}. Must match the name for this * property as given in {@link AppSearchSchema.PropertyConfig#getName}. * @param values the {@code double} values of the property. * @throws IllegalArgumentException if the name is empty or {@code null}. */ @CanIgnoreReturnValue @NonNull public BuilderType setPropertyDouble(@NonNull String name, @NonNull double... values) { Objects.requireNonNull(name); Objects.requireNonNull(values); resetIfBuilt(); putInPropertyBundle(name, values); return mBuilderTypeInstance; } /** * Sets one or multiple {@code byte[]} for a property, replacing its previous values. * * @param name the name associated with the {@code values}. Must match the name for this * property as given in {@link AppSearchSchema.PropertyConfig#getName}. * @param values the {@code byte[]} of the property. * @throws IllegalArgumentException if no values are provided, or if a passed in {@code * byte[]} is {@code null}, or if name is empty. */ @CanIgnoreReturnValue @NonNull public BuilderType setPropertyBytes(@NonNull String name, @NonNull byte[]... values) { Objects.requireNonNull(name); Objects.requireNonNull(values); resetIfBuilt(); putInPropertyBundle(name, values); return mBuilderTypeInstance; } /** * Sets one or multiple {@link GenericDocument} values for a property, replacing its * previous values. * * @param name the name associated with the {@code values}. Must match the name for this * property as given in {@link AppSearchSchema.PropertyConfig#getName}. * @param values the {@link GenericDocument} values of the property. * @throws IllegalArgumentException if no values are provided, or if a passed in {@link * GenericDocument} is {@code null}, or if name is empty. */ @CanIgnoreReturnValue @NonNull public BuilderType setPropertyDocument( @NonNull String name, @NonNull GenericDocument... values) { Objects.requireNonNull(name); Objects.requireNonNull(values); resetIfBuilt(); putInPropertyBundle(name, values); return mBuilderTypeInstance; } /** * Clears the value for the property with the given name. * *

Note that this method does not support property paths. * * @param name The name of the property to clear. * @hide */ @CanIgnoreReturnValue @NonNull public BuilderType clearProperty(@NonNull String name) { Objects.requireNonNull(name); resetIfBuilt(); mProperties.remove(name); return mBuilderTypeInstance; } private void putInPropertyBundle(@NonNull String name, @NonNull String[] values) throws IllegalArgumentException { validatePropertyName(name); for (int i = 0; i < values.length; i++) { if (values[i] == null) { throw new IllegalArgumentException("The String at " + i + " is null."); } } mProperties.putStringArray(name, values); } private void putInPropertyBundle(@NonNull String name, @NonNull boolean[] values) { validatePropertyName(name); mProperties.putBooleanArray(name, values); } private void putInPropertyBundle(@NonNull String name, @NonNull double[] values) { validatePropertyName(name); mProperties.putDoubleArray(name, values); } private void putInPropertyBundle(@NonNull String name, @NonNull long[] values) { validatePropertyName(name); mProperties.putLongArray(name, values); } /** * Converts and saves a byte[][] into {@link #mProperties}. * *

Bundle doesn't support for two dimension array byte[][], we are converting byte[][] * into ArrayList, and each elements will contain a one dimension byte[]. */ private void putInPropertyBundle(@NonNull String name, @NonNull byte[][] values) { validatePropertyName(name); ArrayList bundles = new ArrayList<>(values.length); for (int i = 0; i < values.length; i++) { if (values[i] == null) { throw new IllegalArgumentException("The byte[] at " + i + " is null."); } Bundle bundle = new Bundle(); bundle.putByteArray(BYTE_ARRAY_FIELD, values[i]); bundles.add(bundle); } mProperties.putParcelableArrayList(name, bundles); } private void putInPropertyBundle(@NonNull String name, @NonNull GenericDocument[] values) { validatePropertyName(name); Parcelable[] documentBundles = new Parcelable[values.length]; for (int i = 0; i < values.length; i++) { if (values[i] == null) { throw new IllegalArgumentException("The document at " + i + " is null."); } documentBundles[i] = values[i].mBundle; } mProperties.putParcelableArray(name, documentBundles); } /** Builds the {@link GenericDocument} object. */ @NonNull public GenericDocument build() { mBuilt = true; // Set current timestamp for creation timestamp by default. if (mBundle.getLong(GenericDocument.CREATION_TIMESTAMP_MILLIS_FIELD, -1) == -1) { mBundle.putLong( GenericDocument.CREATION_TIMESTAMP_MILLIS_FIELD, System.currentTimeMillis()); } return new GenericDocument(mBundle); } private void resetIfBuilt() { if (mBuilt) { mBundle = BundleUtil.deepCopy(mBundle); // mProperties is NonNull and initialized to empty Bundle() in builder. mProperties = Objects.requireNonNull(mBundle.getBundle(PROPERTIES_FIELD)); mBuilt = false; } } /** Method to ensure property names are not blank */ private void validatePropertyName(@NonNull String name) { if (name.isEmpty()) { throw new IllegalArgumentException("Property name cannot be blank."); } } } }