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