/* * Copyright (C) 2017 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.settings.intelligence.search.indexing; import android.content.Context; import android.content.res.Resources; import android.content.res.XmlResourceParser; import android.provider.SearchIndexableData; import android.provider.SearchIndexableResource; import androidx.annotation.DrawableRes; import androidx.annotation.Nullable; import androidx.collection.ArraySet; import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; import android.util.Pair; import android.util.Xml; import com.android.settings.intelligence.search.ResultPayload; import com.android.settings.intelligence.search.SearchFeatureProvider; import com.android.settings.intelligence.search.SearchIndexableRaw; import com.android.settings.intelligence.search.sitemap.HighlightableMenu; import com.android.settings.intelligence.search.sitemap.SiteMapManager; import com.android.settings.intelligence.search.sitemap.SiteMapPair; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; /** * Helper class to convert {@link PreIndexData} to {@link IndexData}. */ public class IndexDataConverter { private static final String TAG = "IndexDataConverter"; private static final String SETTINGS_PACKAGE_NAME = "com.android.settings"; private static final String NODE_NAME_PREFERENCE_SCREEN = "PreferenceScreen"; private static final String NODE_NAME_CHECK_BOX_PREFERENCE = "CheckBoxPreference"; private static final String NODE_NAME_LIST_PREFERENCE = "ListPreference"; private static final List SKIP_NODES = Arrays.asList("intent", "extra"); public IndexDataConverter() { } @Deprecated public IndexDataConverter(Context context) { } /** * Return the collection of {@param preIndexData} converted into {@link IndexData}. * * @param preIndexData a collection of {@link SearchIndexableResource}, * {@link SearchIndexableRaw} and non-indexable keys. */ public List convertPreIndexDataToIndexData(PreIndexData preIndexData) { final long startConversion = System.currentTimeMillis(); final Map> indexableDataMap = preIndexData.getDataToUpdate(); final Map> nonIndexableKeys = preIndexData.getNonIndexableKeys(); final List indexData = new ArrayList<>(); for (Map.Entry> entry : indexableDataMap.entrySet()) { final String authority = entry.getKey(); final List indexableData = entry.getValue(); for (SearchIndexableData data : indexableData) { if (data instanceof SearchIndexableRaw) { final SearchIndexableRaw rawData = (SearchIndexableRaw) data; final Set rawNonIndexableKeys = nonIndexableKeys.get(authority); final IndexData convertedRaw = convertRaw(authority, rawData, rawNonIndexableKeys); if (convertedRaw != null) { indexData.add(convertedRaw); } } else if (data instanceof SearchIndexableResource) { final SearchIndexableResource sir = (SearchIndexableResource) data; final Set resourceNonIndexableKeys = getNonIndexableKeysForResource(nonIndexableKeys, authority); final List resourceData = convertResource(sir, authority, resourceNonIndexableKeys); indexData.addAll(resourceData); } } } final long endConversion = System.currentTimeMillis(); Log.d(TAG, "Converting pre-index data to index data took: " + (endConversion - startConversion)); return indexData; } /** * Returns a full list of site map pairs based on metadata from all data sources. * * The content schema follows {@link IndexDatabaseHelper.Tables#TABLE_SITE_MAP} */ public List convertSiteMapPairs(List indexData, List> siteMapClassNames) { final List pairs = new ArrayList<>(); if (indexData == null) { return pairs; } // Step 1: loop indexData and build all static site map pairs. final Map classToTitleMap = new TreeMap<>(); for (IndexData row : indexData) { if (TextUtils.isEmpty(row.className)) { continue; } // Build a map of [class, title] for the next step. classToTitleMap.put(row.className, row.screenTitle); if (!TextUtils.isEmpty(row.childClassName)) { pairs.add(new SiteMapPair(row.className, row.screenTitle, row.childClassName, row.updatedTitle, row.highlightableMenuKey)); } } // Step 2: Extend the sitemap pairs by adding dynamic pairs provided by // SearchIndexableProvider. The provider only tells us class name so we need to finish // the mapping by looking up display title for each class. for (Pair pair : siteMapClassNames) { final String parentName = classToTitleMap.get(pair.first); final String childName = classToTitleMap.get(pair.second); if (TextUtils.isEmpty(parentName) || TextUtils.isEmpty(childName)) { Log.w(TAG, "Cannot build sitemap pair for incomplete names " + pair + parentName + childName); } else { pairs.add(new SiteMapPair(pair.first, parentName, pair.second, childName, null /* highlightableMenuKey*/)); } } // Done return pairs; } public List updateIndexDataPayload(Context context, List indexData) { final long startTime = System.currentTimeMillis(); final List updatedIndexData = new ArrayList<>(indexData); for (IndexData row : indexData) { String menuKey = row.highlightableMenuKey; if (!TextUtils.isEmpty(menuKey)) { // top level settings continue; } menuKey = HighlightableMenu.getMenuKey(context, row); if (TextUtils.isEmpty(menuKey)) { continue; } updatedIndexData.remove(row); updatedIndexData.add(row.mutate().setTopLevelMenuKey(menuKey).build()); } Log.d(TAG, "Updating index data payload took: " + (System.currentTimeMillis() - startTime)); return updatedIndexData; } /** * Return the conversion of {@link SearchIndexableRaw} to {@link IndexData}. * The fields of {@link SearchIndexableRaw} are a subset of {@link IndexData}, * and there is some data sanitization in the conversion. */ @Nullable private IndexData convertRaw(String authority, SearchIndexableRaw raw, Set nonIndexableKeys) { if (TextUtils.isEmpty(raw.key)) { Log.w(TAG, "Skipping null key for raw indexable " + authority + "/" + raw.title); return null; } // A row is enabled if it does not show up as an nonIndexableKey boolean enabled = !(nonIndexableKeys != null && nonIndexableKeys.contains(raw.key)); final IndexData.Builder builder = getIndexDataBuilder(); builder.setTitle(raw.title) .setSummaryOn(raw.summaryOn) .setEntries(raw.entries) .setKeywords(raw.keywords) .setClassName(raw.className) .setScreenTitle(raw.screenTitle) .setIconResId(raw.iconResId) .setIntentAction(raw.intentAction) .setIntentTargetPackage(raw.intentTargetPackage) .setIntentTargetClass(raw.intentTargetClass) .setEnabled(enabled) .setPackageName(raw.packageName) .setAuthority(authority) .setKey(raw.key); return builder.build(); } /** * Return the conversion of the {@link SearchIndexableResource} to {@link IndexData}. * Each of the elements in the xml layout attribute of {@param sir} is a candidate to be * converted (including the header element). * * TODO (b/33577327) simplify this method. */ private List convertResource(SearchIndexableResource sir, String authority, Set nonIndexableKeys) { final Context context = sir.context; XmlResourceParser parser = null; List resourceIndexData = new ArrayList<>(); try { parser = context.getResources().getXml(sir.xmlResId); int type; while ((type = parser.next()) != XmlPullParser.END_DOCUMENT && type != XmlPullParser.START_TAG) { // Parse next until start tag is found } String nodeName = parser.getName(); if (!NODE_NAME_PREFERENCE_SCREEN.equals(nodeName)) { throw new RuntimeException( "XML document must start with tag; found" + nodeName + " at " + parser.getPositionDescription()); } final int outerDepth = parser.getDepth(); final AttributeSet attrs = Xml.asAttributeSet(parser); final String screenTitle = XmlParserUtils.getDataTitle(context, attrs); final String headerKey = XmlParserUtils.getDataKey(context, attrs); String title; String key; String headerTitle; String summary; String headerSummary; String keywords; String headerKeywords; String childFragment; String highlightableMenuKey = null; @DrawableRes int iconResId; ResultPayload payload; boolean enabled; // TODO REFACTOR (b/62807132) Add proper inline support // Map controllerUriMap = null; // // if (fragmentName != null) { // controllerUriMap = DatabaseIndexingUtils // .getPreferenceControllerUriMap(fragmentName, context); // } headerTitle = XmlParserUtils.getDataTitle(context, attrs); headerSummary = XmlParserUtils.getDataSummary(context, attrs); headerKeywords = XmlParserUtils.getDataKeywords(context, attrs); enabled = !nonIndexableKeys.contains(headerKey); // TODO: Set payload type for header results IndexData.Builder headerBuilder = getIndexDataBuilder(); headerBuilder.setTitle(headerTitle) .setSummaryOn(headerSummary) .setScreenTitle(screenTitle) .setKeywords(headerKeywords) .setClassName(sir.className) .setPackageName(sir.packageName) .setAuthority(authority) .setIntentAction(sir.intentAction) .setIntentTargetPackage(sir.intentTargetPackage) .setIntentTargetClass(sir.intentTargetClass) .setEnabled(enabled) .setKey(headerKey); // Flag for XML headers which a child element's title. boolean isHeaderUnique = true; IndexData.Builder builder; while ((type = parser.next()) != XmlPullParser.END_DOCUMENT && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) { continue; } nodeName = parser.getName(); if (SKIP_NODES.contains(nodeName)) { if (SearchFeatureProvider.DEBUG) { Log.d(TAG, nodeName + " is not a valid entity to index, skip."); } continue; } title = XmlParserUtils.getDataTitle(context, attrs); key = XmlParserUtils.getDataKey(context, attrs); enabled = !nonIndexableKeys.contains(key); keywords = XmlParserUtils.getDataKeywords(context, attrs); iconResId = XmlParserUtils.getDataIcon(context, attrs); if (TextUtils.equals(sir.packageName, SETTINGS_PACKAGE_NAME) && SiteMapManager.isTopLevelSettings(sir.className)) { highlightableMenuKey = XmlParserUtils.getHighlightableMenuKey(context, attrs); } if (isHeaderUnique && TextUtils.equals(headerTitle, title)) { isHeaderUnique = false; } builder = getIndexDataBuilder(); builder.setTitle(title) .setKeywords(keywords) .setClassName(sir.className) .setScreenTitle(screenTitle) .setIconResId(iconResId) .setPackageName(sir.packageName) .setAuthority(authority) .setIntentAction(sir.intentAction) .setIntentTargetPackage(sir.intentTargetPackage) .setIntentTargetClass(sir.intentTargetClass) .setEnabled(enabled) .setHighlightableMenuKey(highlightableMenuKey) .setTopLevelMenuKey(highlightableMenuKey) .setKey(key); if (!nodeName.equals(NODE_NAME_CHECK_BOX_PREFERENCE)) { summary = XmlParserUtils.getDataSummary(context, attrs); String entries = null; if (nodeName.endsWith(NODE_NAME_LIST_PREFERENCE)) { entries = XmlParserUtils.getDataEntries(context, attrs); } // TODO (b/62254931) index primitives instead of payload // TODO (b/62807132) Add proper inline support //payload = DatabaseIndexingUtils.getPayloadFromUriMap(controllerUriMap, key); childFragment = XmlParserUtils.getDataChildFragment(context, attrs); builder.setSummaryOn(summary) .setEntries(entries) .setChildClassName(childFragment); tryAddIndexDataToList(resourceIndexData, builder); } else { // TODO (b/33577327) We removed summary off here. We should check if we can // merge this 'else' section with the one above. Put a break point to // investigate. String summaryOn = XmlParserUtils.getDataSummaryOn(context, attrs); if (TextUtils.isEmpty(summaryOn)) { summaryOn = XmlParserUtils.getDataSummary(context, attrs); } builder.setSummaryOn(summaryOn); tryAddIndexDataToList(resourceIndexData, builder); } } // The xml header's title does not match the title of one of the child settings. if (isHeaderUnique) { tryAddIndexDataToList(resourceIndexData, headerBuilder); } } catch (XmlPullParserException e) { Log.w(TAG, "XML Error parsing PreferenceScreen: " + sir.className, e); } catch (IOException e) { Log.w(TAG, "IO Error parsing PreferenceScreen: " + sir.className, e); } catch (Resources.NotFoundException e) { Log.w(TAG, "Resoucre not found error parsing PreferenceScreen: " + sir.className, e); } finally { if (parser != null) { parser.close(); } } return resourceIndexData; } private void tryAddIndexDataToList(List list, IndexData.Builder data) { if (!TextUtils.isEmpty(data.getKey())) { list.add(data.build()); } else { Log.w(TAG, "Skipping index for null-key item " + data); } } private Set getNonIndexableKeysForResource(Map> nonIndexableKeys, String authority) { final Set result = nonIndexableKeys.get(authority); return result != null ? result : new ArraySet<>(); } protected IndexData.Builder getIndexDataBuilder() { return new IndexData.Builder(); } }