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 package com.android.settingslib.suggestions; 17 18 import android.Manifest; 19 import android.accounts.Account; 20 import android.accounts.AccountManager; 21 import android.annotation.RequiresPermission; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.content.SharedPreferences; 25 import android.content.pm.PackageManager; 26 import android.content.pm.UserInfo; 27 import android.content.res.Resources; 28 import android.net.ConnectivityManager; 29 import android.net.NetworkInfo; 30 import android.os.UserHandle; 31 import android.os.UserManager; 32 import android.provider.Settings; 33 import android.support.annotation.VisibleForTesting; 34 import android.text.TextUtils; 35 import android.text.format.DateUtils; 36 import android.util.ArrayMap; 37 import android.util.AttributeSet; 38 import android.util.Log; 39 import android.util.Pair; 40 import android.util.Xml; 41 import android.view.InflateException; 42 43 import com.android.settingslib.drawer.Tile; 44 import com.android.settingslib.drawer.TileUtils; 45 46 import org.xmlpull.v1.XmlPullParser; 47 import org.xmlpull.v1.XmlPullParserException; 48 49 import java.io.IOException; 50 import java.util.ArrayList; 51 import java.util.List; 52 53 public class SuggestionParser { 54 55 private static final String TAG = "SuggestionParser"; 56 57 // If defined, only returns this suggestion if the feature is supported. 58 public static final String META_DATA_REQUIRE_FEATURE = "com.android.settings.require_feature"; 59 60 // If defined, only display this optional step if an account of that type exists. 61 private static final String META_DATA_REQUIRE_ACCOUNT = "com.android.settings.require_account"; 62 63 // If defined and not true, do not should optional step. 64 private static final String META_DATA_IS_SUPPORTED = "com.android.settings.is_supported"; 65 66 // If defined, only display this optional step if the current user is of that type. 67 private static final String META_DATA_REQUIRE_USER_TYPE = 68 "com.android.settings.require_user_type"; 69 70 // If defined, only display this optional step if a connection is available. 71 private static final String META_DATA_IS_CONNECTION_REQUIRED = 72 "com.android.settings.require_connection"; 73 74 // The valid values that setup wizard recognizes for differentiating user types. 75 private static final String META_DATA_PRIMARY_USER_TYPE_VALUE = "primary"; 76 private static final String META_DATA_ADMIN_USER_TYPE_VALUE = "admin"; 77 private static final String META_DATA_GUEST_USER_TYPE_VALUE = "guest"; 78 private static final String META_DATA_RESTRICTED_USER_TYPE_VALUE = "restricted"; 79 80 /** 81 * Allows suggestions to appear after a certain number of days, and to re-appear if dismissed. 82 * For instance: 83 * 0,10 84 * Will appear immediately, but if the user removes it, it will come back after 10 days. 85 * 86 * Another example: 87 * 10,30 88 * Will only show up after 10 days, and then again after 30. 89 */ 90 public static final String META_DATA_DISMISS_CONTROL = "com.android.settings.dismiss"; 91 92 // Shared prefs keys for storing dismissed state. 93 // Index into current dismissed state. 94 public static final String SETUP_TIME = "_setup_time"; 95 private static final String IS_DISMISSED = "_is_dismissed"; 96 97 // Default dismiss control for smart suggestions. 98 private static final String DEFAULT_SMART_DISMISS_CONTROL = "0"; 99 100 private final Context mContext; 101 private final List<SuggestionCategory> mSuggestionList; 102 private final ArrayMap<Pair<String, String>, Tile> mAddCache = new ArrayMap<>(); 103 private final SharedPreferences mSharedPrefs; 104 private final String mDefaultDismissControl; 105 106 public SuggestionParser(Context context, SharedPreferences sharedPrefs, int orderXml, 107 String defaultDismissControl) { 108 this( 109 context, 110 sharedPrefs, 111 (List<SuggestionCategory>) new SuggestionOrderInflater(context).parse(orderXml), 112 defaultDismissControl); 113 } 114 115 public SuggestionParser(Context context, SharedPreferences sharedPrefs, int orderXml) { 116 this(context, sharedPrefs, orderXml, DEFAULT_SMART_DISMISS_CONTROL); 117 } 118 119 @VisibleForTesting 120 public SuggestionParser( 121 Context context, 122 SharedPreferences sharedPrefs, 123 List<SuggestionCategory> suggestionList, 124 String defaultDismissControl) { 125 mContext = context; 126 mSuggestionList = suggestionList; 127 mSharedPrefs = sharedPrefs; 128 mDefaultDismissControl = defaultDismissControl; 129 } 130 131 public SuggestionList getSuggestions(boolean isSmartSuggestionEnabled) { 132 final SuggestionList suggestionList = new SuggestionList(); 133 final int N = mSuggestionList.size(); 134 for (int i = 0; i < N; i++) { 135 final SuggestionCategory category = mSuggestionList.get(i); 136 if (category.exclusive && !isExclusiveCategoryExpired(category)) { 137 // If suggestions from an exclusive category are present, parsing is stopped 138 // and only suggestions from that category are displayed. Note that subsequent 139 // exclusive categories are also ignored. 140 final List<Tile> exclusiveSuggestions = new ArrayList<>(); 141 142 // Read suggestion and force isSmartSuggestion to be false so the rule defined 143 // from each suggestion itself is used. 144 readSuggestions(category, exclusiveSuggestions, false /* isSmartSuggestion */); 145 if (!exclusiveSuggestions.isEmpty()) { 146 final SuggestionList exclusiveList = new SuggestionList(); 147 exclusiveList.addSuggestions(category, exclusiveSuggestions); 148 return exclusiveList; 149 } 150 } else { 151 // Either the category is not exclusive, or the exclusiveness expired so we should 152 // treat it as a normal category. 153 final List<Tile> suggestions = new ArrayList<>(); 154 readSuggestions(category, suggestions, isSmartSuggestionEnabled); 155 suggestionList.addSuggestions(category, suggestions); 156 } 157 } 158 return suggestionList; 159 } 160 161 /** 162 * Dismisses a suggestion, returns true if the suggestion has no more dismisses left and should 163 * be disabled. 164 */ 165 public boolean dismissSuggestion(Tile suggestion) { 166 final String keyBase = suggestion.intent.getComponent().flattenToShortString(); 167 mSharedPrefs.edit() 168 .putBoolean(keyBase + IS_DISMISSED, true) 169 .commit(); 170 return true; 171 } 172 173 @VisibleForTesting 174 public void filterSuggestions( 175 List<Tile> suggestions, int countBefore, boolean isSmartSuggestionEnabled) { 176 for (int i = countBefore; i < suggestions.size(); i++) { 177 if (!isAvailable(suggestions.get(i)) || 178 !isSupported(suggestions.get(i)) || 179 !satisifesRequiredUserType(suggestions.get(i)) || 180 !satisfiesRequiredAccount(suggestions.get(i)) || 181 !satisfiesConnectivity(suggestions.get(i)) || 182 isDismissed(suggestions.get(i), isSmartSuggestionEnabled)) { 183 suggestions.remove(i--); 184 } 185 } 186 } 187 188 @VisibleForTesting 189 void readSuggestions( 190 SuggestionCategory category, List<Tile> suggestions, boolean isSmartSuggestionEnabled) { 191 int countBefore = suggestions.size(); 192 Intent intent = new Intent(Intent.ACTION_MAIN); 193 intent.addCategory(category.category); 194 if (category.pkg != null) { 195 intent.setPackage(category.pkg); 196 } 197 TileUtils.getTilesForIntent(mContext, new UserHandle(UserHandle.myUserId()), intent, 198 mAddCache, null, suggestions, true, false, false, true /* shouldUpdateTiles */); 199 filterSuggestions(suggestions, countBefore, isSmartSuggestionEnabled); 200 if (!category.multiple && suggestions.size() > (countBefore + 1)) { 201 // If there are too many, remove them all and only re-add the one with the highest 202 // priority. 203 Tile item = suggestions.remove(suggestions.size() - 1); 204 while (suggestions.size() > countBefore) { 205 Tile last = suggestions.remove(suggestions.size() - 1); 206 if (last.priority > item.priority) { 207 item = last; 208 } 209 } 210 // If category is marked as done, do not add any item. 211 if (!isCategoryDone(category.category)) { 212 suggestions.add(item); 213 } 214 } 215 } 216 217 private boolean isAvailable(Tile suggestion) { 218 final String featuresRequired = suggestion.metaData.getString(META_DATA_REQUIRE_FEATURE); 219 if (featuresRequired != null) { 220 for (String feature : featuresRequired.split(",")) { 221 if (TextUtils.isEmpty(feature)) { 222 Log.w(TAG, "Found empty substring when parsing required features: " 223 + featuresRequired); 224 } else if (!mContext.getPackageManager().hasSystemFeature(feature)) { 225 Log.i(TAG, suggestion.title + " requires unavailable feature " + feature); 226 return false; 227 } 228 } 229 } 230 return true; 231 } 232 233 @RequiresPermission(Manifest.permission.MANAGE_USERS) 234 private boolean satisifesRequiredUserType(Tile suggestion) { 235 final String requiredUser = suggestion.metaData.getString(META_DATA_REQUIRE_USER_TYPE); 236 if (requiredUser != null) { 237 final UserManager userManager = mContext.getSystemService(UserManager.class); 238 UserInfo userInfo = userManager.getUserInfo(UserHandle.myUserId()); 239 for (String userType : requiredUser.split("\\|")) { 240 final boolean primaryUserCondtionMet = userInfo.isPrimary() 241 && META_DATA_PRIMARY_USER_TYPE_VALUE.equals(userType); 242 final boolean adminUserConditionMet = userInfo.isAdmin() 243 && META_DATA_ADMIN_USER_TYPE_VALUE.equals(userType); 244 final boolean guestUserCondtionMet = userInfo.isGuest() 245 && META_DATA_GUEST_USER_TYPE_VALUE.equals(userType); 246 final boolean restrictedUserCondtionMet = userInfo.isRestricted() 247 && META_DATA_RESTRICTED_USER_TYPE_VALUE.equals(userType); 248 if (primaryUserCondtionMet || adminUserConditionMet || guestUserCondtionMet 249 || restrictedUserCondtionMet) { 250 return true; 251 } 252 } 253 Log.i(TAG, suggestion.title + " requires user type " + requiredUser); 254 return false; 255 } 256 return true; 257 } 258 259 public boolean satisfiesRequiredAccount(Tile suggestion) { 260 final String requiredAccountType = suggestion.metaData.getString(META_DATA_REQUIRE_ACCOUNT); 261 if (requiredAccountType == null) { 262 return true; 263 } 264 AccountManager accountManager = AccountManager.get(mContext); 265 Account[] accounts = accountManager.getAccountsByType(requiredAccountType); 266 boolean satisfiesRequiredAccount = accounts.length > 0; 267 if (!satisfiesRequiredAccount) { 268 Log.i(TAG, suggestion.title + " requires unavailable account type " 269 + requiredAccountType); 270 } 271 return satisfiesRequiredAccount; 272 } 273 274 public boolean isSupported(Tile suggestion) { 275 final int isSupportedResource = suggestion.metaData.getInt(META_DATA_IS_SUPPORTED); 276 try { 277 if (suggestion.intent == null) { 278 return false; 279 } 280 final Resources res = mContext.getPackageManager().getResourcesForActivity( 281 suggestion.intent.getComponent()); 282 boolean isSupported = 283 isSupportedResource != 0 ? res.getBoolean(isSupportedResource) : true; 284 if (!isSupported) { 285 Log.i(TAG, suggestion.title + " requires unsupported resource " 286 + isSupportedResource); 287 } 288 return isSupported; 289 } catch (PackageManager.NameNotFoundException e) { 290 Log.w(TAG, "Cannot find resources for " + suggestion.intent.getComponent()); 291 return false; 292 } catch (Resources.NotFoundException e) { 293 Log.w(TAG, "Cannot find resources for " + suggestion.intent.getComponent(), e); 294 return false; 295 } 296 } 297 298 private boolean satisfiesConnectivity(Tile suggestion) { 299 final boolean isConnectionRequired = 300 suggestion.metaData.getBoolean(META_DATA_IS_CONNECTION_REQUIRED); 301 if (!isConnectionRequired) { 302 return true; 303 } 304 ConnectivityManager cm = 305 (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); 306 NetworkInfo netInfo = cm.getActiveNetworkInfo(); 307 boolean satisfiesConnectivity = netInfo != null && netInfo.isConnectedOrConnecting(); 308 if (!satisfiesConnectivity) { 309 Log.i(TAG, suggestion.title + " is missing required connection."); 310 } 311 return satisfiesConnectivity; 312 } 313 314 public boolean isCategoryDone(String category) { 315 String name = Settings.Secure.COMPLETED_CATEGORY_PREFIX + category; 316 return Settings.Secure.getInt(mContext.getContentResolver(), name, 0) != 0; 317 } 318 319 public void markCategoryDone(String category) { 320 String name = Settings.Secure.COMPLETED_CATEGORY_PREFIX + category; 321 Settings.Secure.putInt(mContext.getContentResolver(), name, 1); 322 } 323 324 /** 325 * Whether or not the category's exclusiveness has expired. 326 */ 327 private boolean isExclusiveCategoryExpired(SuggestionCategory category) { 328 final String keySetupTime = category.category + SETUP_TIME; 329 final long currentTime = System.currentTimeMillis(); 330 if (!mSharedPrefs.contains(keySetupTime)) { 331 mSharedPrefs.edit() 332 .putLong(keySetupTime, currentTime) 333 .commit(); 334 } 335 if (category.exclusiveExpireDaysInMillis < 0) { 336 // negative means never expires 337 return false; 338 } 339 final long setupTime = mSharedPrefs.getLong(keySetupTime, 0); 340 final long elapsedTime = currentTime - setupTime; 341 Log.d(TAG, "Day " + elapsedTime / DateUtils.DAY_IN_MILLIS + " for " + category.category); 342 return elapsedTime > category.exclusiveExpireDaysInMillis; 343 } 344 345 @VisibleForTesting 346 boolean isDismissed(Tile suggestion, boolean isSmartSuggestionEnabled) { 347 String dismissControl = getDismissControl(suggestion, isSmartSuggestionEnabled); 348 String keyBase = suggestion.intent.getComponent().flattenToShortString(); 349 if (!mSharedPrefs.contains(keyBase + SETUP_TIME)) { 350 mSharedPrefs.edit() 351 .putLong(keyBase + SETUP_TIME, System.currentTimeMillis()) 352 .commit(); 353 } 354 // Check if it's already manually dismissed 355 final boolean isDismissed = mSharedPrefs.getBoolean(keyBase + IS_DISMISSED, false); 356 if (isDismissed) { 357 return true; 358 } 359 if (dismissControl == null) { 360 return false; 361 } 362 // Parse when suggestion should first appear. return true to artificially hide suggestion 363 // before then. 364 int firstAppearDay = parseDismissString(dismissControl); 365 long firstAppearDayInMs = getEndTime(mSharedPrefs.getLong(keyBase + SETUP_TIME, 0), 366 firstAppearDay); 367 if (System.currentTimeMillis() >= firstAppearDayInMs) { 368 // Dismiss timeout has passed, undismiss it. 369 mSharedPrefs.edit() 370 .putBoolean(keyBase + IS_DISMISSED, false) 371 .commit(); 372 return false; 373 } 374 return true; 375 } 376 377 private long getEndTime(long startTime, int daysDelay) { 378 long days = daysDelay * DateUtils.DAY_IN_MILLIS; 379 return startTime + days; 380 } 381 382 /** 383 * Parse the first int from a string formatted as "0,1,2..." 384 * The value means suggestion should first appear on Day X. 385 */ 386 private int parseDismissString(String dismissControl) { 387 final String[] dismissStrs = dismissControl.split(","); 388 return Integer.parseInt(dismissStrs[0]); 389 } 390 391 private String getDismissControl(Tile suggestion, boolean isSmartSuggestionEnabled) { 392 if (isSmartSuggestionEnabled) { 393 return mDefaultDismissControl; 394 } else { 395 return suggestion.metaData.getString(META_DATA_DISMISS_CONTROL); 396 } 397 } 398 399 private static class SuggestionOrderInflater { 400 private static final String TAG_LIST = "optional-steps"; 401 private static final String TAG_ITEM = "step"; 402 403 private static final String ATTR_CATEGORY = "category"; 404 private static final String ATTR_PACKAGE = "package"; 405 private static final String ATTR_MULTIPLE = "multiple"; 406 private static final String ATTR_EXCLUSIVE = "exclusive"; 407 private static final String ATTR_EXCLUSIVE_EXPIRE_DAYS = "exclusiveExpireDays"; 408 409 private final Context mContext; 410 411 public SuggestionOrderInflater(Context context) { 412 mContext = context; 413 } 414 415 public Object parse(int resource) { 416 XmlPullParser parser = mContext.getResources().getXml(resource); 417 final AttributeSet attrs = Xml.asAttributeSet(parser); 418 try { 419 // Look for the root node. 420 int type; 421 do { 422 type = parser.next(); 423 } while (type != XmlPullParser.START_TAG && type != XmlPullParser.END_DOCUMENT); 424 425 if (type != XmlPullParser.START_TAG) { 426 throw new InflateException(parser.getPositionDescription() 427 + ": No start tag found!"); 428 } 429 430 // Temp is the root that was found in the xml 431 Object xmlRoot = onCreateItem(parser.getName(), attrs); 432 433 // Inflate all children under temp 434 rParse(parser, xmlRoot, attrs); 435 return xmlRoot; 436 } catch (XmlPullParserException | IOException e) { 437 Log.w(TAG, "Problem parser resource " + resource, e); 438 return null; 439 } 440 } 441 442 /** 443 * Recursive method used to descend down the xml hierarchy and instantiate 444 * items, instantiate their children. 445 */ 446 private void rParse(XmlPullParser parser, Object parent, final AttributeSet attrs) 447 throws XmlPullParserException, IOException { 448 final int depth = parser.getDepth(); 449 450 int type; 451 while (((type = parser.next()) != XmlPullParser.END_TAG || 452 parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) { 453 if (type != XmlPullParser.START_TAG) { 454 continue; 455 } 456 457 final String name = parser.getName(); 458 459 Object item = onCreateItem(name, attrs); 460 onAddChildItem(parent, item); 461 rParse(parser, item, attrs); 462 } 463 } 464 465 protected void onAddChildItem(Object parent, Object child) { 466 if (parent instanceof List<?> && child instanceof SuggestionCategory) { 467 ((List<SuggestionCategory>) parent).add((SuggestionCategory) child); 468 } else { 469 throw new IllegalArgumentException("Parent was not a list"); 470 } 471 } 472 473 protected Object onCreateItem(String name, AttributeSet attrs) { 474 if (name.equals(TAG_LIST)) { 475 return new ArrayList<SuggestionCategory>(); 476 } else if (name.equals(TAG_ITEM)) { 477 SuggestionCategory category = new SuggestionCategory(); 478 category.category = attrs.getAttributeValue(null, ATTR_CATEGORY); 479 category.pkg = attrs.getAttributeValue(null, ATTR_PACKAGE); 480 String multiple = attrs.getAttributeValue(null, ATTR_MULTIPLE); 481 category.multiple = !TextUtils.isEmpty(multiple) && Boolean.parseBoolean(multiple); 482 String exclusive = attrs.getAttributeValue(null, ATTR_EXCLUSIVE); 483 category.exclusive = 484 !TextUtils.isEmpty(exclusive) && Boolean.parseBoolean(exclusive); 485 String expireDaysAttr = attrs.getAttributeValue(null, 486 ATTR_EXCLUSIVE_EXPIRE_DAYS); 487 long expireDays = !TextUtils.isEmpty(expireDaysAttr) 488 ? Integer.parseInt(expireDaysAttr) 489 : -1; 490 category.exclusiveExpireDaysInMillis = DateUtils.DAY_IN_MILLIS * expireDays; 491 return category; 492 } else { 493 throw new IllegalArgumentException("Unknown item " + name); 494 } 495 } 496 } 497 } 498 499