1/*
2 * Copyright (C) 2008 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.content.BroadcastReceiver;
20import android.content.ComponentName;
21import android.content.Context;
22import android.content.Intent;
23import android.content.SharedPreferences;
24import android.content.pm.ActivityInfo;
25import android.content.pm.PackageManager;
26import android.graphics.Bitmap;
27import android.graphics.BitmapFactory;
28import android.text.TextUtils;
29import android.util.Base64;
30import android.util.Log;
31import android.widget.Toast;
32
33import com.android.launcher3.compat.UserHandleCompat;
34
35import org.json.JSONObject;
36import org.json.JSONStringer;
37import org.json.JSONTokener;
38
39import java.util.ArrayList;
40import java.util.HashSet;
41import java.util.Iterator;
42import java.util.Set;
43
44public class InstallShortcutReceiver extends BroadcastReceiver {
45    private static final String TAG = "InstallShortcutReceiver";
46    private static final boolean DBG = false;
47
48    public static final String ACTION_INSTALL_SHORTCUT =
49            "com.android.launcher.action.INSTALL_SHORTCUT";
50
51    public static final String DATA_INTENT_KEY = "intent.data";
52    public static final String LAUNCH_INTENT_KEY = "intent.launch";
53    public static final String NAME_KEY = "name";
54    public static final String ICON_KEY = "icon";
55    public static final String ICON_RESOURCE_NAME_KEY = "iconResource";
56    public static final String ICON_RESOURCE_PACKAGE_NAME_KEY = "iconResourcePackage";
57    // The set of shortcuts that are pending install
58    public static final String APPS_PENDING_INSTALL = "apps_to_install";
59
60    public static final int NEW_SHORTCUT_BOUNCE_DURATION = 450;
61    public static final int NEW_SHORTCUT_STAGGER_DELAY = 85;
62
63    private static final int INSTALL_SHORTCUT_SUCCESSFUL = 0;
64    private static final int INSTALL_SHORTCUT_IS_DUPLICATE = -1;
65
66    // A mime-type representing shortcut data
67    public static final String SHORTCUT_MIMETYPE =
68            "com.android.launcher3/shortcut";
69
70    private static Object sLock = new Object();
71
72    private static void addToStringSet(SharedPreferences sharedPrefs,
73            SharedPreferences.Editor editor, String key, String value) {
74        Set<String> strings = sharedPrefs.getStringSet(key, null);
75        if (strings == null) {
76            strings = new HashSet<String>(0);
77        } else {
78            strings = new HashSet<String>(strings);
79        }
80        strings.add(value);
81        editor.putStringSet(key, strings);
82    }
83
84    private static void addToInstallQueue(
85            SharedPreferences sharedPrefs, PendingInstallShortcutInfo info) {
86        synchronized(sLock) {
87            try {
88                JSONStringer json = new JSONStringer()
89                    .object()
90                    .key(DATA_INTENT_KEY).value(info.data.toUri(0))
91                    .key(LAUNCH_INTENT_KEY).value(info.launchIntent.toUri(0))
92                    .key(NAME_KEY).value(info.name);
93                if (info.icon != null) {
94                    byte[] iconByteArray = ItemInfo.flattenBitmap(info.icon);
95                    json = json.key(ICON_KEY).value(
96                        Base64.encodeToString(
97                            iconByteArray, 0, iconByteArray.length, Base64.DEFAULT));
98                }
99                if (info.iconResource != null) {
100                    json = json.key(ICON_RESOURCE_NAME_KEY).value(info.iconResource.resourceName);
101                    json = json.key(ICON_RESOURCE_PACKAGE_NAME_KEY)
102                        .value(info.iconResource.packageName);
103                }
104                json = json.endObject();
105                SharedPreferences.Editor editor = sharedPrefs.edit();
106                if (DBG) Log.d(TAG, "Adding to APPS_PENDING_INSTALL: " + json);
107                addToStringSet(sharedPrefs, editor, APPS_PENDING_INSTALL, json.toString());
108                editor.commit();
109            } catch (org.json.JSONException e) {
110                Log.d(TAG, "Exception when adding shortcut: " + e);
111            }
112        }
113    }
114
115    public static void removeFromInstallQueue(SharedPreferences sharedPrefs,
116                                              ArrayList<String> packageNames) {
117        if (packageNames.isEmpty()) {
118            return;
119        }
120        synchronized(sLock) {
121            Set<String> strings = sharedPrefs.getStringSet(APPS_PENDING_INSTALL, null);
122            if (DBG) {
123                Log.d(TAG, "APPS_PENDING_INSTALL: " + strings
124                        + ", removing packages: " + packageNames);
125            }
126            if (strings != null) {
127                Set<String> newStrings = new HashSet<String>(strings);
128                Iterator<String> newStringsIter = newStrings.iterator();
129                while (newStringsIter.hasNext()) {
130                    String json = newStringsIter.next();
131                    try {
132                        JSONObject object = (JSONObject) new JSONTokener(json).nextValue();
133                        Intent launchIntent = Intent.parseUri(object.getString(LAUNCH_INTENT_KEY), 0);
134                        String pn = launchIntent.getPackage();
135                        if (pn == null) {
136                            pn = launchIntent.getComponent().getPackageName();
137                        }
138                        if (packageNames.contains(pn)) {
139                            newStringsIter.remove();
140                        }
141                    } catch (org.json.JSONException e) {
142                        Log.d(TAG, "Exception reading shortcut to remove: " + e);
143                    } catch (java.net.URISyntaxException e) {
144                        Log.d(TAG, "Exception reading shortcut to remove: " + e);
145                    }
146                }
147                sharedPrefs.edit().putStringSet(APPS_PENDING_INSTALL,
148                        new HashSet<String>(newStrings)).commit();
149            }
150        }
151    }
152
153    private static ArrayList<PendingInstallShortcutInfo> getAndClearInstallQueue(
154            SharedPreferences sharedPrefs) {
155        synchronized(sLock) {
156            Set<String> strings = sharedPrefs.getStringSet(APPS_PENDING_INSTALL, null);
157            if (DBG) Log.d(TAG, "Getting and clearing APPS_PENDING_INSTALL: " + strings);
158            if (strings == null) {
159                return new ArrayList<PendingInstallShortcutInfo>();
160            }
161            ArrayList<PendingInstallShortcutInfo> infos =
162                new ArrayList<PendingInstallShortcutInfo>();
163            for (String json : strings) {
164                try {
165                    JSONObject object = (JSONObject) new JSONTokener(json).nextValue();
166                    Intent data = Intent.parseUri(object.getString(DATA_INTENT_KEY), 0);
167                    Intent launchIntent =
168                            Intent.parseUri(object.getString(LAUNCH_INTENT_KEY), 0);
169                    String name = object.getString(NAME_KEY);
170                    String iconBase64 = object.optString(ICON_KEY);
171                    String iconResourceName = object.optString(ICON_RESOURCE_NAME_KEY);
172                    String iconResourcePackageName =
173                        object.optString(ICON_RESOURCE_PACKAGE_NAME_KEY);
174                    if (iconBase64 != null && !iconBase64.isEmpty()) {
175                        byte[] iconArray = Base64.decode(iconBase64, Base64.DEFAULT);
176                        Bitmap b = BitmapFactory.decodeByteArray(iconArray, 0, iconArray.length);
177                        data.putExtra(Intent.EXTRA_SHORTCUT_ICON, b);
178                    } else if (iconResourceName != null && !iconResourceName.isEmpty()) {
179                        Intent.ShortcutIconResource iconResource =
180                            new Intent.ShortcutIconResource();
181                        iconResource.resourceName = iconResourceName;
182                        iconResource.packageName = iconResourcePackageName;
183                        data.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, iconResource);
184                    }
185                    data.putExtra(Intent.EXTRA_SHORTCUT_INTENT, launchIntent);
186                    PendingInstallShortcutInfo info =
187                        new PendingInstallShortcutInfo(data, name, launchIntent);
188                    infos.add(info);
189                } catch (org.json.JSONException e) {
190                    Log.d(TAG, "Exception reading shortcut to add: " + e);
191                } catch (java.net.URISyntaxException e) {
192                    Log.d(TAG, "Exception reading shortcut to add: " + e);
193                }
194            }
195            sharedPrefs.edit().putStringSet(APPS_PENDING_INSTALL, new HashSet<String>()).commit();
196            return infos;
197        }
198    }
199
200    // Determines whether to defer installing shortcuts immediately until
201    // processAllPendingInstalls() is called.
202    private static boolean mUseInstallQueue = false;
203
204    private static class PendingInstallShortcutInfo {
205        Intent data;
206        Intent launchIntent;
207        String name;
208        Bitmap icon;
209        Intent.ShortcutIconResource iconResource;
210
211        public PendingInstallShortcutInfo(Intent rawData, String shortcutName,
212                Intent shortcutIntent) {
213            data = rawData;
214            name = shortcutName;
215            launchIntent = shortcutIntent;
216        }
217    }
218
219    public void onReceive(Context context, Intent data) {
220        if (!ACTION_INSTALL_SHORTCUT.equals(data.getAction())) {
221            return;
222        }
223
224        if (DBG) Log.d(TAG, "Got INSTALL_SHORTCUT: " + data.toUri(0));
225
226        Intent intent = data.getParcelableExtra(Intent.EXTRA_SHORTCUT_INTENT);
227        if (intent == null) {
228            return;
229        }
230
231        // This name is only used for comparisons and notifications, so fall back to activity name
232        // if not supplied
233        String name = ensureValidName(context, intent,
234                data.getStringExtra(Intent.EXTRA_SHORTCUT_NAME)).toString();
235        Bitmap icon = data.getParcelableExtra(Intent.EXTRA_SHORTCUT_ICON);
236        Intent.ShortcutIconResource iconResource =
237            data.getParcelableExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE);
238
239        // Queue the item up for adding if launcher has not loaded properly yet
240        LauncherAppState.setApplicationContext(context.getApplicationContext());
241        LauncherAppState app = LauncherAppState.getInstance();
242        boolean launcherNotLoaded = (app.getDynamicGrid() == null);
243
244        PendingInstallShortcutInfo info = new PendingInstallShortcutInfo(data, name, intent);
245        info.icon = icon;
246        info.iconResource = iconResource;
247
248        String spKey = LauncherAppState.getSharedPreferencesKey();
249        SharedPreferences sp = context.getSharedPreferences(spKey, Context.MODE_PRIVATE);
250        addToInstallQueue(sp, info);
251        if (!mUseInstallQueue && !launcherNotLoaded) {
252            flushInstallQueue(context);
253        }
254    }
255
256    static void enableInstallQueue() {
257        mUseInstallQueue = true;
258    }
259    static void disableAndFlushInstallQueue(Context context) {
260        mUseInstallQueue = false;
261        flushInstallQueue(context);
262    }
263    static void flushInstallQueue(Context context) {
264        String spKey = LauncherAppState.getSharedPreferencesKey();
265        SharedPreferences sp = context.getSharedPreferences(spKey, Context.MODE_PRIVATE);
266        ArrayList<PendingInstallShortcutInfo> installQueue = getAndClearInstallQueue(sp);
267        if (!installQueue.isEmpty()) {
268            Iterator<PendingInstallShortcutInfo> iter = installQueue.iterator();
269            ArrayList<ItemInfo> addShortcuts = new ArrayList<ItemInfo>();
270            int result = INSTALL_SHORTCUT_SUCCESSFUL;
271            String duplicateName = "";
272            while (iter.hasNext()) {
273                final PendingInstallShortcutInfo pendingInfo = iter.next();
274                //final Intent data = pendingInfo.data;
275                final Intent intent = pendingInfo.launchIntent;
276                final String name = pendingInfo.name;
277
278                if (LauncherAppState.isDisableAllApps() && !isValidShortcutLaunchIntent(intent)) {
279                    if (DBG) Log.d(TAG, "Ignoring shortcut with launchIntent:" + intent);
280                    continue;
281                }
282
283                final boolean exists = LauncherModel.shortcutExists(context, name, intent);
284                //final boolean allowDuplicate = data.getBooleanExtra(Launcher.EXTRA_SHORTCUT_DUPLICATE, true);
285
286                // If the intent specifies a package, make sure the package exists
287                String packageName = intent.getPackage();
288                if (packageName == null) {
289                    packageName = intent.getComponent() == null ? null :
290                        intent.getComponent().getPackageName();
291                }
292                if (packageName != null && !packageName.isEmpty()) {
293                    UserHandleCompat myUserHandle = UserHandleCompat.myUserHandle();
294                    if (!LauncherModel.isValidPackage(context, packageName, myUserHandle)) {
295                        if (DBG) Log.d(TAG, "Ignoring shortcut for absent package:" + intent);
296                        continue;
297                    }
298                }
299
300                if (!exists) {
301                    // Generate a shortcut info to add into the model
302                    ShortcutInfo info = getShortcutInfo(context, pendingInfo.data,
303                            pendingInfo.launchIntent);
304                    addShortcuts.add(info);
305                }
306
307            }
308
309            // Notify the user once if we weren't able to place any duplicates
310            if (result == INSTALL_SHORTCUT_IS_DUPLICATE) {
311                Toast.makeText(context, context.getString(R.string.shortcut_duplicate,
312                        duplicateName), Toast.LENGTH_SHORT).show();
313            }
314
315            // Add the new apps to the model and bind them
316            if (!addShortcuts.isEmpty()) {
317                LauncherAppState app = LauncherAppState.getInstance();
318                app.getModel().addAndBindAddedWorkspaceApps(context, addShortcuts);
319            }
320        }
321    }
322
323    /**
324     * Returns true if the intent is a valid launch intent for a shortcut.
325     * This is used to identify shortcuts which are different from the ones exposed by the
326     * applications' manifest file.
327     *
328     * When DISABLE_ALL_APPS is true, shortcuts exposed via the app's manifest should never be
329     * duplicated or removed(unless the app is un-installed).
330     *
331     * @param launchIntent The intent that will be launched when the shortcut is clicked.
332     */
333    static boolean isValidShortcutLaunchIntent(Intent launchIntent) {
334        if (launchIntent != null
335                && Intent.ACTION_MAIN.equals(launchIntent.getAction())
336                && launchIntent.getComponent() != null
337                && launchIntent.getCategories() != null
338                && launchIntent.getCategories().size() == 1
339                && launchIntent.hasCategory(Intent.CATEGORY_LAUNCHER)
340                && launchIntent.getExtras() == null
341                && TextUtils.isEmpty(launchIntent.getDataString())) {
342            return false;
343        }
344        return true;
345    }
346
347    private static ShortcutInfo getShortcutInfo(Context context, Intent data,
348                                                Intent launchIntent) {
349        if (launchIntent.getAction() == null) {
350            launchIntent.setAction(Intent.ACTION_VIEW);
351        } else if (launchIntent.getAction().equals(Intent.ACTION_MAIN) &&
352                launchIntent.getCategories() != null &&
353                launchIntent.getCategories().contains(Intent.CATEGORY_LAUNCHER)) {
354            launchIntent.addFlags(
355                    Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
356        }
357        LauncherAppState app = LauncherAppState.getInstance();
358        ShortcutInfo info = app.getModel().infoFromShortcutIntent(context, data, null);
359        info.title = ensureValidName(context, launchIntent, info.title);
360        return info;
361    }
362
363    /**
364     * Ensures that we have a valid, non-null name.  If the provided name is null, we will return
365     * the application name instead.
366     */
367    private static CharSequence ensureValidName(Context context, Intent intent, CharSequence name) {
368        if (name == null) {
369            try {
370                PackageManager pm = context.getPackageManager();
371                ActivityInfo info = pm.getActivityInfo(intent.getComponent(), 0);
372                name = info.loadLabel(pm).toString();
373            } catch (PackageManager.NameNotFoundException nnfe) {
374                return "";
375            }
376        }
377        return name;
378    }
379}
380