PrefixUtil.java

/*
 * Copyright 2021 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 androidx.appsearch.localstorage.util;

import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.appsearch.app.AppSearchResult;
import androidx.appsearch.exceptions.AppSearchException;

import com.google.android.icing.proto.DocumentProto;
import com.google.android.icing.proto.PropertyProto;

/**
 * Provides utility functions for working with package + database prefixes.
 * @hide
 */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public class PrefixUtil {
    private static final String TAG = "AppSearchPrefixUtil";

    @VisibleForTesting
    public static final char DATABASE_DELIMITER = '/';

    @VisibleForTesting
    public static final char PACKAGE_DELIMITER = '$';

    private PrefixUtil() {}

    /**
     * Creates prefix string for given package name and database name.
     */
    @NonNull
    public static String createPrefix(@NonNull String packageName, @NonNull String databaseName) {
        return packageName + PACKAGE_DELIMITER + databaseName + DATABASE_DELIMITER;
    }
    /**
     * Creates prefix string for given package name.
     */
    @NonNull
    public static String createPackagePrefix(@NonNull String packageName) {
        return packageName + PACKAGE_DELIMITER;
    }

    /**
     * Returns the package name that's contained within the {@code prefix}.
     *
     * @param prefix Prefix string that contains the package name inside of it. The package name
     *               must be in the front of the string, and separated from the rest of the
     *               string by the {@link #PACKAGE_DELIMITER}.
     * @return Valid package name.
     */
    @NonNull
    public static String getPackageName(@NonNull String prefix) {
        int delimiterIndex = prefix.indexOf(PACKAGE_DELIMITER);
        if (delimiterIndex == -1) {
            // This should never happen if we construct our prefixes properly
            Log.wtf(TAG, "Malformed prefix doesn't contain package delimiter: " + prefix);
            return "";
        }
        return prefix.substring(0, delimiterIndex);
    }

    /**
     * Returns the database name that's contained within the {@code prefix}.
     *
     * @param prefix Prefix string that contains the database name inside of it. The database name
     *               must be between the {@link #PACKAGE_DELIMITER} and {@link #DATABASE_DELIMITER}
     * @return Valid database name.
     */
    @NonNull
    public static String getDatabaseName(@NonNull String prefix) {
        // TODO (b/184050178) Start database delimiter index search from after package delimiter
        int packageDelimiterIndex = prefix.indexOf(PACKAGE_DELIMITER);
        int databaseDelimiterIndex = prefix.indexOf(DATABASE_DELIMITER);
        if (packageDelimiterIndex == -1) {
            // This should never happen if we construct our prefixes properly
            Log.wtf(TAG, "Malformed prefix doesn't contain package delimiter: " + prefix);
            return "";
        }
        if (databaseDelimiterIndex == -1) {
            // This should never happen if we construct our prefixes properly
            Log.wtf(TAG, "Malformed prefix doesn't contain database delimiter: " + prefix);
            return "";
        }
        return prefix.substring(packageDelimiterIndex + 1, databaseDelimiterIndex);
    }

    /**
     * Creates a string with the package and database prefix removed from the input string.
     *
     * @param prefixedString a string containing a package and database prefix.
     * @return a string with the package and database prefix removed.
     * @throws AppSearchException if the prefixed value does not contain a valid database name.
     */
    @NonNull
    public static String removePrefix(@NonNull String prefixedString)
            throws AppSearchException {
        // The prefix is made up of the package, then the database. So we only need to find the
        // database cutoff.
        int delimiterIndex;
        if ((delimiterIndex = prefixedString.indexOf(DATABASE_DELIMITER)) != -1) {
            // Add 1 to include the char size of the DATABASE_DELIMITER
            return prefixedString.substring(delimiterIndex + 1);
        }
        throw new AppSearchException(
                AppSearchResult.RESULT_INTERNAL_ERROR,
                "The prefixed value \"" + prefixedString + "\" doesn't contain a valid "
                        + "database name");
    }

    /**
     * Creates a package and database prefix string from the input string.
     *
     * @param prefixedString a string containing a package and database prefix.
     * @return a string with the package and database prefix
     * @throws AppSearchException if the prefixed value does not contain a valid database name.
     */
    @NonNull
    public static String getPrefix(@NonNull String prefixedString) throws AppSearchException {
        int databaseDelimiterIndex = prefixedString.indexOf(DATABASE_DELIMITER);
        if (databaseDelimiterIndex == -1) {
            throw new AppSearchException(
                    AppSearchResult.RESULT_INTERNAL_ERROR,
                    "The prefixed value \"" + prefixedString + "\" doesn't contain a valid "
                            + "database name");
        }

        // Add 1 to include the char size of the DATABASE_DELIMITER
        return prefixedString.substring(0, databaseDelimiterIndex + 1);
    }

    /**
     * Prepends {@code prefix} to all types and namespaces mentioned anywhere in
     * {@code documentBuilder}.
     *
     * @param documentBuilder The document to mutate
     * @param prefix          The prefix to add
     */
    public static void addPrefixToDocument(
            @NonNull DocumentProto.Builder documentBuilder,
            @NonNull String prefix) {
        // Rewrite the type name to include/remove the prefix.
        String newSchema = prefix + documentBuilder.getSchema();
        documentBuilder.setSchema(newSchema);

        // Rewrite the namespace to include/remove the prefix.
        documentBuilder.setNamespace(prefix + documentBuilder.getNamespace());

        // Recurse into derived documents
        for (int propertyIdx = 0;
                propertyIdx < documentBuilder.getPropertiesCount();
                propertyIdx++) {
            int documentCount = documentBuilder.getProperties(propertyIdx).getDocumentValuesCount();
            if (documentCount > 0) {
                PropertyProto.Builder propertyBuilder =
                        documentBuilder.getProperties(propertyIdx).toBuilder();
                for (int documentIdx = 0; documentIdx < documentCount; documentIdx++) {
                    DocumentProto.Builder derivedDocumentBuilder =
                            propertyBuilder.getDocumentValues(documentIdx).toBuilder();
                    addPrefixToDocument(derivedDocumentBuilder, prefix);
                    propertyBuilder.setDocumentValues(documentIdx, derivedDocumentBuilder);
                }
                documentBuilder.setProperties(propertyIdx, propertyBuilder);
            }
        }
    }

    /**
     * Removes any prefixes from types and namespaces mentioned anywhere in
     * {@code documentBuilder}.
     *
     * @param documentBuilder The document to mutate
     * @return Prefix name that was removed from the document.
     * @throws AppSearchException if there are unexpected database prefixing errors.
     */
    @NonNull
    public static String removePrefixesFromDocument(@NonNull DocumentProto.Builder documentBuilder)
            throws AppSearchException {
        // Rewrite the type name and namespace to remove the prefix.
        String schemaPrefix = getPrefix(documentBuilder.getSchema());
        String namespacePrefix = getPrefix(documentBuilder.getNamespace());

        if (!schemaPrefix.equals(namespacePrefix)) {
            throw new AppSearchException(AppSearchResult.RESULT_INTERNAL_ERROR, "Found unexpected"
                    + " multiple prefix names in document: " + schemaPrefix + ", "
                    + namespacePrefix);
        }

        documentBuilder.setSchema(removePrefix(documentBuilder.getSchema()));
        documentBuilder.setNamespace(removePrefix(documentBuilder.getNamespace()));

        // Recurse into derived documents
        for (int propertyIdx = 0;
                propertyIdx < documentBuilder.getPropertiesCount();
                propertyIdx++) {
            int documentCount = documentBuilder.getProperties(propertyIdx).getDocumentValuesCount();
            if (documentCount > 0) {
                PropertyProto.Builder propertyBuilder =
                        documentBuilder.getProperties(propertyIdx).toBuilder();
                for (int documentIdx = 0; documentIdx < documentCount; documentIdx++) {
                    DocumentProto.Builder derivedDocumentBuilder =
                            propertyBuilder.getDocumentValues(documentIdx).toBuilder();
                    String nestedPrefix = removePrefixesFromDocument(derivedDocumentBuilder);
                    if (!nestedPrefix.equals(schemaPrefix)) {
                        throw new AppSearchException(AppSearchResult.RESULT_INTERNAL_ERROR,
                                "Found unexpected multiple prefix names in document: "
                                        + schemaPrefix + ", " + nestedPrefix);
                    }
                    propertyBuilder.setDocumentValues(documentIdx, derivedDocumentBuilder);
                }
                documentBuilder.setProperties(propertyIdx, propertyBuilder);
            }
        }

        return schemaPrefix;
    }
}