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