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