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 */
16package com.android.settingslib;
17
18import android.content.Context;
19import android.content.Intent;
20import android.content.SharedPreferences;
21import android.os.UserHandle;
22import android.text.TextUtils;
23import android.util.ArrayMap;
24import android.util.AttributeSet;
25import android.util.Log;
26import android.util.Pair;
27import android.util.Xml;
28import android.provider.Settings;
29import android.accounts.Account;
30import android.accounts.AccountManager;
31import android.content.pm.PackageManager;
32import android.content.res.Resources;
33import android.view.InflateException;
34import com.android.settingslib.drawer.Tile;
35import com.android.settingslib.drawer.TileUtils;
36import org.xmlpull.v1.XmlPullParser;
37import org.xmlpull.v1.XmlPullParserException;
38
39import java.io.IOException;
40import java.util.ArrayList;
41import java.util.List;
42
43public 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