/* * Copyright (C) 2024 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 com.android.server.appsearch.appsindexer; import android.annotation.NonNull; import android.app.appsearch.AppSearchSchema; import android.app.appsearch.AppSearchSchema.BooleanPropertyConfig; import android.app.appsearch.AppSearchSchema.LongPropertyConfig; import android.app.appsearch.AppSearchSchema.PropertyConfig; import android.app.appsearch.AppSearchSchema.StringPropertyConfig; import android.content.pm.PackageManager; import android.content.res.AssetManager; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; import com.android.server.appsearch.appsindexer.appsearchtypes.AppFunctionDocument; import com.android.server.appsearch.appsindexer.appsearchtypes.AppFunctionStaticMetadata; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserFactory; import java.io.IOException; import java.io.InputStream; import java.util.Collections; import java.util.Map; import java.util.Objects; import java.util.Set; /** * This class parses the XSD file from an app's assets and creates AppSearch schemas from document * types. * *

The generated {@link AppSearchSchema} objects are used to set the schema under the {@link * AppSearchHelper#APP_DATABASE} database. Within the database, each {@link AppSearchSchema} is * named dynamically to be unique to the app package name. * *

Note: The XSD file should be generated by the App Functions SDK and always define * AppFunctionStaticMetadata document type which will have {@link * AppFunctionStaticMetadata#PARENT_TYPE_APPSEARCH_SCHEMA} as parent type. */ public class AppFunctionSchemaParser { private static final String TAG = "AppSearchSchemaParser"; private static final String XML_TAG_DOCUMENT_TYPE = "xs:documentType"; private static final String XML_TAG_ELEMENT = "xs:element"; private static final String XML_APPFN_NAMESPACE_PREFIX = "appfn:"; private static final String XML_TAG_STRING_TYPE = "xs:string"; private static final String XML_TAG_LONG_TYPE = "xs:long"; private static final String XML_TAG_INT_TYPE = "xs:int"; private static final String XML_TAG_BOOLEAN_TYPE = "xs:boolean"; private static final String XML_ATTRIBUTE_INDEXING_TYPE = "indexingType"; private static final String XML_ATTRIBUTE_TOKENIZER_TYPE = "tokenizerType"; private static final String XML_ATTRIBUTE_JOINABLE_VALUE_TYPE = "joinableValueType"; private static final String XML_ATTRIBUTE_CARDINALITY = "cardinality"; private static final String XML_ATTRIBUTE_SHOULD_INDEX_NESTED_PROPERTIES = "shouldIndexNestedProperties"; private static final String XML_ATTRIBUTE_NAME = "name"; private static final String XML_ATTRIBUTE_TYPE = "type"; /** * The maximum number of document types allowed in the XSD file. This is to prevent malicious * apps from creating too many schema types in AppSearch by modifying the XSD file defined in * App Functions SDK. */ private final int mMaxAllowedDocumentType; /** * @param maxAllowedDocumentType The maximum number of document types allowed in the XSD file. * This is to prevent malicious apps from creating too many schema types in AppSearch by * modifying the XSD file defined in App Functions SDK. */ public AppFunctionSchemaParser(int maxAllowedDocumentType) { mMaxAllowedDocumentType = maxAllowedDocumentType; } private static boolean getAttributeBoolOrDefault( @NonNull XmlPullParser parser, @NonNull String attributeName, boolean defaultValue) { Objects.requireNonNull(parser); Objects.requireNonNull(attributeName); String value = parser.getAttributeValue(/* namespace= */ null, attributeName); return value == null ? defaultValue : Boolean.parseBoolean(value); } private static int getAttributeIntOrDefault( @NonNull XmlPullParser parser, @NonNull String attributeName, int defaultValue) { Objects.requireNonNull(parser); Objects.requireNonNull(attributeName); String value = parser.getAttributeValue(/* namespace= */ null, attributeName); return value == null ? defaultValue : Integer.parseInt(value); } /** * Parses the XSD and create AppSearch schemas from document types. * *

The schema output isn't guaranteed to have valid dependencies, which can be caught during * a {@link SyncAppSearchSession#setSchema} call. * * @param packageManager The PackageManager used to access app resources. * @param packageName The package name of the app whose assets contain the XSD file. * @param assetFilePath The path to the XSD file within the app's assets. * @return A mapping of schema types to their corresponding {@link AppSearchSchema} objects, or * an empty map if there's an error during parsing or if the AppFunctionStaticMetadata * document type is not found. */ @NonNull public Map parseAndCreateSchemas( @NonNull PackageManager packageManager, @NonNull String packageName, @NonNull String assetFilePath) { Objects.requireNonNull(packageManager); Objects.requireNonNull(packageName); Objects.requireNonNull(assetFilePath); // Keep track of all nested schema types to validate root schema later. Set nestedSchemaTypes = new ArraySet<>(); try { AssetManager assetManager = packageManager.getResourcesForApplication(packageName).getAssets(); InputStream xsdInputStream = assetManager.open(assetFilePath); Map schemas = parseDocumentTypeAndCreateSchemas( packageName, xsdInputStream, nestedSchemaTypes); return getValidatedSchemas(schemas, packageName, nestedSchemaTypes); } catch (Exception ex) { // The code parses an XSD file from another app's assets, using a broad try-catch to // handle potential errors since the XML structure might be unpredictable. Log.e( TAG, String.format( "Failed to parse XSD from package '%s', asset file '%s'", packageName, assetFilePath), ex); } return Collections.emptyMap(); } /** * Parses the XSD and create AppSearch schemas from document types. * * @param packageName The package for which schemas are being parsed. * @param xsdInputStream The input stream to the XSD file within the app's assets. * @param nestedSchemaTypes A set to maintain all the nested schema types encountered when * processing a root tag. This will be later used to validate that correct schema * definitions exist for each nested type. * @return A mapping of schema types to their corresponding {@link AppSearchSchema} objects, or * an empty map if there's an error during parsing or if the AppFunctionStaticMetadata * document type is not found. */ private Map parseDocumentTypeAndCreateSchemas( @NonNull String packageName, @NonNull InputStream xsdInputStream, @NonNull Set nestedSchemaTypes) throws XmlPullParserException, IOException, InvalidAppFunctionSchemaException { Objects.requireNonNull(packageName); Objects.requireNonNull(xsdInputStream); Objects.requireNonNull(nestedSchemaTypes); Map schemas = new ArrayMap<>(); XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); XmlPullParser parser = factory.newPullParser(); parser.setInput(xsdInputStream, null); AppSearchSchema.Builder schemaBuilder = null; while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { switch (parser.getEventType()) { case XmlPullParser.START_TAG: if (XML_TAG_DOCUMENT_TYPE.equals(parser.getName())) { if (schemas.size() >= mMaxAllowedDocumentType) { throw new IllegalStateException( "Exceeded max allowed document types: " + mMaxAllowedDocumentType); } String documentTypeName = parser.getAttributeValue(null, XML_ATTRIBUTE_NAME); if (documentTypeName != null) { schemaBuilder = new AppSearchSchema.Builder( AppFunctionDocument.getSchemaNameForPackage( packageName, documentTypeName)); // All AppFunctionStaticMetadata schemas defined in packages will // inherit from AppFunctionStaticMetadata#PARENT_TYPE_APPSEARCH_SCHEMA. if (documentTypeName.equals(AppFunctionStaticMetadata.SCHEMA_TYPE) && AppFunctionStaticMetadata.shouldSetParentType()) { schemaBuilder.addParentType(AppFunctionStaticMetadata.SCHEMA_TYPE); } } } else if (XML_TAG_ELEMENT.equals(parser.getName()) && schemaBuilder != null) { AppSearchSchema.PropertyConfig propertyConfig = computePropertyConfigFromXsdType( parser, packageName, nestedSchemaTypes); if (propertyConfig != null) schemaBuilder.addProperty(propertyConfig); } break; case XmlPullParser.END_TAG: if (XML_TAG_DOCUMENT_TYPE.equals(parser.getName())) { if (schemaBuilder != null) { AppSearchSchema schema = schemaBuilder.build(); schemas.put(schema.getSchemaType(), schema); schemaBuilder = null; } } break; } parser.next(); } return schemas; } private PropertyConfig computePropertyConfigFromXsdType( @NonNull XmlPullParser parser, @NonNull String packageName, @NonNull Set nestedSchemaTypes) throws InvalidAppFunctionSchemaException { Objects.requireNonNull(parser); Objects.requireNonNull(packageName); Objects.requireNonNull(nestedSchemaTypes); String name = parser.getAttributeValue(null, XML_ATTRIBUTE_NAME); validatePropertyName(name); String type = parser.getAttributeValue(null, XML_ATTRIBUTE_TYPE); if (name == null || type == null) return null; int cardinality = getAttributeIntOrDefault( parser, XML_ATTRIBUTE_CARDINALITY, PropertyConfig.CARDINALITY_OPTIONAL); switch (type) { case XML_TAG_STRING_TYPE: return new StringPropertyConfig.Builder(name) .setCardinality(cardinality) .setIndexingType( getAttributeIntOrDefault( parser, XML_ATTRIBUTE_INDEXING_TYPE, StringPropertyConfig.INDEXING_TYPE_NONE)) .setTokenizerType( getAttributeIntOrDefault( parser, XML_ATTRIBUTE_TOKENIZER_TYPE, StringPropertyConfig.TOKENIZER_TYPE_NONE)) .setJoinableValueType( getAttributeIntOrDefault( parser, XML_ATTRIBUTE_JOINABLE_VALUE_TYPE, StringPropertyConfig.JOINABLE_VALUE_TYPE_NONE)) .build(); case XML_TAG_LONG_TYPE: case XML_TAG_INT_TYPE: return new LongPropertyConfig.Builder(name) .setCardinality(cardinality) .setIndexingType( getAttributeIntOrDefault( parser, XML_ATTRIBUTE_INDEXING_TYPE, LongPropertyConfig.INDEXING_TYPE_NONE)) .build(); case XML_TAG_BOOLEAN_TYPE: return new BooleanPropertyConfig.Builder(name).setCardinality(cardinality).build(); default: if (type.contains(XML_APPFN_NAMESPACE_PREFIX)) { String localType = type.substring(type.indexOf(':') + 1); String schemaType = AppFunctionDocument.getSchemaNameForPackage(packageName, localType); nestedSchemaTypes.add(schemaType); return new AppSearchSchema.DocumentPropertyConfig.Builder(name, schemaType) .setCardinality(cardinality) .setShouldIndexNestedProperties( getAttributeBoolOrDefault( parser, XML_ATTRIBUTE_SHOULD_INDEX_NESTED_PROPERTIES, false)) .build(); } throw new IllegalArgumentException("Unsupported type: " + type); } } /** * Validates the given property name to ensure it is not null, empty, and contains only * alphanumeric characters (letters and digits). * * @param name the property name to validate * @throws InvalidAppFunctionSchemaException if the name is empty, or contains non-alphanumeric * characters */ private static void validatePropertyName(@NonNull String name) throws InvalidAppFunctionSchemaException { Objects.requireNonNull(name); if (name.isEmpty()) { throw new InvalidAppFunctionSchemaException( "Property name in the schema cannot be null or empty."); } for (int i = 0; i < name.length(); i++) { char c = name.charAt(i); if (!Character.isLetterOrDigit(c)) { throw new InvalidAppFunctionSchemaException( "Property name must contain only alphanumeric characters: " + name); } } } /** * Validates and returns the provided schema map for a given package. * *

This method ensures that the required schemas and properties are present in the schema * map. Specifically: * *

* * @param schemaMap A map where the key is the schema type name, and the value is the * corresponding {@link AppSearchSchema}. * @param packageName The name of the package for which the schemas are being validated. * @param nestedSchemaTypes A set of all the nested schema types used to validate that correct * schema definitions exist for each nested type. * @return The validated schema map. * @throws InvalidAppFunctionSchemaException If any required schema or properties are missing. */ private Map getValidatedSchemas( @NonNull Map schemaMap, @NonNull String packageName, @NonNull Set nestedSchemaTypes) throws InvalidAppFunctionSchemaException { Objects.requireNonNull(schemaMap); Objects.requireNonNull(packageName); Objects.requireNonNull(nestedSchemaTypes); String appFunctionStaticMetadataSchemaType = AppFunctionDocument.getSchemaNameForPackage( packageName, AppFunctionStaticMetadata.SCHEMA_TYPE); if (!schemaMap.containsKey(appFunctionStaticMetadataSchemaType)) { throw new InvalidAppFunctionSchemaException( "Missing schema definition for AppFunctionStaticMetadata in package: " + packageName); } Set undefinedSchemas = new ArraySet<>(nestedSchemaTypes); undefinedSchemas.removeAll(schemaMap.keySet()); if (!undefinedSchemas.isEmpty()) { throw new InvalidAppFunctionSchemaException( "In package: " + packageName + ", missing schema definitions for following nested schema types: " + undefinedSchemas); } return schemaMap; } /** Exception thrown when the parsed schema fails any of the validations. */ private static class InvalidAppFunctionSchemaException extends Exception { InvalidAppFunctionSchemaException(String message) { super(message); } } }