1 /*
2  * Copyright (C) 2018 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 package com.android.launcher3.touch;
17 
18 import static com.android.launcher3.LauncherConstants.ActivityCodes.REQUEST_BIND_PENDING_APPWIDGET;
19 import static com.android.launcher3.LauncherConstants.ActivityCodes.REQUEST_RECONFIGURE_APPWIDGET;
20 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_FOLDER_OPEN;
21 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_PRIVATE_SPACE_INSTALL_APP_BUTTON_TAP;
22 import static com.android.launcher3.model.data.ItemInfoWithIcon.FLAG_DISABLED_BY_PUBLISHER;
23 import static com.android.launcher3.model.data.ItemInfoWithIcon.FLAG_DISABLED_LOCKED_USER;
24 import static com.android.launcher3.model.data.ItemInfoWithIcon.FLAG_DISABLED_QUIET_USER;
25 import static com.android.launcher3.model.data.ItemInfoWithIcon.FLAG_DISABLED_SAFEMODE;
26 import static com.android.launcher3.model.data.ItemInfoWithIcon.FLAG_DISABLED_SUSPENDED;
27 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
28 import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
29 
30 import android.app.AlertDialog;
31 import android.content.Context;
32 import android.content.Intent;
33 import android.content.pm.LauncherApps;
34 import android.content.pm.PackageInstaller.SessionInfo;
35 import android.os.Process;
36 import android.text.TextUtils;
37 import android.util.Log;
38 import android.view.View;
39 import android.view.View.OnClickListener;
40 import android.widget.Toast;
41 
42 import com.android.launcher3.BubbleTextView;
43 import com.android.launcher3.BuildConfig;
44 import com.android.launcher3.Flags;
45 import com.android.launcher3.InvariantDeviceProfile;
46 import com.android.launcher3.Launcher;
47 import com.android.launcher3.LauncherSettings;
48 import com.android.launcher3.R;
49 import com.android.launcher3.Utilities;
50 import com.android.launcher3.apppairs.AppPairIcon;
51 import com.android.launcher3.folder.Folder;
52 import com.android.launcher3.folder.FolderIcon;
53 import com.android.launcher3.logging.InstanceId;
54 import com.android.launcher3.logging.InstanceIdSequence;
55 import com.android.launcher3.logging.StatsLogManager;
56 import com.android.launcher3.model.data.AppInfo;
57 import com.android.launcher3.model.data.AppPairInfo;
58 import com.android.launcher3.model.data.FolderInfo;
59 import com.android.launcher3.model.data.ItemInfo;
60 import com.android.launcher3.model.data.ItemInfoWithIcon;
61 import com.android.launcher3.model.data.LauncherAppWidgetInfo;
62 import com.android.launcher3.model.data.WorkspaceItemInfo;
63 import com.android.launcher3.pm.InstallSessionHelper;
64 import com.android.launcher3.shortcuts.ShortcutKey;
65 import com.android.launcher3.testing.TestLogging;
66 import com.android.launcher3.testing.shared.TestProtocol;
67 import com.android.launcher3.util.ApiWrapper;
68 import com.android.launcher3.util.ItemInfoMatcher;
69 import com.android.launcher3.views.FloatingIconView;
70 import com.android.launcher3.views.Snackbar;
71 import com.android.launcher3.widget.LauncherAppWidgetProviderInfo;
72 import com.android.launcher3.widget.PendingAddShortcutInfo;
73 import com.android.launcher3.widget.PendingAddWidgetInfo;
74 import com.android.launcher3.widget.PendingAppWidgetHostView;
75 import com.android.launcher3.widget.WidgetAddFlowHandler;
76 import com.android.launcher3.widget.WidgetManagerHelper;
77 
78 import java.util.Collections;
79 import java.util.concurrent.CompletableFuture;
80 import java.util.function.Consumer;
81 
82 /**
83  * Class for handling clicks on workspace and all-apps items
84  */
85 public class ItemClickHandler {
86 
87     private static final String TAG = "ItemClickHandler";
88     private static final boolean DEBUG = true;
89 
90     /**
91      * Instance used for click handling on items
92      */
93     public static final OnClickListener INSTANCE = ItemClickHandler::onClick;
94 
onClick(View v)95     private static void onClick(View v) {
96         // Make sure that rogue clicks don't get through while allapps is launching, or after the
97         // view has detached (it's possible for this to happen if the view is removed mid touch).
98         if (v.getWindowToken() == null) return;
99 
100         Launcher launcher = Launcher.getLauncher(v.getContext());
101         if (!launcher.getWorkspace().isFinishedSwitchingState()) return;
102 
103         Object tag = v.getTag();
104         if (tag instanceof WorkspaceItemInfo) {
105             onClickAppShortcut(v, (WorkspaceItemInfo) tag, launcher);
106         } else if (tag instanceof FolderInfo) {
107             onClickFolderIcon(v);
108         } else if (tag instanceof AppPairInfo) {
109             onClickAppPairIcon(v);
110         } else if (tag instanceof AppInfo) {
111             startAppShortcutOrInfoActivity(v, (AppInfo) tag, launcher);
112         } else if (tag instanceof LauncherAppWidgetInfo) {
113             if (v instanceof PendingAppWidgetHostView) {
114                 if (DEBUG) {
115                     String targetPackage = ((LauncherAppWidgetInfo) tag).getTargetPackage();
116                     Log.d(TAG, "onClick: PendingAppWidgetHostView clicked for"
117                             + " package=" + targetPackage);
118                 }
119                 onClickPendingWidget((PendingAppWidgetHostView) v, launcher);
120             } else {
121                 if (DEBUG) {
122                     String targetPackage = ((LauncherAppWidgetInfo) tag).getTargetPackage();
123                     Log.d(TAG, "onClick: LauncherAppWidgetInfo clicked,"
124                             + " but not instance of PendingAppWidgetHostView. Returning."
125                             + " package=" + targetPackage);
126                 }
127             }
128         } else if (tag instanceof ItemClickProxy) {
129             ((ItemClickProxy) tag).onItemClicked(v);
130         } else if (tag instanceof PendingAddShortcutInfo) {
131             CharSequence msg = Utilities.wrapForTts(
132                     launcher.getText(R.string.long_press_shortcut_to_add),
133                     launcher.getString(R.string.long_accessible_way_to_add_shortcut));
134             Snackbar.show(launcher, msg, null);
135         } else if (tag instanceof PendingAddWidgetInfo) {
136             if (DEBUG) {
137                 String targetPackage = ((PendingAddWidgetInfo) tag).getTargetPackage();
138                 Log.d(TAG, "onClick: PendingAddWidgetInfo clicked for package=" + targetPackage);
139             }
140             CharSequence msg = Utilities.wrapForTts(
141                     launcher.getText(R.string.long_press_widget_to_add),
142                     launcher.getString(R.string.long_accessible_way_to_add));
143             Snackbar.show(launcher, msg, null);
144         }
145     }
146 
147     /**
148      * Event handler for a folder icon click.
149      *
150      * @param v The view that was clicked. Must be an instance of {@link FolderIcon}.
151      */
onClickFolderIcon(View v)152     private static void onClickFolderIcon(View v) {
153         Folder folder = ((FolderIcon) v).getFolder();
154         if (!folder.isOpen() && !folder.isDestroyed()) {
155             // Open the requested folder
156             folder.animateOpen();
157             StatsLogManager.newInstance(v.getContext()).logger().withItemInfo(folder.mInfo)
158                     .log(LAUNCHER_FOLDER_OPEN);
159         }
160     }
161 
162     /**
163      * Event handler for an app pair icon click.
164      *
165      * @param v The view that was clicked. Must be an instance of {@link AppPairIcon}.
166      */
onClickAppPairIcon(View v)167     private static void onClickAppPairIcon(View v) {
168         Launcher launcher = Launcher.getLauncher(v.getContext());
169         AppPairIcon icon = (AppPairIcon) v;
170         AppPairInfo info = icon.getInfo();
171         boolean isApp1Launchable = info.isLaunchable(launcher).getFirst(),
172                 isApp2Launchable = info.isLaunchable(launcher).getSecond();
173         if (!isApp1Launchable || !isApp2Launchable) {
174             // App pair is unlaunchable due to screen size.
175             boolean isFoldable = InvariantDeviceProfile.INSTANCE.get(launcher)
176                     .supportedProfiles.stream().anyMatch(dp -> dp.isTwoPanels);
177             Toast.makeText(launcher, isFoldable
178                             ? R.string.app_pair_needs_unfold
179                             : R.string.app_pair_unlaunchable_at_screen_size,
180                     Toast.LENGTH_SHORT).show();
181             return;
182         } else if (info.isDisabled()) {
183             // App pair is disabled for another reason.
184             WorkspaceItemInfo app1 = info.getFirstApp();
185             WorkspaceItemInfo app2 = info.getSecondApp();
186             // Show the user why the app pair is disabled.
187             if (app1.isDisabled() && app2.isDisabled()) {
188                 // Both apps are disabled, show generic "app pair is not available" toast.
189                 Toast.makeText(launcher, R.string.app_pair_not_available, Toast.LENGTH_SHORT)
190                         .show();
191                 return;
192             } else if ((app1.isDisabled() && handleDisabledItemClicked(app1, launcher))
193                     || (app2.isDisabled() && handleDisabledItemClicked(app2, launcher))) {
194                 // Only one is disabled, and handleDisabledItemClicked() showed a specific toast
195                 // explaining why, so we are done.
196                 return;
197             }
198         }
199 
200         // Either the app pair is not disabled, or it is a disabled state that can be handled by
201         // framework directly (e.g. one app is paused), so go ahead and launch.
202         launcher.launchAppPair(icon);
203     }
204 
205     /**
206      * Event handler for the app widget view which has not fully restored.
207      */
onClickPendingWidget(PendingAppWidgetHostView v, Launcher launcher)208     private static void onClickPendingWidget(PendingAppWidgetHostView v, Launcher launcher) {
209         if (launcher.getPackageManager().isSafeMode()) {
210             Toast.makeText(launcher, R.string.safemode_widget_error, Toast.LENGTH_SHORT).show();
211             return;
212         }
213 
214         final LauncherAppWidgetInfo info = (LauncherAppWidgetInfo) v.getTag();
215         if (v.isReadyForClickSetup()) {
216             LauncherAppWidgetProviderInfo appWidgetInfo = new WidgetManagerHelper(launcher)
217                     .findProvider(info.providerName, info.user);
218             if (appWidgetInfo == null) {
219                 Log.e(TAG, "onClickPendingWidget: Pending widget ready for click setup,"
220                         + " but LauncherAppWidgetProviderInfo was null. Returning."
221                         + " component=" + info.getTargetComponent());
222                 return;
223             }
224             WidgetAddFlowHandler addFlowHandler = new WidgetAddFlowHandler(appWidgetInfo);
225 
226             if (info.hasRestoreFlag(LauncherAppWidgetInfo.FLAG_ID_NOT_VALID)) {
227                 if (!info.hasRestoreFlag(LauncherAppWidgetInfo.FLAG_ID_ALLOCATED)) {
228                     // This should not happen, as we make sure that an Id is allocated during bind.
229                     Log.e(TAG, "onClickPendingWidget: Pending widget ready for click setup,"
230                             + " and LauncherAppWidgetProviderInfo was found. However,"
231                             + " no appWidgetId was allocated. Returning."
232                             + " component=" + info.getTargetComponent());
233                     return;
234                 }
235                 addFlowHandler.startBindFlow(launcher, info.appWidgetId, info,
236                         REQUEST_BIND_PENDING_APPWIDGET);
237             } else {
238                 addFlowHandler.startConfigActivity(launcher, info, REQUEST_RECONFIGURE_APPWIDGET);
239             }
240         } else {
241             final String packageName = info.providerName.getPackageName();
242             onClickPendingAppItem(v, launcher, packageName, info.installProgress >= 0);
243         }
244     }
245 
onClickPendingAppItem(View v, Launcher launcher, String packageName, boolean downloadStarted)246     private static void onClickPendingAppItem(View v, Launcher launcher, String packageName,
247             boolean downloadStarted) {
248         ItemInfo item = (ItemInfo) v.getTag();
249         CompletableFuture<SessionInfo> siFuture;
250         siFuture = CompletableFuture.supplyAsync(() ->
251                         InstallSessionHelper.INSTANCE.get(launcher)
252                                 .getActiveSessionInfo(item.user, packageName),
253                 UI_HELPER_EXECUTOR);
254         Consumer<SessionInfo> marketLaunchAction = sessionInfo -> {
255             if (sessionInfo != null) {
256                 LauncherApps launcherApps = launcher.getSystemService(LauncherApps.class);
257                 try {
258                     launcherApps.startPackageInstallerSessionDetailsActivity(sessionInfo, null,
259                             launcher.getActivityLaunchOptions(v, item).toBundle());
260                     return;
261                 } catch (Exception e) {
262                     Log.e(TAG, "Unable to launch market intent for package=" + packageName, e);
263                 }
264             }
265             // Fallback to using custom market intent.
266             Intent intent = ApiWrapper.INSTANCE.get(launcher).getAppMarketActivityIntent(
267                     packageName, Process.myUserHandle());
268             launcher.startActivitySafely(v, intent, item);
269         };
270 
271         if (downloadStarted) {
272             // If the download has started, simply direct to the market app.
273             siFuture.thenAcceptAsync(marketLaunchAction, MAIN_EXECUTOR);
274             return;
275         }
276         new AlertDialog.Builder(launcher)
277                 .setTitle(R.string.abandoned_promises_title)
278                 .setMessage(R.string.abandoned_promise_explanation)
279                 .setPositiveButton(R.string.abandoned_search,
280                         (d, i) -> siFuture.thenAcceptAsync(marketLaunchAction, MAIN_EXECUTOR))
281                 .setNeutralButton(R.string.abandoned_clean_this,
282                         (d, i) -> launcher.getWorkspace()
283                                 .persistRemoveItemsByMatcher(ItemInfoMatcher.ofPackages(
284                                         Collections.singleton(packageName), item.user),
285                                         "user explicitly removes the promise app icon"))
286                 .create().show();
287     }
288 
289     /**
290      * Handles clicking on a disabled shortcut
291      *
292      * @return true iff the disabled item click has been handled.
293      */
handleDisabledItemClicked(WorkspaceItemInfo shortcut, Context context)294     public static boolean handleDisabledItemClicked(WorkspaceItemInfo shortcut, Context context) {
295         final int disabledFlags = shortcut.runtimeStatusFlags
296                 & WorkspaceItemInfo.FLAG_DISABLED_MASK;
297         // Handle the case where the disabled reason is DISABLED_REASON_VERSION_LOWER.
298         // Show an AlertDialog for the user to choose either updating the app or cancel the launch.
299         if (maybeCreateAlertDialogForShortcut(shortcut, context)) {
300             return true;
301         }
302 
303         if ((disabledFlags
304                 & ~FLAG_DISABLED_SUSPENDED
305                 & ~FLAG_DISABLED_QUIET_USER) == 0) {
306             // If the app is only disabled because of the above flags, launch activity anyway.
307             // Framework will tell the user why the app is suspended.
308             return false;
309         } else {
310             if (!TextUtils.isEmpty(shortcut.disabledMessage)) {
311                 // Use a message specific to this shortcut, if it has one.
312                 Toast.makeText(context, shortcut.disabledMessage, Toast.LENGTH_SHORT).show();
313                 return true;
314             }
315             // Otherwise just use a generic error message.
316             int error = R.string.activity_not_available;
317             if ((shortcut.runtimeStatusFlags & FLAG_DISABLED_SAFEMODE) != 0) {
318                 error = R.string.safemode_shortcut_error;
319             } else if ((shortcut.runtimeStatusFlags & FLAG_DISABLED_BY_PUBLISHER) != 0
320                     || (shortcut.runtimeStatusFlags & FLAG_DISABLED_LOCKED_USER) != 0) {
321                 error = R.string.shortcut_not_available;
322             }
323             Toast.makeText(context, error, Toast.LENGTH_SHORT).show();
324             return true;
325         }
326     }
327 
maybeCreateAlertDialogForShortcut(final WorkspaceItemInfo shortcut, Context context)328     private static boolean maybeCreateAlertDialogForShortcut(final WorkspaceItemInfo shortcut,
329             Context context) {
330         try {
331             final Launcher launcher = Launcher.getLauncher(context);
332             if (shortcut.itemType == LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT
333                     && shortcut.isDisabledVersionLower()) {
334                 final Intent marketIntent = shortcut.getMarketIntent(context);
335                 // No market intent means no target package for the shortcut, which should be an
336                 // issue. Falling back to showing toast messages.
337                 if (marketIntent == null) {
338                     return false;
339                 }
340 
341                 new AlertDialog.Builder(context)
342                         .setTitle(R.string.dialog_update_title)
343                         .setMessage(R.string.dialog_update_message)
344                         .setPositiveButton(R.string.dialog_update, (d, i) -> {
345                             // Direct the user to the play store to update the app
346                             context.startActivity(marketIntent);
347                         })
348                         .setNeutralButton(R.string.dialog_remove, (d, i) -> {
349                             // Remove the icon if launcher is successfully initialized
350                             launcher.getWorkspace().persistRemoveItemsByMatcher(ItemInfoMatcher
351                                     .ofShortcutKeys(Collections.singleton(ShortcutKey
352                                             .fromItemInfo(shortcut))),
353                                     "user explicitly removes disabled shortcut");
354                         })
355                         .create()
356                         .show();
357                 return true;
358             }
359         } catch (Exception e) {
360             Log.e(TAG, "Error creating alert dialog", e);
361         }
362 
363         return false;
364     }
365 
366     /**
367      * Event handler for an app shortcut click.
368      *
369      * @param v The view that was clicked. Must be a tagged with a {@link WorkspaceItemInfo}.
370      */
onClickAppShortcut(View v, WorkspaceItemInfo shortcut, Launcher launcher)371     public static void onClickAppShortcut(View v, WorkspaceItemInfo shortcut, Launcher launcher) {
372         if (shortcut.isDisabled() && handleDisabledItemClicked(shortcut, launcher)) {
373             return;
374         }
375 
376         // Check for abandoned promise
377         if ((v instanceof BubbleTextView) && shortcut.hasPromiseIconUi()
378                 && (!Flags.enableSupportForArchiving() || !shortcut.isArchived())) {
379             String packageName = shortcut.getIntent().getComponent() != null
380                     ? shortcut.getIntent().getComponent().getPackageName()
381                     : shortcut.getIntent().getPackage();
382             if (!TextUtils.isEmpty(packageName)) {
383                 onClickPendingAppItem(
384                         v,
385                         launcher,
386                         packageName,
387                         (shortcut.runtimeStatusFlags
388                                 & ItemInfoWithIcon.FLAG_INSTALL_SESSION_ACTIVE) != 0);
389                 return;
390             }
391         }
392 
393         // Start activities
394         startAppShortcutOrInfoActivity(v, shortcut, launcher);
395     }
396 
startAppShortcutOrInfoActivity(View v, ItemInfo item, Launcher launcher)397     private static void startAppShortcutOrInfoActivity(View v, ItemInfo item, Launcher launcher) {
398         TestLogging.recordEvent(
399                 TestProtocol.SEQUENCE_MAIN, "start: startAppShortcutOrInfoActivity");
400         Intent intent = item.getIntent();
401         if (item instanceof ItemInfoWithIcon itemInfoWithIcon) {
402             if ((itemInfoWithIcon.runtimeStatusFlags
403                     & ItemInfoWithIcon.FLAG_INSTALL_SESSION_ACTIVE) != 0) {
404                 intent = ApiWrapper.INSTANCE.get(launcher).getAppMarketActivityIntent(
405                         itemInfoWithIcon.getTargetComponent().getPackageName(),
406                         Process.myUserHandle());
407             } else if (itemInfoWithIcon.itemType
408                     == LauncherSettings.Favorites.ITEM_TYPE_PRIVATE_SPACE_INSTALL_APP_BUTTON) {
409                 intent = ApiWrapper.INSTANCE.get(launcher).getAppMarketActivityIntent(
410                         BuildConfig.APPLICATION_ID,
411                         launcher.getAppsView().getPrivateProfileManager().getProfileUser());
412                 launcher.getStatsLogManager().logger().log(
413                         LAUNCHER_PRIVATE_SPACE_INSTALL_APP_BUTTON_TAP);
414             }
415         }
416         if (intent == null) {
417             throw new IllegalArgumentException("Input must have a valid intent");
418         }
419         if (item instanceof WorkspaceItemInfo) {
420             WorkspaceItemInfo si = (WorkspaceItemInfo) item;
421             if (si.hasStatusFlag(WorkspaceItemInfo.FLAG_SUPPORTS_WEB_UI)
422                     && Intent.ACTION_VIEW.equals(intent.getAction())) {
423                 // make a copy of the intent that has the package set to null
424                 // we do this because the platform sometimes disables instant
425                 // apps temporarily (triggered by the user) and fallbacks to the
426                 // web ui. This only works though if the package isn't set
427                 intent = new Intent(intent);
428                 intent.setPackage(null);
429             }
430             if ((si.options & WorkspaceItemInfo.FLAG_START_FOR_RESULT) != 0) {
431                 launcher.startActivityForResult(item.getIntent(), 0);
432                 InstanceId instanceId = new InstanceIdSequence().newInstanceId();
433                 launcher.logAppLaunch(launcher.getStatsLogManager(), item, instanceId);
434                 return;
435             }
436         }
437         if (v != null && launcher.supportsAdaptiveIconAnimation(v)
438                 && !item.shouldUseBackgroundAnimation()) {
439             // Preload the icon to reduce latency b/w swapping the floating view with the original.
440             FloatingIconView.fetchIcon(launcher, v, item, true /* isOpening */);
441         }
442         launcher.startActivitySafely(v, intent, item);
443     }
444 
445     /**
446      * Interface to indicate that an item will handle the click itself.
447      */
448     public interface ItemClickProxy {
449 
450         /**
451          * Called when the item is clicked
452          */
onItemClicked(View view)453         void onItemClicked(View view);
454     }
455 }
456