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.content.Context; 19 import android.content.Intent; 20 import android.content.SharedPreferences; 21 import android.os.UserHandle; 22 import android.text.TextUtils; 23 import android.util.ArrayMap; 24 import android.util.AttributeSet; 25 import android.util.Log; 26 import android.util.Pair; 27 import android.util.Xml; 28 import android.provider.Settings; 29 import android.accounts.Account; 30 import android.accounts.AccountManager; 31 import android.content.pm.PackageManager; 32 import android.content.res.Resources; 33 import android.view.InflateException; 34 import com.android.settingslib.drawer.Tile; 35 import com.android.settingslib.drawer.TileUtils; 36 import org.xmlpull.v1.XmlPullParser; 37 import org.xmlpull.v1.XmlPullParserException; 38 39 import java.io.IOException; 40 import java.util.ArrayList; 41 import java.util.List; 42 43 public class SuggestionParser { 44 45 private static final String TAG = "SuggestionParser"; 46 47 // If defined, only returns this suggestion if the feature is supported. 48 public static final String META_DATA_REQUIRE_FEATURE = "com.android.settings.require_feature"; 49 50 // If defined, only display this optional step if an account of that type exists. 51 private static final String META_DATA_REQUIRE_ACCOUNT = "com.android.settings.require_account"; 52 53 // If defined and not true, do not should optional step. 54 private static final String META_DATA_IS_SUPPORTED = "com.android.settings.is_supported"; 55 56 /** 57 * Allows suggestions to appear after a certain number of days, and to re-appear if dismissed. 58 * For instance: 59 * 0,10 60 * Will appear immediately, but if the user removes it, it will come back after 10 days. 61 * 62 * Another example: 63 * 10,30 64 * Will only show up after 10 days, and then again after 30. 65 */ 66 public static final String META_DATA_DISMISS_CONTROL = "com.android.settings.dismiss"; 67 68 // Shared prefs keys for storing dismissed state. 69 // Index into current dismissed state. 70 private static final String DISMISS_INDEX = "_dismiss_index"; 71 private static final String SETUP_TIME = "_setup_time"; 72 private static final String IS_DISMISSED = "_is_dismissed"; 73 74 private static final long MILLIS_IN_DAY = 24 * 60 * 60 * 1000; 75 76 private final Context mContext; 77 private final List<SuggestionCategory> mSuggestionList; 78 private final ArrayMap<Pair<String, String>, Tile> addCache = new ArrayMap<>(); 79 private final SharedPreferences mSharedPrefs; 80 81 public SuggestionParser(Context context, SharedPreferences sharedPrefs, int orderXml) { 82 mContext = context; 83 mSuggestionList = (List<SuggestionCategory>) new SuggestionOrderInflater(mContext) 84 .parse(orderXml); 85 mSharedPrefs = sharedPrefs; 86 } 87 88 public List<Tile> getSuggestions() { 89 List<Tile> suggestions = new ArrayList<>(); 90 final int N = mSuggestionList.size(); 91 for (int i = 0; i < N; i++) { 92 readSuggestions(mSuggestionList.get(i), suggestions); 93 } 94 return suggestions; 95 } 96 97 /** 98 * Dismisses a suggestion, returns true if the suggestion has no more dismisses left and should 99 * be disabled. 100 */ 101 public boolean dismissSuggestion(Tile suggestion) { 102 String keyBase = suggestion.intent.getComponent().flattenToShortString(); 103 int index = mSharedPrefs.getInt(keyBase + DISMISS_INDEX, 0); 104 String dismissControl = suggestion.metaData.getString(META_DATA_DISMISS_CONTROL); 105 if (dismissControl == null || parseDismissString(dismissControl).length == index) { 106 return true; 107 } 108 mSharedPrefs.edit() 109 .putBoolean(keyBase + IS_DISMISSED, true) 110 .commit(); 111 return false; 112 } 113 114 private void readSuggestions(SuggestionCategory category, List<Tile> suggestions) { 115 int countBefore = suggestions.size(); 116 Intent intent = new Intent(Intent.ACTION_MAIN); 117 intent.addCategory(category.category); 118 if (category.pkg != null) { 119 intent.setPackage(category.pkg); 120 } 121 TileUtils.getTilesForIntent(mContext, new UserHandle(UserHandle.myUserId()), intent, 122 addCache, null, suggestions, true, false); 123 for (int i = countBefore; i < suggestions.size(); i++) { 124 if (!isAvailable(suggestions.get(i)) || 125 !isSupported(suggestions.get(i)) || 126 !satisfiesRequiredAccount(suggestions.get(i)) || 127 isDismissed(suggestions.get(i))) { 128 suggestions.remove(i--); 129 } 130 } 131 if (!category.multiple && suggestions.size() > (countBefore + 1)) { 132 // If there are too many, remove them all and only re-add the one with the highest 133 // priority. 134 Tile item = suggestions.remove(suggestions.size() - 1); 135 while (suggestions.size() > countBefore) { 136 Tile last = suggestions.remove(suggestions.size() - 1); 137 if (last.priority > item.priority) { 138 item = last; 139 } 140 } 141 // If category is marked as done, do not add any item. 142 if (!isCategoryDone(category.category)) { 143 suggestions.add(item); 144 } 145 } 146 } 147 148 private boolean isAvailable(Tile suggestion) { 149 String featureRequired = suggestion.metaData.getString(META_DATA_REQUIRE_FEATURE); 150 if (featureRequired != null) { 151 return mContext.getPackageManager().hasSystemFeature(featureRequired); 152 } 153 return true; 154 } 155 156 public boolean satisfiesRequiredAccount(Tile suggestion) { 157 String requiredAccountType = suggestion.metaData.getString(META_DATA_REQUIRE_ACCOUNT); 158 if (requiredAccountType == null) { 159 return true; 160 } 161 AccountManager accountManager = AccountManager.get(mContext); 162 Account[] accounts = accountManager.getAccountsByType(requiredAccountType); 163 return accounts.length > 0; 164 } 165 166 public boolean isSupported(Tile suggestion) { 167 int isSupportedResource = suggestion.metaData.getInt(META_DATA_IS_SUPPORTED); 168 try { 169 if (suggestion.intent == null) { 170 return false; 171 } 172 final Resources res = mContext.getPackageManager().getResourcesForActivity( 173 suggestion.intent.getComponent()); 174 return isSupportedResource != 0 ? res.getBoolean(isSupportedResource) : true; 175 } catch (PackageManager.NameNotFoundException e) { 176 Log.w(TAG, "Cannot find resources for " + suggestion.intent.getComponent()); 177 return false; 178 } catch (Resources.NotFoundException e) { 179 Log.w(TAG, "Cannot find resources for " + suggestion.intent.getComponent(), e); 180 return false; 181 } 182 } 183 184 public boolean isCategoryDone(String category) { 185 String name = Settings.Secure.COMPLETED_CATEGORY_PREFIX + category; 186 return Settings.Secure.getInt(mContext.getContentResolver(), name, 0) != 0; 187 } 188 189 public void markCategoryDone(String category) { 190 String name = Settings.Secure.COMPLETED_CATEGORY_PREFIX + category; 191 Settings.Secure.putInt(mContext.getContentResolver(), name, 1); 192 } 193 194 private boolean isDismissed(Tile suggestion) { 195 Object dismissObj = suggestion.metaData.get(META_DATA_DISMISS_CONTROL); 196 if (dismissObj == null) { 197 return false; 198 } 199 String dismissControl = String.valueOf(dismissObj); 200 String keyBase = suggestion.intent.getComponent().flattenToShortString(); 201 if (!mSharedPrefs.contains(keyBase + SETUP_TIME)) { 202 mSharedPrefs.edit() 203 .putLong(keyBase + SETUP_TIME, System.currentTimeMillis()) 204 .commit(); 205 } 206 // Default to dismissed, so that we can have suggestions that only first appear after 207 // some number of days. 208 if (!mSharedPrefs.getBoolean(keyBase + IS_DISMISSED, true)) { 209 return false; 210 } 211 int index = mSharedPrefs.getInt(keyBase + DISMISS_INDEX, 0); 212 int currentDismiss = parseDismissString(dismissControl)[index]; 213 long time = getEndTime(mSharedPrefs.getLong(keyBase + SETUP_TIME, 0), currentDismiss); 214 if (System.currentTimeMillis() >= time) { 215 // Dismiss timeout has passed, undismiss it. 216 mSharedPrefs.edit() 217 .putBoolean(keyBase + IS_DISMISSED, false) 218 .putInt(keyBase + DISMISS_INDEX, index + 1) 219 .commit(); 220 return false; 221 } 222 return true; 223 } 224 225 private long getEndTime(long startTime, int daysDelay) { 226 long days = daysDelay * MILLIS_IN_DAY; 227 return startTime + days; 228 } 229 230 private int[] parseDismissString(String dismissControl) { 231 String[] dismissStrs = dismissControl.split(","); 232 int[] dismisses = new int[dismissStrs.length]; 233 for (int i = 0; i < dismissStrs.length; i++) { 234 dismisses[i] = Integer.parseInt(dismissStrs[i]); 235 } 236 return dismisses; 237 } 238 239 private static class SuggestionCategory { 240 public String category; 241 public String pkg; 242 public boolean multiple; 243 } 244 245 private static class SuggestionOrderInflater { 246 private static final String TAG_LIST = "optional-steps"; 247 private static final String TAG_ITEM = "step"; 248 249 private static final String ATTR_CATEGORY = "category"; 250 private static final String ATTR_PACKAGE = "package"; 251 private static final String ATTR_MULTIPLE = "multiple"; 252 253 private final Context mContext; 254 255 public SuggestionOrderInflater(Context context) { 256 mContext = context; 257 } 258 259 public Object parse(int resource) { 260 XmlPullParser parser = mContext.getResources().getXml(resource); 261 final AttributeSet attrs = Xml.asAttributeSet(parser); 262 try { 263 // Look for the root node. 264 int type; 265 do { 266 type = parser.next(); 267 } while (type != XmlPullParser.START_TAG && type != XmlPullParser.END_DOCUMENT); 268 269 if (type != XmlPullParser.START_TAG) { 270 throw new InflateException(parser.getPositionDescription() 271 + ": No start tag found!"); 272 } 273 274 // Temp is the root that was found in the xml 275 Object xmlRoot = onCreateItem(parser.getName(), attrs); 276 277 // Inflate all children under temp 278 rParse(parser, xmlRoot, attrs); 279 return xmlRoot; 280 } catch (XmlPullParserException | IOException e) { 281 Log.w(TAG, "Problem parser resource " + resource, e); 282 return null; 283 } 284 } 285 286 /** 287 * Recursive method used to descend down the xml hierarchy and instantiate 288 * items, instantiate their children. 289 */ 290 private void rParse(XmlPullParser parser, Object parent, final AttributeSet attrs) 291 throws XmlPullParserException, IOException { 292 final int depth = parser.getDepth(); 293 294 int type; 295 while (((type = parser.next()) != XmlPullParser.END_TAG || 296 parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) { 297 if (type != XmlPullParser.START_TAG) { 298 continue; 299 } 300 301 final String name = parser.getName(); 302 303 Object item = onCreateItem(name, attrs); 304 onAddChildItem(parent, item); 305 rParse(parser, item, attrs); 306 } 307 } 308 309 protected void onAddChildItem(Object parent, Object child) { 310 if (parent instanceof List<?> && child instanceof SuggestionCategory) { 311 ((List<SuggestionCategory>) parent).add((SuggestionCategory) child); 312 } else { 313 throw new IllegalArgumentException("Parent was not a list"); 314 } 315 } 316 317 protected Object onCreateItem(String name, AttributeSet attrs) { 318 if (name.equals(TAG_LIST)) { 319 return new ArrayList<SuggestionCategory>(); 320 } else if (name.equals(TAG_ITEM)) { 321 SuggestionCategory category = new SuggestionCategory(); 322 category.category = attrs.getAttributeValue(null, ATTR_CATEGORY); 323 category.pkg = attrs.getAttributeValue(null, ATTR_PACKAGE); 324 String multiple = attrs.getAttributeValue(null, ATTR_MULTIPLE); 325 category.multiple = !TextUtils.isEmpty(multiple) && Boolean.parseBoolean(multiple); 326 return category; 327 } else { 328 throw new IllegalArgumentException("Unknown item " + name); 329 } 330 } 331 } 332 } 333 334