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