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