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