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