1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  *
16  */
17 
18 package com.android.settings.intelligence.search.indexing;
19 
20 import android.content.Context;
21 import android.content.res.Resources;
22 import android.content.res.XmlResourceParser;
23 import android.provider.SearchIndexableData;
24 import android.provider.SearchIndexableResource;
25 import androidx.annotation.DrawableRes;
26 import androidx.annotation.Nullable;
27 import androidx.collection.ArraySet;
28 import android.text.TextUtils;
29 import android.util.AttributeSet;
30 import android.util.Log;
31 import android.util.Pair;
32 import android.util.Xml;
33 
34 import com.android.settings.intelligence.search.ResultPayload;
35 import com.android.settings.intelligence.search.SearchFeatureProvider;
36 import com.android.settings.intelligence.search.SearchIndexableRaw;
37 import com.android.settings.intelligence.search.sitemap.HighlightableMenu;
38 import com.android.settings.intelligence.search.sitemap.SiteMapManager;
39 import com.android.settings.intelligence.search.sitemap.SiteMapPair;
40 
41 import org.xmlpull.v1.XmlPullParser;
42 import org.xmlpull.v1.XmlPullParserException;
43 
44 import java.io.IOException;
45 import java.util.ArrayList;
46 import java.util.Arrays;
47 import java.util.List;
48 import java.util.Map;
49 import java.util.Set;
50 import java.util.TreeMap;
51 
52 /**
53  * Helper class to convert {@link PreIndexData} to {@link IndexData}.
54  */
55 public class IndexDataConverter {
56 
57     private static final String TAG = "IndexDataConverter";
58 
59     private static final String SETTINGS_PACKAGE_NAME = "com.android.settings";
60     private static final String NODE_NAME_PREFERENCE_SCREEN = "PreferenceScreen";
61     private static final String NODE_NAME_CHECK_BOX_PREFERENCE = "CheckBoxPreference";
62     private static final String NODE_NAME_LIST_PREFERENCE = "ListPreference";
63     private static final List<String> SKIP_NODES = Arrays.asList("intent", "extra");
64 
IndexDataConverter()65     public IndexDataConverter() {
66     }
67 
68     @Deprecated
IndexDataConverter(Context context)69     public IndexDataConverter(Context context) {
70     }
71 
72     /**
73      * Return the collection of {@param preIndexData} converted into {@link IndexData}.
74      *
75      * @param preIndexData a collection of {@link SearchIndexableResource},
76      *                     {@link SearchIndexableRaw} and non-indexable keys.
77      */
convertPreIndexDataToIndexData(PreIndexData preIndexData)78     public List<IndexData> convertPreIndexDataToIndexData(PreIndexData preIndexData) {
79         final long startConversion = System.currentTimeMillis();
80         final Map<String, List<SearchIndexableData>> indexableDataMap =
81                 preIndexData.getDataToUpdate();
82         final Map<String, Set<String>> nonIndexableKeys = preIndexData.getNonIndexableKeys();
83         final List<IndexData> indexData = new ArrayList<>();
84 
85         for (Map.Entry<String, List<SearchIndexableData>> entry : indexableDataMap.entrySet()) {
86             final String authority = entry.getKey();
87             final List<SearchIndexableData> indexableData = entry.getValue();
88 
89             for (SearchIndexableData data : indexableData) {
90                 if (data instanceof SearchIndexableRaw) {
91                     final SearchIndexableRaw rawData = (SearchIndexableRaw) data;
92                     final Set<String> rawNonIndexableKeys = nonIndexableKeys.get(authority);
93                     final IndexData convertedRaw = convertRaw(authority, rawData,
94                             rawNonIndexableKeys);
95                     if (convertedRaw != null) {
96                         indexData.add(convertedRaw);
97                     }
98                 } else if (data instanceof SearchIndexableResource) {
99                     final SearchIndexableResource sir = (SearchIndexableResource) data;
100                     final Set<String> resourceNonIndexableKeys =
101                             getNonIndexableKeysForResource(nonIndexableKeys, authority);
102                     final List<IndexData> resourceData = convertResource(sir, authority,
103                             resourceNonIndexableKeys);
104                     indexData.addAll(resourceData);
105                 }
106             }
107         }
108 
109         final long endConversion = System.currentTimeMillis();
110         Log.d(TAG, "Converting pre-index data to index data took: "
111                 + (endConversion - startConversion));
112 
113         return indexData;
114     }
115 
116     /**
117      * Returns a full list of site map pairs based on metadata from all data sources.
118      *
119      * The content schema follows {@link IndexDatabaseHelper.Tables#TABLE_SITE_MAP}
120      */
convertSiteMapPairs(List<IndexData> indexData, List<Pair<String, String>> siteMapClassNames)121     public List<SiteMapPair> convertSiteMapPairs(List<IndexData> indexData,
122             List<Pair<String, String>> siteMapClassNames) {
123         final List<SiteMapPair> pairs = new ArrayList<>();
124         if (indexData == null) {
125             return pairs;
126         }
127         // Step 1: loop indexData and build all static site map pairs.
128         final Map<String, String> classToTitleMap = new TreeMap<>();
129         for (IndexData row : indexData) {
130             if (TextUtils.isEmpty(row.className)) {
131                 continue;
132             }
133             // Build a map of [class, title] for the next step.
134             classToTitleMap.put(row.className, row.screenTitle);
135             if (!TextUtils.isEmpty(row.childClassName)) {
136                 pairs.add(new SiteMapPair(row.className, row.screenTitle,
137                         row.childClassName, row.updatedTitle, row.highlightableMenuKey));
138             }
139         }
140         // Step 2: Extend the sitemap pairs by adding dynamic pairs provided by
141         // SearchIndexableProvider. The provider only tells us class name so we need to finish
142         // the mapping by looking up display title for each class.
143         for (Pair<String, String> pair : siteMapClassNames) {
144             final String parentName = classToTitleMap.get(pair.first);
145             final String childName = classToTitleMap.get(pair.second);
146             if (TextUtils.isEmpty(parentName) || TextUtils.isEmpty(childName)) {
147                 Log.w(TAG, "Cannot build sitemap pair for incomplete names "
148                         + pair + parentName + childName);
149             } else {
150                 pairs.add(new SiteMapPair(pair.first, parentName, pair.second, childName,
151                         null /* highlightableMenuKey*/));
152             }
153         }
154         // Done
155         return pairs;
156     }
157 
updateIndexDataPayload(Context context, List<IndexData> indexData)158     public List<IndexData> updateIndexDataPayload(Context context, List<IndexData> indexData) {
159         final long startTime = System.currentTimeMillis();
160         final List<IndexData> updatedIndexData = new ArrayList<>(indexData);
161         for (IndexData row : indexData) {
162             String menuKey = row.highlightableMenuKey;
163             if (!TextUtils.isEmpty(menuKey)) {
164                 // top level settings
165                 continue;
166             }
167             menuKey = HighlightableMenu.getMenuKey(context, row);
168             if (TextUtils.isEmpty(menuKey)) {
169                 continue;
170             }
171             updatedIndexData.remove(row);
172             updatedIndexData.add(row.mutate().setTopLevelMenuKey(menuKey).build());
173         }
174         Log.d(TAG, "Updating index data payload took: " + (System.currentTimeMillis() - startTime));
175         return updatedIndexData;
176     }
177 
178     /**
179      * Return the conversion of {@link SearchIndexableRaw} to {@link IndexData}.
180      * The fields of {@link SearchIndexableRaw} are a subset of {@link IndexData},
181      * and there is some data sanitization in the conversion.
182      */
183     @Nullable
convertRaw(String authority, SearchIndexableRaw raw, Set<String> nonIndexableKeys)184     private IndexData convertRaw(String authority, SearchIndexableRaw raw,
185             Set<String> nonIndexableKeys) {
186         if (TextUtils.isEmpty(raw.key)) {
187             Log.w(TAG, "Skipping null key for raw indexable " + authority + "/" + raw.title);
188             return null;
189         }
190         // A row is enabled if it does not show up as an nonIndexableKey
191         boolean enabled = !(nonIndexableKeys != null && nonIndexableKeys.contains(raw.key));
192 
193         final IndexData.Builder builder = getIndexDataBuilder();
194         builder.setTitle(raw.title)
195                 .setSummaryOn(raw.summaryOn)
196                 .setEntries(raw.entries)
197                 .setKeywords(raw.keywords)
198                 .setClassName(raw.className)
199                 .setScreenTitle(raw.screenTitle)
200                 .setIconResId(raw.iconResId)
201                 .setIntentAction(raw.intentAction)
202                 .setIntentTargetPackage(raw.intentTargetPackage)
203                 .setIntentTargetClass(raw.intentTargetClass)
204                 .setEnabled(enabled)
205                 .setPackageName(raw.packageName)
206                 .setAuthority(authority)
207                 .setKey(raw.key);
208 
209         return builder.build();
210     }
211 
212     /**
213      * Return the conversion of the {@link SearchIndexableResource} to {@link IndexData}.
214      * Each of the elements in the xml layout attribute of {@param sir} is a candidate to be
215      * converted (including the header element).
216      *
217      * TODO (b/33577327) simplify this method.
218      */
convertResource(SearchIndexableResource sir, String authority, Set<String> nonIndexableKeys)219     private List<IndexData> convertResource(SearchIndexableResource sir, String authority,
220             Set<String> nonIndexableKeys) {
221         final Context context = sir.context;
222         XmlResourceParser parser = null;
223 
224         List<IndexData> resourceIndexData = new ArrayList<>();
225         try {
226             parser = context.getResources().getXml(sir.xmlResId);
227 
228             int type;
229             while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
230                     && type != XmlPullParser.START_TAG) {
231                 // Parse next until start tag is found
232             }
233 
234             String nodeName = parser.getName();
235             if (!NODE_NAME_PREFERENCE_SCREEN.equals(nodeName)) {
236                 throw new RuntimeException(
237                         "XML document must start with <PreferenceScreen> tag; found"
238                                 + nodeName + " at " + parser.getPositionDescription());
239             }
240 
241             final int outerDepth = parser.getDepth();
242             final AttributeSet attrs = Xml.asAttributeSet(parser);
243 
244             final String screenTitle = XmlParserUtils.getDataTitle(context, attrs);
245             final String headerKey = XmlParserUtils.getDataKey(context, attrs);
246 
247             String title;
248             String key;
249             String headerTitle;
250             String summary;
251             String headerSummary;
252             String keywords;
253             String headerKeywords;
254             String childFragment;
255             String highlightableMenuKey = null;
256             @DrawableRes int iconResId;
257             ResultPayload payload;
258             boolean enabled;
259 
260             // TODO REFACTOR (b/62807132) Add proper inline support
261 //            Map<String, PreferenceControllerMixin> controllerUriMap = null;
262 //
263 //            if (fragmentName != null) {
264 //                controllerUriMap = DatabaseIndexingUtils
265 //                        .getPreferenceControllerUriMap(fragmentName, context);
266 //            }
267 
268             headerTitle = XmlParserUtils.getDataTitle(context, attrs);
269             headerSummary = XmlParserUtils.getDataSummary(context, attrs);
270             headerKeywords = XmlParserUtils.getDataKeywords(context, attrs);
271             enabled = !nonIndexableKeys.contains(headerKey);
272             // TODO: Set payload type for header results
273             IndexData.Builder headerBuilder = getIndexDataBuilder();
274             headerBuilder.setTitle(headerTitle)
275                     .setSummaryOn(headerSummary)
276                     .setScreenTitle(screenTitle)
277                     .setKeywords(headerKeywords)
278                     .setClassName(sir.className)
279                     .setPackageName(sir.packageName)
280                     .setAuthority(authority)
281                     .setIntentAction(sir.intentAction)
282                     .setIntentTargetPackage(sir.intentTargetPackage)
283                     .setIntentTargetClass(sir.intentTargetClass)
284                     .setEnabled(enabled)
285                     .setKey(headerKey);
286 
287             // Flag for XML headers which a child element's title.
288             boolean isHeaderUnique = true;
289             IndexData.Builder builder;
290 
291             while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
292                     && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {
293                 if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) {
294                     continue;
295                 }
296 
297                 nodeName = parser.getName();
298                 if (SKIP_NODES.contains(nodeName)) {
299                     if (SearchFeatureProvider.DEBUG) {
300                         Log.d(TAG, nodeName + " is not a valid entity to index, skip.");
301                     }
302                     continue;
303                 }
304 
305                 title = XmlParserUtils.getDataTitle(context, attrs);
306                 key = XmlParserUtils.getDataKey(context, attrs);
307                 enabled = !nonIndexableKeys.contains(key);
308                 keywords = XmlParserUtils.getDataKeywords(context, attrs);
309                 iconResId = XmlParserUtils.getDataIcon(context, attrs);
310                 if (TextUtils.equals(sir.packageName, SETTINGS_PACKAGE_NAME)
311                         && SiteMapManager.isTopLevelSettings(sir.className)) {
312                     highlightableMenuKey = XmlParserUtils.getHighlightableMenuKey(context, attrs);
313                 }
314 
315 
316                 if (isHeaderUnique && TextUtils.equals(headerTitle, title)) {
317                     isHeaderUnique = false;
318                 }
319 
320                 builder = getIndexDataBuilder();
321                 builder.setTitle(title)
322                         .setKeywords(keywords)
323                         .setClassName(sir.className)
324                         .setScreenTitle(screenTitle)
325                         .setIconResId(iconResId)
326                         .setPackageName(sir.packageName)
327                         .setAuthority(authority)
328                         .setIntentAction(sir.intentAction)
329                         .setIntentTargetPackage(sir.intentTargetPackage)
330                         .setIntentTargetClass(sir.intentTargetClass)
331                         .setEnabled(enabled)
332                         .setHighlightableMenuKey(highlightableMenuKey)
333                         .setTopLevelMenuKey(highlightableMenuKey)
334                         .setKey(key);
335 
336                 if (!nodeName.equals(NODE_NAME_CHECK_BOX_PREFERENCE)) {
337                     summary = XmlParserUtils.getDataSummary(context, attrs);
338 
339                     String entries = null;
340 
341                     if (nodeName.endsWith(NODE_NAME_LIST_PREFERENCE)) {
342                         entries = XmlParserUtils.getDataEntries(context, attrs);
343                     }
344 
345                     // TODO (b/62254931) index primitives instead of payload
346                     // TODO (b/62807132) Add proper inline support
347                     //payload = DatabaseIndexingUtils.getPayloadFromUriMap(controllerUriMap, key);
348                     childFragment = XmlParserUtils.getDataChildFragment(context, attrs);
349 
350                     builder.setSummaryOn(summary)
351                             .setEntries(entries)
352                             .setChildClassName(childFragment);
353                     tryAddIndexDataToList(resourceIndexData, builder);
354                 } else {
355                     // TODO (b/33577327) We removed summary off here. We should check if we can
356                     // merge this 'else' section with the one above. Put a break point to
357                     // investigate.
358                     String summaryOn = XmlParserUtils.getDataSummaryOn(context, attrs);
359 
360                     if (TextUtils.isEmpty(summaryOn)) {
361                         summaryOn = XmlParserUtils.getDataSummary(context, attrs);
362                     }
363 
364                     builder.setSummaryOn(summaryOn);
365 
366                     tryAddIndexDataToList(resourceIndexData, builder);
367                 }
368             }
369 
370             // The xml header's title does not match the title of one of the child settings.
371             if (isHeaderUnique) {
372                 tryAddIndexDataToList(resourceIndexData, headerBuilder);
373             }
374         } catch (XmlPullParserException e) {
375             Log.w(TAG, "XML Error parsing PreferenceScreen: " + sir.className, e);
376         } catch (IOException e) {
377             Log.w(TAG, "IO Error parsing PreferenceScreen: " + sir.className, e);
378         } catch (Resources.NotFoundException e) {
379             Log.w(TAG, "Resoucre not found error parsing PreferenceScreen: " + sir.className, e);
380         } finally {
381             if (parser != null) {
382                 parser.close();
383             }
384         }
385         return resourceIndexData;
386     }
387 
tryAddIndexDataToList(List<IndexData> list, IndexData.Builder data)388     private void tryAddIndexDataToList(List<IndexData> list, IndexData.Builder data) {
389         if (!TextUtils.isEmpty(data.getKey())) {
390             list.add(data.build());
391         } else {
392             Log.w(TAG, "Skipping index for null-key item " + data);
393         }
394     }
395 
getNonIndexableKeysForResource(Map<String, Set<String>> nonIndexableKeys, String authority)396     private Set<String> getNonIndexableKeysForResource(Map<String, Set<String>> nonIndexableKeys,
397             String authority) {
398         final Set<String> result = nonIndexableKeys.get(authority);
399         return result != null ? result : new ArraySet<>();
400     }
401 
getIndexDataBuilder()402     protected IndexData.Builder getIndexDataBuilder() {
403         return new IndexData.Builder();
404     }
405 }
406