/*
* 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:
*
*
* - It verifies that the schema for {@link AppFunctionStaticMetadata} exists for the given
* package and checks for the presence of required properties from {@link
* AppFunctionStaticMetadata#PARENT_TYPE_APPSEARCH_SCHEMA}.
*
- It checks for any missing schema definitions for nested schema types.
*
*
* @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);
}
}
}