/* * Copyright (C) 2025 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.DocumentPropertyConfig; import android.app.appsearch.AppSearchSchema.PropertyConfig; import android.app.appsearch.GenericDocument; import android.app.appsearch.util.LogUtil; import android.content.pm.PackageManager; import android.content.res.AssetManager; import android.util.ArrayMap; 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.InputStreamReader; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; /** * This class parses static metadata about App Functions from an XML file located within an app's * assets. */ public class AppFunctionDocumentParserImpl implements AppFunctionDocumentParser { private static final String TAG = "AppSearchMetadataParser"; private static final String XML_TAG_APPFUNCTION = "appfunction"; private static final String XML_TAG_APPFUNCTIONS_ROOT = "appfunctions"; private static final String XML_TAG_ID = "id"; private static final String SNAKE_CASE_SEPARATOR = "_"; @NonNull private final String mIndexerPackageName; private final int mMaxAppFunctions; /** * @param indexerPackageName the name of the package performing the indexing. This should be the * same as the package running the apps indexer. * @param config the app indexer config used to enforce various limits during parsing. */ public AppFunctionDocumentParserImpl( @NonNull String indexerPackageName, AppsIndexerConfig config) { mIndexerPackageName = Objects.requireNonNull(indexerPackageName); mMaxAppFunctions = config.getMaxAppFunctionsPerPackage(); } // TODO(b/367410454): Remove this method once enable_apps_indexer_incremental_put flag is // rolled out @NonNull @Override public List parse( @NonNull PackageManager packageManager, @NonNull String packageName, @NonNull String assetFilePath) { Objects.requireNonNull(packageManager); Objects.requireNonNull(packageName); Objects.requireNonNull(assetFilePath); try { return parseAppFunctions( initializeParser(packageManager, packageName, assetFilePath), packageName); } catch (Exception ex) { // The code parses an XML 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 XML from package '%s', asset file '%s'", packageName, assetFilePath), ex); } return Collections.emptyList(); } @NonNull @Override public Map parseIntoMap( @NonNull PackageManager packageManager, @NonNull String packageName, @NonNull String assetFilePath) { Objects.requireNonNull(packageManager); Objects.requireNonNull(packageName); Objects.requireNonNull(assetFilePath); try { return parseAppFunctionsIntoMap( initializeParser(packageManager, packageName, assetFilePath), packageName); } catch (Exception ex) { // The code parses an XML 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 XML from package '%s', asset file '%s'", packageName, assetFilePath), ex); } return Collections.emptyMap(); } /** * Initializes an {@link XmlPullParser} to parse xml based on the packageName and assetFilePath. */ @NonNull private XmlPullParser initializeParser( @NonNull PackageManager packageManager, @NonNull String packageName, @NonNull String assetFilePath) throws XmlPullParserException, PackageManager.NameNotFoundException, IOException { Objects.requireNonNull(packageManager); Objects.requireNonNull(packageName); Objects.requireNonNull(assetFilePath); XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); factory.setNamespaceAware(true); XmlPullParser parser = factory.newPullParser(); AssetManager assetManager = packageManager.getResourcesForApplication(packageName).getAssets(); parser.setInput(new InputStreamReader(assetManager.open(assetFilePath))); return parser; } // TODO(b/367410454): Remove this method once enable_apps_indexer_incremental_put flag is // rolled out /** * Parses a sequence of `appfunction` elements from the XML into a list of {@link * AppFunctionStaticMetadata}. * * @param parser the XmlPullParser positioned at the start of the xml file */ @NonNull private List parseAppFunctions( @NonNull XmlPullParser parser, @NonNull String packageName) throws XmlPullParserException, IOException { List appFunctions = new ArrayList<>(); int eventType = parser.getEventType(); while (eventType != XmlPullParser.END_DOCUMENT) { String tagName = parser.getName(); if (eventType == XmlPullParser.START_TAG && XML_TAG_APPFUNCTION.equals(tagName)) { AppFunctionStaticMetadata appFunction = parseAppFunction(parser, packageName); appFunctions.add(appFunction); if (appFunctions.size() >= mMaxAppFunctions) { Log.d(TAG, "Exceeding the max number of app functions: " + packageName); return appFunctions; } } eventType = parser.next(); } return appFunctions; } /** * Parses a sequence of `appfunction` elements from the XML into a map of function ids to their * corresponding {@link AppFunctionStaticMetadata}. * * @param parser the XmlPullParser positioned at the start of the xml file */ @NonNull private Map parseAppFunctionsIntoMap( @NonNull XmlPullParser parser, @NonNull String packageName) throws XmlPullParserException, IOException { Map appFunctions = new ArrayMap<>(); int eventType = parser.getEventType(); while (eventType != XmlPullParser.END_DOCUMENT) { String tagName = parser.getName(); if (eventType == XmlPullParser.START_TAG && XML_TAG_APPFUNCTION.equals(tagName)) { AppFunctionStaticMetadata appFunction = parseAppFunction(parser, packageName); appFunctions.put(appFunction.getId(), appFunction); if (appFunctions.size() >= mMaxAppFunctions) { Log.d(TAG, "Exceeding the max number of app functions: " + packageName); return appFunctions; } } eventType = parser.next(); } return appFunctions; } /** * Parses a single `appfunction` element from the XML into an {@link AppFunctionStaticMetadata} * object. * * @param parser the XmlPullParser positioned at the start of an `appfunction` element. * @return an AppFunction object populated with the data from the XML. */ @NonNull private AppFunctionStaticMetadata parseAppFunction( @NonNull XmlPullParser parser, @NonNull String packageName) throws XmlPullParserException, IOException { String functionId = null; String schemaName = null; Long schemaVersion = null; String schemaCategory = null; Boolean enabledByDefault = null; Integer displayNameStringRes = null; Boolean restrictCallersWithExecuteAppFunctions = null; int eventType = parser.getEventType(); while (!(eventType == XmlPullParser.END_TAG && XML_TAG_APPFUNCTION.equals(parser.getName()))) { if (eventType == XmlPullParser.START_TAG && !XML_TAG_APPFUNCTION.equals(parser.getName())) { String tagName = parser.getName(); switch (tagName) { case "function_id": functionId = parser.nextText().trim(); break; case "schema_name": schemaName = parser.nextText().trim(); break; case "schema_version": schemaVersion = Long.parseLong(parser.nextText().trim()); break; case "schema_category": schemaCategory = parser.nextText().trim(); break; case "enabled_by_default": enabledByDefault = Boolean.parseBoolean(parser.nextText().trim()); break; case "restrict_callers_with_execute_app_functions": restrictCallersWithExecuteAppFunctions = Boolean.parseBoolean(parser.nextText().trim()); break; case "display_name_string_res": displayNameStringRes = Integer.parseInt(parser.nextText().trim()); break; } } eventType = parser.next(); } if (functionId == null) { throw new XmlPullParserException("parseAppFunction: Missing functionId in the xml."); } AppFunctionStaticMetadata.Builder builder = new AppFunctionStaticMetadata.Builder(packageName, functionId, mIndexerPackageName); if (schemaName != null) { builder.setSchemaName(schemaName); } if (schemaVersion != null) { builder.setSchemaVersion(schemaVersion); } if (schemaCategory != null) { builder.setSchemaCategory(schemaCategory); } if (enabledByDefault != null) { builder.setEnabledByDefault(enabledByDefault); } if (restrictCallersWithExecuteAppFunctions != null) { builder.setRestrictCallersWithExecuteAppFunctions( restrictCallersWithExecuteAppFunctions); } if (displayNameStringRes != null) { builder.setDisplayNameStringRes(displayNameStringRes); } return builder.build(); } @NonNull @Override public Map parseIntoMapForGivenSchemas( @NonNull PackageManager packageManager, @NonNull String packageName, @NonNull String assetFilePath, @NonNull Map schemas) { Objects.requireNonNull(packageManager); Objects.requireNonNull(packageName); Objects.requireNonNull(assetFilePath); Objects.requireNonNull(schemas); try { return parseAppFunctionsIntoMapForGivenSchemas( initializeParser(packageManager, packageName, assetFilePath), packageName, schemas); } catch (Exception ex) { // The code parses an XML 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 XML from package '%s', asset file '%s'", packageName, assetFilePath), ex); } return Collections.emptyMap(); } @NonNull private Map parseAppFunctionsIntoMapForGivenSchemas( @NonNull XmlPullParser parser, @NonNull String packageName, @NonNull Map schemas) throws XmlPullParserException, IOException { Objects.requireNonNull(parser); Objects.requireNonNull(packageName); Objects.requireNonNull(schemas); Map appFnMetadatas = new ArrayMap<>(); Map qualifiedPropertyNamesToPropertyConfig = buildQualifiedPropertyNameToPropertyConfigMap(schemas); int eventType = parser.getEventType(); while (eventType != XmlPullParser.END_DOCUMENT) { String tagName = parser.getName(); // In previous document formats XML tag was used for denoting // AppFunctionStaticMetadata type. String schemaType = XML_TAG_APPFUNCTION.equals(tagName) ? AppFunctionStaticMetadata.SCHEMA_TYPE : tagName; String schemaNameForPackage = AppFunctionDocument.getSchemaNameForPackage(packageName, schemaType); if (eventType == XmlPullParser.START_TAG && schemas.containsKey(schemaNameForPackage)) { // Id of the document will be set after parsing the value from xml. AppFunctionDocument.Builder appFnDocBuilder = new AppFunctionDocument.Builder( packageName, "", mIndexerPackageName, schemaType); buildGenericDocumentFromXmlElement( parser, packageName, schemaNameForPackage, qualifiedPropertyNamesToPropertyConfig, appFnDocBuilder); AppFunctionDocument appFunctionDocument = appFnDocBuilder.build(); appFnMetadatas.put(appFunctionDocument.getId(), appFunctionDocument); if (appFnMetadatas.size() >= mMaxAppFunctions) { if (LogUtil.DEBUG) { Log.d(TAG, "Exceeding the max number of app functions: " + packageName); } return appFnMetadatas; } } eventType = parser.next(); } return appFnMetadatas; } /** * Tries to parse a single XML element and populate the {@link GenericDocument.Builder} object * recursively. * *

When this function is called the parser should point to the xml element that marks the * beginning of the {@link GenericDocument}, and would point to the end tag of the corresponding * doc once this function completes. * * @param parser the XmlPullParser positioned at the start of an XML element. * @param packageName the package name of the app that owns the XML element. * @param schemaType the type of the schema that the XML element belongs to. * @param qualifiedPropertyNamesToPropertyConfig the mapping of qualified property names to * their corresponding {@link PropertyConfig} objects. * @param docBuilder {@link GenericDocument.Builder} object to populate with the data from the * XML element. * @throws XmlPullParserException if the XML element is malformed. */ private static void buildGenericDocumentFromXmlElement( @NonNull XmlPullParser parser, @NonNull String packageName, @NonNull String schemaType, @NonNull Map qualifiedPropertyNamesToPropertyConfig, @NonNull GenericDocument.Builder docBuilder) throws XmlPullParserException, IOException { Objects.requireNonNull(parser); Objects.requireNonNull(packageName); Objects.requireNonNull(qualifiedPropertyNamesToPropertyConfig); Map> primitivePropertyValues = new ArrayMap<>(); Map> nestedDocumentValues = new ArrayMap<>(); String startTag = parser.getName(); String currentPropertyPath; boolean wasDocIdSet = false; // Skip the current tag that marks the beginning of the current document. parser.next(); while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { switch (parser.getEventType()) { case XmlPullParser.START_TAG: currentPropertyPath = createQualifiedPropertyName( schemaType, toLowerCamelCase(parser.getName(), SNAKE_CASE_SEPARATOR)); PropertyConfig propertyConfig = qualifiedPropertyNamesToPropertyConfig.get(currentPropertyPath); if (propertyConfig instanceof DocumentPropertyConfig) { String nestedSchemaType = ((DocumentPropertyConfig) propertyConfig).getSchemaType(); GenericDocument.Builder nestedDoc = new GenericDocument.Builder( AppFunctionStaticMetadata.APP_FUNCTION_NAMESPACE, "", nestedSchemaType); buildGenericDocumentFromXmlElement( parser, packageName, nestedSchemaType, qualifiedPropertyNamesToPropertyConfig, nestedDoc); nestedDocumentValues .computeIfAbsent(currentPropertyPath, k -> new ArrayList<>()) .add(nestedDoc.build()); } else if (propertyConfig != null) { primitivePropertyValues .computeIfAbsent(currentPropertyPath, k -> new ArrayList<>()) .add(parser.nextText().trim()); } else if (parser.getName().equals(XML_TAG_ID)) { String id = parser.nextText().trim(); if (!id.isEmpty()) { docBuilder.setId(packageName + "/" + id); wasDocIdSet = true; } } break; case XmlPullParser.END_TAG: if (startTag.equals(parser.getName())) { for (Map.Entry> entry : primitivePropertyValues.entrySet()) { addPrimitiveProperty( docBuilder, qualifiedPropertyNamesToPropertyConfig.get(entry.getKey()), entry.getValue()); } for (Map.Entry> entry : nestedDocumentValues.entrySet()) { String propertyName = qualifiedPropertyNamesToPropertyConfig .get(entry.getKey()) .getName(); docBuilder.setPropertyDocument( propertyName, entry.getValue().toArray(new GenericDocument[0])); } if (!wasDocIdSet) { throw new XmlPullParserException( "No id found for document of type: " + schemaType); } return; } break; } parser.next(); } throw new IllegalStateException("Code should never reach here."); } /** * Builds a mapping of qualified property names to their corresponding {@link PropertyConfig} * objects. * *

The key is a concatenation of enclosing schema type and property name, separated by a * period to avoid conflicts between properties with the same name in different schemas. For * example, if the "Person" and "Address" schemas both have a property named "name", then the * qualified property names will be "Person#name" and "Address#name" respectively. * * @param schemaMap the mapping of schema types to their corresponding {@link AppSearchSchema} * objects. * @return a {@link Map} of qualified property names to their corresponding {@link * PropertyConfig} objects. */ @NonNull private static Map buildQualifiedPropertyNameToPropertyConfigMap( @NonNull Map schemaMap) { Objects.requireNonNull(schemaMap); Map propertyMap = new ArrayMap<>(); for (Map.Entry entry : schemaMap.entrySet()) { String schemaType = entry.getKey(); AppSearchSchema schema = entry.getValue(); List properties = schema.getProperties(); for (int i = 0; i < properties.size(); i++) { AppSearchSchema.PropertyConfig property = properties.get(i); String propertyPath = createQualifiedPropertyName(schemaType, property.getName()); propertyMap.put(propertyPath, property); } } return propertyMap; } /** * Converts a string of words separated by separator to lowerCamelCase. * *

Returns the same string if string doesn't contain the separator. */ private static String toLowerCamelCase(@NonNull String str, @NonNull String separator) { if (str.isEmpty()) { return ""; } // Return the original string if the separator is not present if (!str.contains(separator)) { return str; } StringBuilder builder = new StringBuilder(str.length()); boolean capitalizeNext = false; for (int i = 0; i < str.length(); i++) { char currentChar = str.charAt(i); // skip multiple consecutive separators if (str.startsWith(separator, i)) { capitalizeNext = true; i += separator.length() - 1; } else { if (capitalizeNext) { builder.append(Character.toUpperCase(currentChar)); capitalizeNext = false; } else { builder.append(Character.toLowerCase(currentChar)); } } } return builder.toString(); } /** * Creates a qualified property name by concatenating the schema type and property name with a # * separator to avoid conflicts between properties with the same name in different schemas. */ @NonNull private static String createQualifiedPropertyName( @NonNull String schemaType, @NonNull String propertyName) { return Objects.requireNonNull(schemaType) + "#" + Objects.requireNonNull(propertyName); } /** * Adds primitive property values to the given {@link GenericDocument.Builder} based on the * given {@link PropertyConfig}. * *

Ignores unsupported data types. */ private static void addPrimitiveProperty( @NonNull GenericDocument.Builder builder, @NonNull PropertyConfig propertyConfig, @NonNull List values) { Objects.requireNonNull(builder); Objects.requireNonNull(propertyConfig); Objects.requireNonNull(values); switch (propertyConfig.getDataType()) { case PropertyConfig.DATA_TYPE_BOOLEAN: boolean[] booleanValues = new boolean[values.size()]; for (int i = 0; i < values.size(); i++) { booleanValues[i] = Boolean.parseBoolean(values.get(i)); } builder.setPropertyBoolean(propertyConfig.getName(), booleanValues); break; case PropertyConfig.DATA_TYPE_LONG: long[] longValues = new long[values.size()]; for (int i = 0; i < values.size(); i++) { longValues[i] = Long.parseLong(values.get(i)); } builder.setPropertyLong(propertyConfig.getName(), longValues); break; case PropertyConfig.DATA_TYPE_STRING: builder.setPropertyString(propertyConfig.getName(), values.toArray(new String[0])); break; default: // fall-through } } }