1/*
2 * Copyright (C) 2014 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
17package com.android.launcher3;
18
19import android.appwidget.AppWidgetHost;
20import android.appwidget.AppWidgetManager;
21import android.content.ComponentName;
22import android.content.ContentValues;
23import android.content.Context;
24import android.content.Intent;
25import android.content.pm.ActivityInfo;
26import android.content.pm.PackageManager;
27import android.content.res.Resources;
28import android.content.res.XmlResourceParser;
29import android.database.sqlite.SQLiteDatabase;
30import android.graphics.drawable.Drawable;
31import android.net.Uri;
32import android.os.Bundle;
33import android.text.TextUtils;
34import android.util.Log;
35import android.util.Pair;
36import android.util.Patterns;
37
38import com.android.launcher3.LauncherProvider.SqlArguments;
39import com.android.launcher3.LauncherProvider.WorkspaceLoader;
40import com.android.launcher3.LauncherSettings.Favorites;
41
42import org.xmlpull.v1.XmlPullParser;
43import org.xmlpull.v1.XmlPullParserException;
44
45import java.io.IOException;
46import java.util.ArrayList;
47import java.util.HashMap;
48
49/**
50 * This class contains contains duplication of functionality as found in
51 * LauncherProvider#DatabaseHelper. It has been isolated and differentiated in order
52 * to cleanly and separately represent AutoInstall default layout format and policy.
53 */
54public class AutoInstallsLayout implements WorkspaceLoader {
55    private static final String TAG = "AutoInstalls";
56    private static final boolean LOGD = true;
57
58    /** Marker action used to discover a package which defines launcher customization */
59    static final String ACTION_LAUNCHER_CUSTOMIZATION =
60            "android.autoinstalls.config.action.PLAY_AUTO_INSTALL";
61
62    private static final String LAYOUT_RES = "default_layout";
63
64    static AutoInstallsLayout get(Context context, AppWidgetHost appWidgetHost,
65            LayoutParserCallback callback) {
66        Pair<String, Resources> customizationApkInfo = Utilities.findSystemApk(
67                ACTION_LAUNCHER_CUSTOMIZATION, context.getPackageManager());
68        if (customizationApkInfo == null) {
69            return null;
70        }
71
72        String pkg = customizationApkInfo.first;
73        Resources res = customizationApkInfo.second;
74        int layoutId = res.getIdentifier(LAYOUT_RES, "xml", pkg);
75        if (layoutId == 0) {
76            Log.e(TAG, "Layout definition not found in package: " + pkg);
77            return null;
78        }
79        return new AutoInstallsLayout(context, appWidgetHost, callback, pkg, res, layoutId);
80    }
81
82    // Object Tags
83    private static final String TAG_WORKSPACE = "workspace";
84    private static final String TAG_APP_ICON = "appicon";
85    private static final String TAG_AUTO_INSTALL = "autoinstall";
86    private static final String TAG_FOLDER = "folder";
87    private static final String TAG_APPWIDGET = "appwidget";
88    private static final String TAG_SHORTCUT = "shortcut";
89    private static final String TAG_EXTRA = "extra";
90
91    private static final String ATTR_CONTAINER = "container";
92    private static final String ATTR_RANK = "rank";
93
94    private static final String ATTR_PACKAGE_NAME = "packageName";
95    private static final String ATTR_CLASS_NAME = "className";
96    private static final String ATTR_TITLE = "title";
97    private static final String ATTR_SCREEN = "screen";
98    private static final String ATTR_X = "x";
99    private static final String ATTR_Y = "y";
100    private static final String ATTR_SPAN_X = "spanX";
101    private static final String ATTR_SPAN_Y = "spanY";
102    private static final String ATTR_ICON = "icon";
103    private static final String ATTR_URL = "url";
104
105    // Style attrs -- "Extra"
106    private static final String ATTR_KEY = "key";
107    private static final String ATTR_VALUE = "value";
108
109    private static final String HOTSEAT_CONTAINER_NAME =
110            Favorites.containerToString(Favorites.CONTAINER_HOTSEAT);
111
112    private static final String ACTION_APPWIDGET_DEFAULT_WORKSPACE_CONFIGURE =
113            "com.android.launcher.action.APPWIDGET_DEFAULT_WORKSPACE_CONFIGURE";
114
115    private final Context mContext;
116    private final AppWidgetHost mAppWidgetHost;
117    private final LayoutParserCallback mCallback;
118
119    private final PackageManager mPackageManager;
120    private final ContentValues mValues;
121
122    private final Resources mRes;
123    private final int mLayoutId;
124
125    private SQLiteDatabase mDb;
126
127    public AutoInstallsLayout(Context context, AppWidgetHost appWidgetHost,
128            LayoutParserCallback callback, String packageName, Resources res, int layoutId) {
129        mContext = context;
130        mAppWidgetHost = appWidgetHost;
131        mCallback = callback;
132
133        mPackageManager = context.getPackageManager();
134        mValues = new ContentValues();
135
136        mRes = res;
137        mLayoutId = layoutId;
138    }
139
140    @Override
141    public int loadLayout(SQLiteDatabase db, ArrayList<Long> screenIds) {
142        mDb = db;
143        try {
144            return parseLayout(mRes, mLayoutId, screenIds);
145        } catch (XmlPullParserException | IOException | RuntimeException e) {
146            Log.w(TAG, "Got exception parsing layout.", e);
147            return -1;
148        }
149    }
150
151    private int parseLayout(Resources res, int layoutId, ArrayList<Long> screenIds)
152            throws XmlPullParserException, IOException {
153        final int hotseatAllAppsRank = LauncherAppState.getInstance()
154                .getDynamicGrid().getDeviceProfile().hotseatAllAppsRank;
155
156        XmlResourceParser parser = res.getXml(layoutId);
157        beginDocument(parser, TAG_WORKSPACE);
158        final int depth = parser.getDepth();
159        int type;
160        HashMap<String, TagParser> tagParserMap = getLayoutElementsMap();
161        int count = 0;
162
163        while (((type = parser.next()) != XmlPullParser.END_TAG ||
164                parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
165            if (type != XmlPullParser.START_TAG) {
166                continue;
167            }
168
169            mValues.clear();
170            final int container;
171            final long screenId;
172
173            if (HOTSEAT_CONTAINER_NAME.equals(getAttributeValue(parser, ATTR_CONTAINER))) {
174                container = Favorites.CONTAINER_HOTSEAT;
175
176                // Hack: hotseat items are stored using screen ids
177                long rank = Long.parseLong(getAttributeValue(parser, ATTR_RANK));
178                screenId = (rank < hotseatAllAppsRank) ? rank : (rank + 1);
179
180            } else {
181                container = Favorites.CONTAINER_DESKTOP;
182                screenId = Long.parseLong(getAttributeValue(parser, ATTR_SCREEN));
183
184                mValues.put(Favorites.CELLX, getAttributeValue(parser, ATTR_X));
185                mValues.put(Favorites.CELLY, getAttributeValue(parser, ATTR_Y));
186            }
187
188            mValues.put(Favorites.CONTAINER, container);
189            mValues.put(Favorites.SCREEN, screenId);
190
191            TagParser tagParser = tagParserMap.get(parser.getName());
192            if (tagParser == null) {
193                if (LOGD) Log.d(TAG, "Ignoring unknown element tag: " + parser.getName());
194                continue;
195            }
196            long newElementId = tagParser.parseAndAdd(parser, res);
197            if (newElementId >= 0) {
198                // Keep track of the set of screens which need to be added to the db.
199                if (!screenIds.contains(screenId) &&
200                        container == Favorites.CONTAINER_DESKTOP) {
201                    screenIds.add(screenId);
202                }
203                count++;
204            }
205        }
206        return count;
207    }
208
209    protected long addShortcut(String title, Intent intent, int type) {
210        long id = mCallback.generateNewItemId();
211        mValues.put(Favorites.INTENT, intent.toUri(0));
212        mValues.put(Favorites.TITLE, title);
213        mValues.put(Favorites.ITEM_TYPE, type);
214        mValues.put(Favorites.SPANX, 1);
215        mValues.put(Favorites.SPANY, 1);
216        mValues.put(Favorites._ID, id);
217        if (mCallback.insertAndCheck(mDb, mValues) < 0) {
218            return -1;
219        } else {
220            return id;
221        }
222    }
223
224    protected HashMap<String, TagParser> getFolderElementsMap() {
225        HashMap<String, TagParser> parsers = new HashMap<String, TagParser>();
226        parsers.put(TAG_APP_ICON, new AppShortcutParser());
227        parsers.put(TAG_AUTO_INSTALL, new AutoInstallParser());
228        parsers.put(TAG_SHORTCUT, new ShortcutParser());
229        return parsers;
230    }
231
232    protected HashMap<String, TagParser> getLayoutElementsMap() {
233        HashMap<String, TagParser> parsers = new HashMap<String, TagParser>();
234        parsers.put(TAG_APP_ICON, new AppShortcutParser());
235        parsers.put(TAG_AUTO_INSTALL, new AutoInstallParser());
236        parsers.put(TAG_FOLDER, new FolderParser());
237        parsers.put(TAG_APPWIDGET, new AppWidgetParser());
238        parsers.put(TAG_SHORTCUT, new ShortcutParser());
239        return parsers;
240    }
241
242    private interface TagParser {
243        /**
244         * Parses the tag and adds to the db
245         * @return the id of the row added or -1;
246         */
247        long parseAndAdd(XmlResourceParser parser, Resources res)
248                throws XmlPullParserException, IOException;
249    }
250
251    private class AppShortcutParser implements TagParser {
252
253        @Override
254        public long parseAndAdd(XmlResourceParser parser, Resources res) {
255            final String packageName = getAttributeValue(parser, ATTR_PACKAGE_NAME);
256            final String className = getAttributeValue(parser, ATTR_CLASS_NAME);
257
258            if (!TextUtils.isEmpty(packageName) && !TextUtils.isEmpty(className)) {
259                ActivityInfo info;
260                try {
261                    ComponentName cn;
262                    try {
263                        cn = new ComponentName(packageName, className);
264                        info = mPackageManager.getActivityInfo(cn, 0);
265                    } catch (PackageManager.NameNotFoundException nnfe) {
266                        String[] packages = mPackageManager.currentToCanonicalPackageNames(
267                                new String[] { packageName });
268                        cn = new ComponentName(packages[0], className);
269                        info = mPackageManager.getActivityInfo(cn, 0);
270                    }
271                    final Intent intent = new Intent(Intent.ACTION_MAIN, null)
272                        .addCategory(Intent.CATEGORY_LAUNCHER)
273                        .setComponent(cn)
274                        .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
275                                Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
276
277                    return addShortcut(info.loadLabel(mPackageManager).toString(),
278                            intent, Favorites.ITEM_TYPE_APPLICATION);
279                } catch (PackageManager.NameNotFoundException e) {
280                    Log.w(TAG, "Unable to add favorite: " + packageName + "/" + className, e);
281                }
282                return -1;
283            } else {
284                if (LOGD) Log.d(TAG, "Skipping invalid <favorite> with no component or uri");
285                return -1;
286            }
287        }
288    }
289
290    private class AutoInstallParser implements TagParser {
291
292        @Override
293        public long parseAndAdd(XmlResourceParser parser, Resources res) {
294            final String packageName = getAttributeValue(parser, ATTR_PACKAGE_NAME);
295            final String className = getAttributeValue(parser, ATTR_CLASS_NAME);
296            if (TextUtils.isEmpty(packageName) || TextUtils.isEmpty(className)) {
297                if (LOGD) Log.d(TAG, "Skipping invalid <favorite> with no component");
298                return -1;
299            }
300
301            mValues.put(Favorites.RESTORED, ShortcutInfo.FLAG_AUTOINTALL_ICON);
302            final Intent intent = new Intent(Intent.ACTION_MAIN, null)
303                .addCategory(Intent.CATEGORY_LAUNCHER)
304                .setComponent(new ComponentName(packageName, className))
305                .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
306                        Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
307            return addShortcut(mContext.getString(R.string.package_state_unknown), intent,
308                    Favorites.ITEM_TYPE_APPLICATION);
309        }
310    }
311
312    private class ShortcutParser implements TagParser {
313
314        @Override
315        public long parseAndAdd(XmlResourceParser parser, Resources res) {
316            final String url = getAttributeValue(parser, ATTR_URL);
317            final int titleResId = getAttributeResourceValue(parser, ATTR_TITLE, 0);
318            final int iconId = getAttributeResourceValue(parser, ATTR_ICON, 0);
319
320            if (titleResId == 0 || iconId == 0) {
321                if (LOGD) Log.d(TAG, "Ignoring shortcut");
322                return -1;
323            }
324
325            if (TextUtils.isEmpty(url) || !Patterns.WEB_URL.matcher(url).matches()) {
326                if (LOGD) Log.d(TAG, "Ignoring shortcut, invalid url: " + url);
327                return -1;
328            }
329            Drawable icon = res.getDrawable(iconId);
330            if (icon == null) {
331                if (LOGD) Log.d(TAG, "Ignoring shortcut, can't load icon");
332                return -1;
333            }
334
335            ItemInfo.writeBitmap(mValues, Utilities.createIconBitmap(icon, mContext));
336            final Intent intent = new Intent(Intent.ACTION_VIEW, null)
337                .setData(Uri.parse(url))
338                .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
339                        Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
340            return addShortcut(res.getString(titleResId), intent, Favorites.ITEM_TYPE_SHORTCUT);
341        }
342    }
343
344    private class AppWidgetParser implements TagParser {
345
346        @Override
347        public long parseAndAdd(XmlResourceParser parser, Resources res)
348                throws XmlPullParserException, IOException {
349            final String packageName = getAttributeValue(parser, ATTR_PACKAGE_NAME);
350            final String className = getAttributeValue(parser, ATTR_CLASS_NAME);
351            if (TextUtils.isEmpty(packageName) || TextUtils.isEmpty(className)) {
352                if (LOGD) Log.d(TAG, "Skipping invalid <favorite> with no component");
353                return -1;
354            }
355
356            ComponentName cn = new ComponentName(packageName, className);
357            try {
358                mPackageManager.getReceiverInfo(cn, 0);
359            } catch (Exception e) {
360                String[] packages = mPackageManager.currentToCanonicalPackageNames(
361                        new String[] { packageName });
362                cn = new ComponentName(packages[0], className);
363                try {
364                    mPackageManager.getReceiverInfo(cn, 0);
365                } catch (Exception e1) {
366                    if (LOGD) Log.d(TAG, "Can't find widget provider: " + className);
367                    return -1;
368                }
369            }
370
371            mValues.put(Favorites.SPANX, getAttributeValue(parser, ATTR_SPAN_X));
372            mValues.put(Favorites.SPANY, getAttributeValue(parser, ATTR_SPAN_Y));
373
374            // Read the extras
375            Bundle extras = new Bundle();
376            int widgetDepth = parser.getDepth();
377            int type;
378            while ((type = parser.next()) != XmlPullParser.END_TAG ||
379                    parser.getDepth() > widgetDepth) {
380                if (type != XmlPullParser.START_TAG) {
381                    continue;
382                }
383
384                if (TAG_EXTRA.equals(parser.getName())) {
385                    String key = getAttributeValue(parser, ATTR_KEY);
386                    String value = getAttributeValue(parser, ATTR_VALUE);
387                    if (key != null && value != null) {
388                        extras.putString(key, value);
389                    } else {
390                        throw new RuntimeException("Widget extras must have a key and value");
391                    }
392                } else {
393                    throw new RuntimeException("Widgets can contain only extras");
394                }
395            }
396
397            final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(mContext);
398            long insertedId = -1;
399            try {
400                int appWidgetId = mAppWidgetHost.allocateAppWidgetId();
401
402                if (!appWidgetManager.bindAppWidgetIdIfAllowed(appWidgetId, cn)) {
403                    if (LOGD) Log.e(TAG, "Unable to bind app widget id " + cn);
404                    return -1;
405                }
406
407                mValues.put(Favorites.ITEM_TYPE, Favorites.ITEM_TYPE_APPWIDGET);
408                mValues.put(Favorites.APPWIDGET_ID, appWidgetId);
409                mValues.put(Favorites.APPWIDGET_PROVIDER, cn.flattenToString());
410                mValues.put(Favorites._ID, mCallback.generateNewItemId());
411                insertedId = mCallback.insertAndCheck(mDb, mValues);
412                if (insertedId < 0) {
413                    mAppWidgetHost.deleteAppWidgetId(appWidgetId);
414                    return insertedId;
415                }
416
417                // Send a broadcast to configure the widget
418                if (!extras.isEmpty()) {
419                    Intent intent = new Intent(ACTION_APPWIDGET_DEFAULT_WORKSPACE_CONFIGURE);
420                    intent.setComponent(cn);
421                    intent.putExtras(extras);
422                    intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
423                    mContext.sendBroadcast(intent);
424                }
425            } catch (RuntimeException ex) {
426                if (LOGD) Log.e(TAG, "Problem allocating appWidgetId", ex);
427            }
428            return insertedId;
429        }
430    }
431
432    private class FolderParser implements TagParser {
433        private final HashMap<String, TagParser> mFolderElements = getFolderElementsMap();
434
435        @Override
436        public long parseAndAdd(XmlResourceParser parser, Resources res)
437                throws XmlPullParserException, IOException {
438            final String title;
439            final int titleResId = getAttributeResourceValue(parser, ATTR_TITLE, 0);
440            if (titleResId != 0) {
441                title = res.getString(titleResId);
442            } else {
443                title = mContext.getResources().getString(R.string.folder_name);
444            }
445
446            mValues.put(Favorites.TITLE, title);
447            mValues.put(Favorites.ITEM_TYPE, Favorites.ITEM_TYPE_FOLDER);
448            mValues.put(Favorites.SPANX, 1);
449            mValues.put(Favorites.SPANY, 1);
450            mValues.put(Favorites._ID, mCallback.generateNewItemId());
451            long folderId = mCallback.insertAndCheck(mDb, mValues);
452            if (folderId < 0) {
453                if (LOGD) Log.e(TAG, "Unable to add folder");
454                return -1;
455            }
456
457            final ContentValues myValues = new ContentValues(mValues);
458            ArrayList<Long> folderItems = new ArrayList<Long>();
459
460            int type;
461            int folderDepth = parser.getDepth();
462            while ((type = parser.next()) != XmlPullParser.END_TAG ||
463                    parser.getDepth() > folderDepth) {
464                if (type != XmlPullParser.START_TAG) {
465                    continue;
466                }
467                mValues.clear();
468                mValues.put(Favorites.CONTAINER, folderId);
469
470                TagParser tagParser = mFolderElements.get(parser.getName());
471                if (tagParser != null) {
472                    final long id = tagParser.parseAndAdd(parser, res);
473                    if (id >= 0) {
474                        folderItems.add(id);
475                    }
476                } else {
477                    throw new RuntimeException("Invalid folder item " + parser.getName());
478                }
479            }
480
481            long addedId = folderId;
482
483            // We can only have folders with >= 2 items, so we need to remove the
484            // folder and clean up if less than 2 items were included, or some
485            // failed to add, and less than 2 were actually added
486            if (folderItems.size() < 2) {
487                // Delete the folder
488                Uri uri = Favorites.getContentUri(folderId, false);
489                SqlArguments args = new SqlArguments(uri, null, null);
490                mDb.delete(args.table, args.where, args.args);
491                addedId = -1;
492
493                // If we have a single item, promote it to where the folder
494                // would have been.
495                if (folderItems.size() == 1) {
496                    final ContentValues childValues = new ContentValues();
497                    copyInteger(myValues, childValues, Favorites.CONTAINER);
498                    copyInteger(myValues, childValues, Favorites.SCREEN);
499                    copyInteger(myValues, childValues, Favorites.CELLX);
500                    copyInteger(myValues, childValues, Favorites.CELLY);
501
502                    addedId = folderItems.get(0);
503                    mDb.update(LauncherProvider.TABLE_FAVORITES, childValues,
504                            Favorites._ID + "=" + addedId, null);
505                }
506            }
507            return addedId;
508        }
509    }
510
511    private static final void beginDocument(XmlPullParser parser, String firstElementName)
512            throws XmlPullParserException, IOException {
513        int type;
514        while ((type = parser.next()) != XmlPullParser.START_TAG
515                && type != XmlPullParser.END_DOCUMENT);
516
517        if (type != XmlPullParser.START_TAG) {
518            throw new XmlPullParserException("No start tag found");
519        }
520
521        if (!parser.getName().equals(firstElementName)) {
522            throw new XmlPullParserException("Unexpected start tag: found " + parser.getName() +
523                    ", expected " + firstElementName);
524        }
525    }
526
527    /**
528     * Return attribute value, attempting launcher-specific namespace first
529     * before falling back to anonymous attribute.
530     */
531    private static String getAttributeValue(XmlResourceParser parser, String attribute) {
532        String value = parser.getAttributeValue(
533                "http://schemas.android.com/apk/res-auto/com.android.launcher3", attribute);
534        if (value == null) {
535            value = parser.getAttributeValue(null, attribute);
536        }
537        return value;
538    }
539
540    /**
541     * Return attribute resource value, attempting launcher-specific namespace
542     * first before falling back to anonymous attribute.
543     */
544    private static int getAttributeResourceValue(XmlResourceParser parser, String attribute,
545            int defaultValue) {
546        int value = parser.getAttributeResourceValue(
547                "http://schemas.android.com/apk/res-auto/com.android.launcher3", attribute,
548                defaultValue);
549        if (value == defaultValue) {
550            value = parser.getAttributeResourceValue(null, attribute, defaultValue);
551        }
552        return value;
553    }
554
555    public static interface LayoutParserCallback {
556        long generateNewItemId();
557
558        long insertAndCheck(SQLiteDatabase db, ContentValues values);
559    }
560
561    private static void copyInteger(ContentValues from, ContentValues to, String key) {
562        to.put(key, from.getAsInteger(key));
563    }
564}
565