/* * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.launcher3.popup; import static com.android.launcher3.Utilities.squaredHypot; import static com.android.launcher3.Utilities.squaredTouchSlop; import static com.android.launcher3.notification.NotificationMainView.NOTIFICATION_ITEM_INFO; import static com.android.launcher3.popup.PopupPopulator.MAX_SHORTCUTS; import static com.android.launcher3.popup.PopupPopulator.MAX_SHORTCUTS_IF_NOTIFICATIONS; import static com.android.launcher3.userevent.nano.LauncherLogProto.ContainerType; import static com.android.launcher3.userevent.nano.LauncherLogProto.ItemType; import static com.android.launcher3.userevent.nano.LauncherLogProto.Target; import static com.android.launcher3.util.Executors.MODEL_EXECUTOR; import android.animation.AnimatorSet; import android.animation.LayoutTransition; import android.annotation.TargetApi; import android.content.Context; import android.graphics.Point; import android.graphics.PointF; import android.graphics.Rect; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.AttributeSet; import android.util.Log; import android.util.Pair; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import com.android.launcher3.AbstractFloatingView; import com.android.launcher3.BubbleTextView; import com.android.launcher3.DragSource; import com.android.launcher3.DropTarget; import com.android.launcher3.DropTarget.DragObject; import com.android.launcher3.ItemInfo; import com.android.launcher3.ItemInfoWithIcon; import com.android.launcher3.Launcher; import com.android.launcher3.R; import com.android.launcher3.Utilities; import com.android.launcher3.accessibility.LauncherAccessibilityDelegate; import com.android.launcher3.accessibility.ShortcutMenuAccessibilityDelegate; import com.android.launcher3.dot.DotInfo; import com.android.launcher3.dragndrop.DragController; import com.android.launcher3.dragndrop.DragOptions; import com.android.launcher3.dragndrop.DragView; import com.android.launcher3.logging.LoggerUtils; import com.android.launcher3.notification.NotificationInfo; import com.android.launcher3.notification.NotificationItemView; import com.android.launcher3.notification.NotificationKeyData; import com.android.launcher3.popup.PopupDataProvider.PopupDataChangeListener; import com.android.launcher3.shortcuts.DeepShortcutView; import com.android.launcher3.shortcuts.ShortcutDragPreviewProvider; import com.android.launcher3.testing.TestProtocol; import com.android.launcher3.touch.ItemClickHandler; import com.android.launcher3.touch.ItemLongClickListener; import com.android.launcher3.util.PackageUserKey; import com.android.launcher3.util.ShortcutUtil; import com.android.launcher3.views.BaseDragLayer; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Predicate; /** * A container for shortcuts to deep links and notifications associated with an app. */ public class PopupContainerWithArrow extends ArrowPopup implements DragSource, DragController.DragListener, View.OnLongClickListener, View.OnTouchListener, PopupDataChangeListener { private final List mShortcuts = new ArrayList<>(); private final PointF mInterceptTouchDown = new PointF(); protected final Point mIconLastTouchPos = new Point(); private final int mStartDragThreshold; private final LauncherAccessibilityDelegate mAccessibilityDelegate; private BubbleTextView mOriginalIcon; private NotificationItemView mNotificationItemView; private int mNumNotifications; private ViewGroup mSystemShortcutContainer; public PopupContainerWithArrow(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); mStartDragThreshold = getResources().getDimensionPixelSize( R.dimen.deep_shortcuts_start_drag_threshold); mAccessibilityDelegate = new ShortcutMenuAccessibilityDelegate(mLauncher); } public PopupContainerWithArrow(Context context, AttributeSet attrs) { this(context, attrs, 0); } public PopupContainerWithArrow(Context context) { this(context, null, 0); } public LauncherAccessibilityDelegate getAccessibilityDelegate() { return mAccessibilityDelegate; } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); mLauncher.getPopupDataProvider().setChangeListener(this); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); mLauncher.getPopupDataProvider().setChangeListener(null); } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { mInterceptTouchDown.set(ev.getX(), ev.getY()); } if (mNotificationItemView != null && mNotificationItemView.onInterceptTouchEvent(ev)) { return true; } // Stop sending touch events to deep shortcut views if user moved beyond touch slop. return squaredHypot(mInterceptTouchDown.x - ev.getX(), mInterceptTouchDown.y - ev.getY()) > squaredTouchSlop(getContext()); } @Override public boolean onTouchEvent(MotionEvent ev) { if (mNotificationItemView != null) { return mNotificationItemView.onTouchEvent(ev) || super.onTouchEvent(ev); } return super.onTouchEvent(ev); } @Override protected boolean isOfType(int type) { return (type & TYPE_ACTION_POPUP) != 0; } @Override public void logActionCommand(int command) { mLauncher.getUserEventDispatcher().logActionCommand( command, mOriginalIcon, getLogContainerType()); } @Override public int getLogContainerType() { return ContainerType.DEEPSHORTCUTS; } public OnClickListener getItemClickListener() { return (view) -> { ItemClickHandler.INSTANCE.onClick(view); close(true); }; } @Override public boolean onControllerInterceptTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { BaseDragLayer dl = getPopupContainer(); if (!dl.isEventOverView(this, ev)) { mLauncher.getUserEventDispatcher().logActionTapOutside( LoggerUtils.newContainerTarget(ContainerType.DEEPSHORTCUTS)); close(true); // We let touches on the original icon go through so that users can launch // the app with one tap if they don't find a shortcut they want. return mOriginalIcon == null || !dl.isEventOverView(mOriginalIcon, ev); } } return false; } /** * Shows the notifications and deep shortcuts associated with {@param icon}. * @return the container if shown or null. */ public static PopupContainerWithArrow showForIcon(BubbleTextView icon) { if (TestProtocol.sDebugTracing) { Log.d(TestProtocol.NO_CONTEXT_MENU, "showForIcon"); } Launcher launcher = Launcher.getLauncher(icon.getContext()); if (getOpen(launcher) != null) { // There is already an items container open, so don't open this one. icon.clearFocus(); return null; } ItemInfo itemInfo = (ItemInfo) icon.getTag(); if (!ShortcutUtil.supportsShortcuts(itemInfo)) { return null; } final PopupContainerWithArrow container = (PopupContainerWithArrow) launcher.getLayoutInflater().inflate( R.layout.popup_container, launcher.getDragLayer(), false); container.populateAndShow(icon, itemInfo, SystemShortcutFactory.INSTANCE.get(launcher)); return container; } @Override protected void onInflationComplete(boolean isReversed) { if (isReversed && mNotificationItemView != null) { mNotificationItemView.inverseGutterMargin(); } // Update dividers int count = getChildCount(); DeepShortcutView lastView = null; for (int i = 0; i < count; i++) { View view = getChildAt(i); if (view.getVisibility() == VISIBLE && view instanceof DeepShortcutView) { if (lastView != null) { lastView.setDividerVisibility(VISIBLE); } lastView = (DeepShortcutView) view; lastView.setDividerVisibility(INVISIBLE); } } } protected void populateAndShow( BubbleTextView icon, ItemInfo item, SystemShortcutFactory factory) { if (TestProtocol.sDebugTracing) { Log.d(TestProtocol.NO_CONTEXT_MENU, "populateAndShow"); } PopupDataProvider popupDataProvider = mLauncher.getPopupDataProvider(); populateAndShow(icon, popupDataProvider.getShortcutCountForItem(item), popupDataProvider.getNotificationKeysForItem(item), factory.getEnabledShortcuts(mLauncher, item)); } public ViewGroup getSystemShortcutContainerForTesting() { return mSystemShortcutContainer; } @TargetApi(Build.VERSION_CODES.P) protected void populateAndShow(final BubbleTextView originalIcon, int shortcutCount, final List notificationKeys, List systemShortcuts) { mNumNotifications = notificationKeys.size(); mOriginalIcon = originalIcon; // Add views if (mNumNotifications > 0) { // Add notification entries View.inflate(getContext(), R.layout.notification_content, this); mNotificationItemView = new NotificationItemView(this); if (mNumNotifications == 1) { mNotificationItemView.removeFooter(); } updateNotificationHeader(); } int viewsToFlip = getChildCount(); mSystemShortcutContainer = this; if (shortcutCount > 0) { if (mNotificationItemView != null) { mNotificationItemView.addGutter(); } for (int i = shortcutCount; i > 0; i--) { mShortcuts.add(inflateAndAdd(R.layout.deep_shortcut, this)); } updateHiddenShortcuts(); if (!systemShortcuts.isEmpty()) { mSystemShortcutContainer = inflateAndAdd(R.layout.system_shortcut_icons, this); for (SystemShortcut shortcut : systemShortcuts) { initializeSystemShortcut( R.layout.system_shortcut_icon_only, mSystemShortcutContainer, shortcut); } } } else if (!systemShortcuts.isEmpty()) { if (mNotificationItemView != null) { mNotificationItemView.addGutter(); } for (SystemShortcut shortcut : systemShortcuts) { initializeSystemShortcut(R.layout.system_shortcut, this, shortcut); } } reorderAndShow(viewsToFlip); ItemInfo originalItemInfo = (ItemInfo) originalIcon.getTag(); if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { setAccessibilityPaneTitle(getTitleForAccessibility()); } mLauncher.getDragController().addDragListener(this); mOriginalIcon.setForceHideDot(true); // All views are added. Animate layout from now on. setLayoutTransition(new LayoutTransition()); // Load the shortcuts on a background thread and update the container as it animates. MODEL_EXECUTOR.getHandler().postAtFrontOfQueue(PopupPopulator.createUpdateRunnable( mLauncher, originalItemInfo, new Handler(Looper.getMainLooper()), this, mShortcuts, notificationKeys)); } private String getTitleForAccessibility() { return getContext().getString(mNumNotifications == 0 ? R.string.action_deep_shortcut : R.string.shortcuts_menu_with_notifications_description); } @Override protected Pair getAccessibilityTarget() { return Pair.create(this, ""); } @Override protected void getTargetObjectLocation(Rect outPos) { getPopupContainer().getDescendantRectRelativeToSelf(mOriginalIcon, outPos); outPos.top += mOriginalIcon.getPaddingTop(); outPos.left += mOriginalIcon.getPaddingLeft(); outPos.right -= mOriginalIcon.getPaddingRight(); outPos.bottom = outPos.top + (mOriginalIcon.getIcon() != null ? mOriginalIcon.getIcon().getBounds().height() : mOriginalIcon.getHeight()); } public void applyNotificationInfos(List notificationInfos) { mNotificationItemView.applyNotificationInfos(notificationInfos); } private void updateHiddenShortcuts() { int allowedCount = mNotificationItemView != null ? MAX_SHORTCUTS_IF_NOTIFICATIONS : MAX_SHORTCUTS; int originalHeight = getResources().getDimensionPixelSize(R.dimen.bg_popup_item_height); int itemHeight = mNotificationItemView != null ? getResources().getDimensionPixelSize(R.dimen.bg_popup_item_condensed_height) : originalHeight; float iconScale = ((float) itemHeight) / originalHeight; int total = mShortcuts.size(); for (int i = 0; i < total; i++) { DeepShortcutView view = mShortcuts.get(i); view.setVisibility(i >= allowedCount ? GONE : VISIBLE); view.getLayoutParams().height = itemHeight; view.getIconView().setScaleX(iconScale); view.getIconView().setScaleY(iconScale); } } private void updateDividers() { int count = getChildCount(); DeepShortcutView lastView = null; for (int i = 0; i < count; i++) { View view = getChildAt(i); if (view.getVisibility() == VISIBLE && view instanceof DeepShortcutView) { if (lastView != null) { lastView.setDividerVisibility(VISIBLE); } lastView = (DeepShortcutView) view; lastView.setDividerVisibility(INVISIBLE); } } } @Override public void onWidgetsBound() { ItemInfo itemInfo = (ItemInfo) mOriginalIcon.getTag(); SystemShortcut widgetInfo = new SystemShortcut.Widgets(); View.OnClickListener onClickListener = widgetInfo.getOnClickListener(mLauncher, itemInfo); View widgetsView = null; int count = mSystemShortcutContainer.getChildCount(); for (int i = 0; i < count; i++) { View systemShortcutView = mSystemShortcutContainer.getChildAt(i); if (systemShortcutView.getTag() instanceof SystemShortcut.Widgets) { widgetsView = systemShortcutView; break; } } if (onClickListener != null && widgetsView == null) { // We didn't have any widgets cached but now there are some, so enable the shortcut. if (mSystemShortcutContainer != this) { initializeSystemShortcut( R.layout.system_shortcut_icon_only, mSystemShortcutContainer, widgetInfo); } else { // If using the expanded system shortcut (as opposed to just the icon), we need to // reopen the container to ensure measurements etc. all work out. While this could // be quite janky, in practice the user would typically see a small flicker as the // animation restarts partway through, and this is a very rare edge case anyway. close(false); PopupContainerWithArrow.showForIcon(mOriginalIcon); } } else if (onClickListener == null && widgetsView != null) { // No widgets exist, but we previously added the shortcut so remove it. if (mSystemShortcutContainer != this) { mSystemShortcutContainer.removeView(widgetsView); } else { close(false); PopupContainerWithArrow.showForIcon(mOriginalIcon); } } } private void initializeSystemShortcut(int resId, ViewGroup container, SystemShortcut info) { View view = inflateAndAdd( resId, container, getInsertIndexForSystemShortcut(container, info)); if (view instanceof DeepShortcutView) { // Expanded system shortcut, with both icon and text shown on white background. final DeepShortcutView shortcutView = (DeepShortcutView) view; info.setIconAndLabelFor(shortcutView.getIconView(), shortcutView.getBubbleText()); } else if (view instanceof ImageView) { // Only the system shortcut icon shows on a gray background header. info.setIconAndContentDescriptionFor((ImageView) view); } view.setTag(info); view.setOnClickListener(info.getOnClickListener(mLauncher, (ItemInfo) mOriginalIcon.getTag())); } /** * Returns an index for inserting a shortcut into a container. */ private int getInsertIndexForSystemShortcut(ViewGroup container, SystemShortcut shortcut) { final View separator = container.findViewById(R.id.separator); return separator != null && shortcut.isLeftGroup() ? container.indexOfChild(separator) : container.getChildCount(); } /** * Determines when the deferred drag should be started. * * Current behavior: * - Start the drag if the touch passes a certain distance from the original touch down. */ public DragOptions.PreDragCondition createPreDragCondition() { return new DragOptions.PreDragCondition() { @Override public boolean shouldStartDrag(double distanceDragged) { return distanceDragged > mStartDragThreshold; } @Override public void onPreDragStart(DropTarget.DragObject dragObject) { if (mIsAboveIcon) { // Hide only the icon, keep the text visible. mOriginalIcon.setIconVisible(false); mOriginalIcon.setVisibility(VISIBLE); } else { // Hide both the icon and text. mOriginalIcon.setVisibility(INVISIBLE); } } @Override public void onPreDragEnd(DropTarget.DragObject dragObject, boolean dragStarted) { mOriginalIcon.setIconVisible(true); if (dragStarted) { // Make sure we keep the original icon hidden while it is being dragged. mOriginalIcon.setVisibility(INVISIBLE); } else { mLauncher.getUserEventDispatcher().logDeepShortcutsOpen(mOriginalIcon); if (!mIsAboveIcon) { // Show the icon but keep the text hidden. mOriginalIcon.setVisibility(VISIBLE); mOriginalIcon.setTextVisibility(false); } } } }; } /** * Updates the notification header if the original icon's dot updated. */ @Override public void onNotificationDotsUpdated(Predicate updatedDots) { ItemInfo itemInfo = (ItemInfo) mOriginalIcon.getTag(); PackageUserKey packageUser = PackageUserKey.fromItemInfo(itemInfo); if (updatedDots.test(packageUser)) { updateNotificationHeader(); } } private void updateNotificationHeader() { ItemInfoWithIcon itemInfo = (ItemInfoWithIcon) mOriginalIcon.getTag(); DotInfo dotInfo = mLauncher.getDotInfoForItem(itemInfo); if (mNotificationItemView != null && dotInfo != null) { mNotificationItemView.updateHeader( dotInfo.getNotificationCount(), itemInfo.iconColor); } } @Override public void trimNotifications(Map updatedDots) { if (mNotificationItemView == null) { return; } ItemInfo originalInfo = (ItemInfo) mOriginalIcon.getTag(); DotInfo dotInfo = updatedDots.get(PackageUserKey.fromItemInfo(originalInfo)); if (dotInfo == null || dotInfo.getNotificationKeys().size() == 0) { // No more notifications, remove the notification views and expand all shortcuts. mNotificationItemView.removeAllViews(); mNotificationItemView = null; updateHiddenShortcuts(); updateDividers(); } else { mNotificationItemView.trimNotifications( NotificationKeyData.extractKeysOnly(dotInfo.getNotificationKeys())); } } @Override public void onDropCompleted(View target, DragObject d, boolean success) { } @Override public void onDragStart(DropTarget.DragObject dragObject, DragOptions options) { // Either the original icon or one of the shortcuts was dragged. // Hide the container, but don't remove it yet because that interferes with touch events. mDeferContainerRemoval = true; animateClose(); } @Override public void onDragEnd() { if (!mIsOpen) { if (mOpenCloseAnimator != null) { // Close animation is running. mDeferContainerRemoval = false; } else { // Close animation is not running. if (mDeferContainerRemoval) { closeComplete(); } } } } @Override public void fillInLogContainerData(View v, ItemInfo info, Target target, Target targetParent) { if (info == NOTIFICATION_ITEM_INFO) { target.itemType = ItemType.NOTIFICATION; } else { target.itemType = ItemType.DEEPSHORTCUT; target.rank = info.rank; } targetParent.containerType = ContainerType.DEEPSHORTCUTS; } @Override protected void onCreateCloseAnimation(AnimatorSet anim) { // Animate original icon's text back in. anim.play(mOriginalIcon.createTextAlphaAnimator(true /* fadeIn */)); mOriginalIcon.setForceHideDot(false); } @Override protected void closeComplete() { PopupContainerWithArrow openPopup = getOpen(mLauncher); if (openPopup == null || openPopup.mOriginalIcon != mOriginalIcon) { mOriginalIcon.setTextVisibility(mOriginalIcon.shouldTextBeVisible()); mOriginalIcon.setForceHideDot(false); } super.closeComplete(); } @Override public boolean onTouch(View v, MotionEvent ev) { // Touched a shortcut, update where it was touched so we can drag from there on long click. switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: mIconLastTouchPos.set((int) ev.getX(), (int) ev.getY()); break; } return false; } @Override public boolean onLongClick(View v) { if (!ItemLongClickListener.canStartDrag(mLauncher)) return false; // Return early if not the correct view if (!(v.getParent() instanceof DeepShortcutView)) return false; // Return early if workspace edit is disabled if (!Utilities.isWorkspaceEditAllowed(mLauncher.getApplicationContext())) return false; // Long clicked on a shortcut. DeepShortcutView sv = (DeepShortcutView) v.getParent(); sv.setWillDrawIcon(false); // Move the icon to align with the center-top of the touch point Point iconShift = new Point(); iconShift.x = mIconLastTouchPos.x - sv.getIconCenter().x; iconShift.y = mIconLastTouchPos.y - mLauncher.getDeviceProfile().iconSizePx; DragView dv = mLauncher.getWorkspace().beginDragShared(sv.getIconView(), this, sv.getFinalInfo(), new ShortcutDragPreviewProvider(sv.getIconView(), iconShift), new DragOptions()); dv.animateShift(-iconShift.x, -iconShift.y); // TODO: support dragging from within folder without having to close it AbstractFloatingView.closeOpenContainer(mLauncher, AbstractFloatingView.TYPE_FOLDER); return false; } /** * Returns a PopupContainerWithArrow which is already open or null */ public static PopupContainerWithArrow getOpen(Launcher launcher) { return getOpenView(launcher, TYPE_ACTION_POPUP); } }