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