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.Context;
21import android.content.Intent;
22import android.content.SharedPreferences;
23import android.content.pm.ActivityInfo;
24import android.content.pm.PackageManager;
25import android.content.pm.ResolveInfo;
26import android.graphics.Bitmap;
27import android.graphics.BitmapFactory;
28import android.text.TextUtils;
29import android.util.Base64;
30import android.util.Log;
31
32import com.android.launcher3.compat.LauncherActivityInfoCompat;
33import com.android.launcher3.compat.LauncherAppsCompat;
34import com.android.launcher3.compat.UserHandleCompat;
35import com.android.launcher3.compat.UserManagerCompat;
36import com.android.launcher3.util.Thunk;
37
38import org.json.JSONException;
39import org.json.JSONObject;
40import org.json.JSONStringer;
41import org.json.JSONTokener;
42
43import java.net.URISyntaxException;
44import java.util.ArrayList;
45import java.util.HashSet;
46import java.util.Iterator;
47import java.util.Set;
48
49public class InstallShortcutReceiver extends BroadcastReceiver {
50    private static final String TAG = "InstallShortcutReceiver";
51    private static final boolean DBG = false;
52
53    private static final String ACTION_INSTALL_SHORTCUT =
54            "com.android.launcher.action.INSTALL_SHORTCUT";
55
56    private static final String LAUNCH_INTENT_KEY = "intent.launch";
57    private static final String NAME_KEY = "name";
58    private static final String ICON_KEY = "icon";
59    private static final String ICON_RESOURCE_NAME_KEY = "iconResource";
60    private static final String ICON_RESOURCE_PACKAGE_NAME_KEY = "iconResourcePackage";
61
62    private static final String APP_SHORTCUT_TYPE_KEY = "isAppShortcut";
63    private static final String USER_HANDLE_KEY = "userHandle";
64
65    // The set of shortcuts that are pending install
66    private static final String APPS_PENDING_INSTALL = "apps_to_install";
67
68    public static final int NEW_SHORTCUT_BOUNCE_DURATION = 450;
69    public static final int NEW_SHORTCUT_STAGGER_DELAY = 85;
70
71    private static final Object sLock = new Object();
72
73    private static void addToInstallQueue(
74            SharedPreferences sharedPrefs, PendingInstallShortcutInfo info) {
75        synchronized(sLock) {
76            String encoded = info.encodeToString();
77            if (encoded != null) {
78                Set<String> strings = sharedPrefs.getStringSet(APPS_PENDING_INSTALL, null);
79                if (strings == null) {
80                    strings = new HashSet<String>(1);
81                } else {
82                    strings = new HashSet<String>(strings);
83                }
84                strings.add(encoded);
85                sharedPrefs.edit().putStringSet(APPS_PENDING_INSTALL, strings).commit();
86            }
87        }
88    }
89
90    public static void removeFromInstallQueue(Context context, ArrayList<String> packageNames,
91            UserHandleCompat user) {
92        if (packageNames.isEmpty()) {
93            return;
94        }
95        String spKey = LauncherAppState.getSharedPreferencesKey();
96        SharedPreferences sp = context.getSharedPreferences(spKey, Context.MODE_PRIVATE);
97        synchronized(sLock) {
98            Set<String> strings = sp.getStringSet(APPS_PENDING_INSTALL, null);
99            if (DBG) {
100                Log.d(TAG, "APPS_PENDING_INSTALL: " + strings
101                        + ", removing packages: " + packageNames);
102            }
103            if (strings != null) {
104                Set<String> newStrings = new HashSet<String>(strings);
105                Iterator<String> newStringsIter = newStrings.iterator();
106                while (newStringsIter.hasNext()) {
107                    String encoded = newStringsIter.next();
108                    PendingInstallShortcutInfo info = decode(encoded, context);
109                    if (info == null || (packageNames.contains(info.getTargetPackage())
110                            && user.equals(info.user))) {
111                        newStringsIter.remove();
112                    }
113                }
114                sp.edit().putStringSet(APPS_PENDING_INSTALL, newStrings).commit();
115            }
116        }
117    }
118
119    private static ArrayList<PendingInstallShortcutInfo> getAndClearInstallQueue(
120            SharedPreferences sharedPrefs, Context context) {
121        synchronized(sLock) {
122            Set<String> strings = sharedPrefs.getStringSet(APPS_PENDING_INSTALL, null);
123            if (DBG) Log.d(TAG, "Getting and clearing APPS_PENDING_INSTALL: " + strings);
124            if (strings == null) {
125                return new ArrayList<PendingInstallShortcutInfo>();
126            }
127            ArrayList<PendingInstallShortcutInfo> infos =
128                new ArrayList<PendingInstallShortcutInfo>();
129            for (String encoded : strings) {
130                PendingInstallShortcutInfo info = decode(encoded, context);
131                if (info != null) {
132                    infos.add(info);
133                }
134            }
135            sharedPrefs.edit().putStringSet(APPS_PENDING_INSTALL, new HashSet<String>()).commit();
136            return infos;
137        }
138    }
139
140    // Determines whether to defer installing shortcuts immediately until
141    // processAllPendingInstalls() is called.
142    private static boolean mUseInstallQueue = false;
143
144    public void onReceive(Context context, Intent data) {
145        if (!ACTION_INSTALL_SHORTCUT.equals(data.getAction())) {
146            return;
147        }
148
149        PendingInstallShortcutInfo info = new PendingInstallShortcutInfo(data, context);
150        if (info.launchIntent == null || info.label == null) {
151            if (DBG) Log.e(TAG, "Invalid install shortcut intent");
152            return;
153        }
154
155        info = convertToLauncherActivityIfPossible(info);
156        queuePendingShortcutInfo(info, context);
157    }
158
159    public static ShortcutInfo fromShortcutIntent(Context context, Intent data) {
160        PendingInstallShortcutInfo info = new PendingInstallShortcutInfo(data, context);
161        if (info.launchIntent == null || info.label == null) {
162            if (DBG) Log.e(TAG, "Invalid install shortcut intent");
163            return null;
164        }
165        info = convertToLauncherActivityIfPossible(info);
166        return info.getShortcutInfo();
167    }
168
169    static void queueInstallShortcut(LauncherActivityInfoCompat info, Context context) {
170        queuePendingShortcutInfo(new PendingInstallShortcutInfo(info, context), context);
171    }
172
173    private static void queuePendingShortcutInfo(PendingInstallShortcutInfo info, Context context) {
174        // Queue the item up for adding if launcher has not loaded properly yet
175        LauncherAppState.setApplicationContext(context.getApplicationContext());
176        LauncherAppState app = LauncherAppState.getInstance();
177        boolean launcherNotLoaded = app.getModel().getCallback() == null;
178
179        String spKey = LauncherAppState.getSharedPreferencesKey();
180        SharedPreferences sp = context.getSharedPreferences(spKey, Context.MODE_PRIVATE);
181        addToInstallQueue(sp, info);
182        if (!mUseInstallQueue && !launcherNotLoaded) {
183            flushInstallQueue(context);
184        }
185    }
186
187    static void enableInstallQueue() {
188        mUseInstallQueue = true;
189    }
190    static void disableAndFlushInstallQueue(Context context) {
191        mUseInstallQueue = false;
192        flushInstallQueue(context);
193    }
194    static void flushInstallQueue(Context context) {
195        String spKey = LauncherAppState.getSharedPreferencesKey();
196        SharedPreferences sp = context.getSharedPreferences(spKey, Context.MODE_PRIVATE);
197        ArrayList<PendingInstallShortcutInfo> installQueue = getAndClearInstallQueue(sp, context);
198        if (!installQueue.isEmpty()) {
199            Iterator<PendingInstallShortcutInfo> iter = installQueue.iterator();
200            ArrayList<ItemInfo> addShortcuts = new ArrayList<ItemInfo>();
201            while (iter.hasNext()) {
202                final PendingInstallShortcutInfo pendingInfo = iter.next();
203                final Intent intent = pendingInfo.launchIntent;
204
205                // If the intent specifies a package, make sure the package exists
206                String packageName = pendingInfo.getTargetPackage();
207                if (!TextUtils.isEmpty(packageName)) {
208                    UserHandleCompat myUserHandle = UserHandleCompat.myUserHandle();
209                    if (!LauncherModel.isValidPackage(context, packageName, myUserHandle)) {
210                        if (DBG) Log.d(TAG, "Ignoring shortcut for absent package:" + intent);
211                        continue;
212                    }
213                }
214
215                // Generate a shortcut info to add into the model
216                addShortcuts.add(pendingInfo.getShortcutInfo());
217            }
218
219            // Add the new apps to the model and bind them
220            if (!addShortcuts.isEmpty()) {
221                LauncherAppState app = LauncherAppState.getInstance();
222                app.getModel().addAndBindAddedWorkspaceItems(context, addShortcuts);
223            }
224        }
225    }
226
227    /**
228     * Ensures that we have a valid, non-null name.  If the provided name is null, we will return
229     * the application name instead.
230     */
231    @Thunk static CharSequence ensureValidName(Context context, Intent intent, CharSequence name) {
232        if (name == null) {
233            try {
234                PackageManager pm = context.getPackageManager();
235                ActivityInfo info = pm.getActivityInfo(intent.getComponent(), 0);
236                name = info.loadLabel(pm);
237            } catch (PackageManager.NameNotFoundException nnfe) {
238                return "";
239            }
240        }
241        return name;
242    }
243
244    private static class PendingInstallShortcutInfo {
245
246        final LauncherActivityInfoCompat activityInfo;
247
248        final Intent data;
249        final Context mContext;
250        final Intent launchIntent;
251        final String label;
252        final UserHandleCompat user;
253
254        /**
255         * Initializes a PendingInstallShortcutInfo received from a different app.
256         */
257        public PendingInstallShortcutInfo(Intent data, Context context) {
258            this.data = data;
259            mContext = context;
260
261            launchIntent = data.getParcelableExtra(Intent.EXTRA_SHORTCUT_INTENT);
262            label = data.getStringExtra(Intent.EXTRA_SHORTCUT_NAME);
263            user = UserHandleCompat.myUserHandle();
264            activityInfo = null;
265        }
266
267        /**
268         * Initializes a PendingInstallShortcutInfo to represent a launcher target.
269         */
270        public PendingInstallShortcutInfo(LauncherActivityInfoCompat info, Context context) {
271            this.data = null;
272            mContext = context;
273            activityInfo = info;
274            user = info.getUser();
275
276            launchIntent = AppInfo.makeLaunchIntent(context, info, user);
277            label = info.getLabel().toString();
278        }
279
280        public String encodeToString() {
281            if (activityInfo != null) {
282                try {
283                    // If it a launcher target, we only need component name, and user to
284                    // recreate this.
285                    return new JSONStringer()
286                        .object()
287                        .key(LAUNCH_INTENT_KEY).value(launchIntent.toUri(0))
288                        .key(APP_SHORTCUT_TYPE_KEY).value(true)
289                        .key(USER_HANDLE_KEY).value(UserManagerCompat.getInstance(mContext)
290                                .getSerialNumberForUser(user))
291                        .endObject().toString();
292                } catch (JSONException e) {
293                    Log.d(TAG, "Exception when adding shortcut: " + e);
294                    return null;
295                }
296            }
297
298            if (launchIntent.getAction() == null) {
299                launchIntent.setAction(Intent.ACTION_VIEW);
300            } else if (launchIntent.getAction().equals(Intent.ACTION_MAIN) &&
301                    launchIntent.getCategories() != null &&
302                    launchIntent.getCategories().contains(Intent.CATEGORY_LAUNCHER)) {
303                launchIntent.addFlags(
304                        Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
305            }
306
307            // This name is only used for comparisons and notifications, so fall back to activity
308            // name if not supplied
309            String name = ensureValidName(mContext, launchIntent, label).toString();
310            Bitmap icon = data.getParcelableExtra(Intent.EXTRA_SHORTCUT_ICON);
311            Intent.ShortcutIconResource iconResource =
312                data.getParcelableExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE);
313
314            // Only encode the parameters which are supported by the API.
315            try {
316                JSONStringer json = new JSONStringer()
317                    .object()
318                    .key(LAUNCH_INTENT_KEY).value(launchIntent.toUri(0))
319                    .key(NAME_KEY).value(name);
320                if (icon != null) {
321                    byte[] iconByteArray = Utilities.flattenBitmap(icon);
322                    json = json.key(ICON_KEY).value(
323                            Base64.encodeToString(
324                                    iconByteArray, 0, iconByteArray.length, Base64.DEFAULT));
325                }
326                if (iconResource != null) {
327                    json = json.key(ICON_RESOURCE_NAME_KEY).value(iconResource.resourceName);
328                    json = json.key(ICON_RESOURCE_PACKAGE_NAME_KEY)
329                            .value(iconResource.packageName);
330                }
331                return json.endObject().toString();
332            } catch (JSONException e) {
333                Log.d(TAG, "Exception when adding shortcut: " + e);
334            }
335            return null;
336        }
337
338        public ShortcutInfo getShortcutInfo() {
339            if (activityInfo != null) {
340                return ShortcutInfo.fromActivityInfo(activityInfo, mContext);
341            } else {
342                return LauncherAppState.getInstance().getModel().infoFromShortcutIntent(mContext, data);
343            }
344        }
345
346        public String getTargetPackage() {
347            String packageName = launchIntent.getPackage();
348            if (packageName == null) {
349                packageName = launchIntent.getComponent() == null ? null :
350                    launchIntent.getComponent().getPackageName();
351            }
352            return packageName;
353        }
354
355        public boolean isLuncherActivity() {
356            return activityInfo != null;
357        }
358    }
359
360    private static PendingInstallShortcutInfo decode(String encoded, Context context) {
361        try {
362            JSONObject object = (JSONObject) new JSONTokener(encoded).nextValue();
363            Intent launcherIntent = Intent.parseUri(object.getString(LAUNCH_INTENT_KEY), 0);
364
365            if (object.optBoolean(APP_SHORTCUT_TYPE_KEY)) {
366                // The is an internal launcher target shortcut.
367                UserHandleCompat user = UserManagerCompat.getInstance(context)
368                        .getUserForSerialNumber(object.getLong(USER_HANDLE_KEY));
369                if (user == null) {
370                    return null;
371                }
372
373                LauncherActivityInfoCompat info = LauncherAppsCompat.getInstance(context)
374                        .resolveActivity(launcherIntent, user);
375                return info == null ? null : new PendingInstallShortcutInfo(info, context);
376            }
377
378            Intent data = new Intent();
379            data.putExtra(Intent.EXTRA_SHORTCUT_INTENT, launcherIntent);
380            data.putExtra(Intent.EXTRA_SHORTCUT_NAME, object.getString(NAME_KEY));
381
382            String iconBase64 = object.optString(ICON_KEY);
383            String iconResourceName = object.optString(ICON_RESOURCE_NAME_KEY);
384            String iconResourcePackageName = object.optString(ICON_RESOURCE_PACKAGE_NAME_KEY);
385            if (iconBase64 != null && !iconBase64.isEmpty()) {
386                byte[] iconArray = Base64.decode(iconBase64, Base64.DEFAULT);
387                Bitmap b = BitmapFactory.decodeByteArray(iconArray, 0, iconArray.length);
388                data.putExtra(Intent.EXTRA_SHORTCUT_ICON, b);
389            } else if (iconResourceName != null && !iconResourceName.isEmpty()) {
390                Intent.ShortcutIconResource iconResource =
391                    new Intent.ShortcutIconResource();
392                iconResource.resourceName = iconResourceName;
393                iconResource.packageName = iconResourcePackageName;
394                data.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, iconResource);
395            }
396
397            return new PendingInstallShortcutInfo(data, context);
398        } catch (JSONException e) {
399            Log.d(TAG, "Exception reading shortcut to add: " + e);
400        } catch (URISyntaxException e) {
401            Log.d(TAG, "Exception reading shortcut to add: " + e);
402        }
403        return null;
404    }
405
406    /**
407     * Tries to create a new PendingInstallShortcutInfo which represents the same target,
408     * but is an app target and not a shortcut.
409     * @return the newly created info or the original one.
410     */
411    private static PendingInstallShortcutInfo convertToLauncherActivityIfPossible(
412            PendingInstallShortcutInfo original) {
413        if (original.isLuncherActivity()) {
414            // Already an activity target
415            return original;
416        }
417        if (!Utilities.isLauncherAppTarget(original.launchIntent)
418                || !original.user.equals(UserHandleCompat.myUserHandle())) {
419            // We can only convert shortcuts which point to a main activity in the current user.
420            return original;
421        }
422
423        PackageManager pm = original.mContext.getPackageManager();
424        ResolveInfo info = pm.resolveActivity(original.launchIntent, 0);
425
426        if (info == null) {
427            return original;
428        }
429
430        // Ignore any conflicts in the label name, as that can change based on locale.
431        LauncherActivityInfoCompat launcherInfo = LauncherActivityInfoCompat
432                .fromResolveInfo(info, original.mContext);
433        return new PendingInstallShortcutInfo(launcherInfo, original.mContext);
434    }
435}
436