| /* |
| * Copyright (C) 2020 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.wm.shell.bubbles; |
| |
| import static android.app.ActivityTaskManager.INVALID_TASK_ID; |
| import static android.service.notification.NotificationListenerService.NOTIFICATION_CHANNEL_OR_GROUP_DELETED; |
| import static android.service.notification.NotificationListenerService.NOTIFICATION_CHANNEL_OR_GROUP_UPDATED; |
| import static android.service.notification.NotificationListenerService.REASON_CANCEL; |
| import static android.view.View.INVISIBLE; |
| import static android.view.View.VISIBLE; |
| import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; |
| |
| import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_CONTROLLER; |
| import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_GESTURE; |
| import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES; |
| import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME; |
| import static com.android.wm.shell.bubbles.Bubbles.DISMISS_BLOCKED; |
| import static com.android.wm.shell.bubbles.Bubbles.DISMISS_GROUP_CANCELLED; |
| import static com.android.wm.shell.bubbles.Bubbles.DISMISS_INVALID_INTENT; |
| import static com.android.wm.shell.bubbles.Bubbles.DISMISS_NOTIF_CANCEL; |
| import static com.android.wm.shell.bubbles.Bubbles.DISMISS_NO_BUBBLE_UP; |
| import static com.android.wm.shell.bubbles.Bubbles.DISMISS_NO_LONGER_BUBBLE; |
| import static com.android.wm.shell.bubbles.Bubbles.DISMISS_PACKAGE_REMOVED; |
| import static com.android.wm.shell.bubbles.Bubbles.DISMISS_SHORTCUT_REMOVED; |
| import static com.android.wm.shell.bubbles.Bubbles.DISMISS_USER_CHANGED; |
| import static com.android.wm.shell.sysui.ShellSharedConstants.KEY_EXTRA_SHELL_BUBBLES; |
| |
| import android.annotation.BinderThread; |
| import android.annotation.NonNull; |
| import android.annotation.UserIdInt; |
| import android.app.ActivityManager; |
| import android.app.Notification; |
| import android.app.NotificationChannel; |
| import android.app.PendingIntent; |
| import android.content.BroadcastReceiver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.pm.ActivityInfo; |
| import android.content.pm.LauncherApps; |
| import android.content.pm.PackageManager; |
| import android.content.pm.ShortcutInfo; |
| import android.content.pm.UserInfo; |
| import android.content.res.Configuration; |
| import android.graphics.PixelFormat; |
| import android.graphics.Rect; |
| import android.graphics.drawable.Icon; |
| import android.os.Binder; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.RemoteException; |
| import android.os.ServiceManager; |
| import android.os.SystemProperties; |
| import android.os.UserHandle; |
| import android.os.UserManager; |
| import android.service.notification.NotificationListenerService; |
| import android.service.notification.NotificationListenerService.RankingMap; |
| import android.util.Log; |
| import android.util.Pair; |
| import android.util.SparseArray; |
| import android.view.IWindowManager; |
| import android.view.SurfaceControl; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewRootImpl; |
| import android.view.WindowInsets; |
| import android.view.WindowManager; |
| import android.window.ScreenCapture; |
| import android.window.ScreenCapture.ScreenCaptureListener; |
| import android.window.ScreenCapture.ScreenshotSync; |
| |
| import androidx.annotation.MainThread; |
| import androidx.annotation.Nullable; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.internal.statusbar.IStatusBarService; |
| import com.android.launcher3.icons.BubbleIconFactory; |
| import com.android.wm.shell.R; |
| import com.android.wm.shell.ShellTaskOrganizer; |
| import com.android.wm.shell.WindowManagerShellWrapper; |
| import com.android.wm.shell.bubbles.bar.BubbleBarLayerView; |
| import com.android.wm.shell.common.DisplayController; |
| import com.android.wm.shell.common.ExternalInterfaceBinder; |
| import com.android.wm.shell.common.FloatingContentCoordinator; |
| import com.android.wm.shell.common.RemoteCallable; |
| import com.android.wm.shell.common.ShellExecutor; |
| import com.android.wm.shell.common.SingleInstanceRemoteListener; |
| import com.android.wm.shell.common.SyncTransactionQueue; |
| import com.android.wm.shell.common.TaskStackListenerCallback; |
| import com.android.wm.shell.common.TaskStackListenerImpl; |
| import com.android.wm.shell.common.annotations.ShellBackgroundThread; |
| import com.android.wm.shell.common.annotations.ShellMainThread; |
| import com.android.wm.shell.common.bubbles.BubbleBarUpdate; |
| import com.android.wm.shell.draganddrop.DragAndDropController; |
| import com.android.wm.shell.onehanded.OneHandedController; |
| import com.android.wm.shell.onehanded.OneHandedTransitionCallback; |
| import com.android.wm.shell.pip.PinnedStackListenerForwarder; |
| import com.android.wm.shell.sysui.ConfigurationChangeListener; |
| import com.android.wm.shell.sysui.ShellCommandHandler; |
| import com.android.wm.shell.sysui.ShellController; |
| import com.android.wm.shell.sysui.ShellInit; |
| import com.android.wm.shell.taskview.TaskView; |
| import com.android.wm.shell.taskview.TaskViewTransitions; |
| |
| import java.io.PrintWriter; |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.concurrent.Executor; |
| import java.util.function.Consumer; |
| import java.util.function.IntConsumer; |
| |
| /** |
| * Bubbles are a special type of content that can "float" on top of other apps or System UI. |
| * Bubbles can be expanded to show more content. |
| * |
| * The controller manages addition, removal, and visible state of bubbles on screen. |
| */ |
| public class BubbleController implements ConfigurationChangeListener, |
| RemoteCallable<BubbleController> { |
| |
| private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleController" : TAG_BUBBLES; |
| |
| // Should match with PhoneWindowManager |
| private static final String SYSTEM_DIALOG_REASON_KEY = "reason"; |
| private static final String SYSTEM_DIALOG_REASON_GESTURE_NAV = "gestureNav"; |
| |
| // TODO(b/256873975) Should use proper flag when available to shell/launcher |
| /** |
| * Whether bubbles are showing in the bubble bar from launcher. This is only available |
| * on large screens and {@link BubbleController#isShowingAsBubbleBar()} should be used |
| * to check all conditions that indicate if the bubble bar is in use. |
| */ |
| private static final boolean BUBBLE_BAR_ENABLED = |
| SystemProperties.getBoolean("persist.wm.debug.bubble_bar", false); |
| |
| |
| /** |
| * Common interface to send updates to bubble views. |
| */ |
| public interface BubbleViewCallback { |
| /** Called when the provided bubble should be removed. */ |
| void removeBubble(Bubble removedBubble); |
| /** Called when the provided bubble should be added. */ |
| void addBubble(Bubble addedBubble); |
| /** Called when the provided bubble should be updated. */ |
| void updateBubble(Bubble updatedBubble); |
| /** Called when the provided bubble should be selected. */ |
| void selectionChanged(BubbleViewProvider selectedBubble); |
| /** Called when the provided bubble's suppression state has changed. */ |
| void suppressionChanged(Bubble bubble, boolean isSuppressed); |
| /** Called when the expansion state of bubbles has changed. */ |
| void expansionChanged(boolean isExpanded); |
| /** |
| * Called when the order of the bubble list has changed. Depending on the expanded state |
| * the pointer might need to be updated. |
| */ |
| void bubbleOrderChanged(List<Bubble> bubbleOrder, boolean updatePointer); |
| } |
| |
| private final Context mContext; |
| private final BubblesImpl mImpl = new BubblesImpl(); |
| private Bubbles.BubbleExpandListener mExpandListener; |
| @Nullable private BubbleStackView.SurfaceSynchronizer mSurfaceSynchronizer; |
| private final FloatingContentCoordinator mFloatingContentCoordinator; |
| private final BubbleDataRepository mDataRepository; |
| private final WindowManagerShellWrapper mWindowManagerShellWrapper; |
| private final UserManager mUserManager; |
| private final LauncherApps mLauncherApps; |
| private final IStatusBarService mBarService; |
| private final WindowManager mWindowManager; |
| private final TaskStackListenerImpl mTaskStackListener; |
| private final ShellTaskOrganizer mTaskOrganizer; |
| private final DisplayController mDisplayController; |
| private final TaskViewTransitions mTaskViewTransitions; |
| private final SyncTransactionQueue mSyncQueue; |
| private final ShellController mShellController; |
| private final ShellCommandHandler mShellCommandHandler; |
| private final IWindowManager mWmService; |
| |
| // Used to post to main UI thread |
| private final ShellExecutor mMainExecutor; |
| private final Handler mMainHandler; |
| private final ShellExecutor mBackgroundExecutor; |
| |
| private BubbleLogger mLogger; |
| private BubbleData mBubbleData; |
| @Nullable private BubbleStackView mStackView; |
| @Nullable private BubbleBarLayerView mLayerView; |
| private BubbleIconFactory mBubbleIconFactory; |
| private BubblePositioner mBubblePositioner; |
| private Bubbles.SysuiProxy mSysuiProxy; |
| |
| // Tracks the id of the current (foreground) user. |
| private int mCurrentUserId; |
| // Current profiles of the user (e.g. user with a workprofile) |
| private SparseArray<UserInfo> mCurrentProfiles; |
| // Saves data about active bubbles when users are switched. |
| private final SparseArray<UserBubbleData> mSavedUserBubbleData; |
| |
| // Used when ranking updates occur and we check if things should bubble / unbubble |
| private NotificationListenerService.Ranking mTmpRanking; |
| |
| // Callback that updates BubbleOverflowActivity on data change. |
| @Nullable private BubbleData.Listener mOverflowListener = null; |
| |
| // Typically only load once & after user switches |
| private boolean mOverflowDataLoadNeeded = true; |
| |
| /** |
| * When the shade status changes to SHADE (from anything but SHADE, like LOCKED) we'll select |
| * this bubble and expand the stack. |
| */ |
| @Nullable private BubbleEntry mNotifEntryToExpandOnShadeUnlock; |
| |
| /** LayoutParams used to add the BubbleStackView to the window manager. */ |
| private WindowManager.LayoutParams mWmLayoutParams; |
| /** Whether or not the BubbleStackView has been added to the WindowManager. */ |
| private boolean mAddedToWindowManager = false; |
| |
| /** Saved screen density, used to detect display size changes in {@link #onConfigChanged}. */ |
| private int mDensityDpi = Configuration.DENSITY_DPI_UNDEFINED; |
| |
| /** Saved screen bounds, used to detect screen size changes in {@link #onConfigChanged}. **/ |
| private Rect mScreenBounds = new Rect(); |
| |
| /** Saved font scale, used to detect font size changes in {@link #onConfigChanged}. */ |
| private float mFontScale = 0; |
| |
| /** Saved direction, used to detect layout direction changes @link #onConfigChanged}. */ |
| private int mLayoutDirection = View.LAYOUT_DIRECTION_UNDEFINED; |
| |
| /** Saved insets, used to detect WindowInset changes. */ |
| private WindowInsets mWindowInsets; |
| |
| private boolean mInflateSynchronously; |
| |
| /** True when user is in status bar unlock shade. */ |
| private boolean mIsStatusBarShade = true; |
| |
| /** One handed mode controller to register transition listener. */ |
| private Optional<OneHandedController> mOneHandedOptional; |
| /** Drag and drop controller to register listener for onDragStarted. */ |
| private Optional<DragAndDropController> mDragAndDropController; |
| /** Used to send bubble events to launcher. */ |
| private Bubbles.BubbleStateListener mBubbleStateListener; |
| |
| /** Used to send updates to the views from {@link #mBubbleDataListener}. */ |
| private BubbleViewCallback mBubbleViewCallback; |
| |
| public BubbleController(Context context, |
| ShellInit shellInit, |
| ShellCommandHandler shellCommandHandler, |
| ShellController shellController, |
| BubbleData data, |
| @Nullable BubbleStackView.SurfaceSynchronizer synchronizer, |
| FloatingContentCoordinator floatingContentCoordinator, |
| BubbleDataRepository dataRepository, |
| @Nullable IStatusBarService statusBarService, |
| WindowManager windowManager, |
| WindowManagerShellWrapper windowManagerShellWrapper, |
| UserManager userManager, |
| LauncherApps launcherApps, |
| BubbleLogger bubbleLogger, |
| TaskStackListenerImpl taskStackListener, |
| ShellTaskOrganizer organizer, |
| BubblePositioner positioner, |
| DisplayController displayController, |
| Optional<OneHandedController> oneHandedOptional, |
| Optional<DragAndDropController> dragAndDropController, |
| @ShellMainThread ShellExecutor mainExecutor, |
| @ShellMainThread Handler mainHandler, |
| @ShellBackgroundThread ShellExecutor bgExecutor, |
| TaskViewTransitions taskViewTransitions, |
| SyncTransactionQueue syncQueue, |
| IWindowManager wmService) { |
| mContext = context; |
| mShellCommandHandler = shellCommandHandler; |
| mShellController = shellController; |
| mLauncherApps = launcherApps; |
| mBarService = statusBarService == null |
| ? IStatusBarService.Stub.asInterface( |
| ServiceManager.getService(Context.STATUS_BAR_SERVICE)) |
| : statusBarService; |
| mWindowManager = windowManager; |
| mWindowManagerShellWrapper = windowManagerShellWrapper; |
| mUserManager = userManager; |
| mFloatingContentCoordinator = floatingContentCoordinator; |
| mDataRepository = dataRepository; |
| mLogger = bubbleLogger; |
| mMainExecutor = mainExecutor; |
| mMainHandler = mainHandler; |
| mBackgroundExecutor = bgExecutor; |
| mTaskStackListener = taskStackListener; |
| mTaskOrganizer = organizer; |
| mSurfaceSynchronizer = synchronizer; |
| mCurrentUserId = ActivityManager.getCurrentUser(); |
| mBubblePositioner = positioner; |
| mBubbleData = data; |
| mSavedUserBubbleData = new SparseArray<>(); |
| mBubbleIconFactory = new BubbleIconFactory(context, |
| context.getResources().getDimensionPixelSize(R.dimen.bubble_size), |
| context.getResources().getDimensionPixelSize(R.dimen.bubble_badge_size), |
| context.getResources().getColor(R.color.important_conversation), |
| context.getResources().getDimensionPixelSize( |
| com.android.internal.R.dimen.importance_ring_stroke_width)); |
| mDisplayController = displayController; |
| mTaskViewTransitions = taskViewTransitions; |
| mOneHandedOptional = oneHandedOptional; |
| mDragAndDropController = dragAndDropController; |
| mSyncQueue = syncQueue; |
| mWmService = wmService; |
| shellInit.addInitCallback(this::onInit, this); |
| } |
| |
| private void registerOneHandedState(OneHandedController oneHanded) { |
| oneHanded.registerTransitionCallback( |
| new OneHandedTransitionCallback() { |
| @Override |
| public void onStartFinished(Rect bounds) { |
| if (mStackView != null) { |
| mStackView.onVerticalOffsetChanged(bounds.top); |
| } |
| } |
| |
| @Override |
| public void onStopFinished(Rect bounds) { |
| if (mStackView != null) { |
| mStackView.onVerticalOffsetChanged(bounds.top); |
| } |
| } |
| }); |
| } |
| |
| protected void onInit() { |
| mBubbleViewCallback = isShowingAsBubbleBar() |
| ? mBubbleBarViewCallback |
| : mBubbleStackViewCallback; |
| mBubbleData.setListener(mBubbleDataListener); |
| mBubbleData.setSuppressionChangedListener(this::onBubbleMetadataFlagChanged); |
| mDataRepository.setSuppressionChangedListener(this::onBubbleMetadataFlagChanged); |
| |
| mBubbleData.setPendingIntentCancelledListener(bubble -> { |
| if (bubble.getBubbleIntent() == null) { |
| return; |
| } |
| if (bubble.isIntentActive() || mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) { |
| bubble.setPendingIntentCanceled(); |
| return; |
| } |
| mMainExecutor.execute(() -> removeBubble(bubble.getKey(), DISMISS_INVALID_INTENT)); |
| }); |
| |
| try { |
| mWindowManagerShellWrapper.addPinnedStackListener(new BubblesImeListener()); |
| } catch (RemoteException e) { |
| e.printStackTrace(); |
| } |
| |
| mBubbleData.setCurrentUserId(mCurrentUserId); |
| |
| mTaskOrganizer.addLocusIdListener((taskId, locus, visible) -> |
| mBubbleData.onLocusVisibilityChanged(taskId, locus, visible)); |
| |
| mLauncherApps.registerCallback(new LauncherApps.Callback() { |
| @Override |
| public void onPackageAdded(String s, UserHandle userHandle) {} |
| |
| @Override |
| public void onPackageChanged(String s, UserHandle userHandle) {} |
| |
| @Override |
| public void onPackageRemoved(String s, UserHandle userHandle) { |
| // Remove bubbles with this package name, since it has been uninstalled and attempts |
| // to open a bubble from an uninstalled app can cause issues. |
| mBubbleData.removeBubblesWithPackageName(s, DISMISS_PACKAGE_REMOVED); |
| } |
| |
| @Override |
| public void onPackagesAvailable(String[] strings, UserHandle userHandle, boolean b) {} |
| |
| @Override |
| public void onPackagesUnavailable(String[] packages, UserHandle userHandle, |
| boolean b) { |
| for (String packageName : packages) { |
| // Remove bubbles from unavailable apps. This can occur when the app is on |
| // external storage that has been removed. |
| mBubbleData.removeBubblesWithPackageName(packageName, DISMISS_PACKAGE_REMOVED); |
| } |
| } |
| |
| @Override |
| public void onShortcutsChanged(String packageName, List<ShortcutInfo> validShortcuts, |
| UserHandle user) { |
| super.onShortcutsChanged(packageName, validShortcuts, user); |
| |
| // Remove bubbles whose shortcuts aren't in the latest list of valid shortcuts. |
| mBubbleData.removeBubblesWithInvalidShortcuts( |
| packageName, validShortcuts, DISMISS_SHORTCUT_REMOVED); |
| } |
| }, mMainHandler); |
| |
| mTaskStackListener.addListener(new TaskStackListenerCallback() { |
| @Override |
| public void onTaskMovedToFront(int taskId) { |
| mMainExecutor.execute(() -> { |
| int expandedId = INVALID_TASK_ID; |
| if (mStackView != null && mStackView.getExpandedBubble() != null |
| && isStackExpanded() |
| && !mStackView.isExpansionAnimating() |
| && !mStackView.isSwitchAnimating()) { |
| expandedId = mStackView.getExpandedBubble().getTaskId(); |
| } |
| if (expandedId != INVALID_TASK_ID && expandedId != taskId) { |
| mBubbleData.setExpanded(false); |
| } |
| }); |
| } |
| |
| @Override |
| public void onActivityRestartAttempt(ActivityManager.RunningTaskInfo task, |
| boolean homeTaskVisible, boolean clearedTask, boolean wasVisible) { |
| for (Bubble b : mBubbleData.getBubbles()) { |
| if (task.taskId == b.getTaskId()) { |
| mBubbleData.setSelectedBubble(b); |
| mBubbleData.setExpanded(true); |
| return; |
| } |
| } |
| for (Bubble b : mBubbleData.getOverflowBubbles()) { |
| if (task.taskId == b.getTaskId()) { |
| promoteBubbleFromOverflow(b); |
| mBubbleData.setExpanded(true); |
| return; |
| } |
| } |
| } |
| }); |
| |
| mDisplayController.addDisplayChangingController( |
| (displayId, fromRotation, toRotation, newDisplayAreaInfo, t) -> { |
| // This is triggered right before the rotation is applied |
| if (fromRotation != toRotation) { |
| if (mStackView != null) { |
| // Layout listener set on stackView will update the positioner |
| // once the rotation is applied |
| mStackView.onOrientationChanged(); |
| } |
| } |
| }); |
| |
| mOneHandedOptional.ifPresent(this::registerOneHandedState); |
| mDragAndDropController.ifPresent(controller -> controller.addListener(this::collapseStack)); |
| |
| // Clear out any persisted bubbles on disk that no longer have a valid user. |
| List<UserInfo> users = mUserManager.getAliveUsers(); |
| mDataRepository.sanitizeBubbles(users); |
| |
| // Init profiles |
| SparseArray<UserInfo> userProfiles = new SparseArray<>(); |
| for (UserInfo user : mUserManager.getProfiles(mCurrentUserId)) { |
| userProfiles.put(user.id, user); |
| } |
| mCurrentProfiles = userProfiles; |
| |
| mShellController.addConfigurationChangeListener(this); |
| mShellController.addExternalInterface(KEY_EXTRA_SHELL_BUBBLES, |
| this::createExternalInterface, this); |
| mShellCommandHandler.addDumpCallback(this::dump, this); |
| } |
| |
| private ExternalInterfaceBinder createExternalInterface() { |
| return new BubbleController.IBubblesImpl(this); |
| } |
| |
| @VisibleForTesting |
| public Bubbles asBubbles() { |
| return mImpl; |
| } |
| |
| @VisibleForTesting |
| public BubblesImpl.CachedState getImplCachedState() { |
| return mImpl.mCachedState; |
| } |
| |
| public ShellExecutor getMainExecutor() { |
| return mMainExecutor; |
| } |
| |
| @Override |
| public Context getContext() { |
| return mContext; |
| } |
| |
| @Override |
| public ShellExecutor getRemoteCallExecutor() { |
| return mMainExecutor; |
| } |
| |
| /** |
| * Sets a listener to be notified of bubble updates. This is used by launcher so that |
| * it may render bubbles in itself. Only one listener is supported. |
| */ |
| public void registerBubbleStateListener(Bubbles.BubbleStateListener listener) { |
| if (isShowingAsBubbleBar()) { |
| // Only set the listener if bubble bar is showing. |
| mBubbleStateListener = listener; |
| sendInitialListenerUpdate(); |
| } else { |
| mBubbleStateListener = null; |
| } |
| } |
| |
| /** |
| * Unregisters the {@link Bubbles.BubbleStateListener}. |
| */ |
| public void unregisterBubbleStateListener() { |
| mBubbleStateListener = null; |
| } |
| |
| /** |
| * If a {@link Bubbles.BubbleStateListener} is present, this will send the current bubble |
| * state to it. |
| */ |
| private void sendInitialListenerUpdate() { |
| if (mBubbleStateListener != null) { |
| BubbleBarUpdate update = mBubbleData.getInitialStateForBubbleBar(); |
| mBubbleStateListener.onBubbleStateChange(update); |
| } |
| } |
| |
| /** |
| * Hides the current input method, wherever it may be focused, via InputMethodManagerInternal. |
| */ |
| void hideCurrentInputMethod() { |
| try { |
| mBarService.hideCurrentInputMethodForBubbles(); |
| } catch (RemoteException e) { |
| e.printStackTrace(); |
| } |
| } |
| |
| private void openBubbleOverflow() { |
| ensureBubbleViewsAndWindowCreated(); |
| mBubbleData.setShowingOverflow(true); |
| mBubbleData.setSelectedBubble(mBubbleData.getOverflow()); |
| mBubbleData.setExpanded(true); |
| } |
| |
| /** |
| * Called when the status bar has become visible or invisible (either permanently or |
| * temporarily). |
| */ |
| private void onStatusBarVisibilityChanged(boolean visible) { |
| if (mStackView != null) { |
| // Hide the stack temporarily if the status bar has been made invisible, and the stack |
| // is collapsed. An expanded stack should remain visible until collapsed. |
| mStackView.setTemporarilyInvisible(!visible && !isStackExpanded()); |
| } |
| } |
| |
| private void onZenStateChanged() { |
| for (Bubble b : mBubbleData.getBubbles()) { |
| b.setShowDot(b.showInShade()); |
| } |
| } |
| |
| @VisibleForTesting |
| public void onStatusBarStateChanged(boolean isShade) { |
| boolean didChange = mIsStatusBarShade != isShade; |
| if (DEBUG_BUBBLE_CONTROLLER) { |
| Log.d(TAG, "onStatusBarStateChanged isShade=" + isShade + " didChange=" + didChange); |
| } |
| mIsStatusBarShade = isShade; |
| if (!mIsStatusBarShade && didChange) { |
| // Only collapse stack on change |
| collapseStack(); |
| } |
| |
| if (mNotifEntryToExpandOnShadeUnlock != null) { |
| expandStackAndSelectBubble(mNotifEntryToExpandOnShadeUnlock); |
| } |
| |
| updateBubbleViews(); |
| } |
| |
| @VisibleForTesting |
| public void onBubbleMetadataFlagChanged(Bubble bubble) { |
| // Make sure NoMan knows suppression state so that anyone querying it can tell. |
| try { |
| mBarService.onBubbleMetadataFlagChanged(bubble.getKey(), bubble.getFlags()); |
| } catch (RemoteException e) { |
| // Bad things have happened |
| } |
| mImpl.mCachedState.updateBubbleSuppressedState(bubble); |
| } |
| |
| /** Called when the current user changes. */ |
| @VisibleForTesting |
| public void onUserChanged(int newUserId) { |
| saveBubbles(mCurrentUserId); |
| mCurrentUserId = newUserId; |
| |
| mBubbleData.dismissAll(DISMISS_USER_CHANGED); |
| mBubbleData.clearOverflow(); |
| mOverflowDataLoadNeeded = true; |
| |
| restoreBubbles(newUserId); |
| mBubbleData.setCurrentUserId(newUserId); |
| } |
| |
| /** Called when the profiles for the current user change. **/ |
| public void onCurrentProfilesChanged(SparseArray<UserInfo> currentProfiles) { |
| mCurrentProfiles = currentProfiles; |
| } |
| |
| /** Called when a user is removed from the device, including work profiles. */ |
| public void onUserRemoved(int removedUserId) { |
| UserInfo parent = mUserManager.getProfileParent(removedUserId); |
| int parentUserId = parent != null ? parent.getUserHandle().getIdentifier() : -1; |
| mBubbleData.removeBubblesForUser(removedUserId); |
| // Typically calls from BubbleData would remove bubbles from the DataRepository as well, |
| // however, this gets complicated when users are removed (mCurrentUserId won't necessarily |
| // be correct for this) so we update the repo directly. |
| mDataRepository.removeBubblesForUser(removedUserId, parentUserId); |
| } |
| |
| /** Whether bubbles are showing in the bubble bar. */ |
| public boolean isShowingAsBubbleBar() { |
| // TODO(b/269670598): should also check that we're in gesture nav |
| return BUBBLE_BAR_ENABLED && mBubblePositioner.isLargeScreen(); |
| } |
| |
| /** Whether this userId belongs to the current user. */ |
| private boolean isCurrentProfile(int userId) { |
| return userId == UserHandle.USER_ALL |
| || (mCurrentProfiles != null && mCurrentProfiles.get(userId) != null); |
| } |
| |
| /** |
| * Sets whether to perform inflation on the same thread as the caller. This method should only |
| * be used in tests, not in production. |
| */ |
| @VisibleForTesting |
| public void setInflateSynchronously(boolean inflateSynchronously) { |
| mInflateSynchronously = inflateSynchronously; |
| } |
| |
| /** Set a listener to be notified of when overflow view update. */ |
| public void setOverflowListener(BubbleData.Listener listener) { |
| mOverflowListener = listener; |
| } |
| |
| /** |
| * @return Bubbles for updating overflow. |
| */ |
| List<Bubble> getOverflowBubbles() { |
| return mBubbleData.getOverflowBubbles(); |
| } |
| |
| /** The task listener for events in bubble tasks. */ |
| public ShellTaskOrganizer getTaskOrganizer() { |
| return mTaskOrganizer; |
| } |
| |
| SyncTransactionQueue getSyncTransactionQueue() { |
| return mSyncQueue; |
| } |
| |
| TaskViewTransitions getTaskViewTransitions() { |
| return mTaskViewTransitions; |
| } |
| |
| /** Contains information to help position things on the screen. */ |
| @VisibleForTesting |
| public BubblePositioner getPositioner() { |
| return mBubblePositioner; |
| } |
| |
| BubbleIconFactory getIconFactory() { |
| return mBubbleIconFactory; |
| } |
| |
| public Bubbles.SysuiProxy getSysuiProxy() { |
| return mSysuiProxy; |
| } |
| |
| /** |
| * The view and window for bubbles is lazily created by this method the first time a Bubble |
| * is added. Depending on the device state, this method will: |
| * - initialize a {@link BubbleStackView} and add it to window manager OR |
| * - initialize a {@link com.android.wm.shell.bubbles.bar.BubbleBarLayerView} and adds |
| * it to window manager. |
| */ |
| private void ensureBubbleViewsAndWindowCreated() { |
| mBubblePositioner.setShowingInBubbleBar(isShowingAsBubbleBar()); |
| if (isShowingAsBubbleBar()) { |
| // When we're showing in launcher / bubble bar is enabled, we don't have bubble stack |
| // view, instead we just show the expanded bubble view as necessary. We still need a |
| // window to show this in, but we use a separate code path. |
| // TODO(b/273312602): consider foldables where we do need a stack view when folded |
| if (mLayerView == null) { |
| mLayerView = new BubbleBarLayerView(mContext, this); |
| } |
| } else { |
| if (mStackView == null) { |
| mStackView = new BubbleStackView( |
| mContext, this, mBubbleData, mSurfaceSynchronizer, |
| mFloatingContentCoordinator, |
| mMainExecutor); |
| mStackView.onOrientationChanged(); |
| if (mExpandListener != null) { |
| mStackView.setExpandListener(mExpandListener); |
| } |
| mStackView.setUnbubbleConversationCallback(mSysuiProxy::onUnbubbleConversation); |
| } |
| } |
| addToWindowManagerMaybe(); |
| } |
| |
| /** Adds the appropriate view to WindowManager if it's not already there. */ |
| private void addToWindowManagerMaybe() { |
| // If already added, don't add it. |
| if (mAddedToWindowManager) { |
| return; |
| } |
| // If the appropriate view is null, don't add it. |
| if (isShowingAsBubbleBar() && mLayerView == null) { |
| return; |
| } else if (!isShowingAsBubbleBar() && mStackView == null) { |
| return; |
| } |
| |
| mWmLayoutParams = new WindowManager.LayoutParams( |
| // Fill the screen so we can use translation animations to position the bubble |
| // views. We'll use touchable regions to ignore touches that are not on the bubbles |
| // themselves. |
| ViewGroup.LayoutParams.MATCH_PARENT, |
| ViewGroup.LayoutParams.MATCH_PARENT, |
| WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, |
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE |
| | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL |
| | WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, |
| PixelFormat.TRANSLUCENT); |
| |
| mWmLayoutParams.setTrustedOverlay(); |
| mWmLayoutParams.setFitInsetsTypes(0); |
| mWmLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; |
| mWmLayoutParams.token = new Binder(); |
| mWmLayoutParams.setTitle("Bubbles!"); |
| mWmLayoutParams.packageName = mContext.getPackageName(); |
| mWmLayoutParams.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; |
| mWmLayoutParams.privateFlags |= WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS; |
| |
| try { |
| mAddedToWindowManager = true; |
| registerBroadcastReceiver(); |
| mBubbleData.getOverflow().initialize(this); |
| // (TODO: b/273314541) some duplication in the inset listener |
| if (isShowingAsBubbleBar()) { |
| mWindowManager.addView(mLayerView, mWmLayoutParams); |
| mLayerView.setOnApplyWindowInsetsListener((view, windowInsets) -> { |
| if (!windowInsets.equals(mWindowInsets)) { |
| mWindowInsets = windowInsets; |
| mBubblePositioner.update(); |
| mLayerView.onDisplaySizeChanged(); |
| } |
| return windowInsets; |
| }); |
| } else { |
| mWindowManager.addView(mStackView, mWmLayoutParams); |
| mStackView.setOnApplyWindowInsetsListener((view, windowInsets) -> { |
| if (!windowInsets.equals(mWindowInsets)) { |
| mWindowInsets = windowInsets; |
| mBubblePositioner.update(); |
| mStackView.onDisplaySizeChanged(); |
| } |
| return windowInsets; |
| }); |
| } |
| } catch (IllegalStateException e) { |
| // This means the view has already been added. This shouldn't happen... |
| e.printStackTrace(); |
| } |
| } |
| |
| /** |
| * In some situations bubble's should be able to receive key events for back: |
| * - when the bubble overflow is showing |
| * - when the user education for the stack is showing. |
| * |
| * @param interceptBack whether back should be intercepted or not. |
| */ |
| void updateWindowFlagsForBackpress(boolean interceptBack) { |
| if (mStackView != null && mAddedToWindowManager) { |
| mWmLayoutParams.flags = interceptBack |
| ? 0 |
| : WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE |
| | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL; |
| mWmLayoutParams.flags |= WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED; |
| mWindowManager.updateViewLayout(mStackView, mWmLayoutParams); |
| } |
| } |
| |
| /** Removes any bubble views from the WindowManager that exist. */ |
| private void removeFromWindowManagerMaybe() { |
| if (!mAddedToWindowManager) { |
| return; |
| } |
| |
| mAddedToWindowManager = false; |
| // Put on background for this binder call, was causing jank |
| mBackgroundExecutor.execute(() -> { |
| try { |
| mContext.unregisterReceiver(mBroadcastReceiver); |
| } catch (IllegalArgumentException e) { |
| // Not sure if this happens in production, but was happening in tests |
| // (b/253647225) |
| e.printStackTrace(); |
| } |
| }); |
| try { |
| if (mStackView != null) { |
| mWindowManager.removeView(mStackView); |
| mBubbleData.getOverflow().cleanUpExpandedState(); |
| } |
| if (mLayerView != null) { |
| mWindowManager.removeView(mLayerView); |
| mBubbleData.getOverflow().cleanUpExpandedState(); |
| } |
| } catch (IllegalArgumentException e) { |
| // This means the stack has already been removed - it shouldn't happen, but ignore if it |
| // does, since we wanted it removed anyway. |
| e.printStackTrace(); |
| } |
| } |
| |
| private void registerBroadcastReceiver() { |
| IntentFilter filter = new IntentFilter(); |
| filter.addAction(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); |
| filter.addAction(Intent.ACTION_SCREEN_OFF); |
| mContext.registerReceiver(mBroadcastReceiver, filter, Context.RECEIVER_EXPORTED); |
| } |
| |
| private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| if (!isStackExpanded()) return; // Nothing to do |
| |
| String action = intent.getAction(); |
| String reason = intent.getStringExtra(SYSTEM_DIALOG_REASON_KEY); |
| if ((Intent.ACTION_CLOSE_SYSTEM_DIALOGS.equals(action) |
| && SYSTEM_DIALOG_REASON_GESTURE_NAV.equals(reason)) |
| || Intent.ACTION_SCREEN_OFF.equals(action)) { |
| mMainExecutor.execute(() -> collapseStack()); |
| } |
| } |
| }; |
| |
| /** |
| * Called by the BubbleStackView and whenever all bubbles have animated out, and none have been |
| * added in the meantime. |
| */ |
| @VisibleForTesting |
| public void onAllBubblesAnimatedOut() { |
| if (mStackView != null) { |
| mStackView.setVisibility(INVISIBLE); |
| removeFromWindowManagerMaybe(); |
| } |
| } |
| |
| /** |
| * Records the notification key for any active bubbles. These are used to restore active |
| * bubbles when the user returns to the foreground. |
| * |
| * @param userId the id of the user |
| */ |
| private void saveBubbles(@UserIdInt int userId) { |
| // First clear any existing keys that might be stored. |
| mSavedUserBubbleData.remove(userId); |
| UserBubbleData userBubbleData = new UserBubbleData(); |
| // Add in all active bubbles for the current user. |
| for (Bubble bubble : mBubbleData.getBubbles()) { |
| userBubbleData.add(bubble.getKey(), bubble.showInShade()); |
| } |
| mSavedUserBubbleData.put(userId, userBubbleData); |
| } |
| |
| /** |
| * Promotes existing notifications to Bubbles if they were previously bubbles. |
| * |
| * @param userId the id of the user |
| */ |
| private void restoreBubbles(@UserIdInt int userId) { |
| UserBubbleData savedBubbleData = mSavedUserBubbleData.get(userId); |
| if (savedBubbleData == null) { |
| // There were no bubbles saved for this used. |
| return; |
| } |
| mSysuiProxy.getShouldRestoredEntries(savedBubbleData.getKeys(), (entries) -> { |
| mMainExecutor.execute(() -> { |
| for (BubbleEntry e : entries) { |
| if (canLaunchInTaskView(mContext, e)) { |
| boolean showInShade = savedBubbleData.isShownInShade(e.getKey()); |
| updateBubble(e, true /* suppressFlyout */, showInShade); |
| } |
| } |
| }); |
| }); |
| // Finally, remove the entries for this user now that bubbles are restored. |
| mSavedUserBubbleData.remove(userId); |
| } |
| |
| @Override |
| public void onThemeChanged() { |
| if (mStackView != null) { |
| mStackView.onThemeChanged(); |
| } |
| mBubbleIconFactory = new BubbleIconFactory(mContext, |
| mContext.getResources().getDimensionPixelSize(R.dimen.bubble_size), |
| mContext.getResources().getDimensionPixelSize(R.dimen.bubble_badge_size), |
| mContext.getResources().getColor(R.color.important_conversation), |
| mContext.getResources().getDimensionPixelSize( |
| com.android.internal.R.dimen.importance_ring_stroke_width)); |
| |
| // Reload each bubble |
| for (Bubble b : mBubbleData.getBubbles()) { |
| b.inflate(null /* callback */, |
| mContext, |
| this, |
| mStackView, |
| mLayerView, |
| mBubbleIconFactory, |
| false /* skipInflation */); |
| } |
| for (Bubble b : mBubbleData.getOverflowBubbles()) { |
| b.inflate(null /* callback */, |
| mContext, |
| this, |
| mStackView, |
| mLayerView, |
| mBubbleIconFactory, |
| false /* skipInflation */); |
| } |
| } |
| |
| @Override |
| public void onConfigurationChanged(Configuration newConfig) { |
| if (mBubblePositioner != null) { |
| mBubblePositioner.update(); |
| } |
| if (mStackView != null && newConfig != null) { |
| if (newConfig.densityDpi != mDensityDpi |
| || !newConfig.windowConfiguration.getBounds().equals(mScreenBounds)) { |
| mDensityDpi = newConfig.densityDpi; |
| mScreenBounds.set(newConfig.windowConfiguration.getBounds()); |
| mBubbleData.onMaxBubblesChanged(); |
| mBubbleIconFactory = new BubbleIconFactory(mContext, |
| mContext.getResources().getDimensionPixelSize(R.dimen.bubble_size), |
| mContext.getResources().getDimensionPixelSize(R.dimen.bubble_badge_size), |
| mContext.getResources().getColor(R.color.important_conversation), |
| mContext.getResources().getDimensionPixelSize( |
| com.android.internal.R.dimen.importance_ring_stroke_width)); |
| mStackView.onDisplaySizeChanged(); |
| } |
| if (newConfig.fontScale != mFontScale) { |
| mFontScale = newConfig.fontScale; |
| mStackView.updateFontScale(); |
| } |
| if (newConfig.getLayoutDirection() != mLayoutDirection) { |
| mLayoutDirection = newConfig.getLayoutDirection(); |
| mStackView.onLayoutDirectionChanged(mLayoutDirection); |
| } |
| } |
| } |
| |
| private void onNotificationPanelExpandedChanged(boolean expanded) { |
| if (DEBUG_BUBBLE_GESTURE) { |
| Log.d(TAG, "onNotificationPanelExpandedChanged: expanded=" + expanded); |
| } |
| if (mStackView != null && mStackView.isExpanded()) { |
| if (expanded) { |
| mStackView.stopMonitoringSwipeUpGesture(); |
| } else { |
| mStackView.startMonitoringSwipeUpGesture(); |
| } |
| } |
| } |
| |
| private void setSysuiProxy(Bubbles.SysuiProxy proxy) { |
| mSysuiProxy = proxy; |
| } |
| |
| @VisibleForTesting |
| public void setExpandListener(Bubbles.BubbleExpandListener listener) { |
| mExpandListener = ((isExpanding, key) -> { |
| if (listener != null) { |
| listener.onBubbleExpandChanged(isExpanding, key); |
| } |
| }); |
| if (mStackView != null) { |
| mStackView.setExpandListener(mExpandListener); |
| } |
| } |
| |
| /** |
| * Whether or not there are bubbles present, regardless of them being visible on the |
| * screen (e.g. if on AOD). |
| */ |
| @VisibleForTesting |
| public boolean hasBubbles() { |
| if (mStackView == null && mLayerView == null) { |
| return false; |
| } |
| return mBubbleData.hasBubbles() || mBubbleData.isShowingOverflow(); |
| } |
| |
| @VisibleForTesting |
| public boolean isStackExpanded() { |
| return mBubbleData.isExpanded(); |
| } |
| |
| public void collapseStack() { |
| mBubbleData.setExpanded(false /* expanded */); |
| } |
| |
| @VisibleForTesting |
| public boolean isBubbleNotificationSuppressedFromShade(String key, String groupKey) { |
| boolean isSuppressedBubble = (mBubbleData.hasAnyBubbleWithKey(key) |
| && !mBubbleData.getAnyBubbleWithkey(key).showInShade()); |
| |
| boolean isSuppressedSummary = mBubbleData.isSummarySuppressed(groupKey); |
| boolean isSummary = key.equals(mBubbleData.getSummaryKey(groupKey)); |
| return (isSummary && isSuppressedSummary) || isSuppressedBubble; |
| } |
| |
| /** Promote the provided bubble from the overflow view. */ |
| public void promoteBubbleFromOverflow(Bubble bubble) { |
| mLogger.log(bubble, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_BACK_TO_STACK); |
| bubble.setInflateSynchronously(mInflateSynchronously); |
| bubble.setShouldAutoExpand(true); |
| bubble.markAsAccessedAt(System.currentTimeMillis()); |
| setIsBubble(bubble, true /* isBubble */); |
| } |
| |
| /** |
| * Expands and selects the provided bubble as long as it already exists in the stack or the |
| * overflow. |
| * |
| * This is used by external callers (launcher). |
| */ |
| public void expandStackAndSelectBubbleFromLauncher(String key) { |
| Bubble b = mBubbleData.getAnyBubbleWithkey(key); |
| if (b == null) { |
| return; |
| } |
| if (mBubbleData.hasBubbleInStackWithKey(b.getKey())) { |
| // already in the stack |
| mBubbleData.setSelectedBubbleFromLauncher(b); |
| mLayerView.showExpandedView(b); |
| } else if (mBubbleData.hasOverflowBubbleWithKey(b.getKey())) { |
| // TODO: (b/271468319) handle overflow |
| } else { |
| Log.w(TAG, "didn't add bubble from launcher: " + key); |
| } |
| } |
| |
| /** |
| * Expands and selects the provided bubble as long as it already exists in the stack or the |
| * overflow. This is currently used when opening a bubble via clicking on a conversation widget. |
| */ |
| public void expandStackAndSelectBubble(Bubble b) { |
| if (b == null) { |
| return; |
| } |
| if (mBubbleData.hasBubbleInStackWithKey(b.getKey())) { |
| // already in the stack |
| mBubbleData.setSelectedBubble(b); |
| mBubbleData.setExpanded(true); |
| } else if (mBubbleData.hasOverflowBubbleWithKey(b.getKey())) { |
| // promote it out of the overflow |
| promoteBubbleFromOverflow(b); |
| } |
| } |
| |
| /** |
| * Expands and selects a bubble based on the provided {@link BubbleEntry}. If no bubble |
| * exists for this entry, and it is able to bubble, a new bubble will be created. |
| * |
| * This is the method to use when opening a bubble via a notification or in a state where |
| * the device might not be unlocked. |
| * |
| * @param entry the entry to use for the bubble. |
| */ |
| public void expandStackAndSelectBubble(BubbleEntry entry) { |
| if (mIsStatusBarShade) { |
| mNotifEntryToExpandOnShadeUnlock = null; |
| |
| String key = entry.getKey(); |
| Bubble bubble = mBubbleData.getBubbleInStackWithKey(key); |
| if (bubble != null) { |
| mBubbleData.setSelectedBubble(bubble); |
| mBubbleData.setExpanded(true); |
| } else { |
| bubble = mBubbleData.getOverflowBubbleWithKey(key); |
| if (bubble != null) { |
| promoteBubbleFromOverflow(bubble); |
| } else if (entry.canBubble()) { |
| // It can bubble but it's not -- it got aged out of the overflow before it |
| // was dismissed or opened, make it a bubble again. |
| setIsBubble(entry, true /* isBubble */, true /* autoExpand */); |
| } |
| } |
| } else { |
| // Wait until we're unlocked to expand, so that the user can see the expand animation |
| // and also to work around bugs with expansion animation + shade unlock happening at the |
| // same time. |
| mNotifEntryToExpandOnShadeUnlock = entry; |
| } |
| } |
| |
| /** |
| * Adds or updates a bubble associated with the provided notification entry. |
| * |
| * @param notif the notification associated with this bubble. |
| */ |
| @VisibleForTesting |
| public void updateBubble(BubbleEntry notif) { |
| int bubbleUserId = notif.getStatusBarNotification().getUserId(); |
| if (isCurrentProfile(bubbleUserId)) { |
| updateBubble(notif, false /* suppressFlyout */, true /* showInShade */); |
| } else { |
| // Skip update, but store it in user bubbles so it gets restored after user switch |
| mSavedUserBubbleData.get(bubbleUserId, new UserBubbleData()).add(notif.getKey(), |
| true /* shownInShade */); |
| if (DEBUG_BUBBLE_CONTROLLER) { |
| Log.d(TAG, |
| "Ignore update to bubble for not active user. Bubble userId=" + bubbleUserId |
| + " current userId=" + mCurrentUserId); |
| } |
| } |
| } |
| |
| /** |
| * This method has different behavior depending on: |
| * - if an app bubble exists |
| * - if an app bubble is expanded |
| * |
| * If no app bubble exists, this will add and expand a bubble with the provided intent. The |
| * intent must be explicit (i.e. include a package name or fully qualified component class name) |
| * and the activity for it should be resizable. |
| * |
| * If an app bubble exists, this will toggle the visibility of it, i.e. if the app bubble is |
| * expanded, calling this method will collapse it. If the app bubble is not expanded, calling |
| * this method will expand it. |
| * |
| * These bubbles are <b>not</b> backed by a notification and remain until the user dismisses |
| * the bubble or bubble stack. |
| * |
| * Some notes: |
| * - Only one app bubble is supported at a time, regardless of users. Multi-users support is |
| * tracked in b/273533235. |
| * - Calling this method with a different intent than the existing app bubble will do nothing |
| * |
| * @param intent the intent to display in the bubble expanded view. |
| * @param user the {@link UserHandle} of the user to start this activity for. |
| * @param icon the {@link Icon} to use for the bubble view. |
| */ |
| public void showOrHideAppBubble(Intent intent, UserHandle user, @Nullable Icon icon) { |
| if (intent == null || intent.getPackage() == null) { |
| Log.w(TAG, "App bubble failed to show, invalid intent: " + intent |
| + ((intent != null) ? " with package: " + intent.getPackage() : " ")); |
| return; |
| } |
| |
| String appBubbleKey = Bubble.getAppBubbleKeyForApp(intent.getPackage(), user); |
| PackageManager packageManager = getPackageManagerForUser(mContext, user.getIdentifier()); |
| if (!isResizableActivity(intent, packageManager, appBubbleKey)) return; |
| |
| Bubble existingAppBubble = mBubbleData.getBubbleInStackWithKey(appBubbleKey); |
| if (existingAppBubble != null) { |
| BubbleViewProvider selectedBubble = mBubbleData.getSelectedBubble(); |
| if (isStackExpanded()) { |
| if (selectedBubble != null && appBubbleKey.equals(selectedBubble.getKey())) { |
| // App bubble is expanded, lets collapse |
| collapseStack(); |
| } else { |
| // App bubble is not selected, select it |
| mBubbleData.setSelectedBubble(existingAppBubble); |
| } |
| } else { |
| // App bubble is not selected, select it & expand |
| mBubbleData.setSelectedBubble(existingAppBubble); |
| mBubbleData.setExpanded(true); |
| } |
| } else { |
| // App bubble does not exist, lets add and expand it |
| Bubble b = Bubble.createAppBubble(intent, user, icon, mMainExecutor); |
| b.setShouldAutoExpand(true); |
| inflateAndAdd(b, /* suppressFlyout= */ true, /* showInShade= */ false); |
| } |
| } |
| |
| /** |
| * Performs a screenshot that may exclude the bubble layer, if one is present. The screenshot |
| * can be access via the supplied {@link ScreenshotSync#get()} asynchronously. |
| */ |
| public void getScreenshotExcludingBubble(int displayId, |
| Pair<ScreenCaptureListener, ScreenshotSync> screenCaptureListener) { |
| try { |
| ScreenCapture.CaptureArgs args = null; |
| if (mStackView != null) { |
| ViewRootImpl viewRoot = mStackView.getViewRootImpl(); |
| if (viewRoot != null) { |
| SurfaceControl bubbleLayer = viewRoot.getSurfaceControl(); |
| if (bubbleLayer != null) { |
| args = new ScreenCapture.CaptureArgs.Builder<>() |
| .setExcludeLayers(new SurfaceControl[] {bubbleLayer}) |
| .build(); |
| } |
| } |
| } |
| |
| mWmService.captureDisplay(displayId, args, screenCaptureListener.first); |
| } catch (RemoteException e) { |
| Log.e(TAG, "Failed to capture screenshot"); |
| } |
| } |
| |
| /** Sets the app bubble's taskId which is cached for SysUI. */ |
| public void setAppBubbleTaskId(String key, int taskId) { |
| mImpl.mCachedState.setAppBubbleTaskId(key, taskId); |
| } |
| |
| /** |
| * Fills the overflow bubbles by loading them from disk. |
| */ |
| void loadOverflowBubblesFromDisk() { |
| if (!mOverflowDataLoadNeeded) { |
| return; |
| } |
| mOverflowDataLoadNeeded = false; |
| mDataRepository.loadBubbles(mCurrentUserId, (bubbles) -> { |
| bubbles.forEach(bubble -> { |
| if (mBubbleData.hasAnyBubbleWithKey(bubble.getKey())) { |
| // if the bubble is already active, there's no need to push it to overflow |
| return; |
| } |
| bubble.inflate( |
| (b) -> mBubbleData.overflowBubble(Bubbles.DISMISS_RELOAD_FROM_DISK, bubble), |
| mContext, |
| this, |
| mStackView, |
| mLayerView, |
| mBubbleIconFactory, |
| true /* skipInflation */); |
| }); |
| return null; |
| }); |
| } |
| |
| /** |
| * Adds or updates a bubble associated with the provided notification entry. |
| * |
| * @param notif the notification associated with this bubble. |
| * @param suppressFlyout this bubble suppress flyout or not. |
| * @param showInShade this bubble show in shade or not. |
| */ |
| @VisibleForTesting |
| public void updateBubble(BubbleEntry notif, boolean suppressFlyout, boolean showInShade) { |
| // If this is an interruptive notif, mark that it's interrupted |
| mSysuiProxy.setNotificationInterruption(notif.getKey()); |
| boolean isNonInterruptiveNotExpanding = !notif.getRanking().isTextChanged() |
| && (notif.getBubbleMetadata() != null |
| && !notif.getBubbleMetadata().getAutoExpandBubble()); |
| if (isNonInterruptiveNotExpanding |
| && mBubbleData.hasOverflowBubbleWithKey(notif.getKey())) { |
| // Update the bubble but don't promote it out of overflow |
| Bubble b = mBubbleData.getOverflowBubbleWithKey(notif.getKey()); |
| if (notif.isBubble()) { |
| notif.setFlagBubble(false); |
| } |
| updateNotNotifyingEntry(b, notif, showInShade); |
| } else if (mBubbleData.hasAnyBubbleWithKey(notif.getKey()) |
| && isNonInterruptiveNotExpanding) { |
| Bubble b = mBubbleData.getAnyBubbleWithkey(notif.getKey()); |
| if (b != null) { |
| updateNotNotifyingEntry(b, notif, showInShade); |
| } |
| } else if (mBubbleData.isSuppressedWithLocusId(notif.getLocusId())) { |
| // Update the bubble but don't promote it out of overflow |
| Bubble b = mBubbleData.getSuppressedBubbleWithKey(notif.getKey()); |
| if (b != null) { |
| updateNotNotifyingEntry(b, notif, showInShade); |
| } |
| } else { |
| Bubble bubble = mBubbleData.getOrCreateBubble(notif, null /* persistedBubble */); |
| if (notif.shouldSuppressNotificationList()) { |
| // If we're suppressing notifs for DND, we don't want the bubbles to randomly |
| // expand when DND turns off so flip the flag. |
| if (bubble.shouldAutoExpand()) { |
| bubble.setShouldAutoExpand(false); |
| } |
| mImpl.mCachedState.updateBubbleSuppressedState(bubble); |
| } else { |
| inflateAndAdd(bubble, suppressFlyout, showInShade); |
| } |
| } |
| } |
| |
| void updateNotNotifyingEntry(Bubble b, BubbleEntry entry, boolean showInShade) { |
| boolean showInShadeBefore = b.showInShade(); |
| boolean isBubbleSelected = Objects.equals(b, mBubbleData.getSelectedBubble()); |
| boolean isBubbleExpandedAndSelected = isStackExpanded() && isBubbleSelected; |
| b.setEntry(entry); |
| boolean suppress = isBubbleExpandedAndSelected || !showInShade || !b.showInShade(); |
| b.setSuppressNotification(suppress); |
| b.setShowDot(!isBubbleExpandedAndSelected); |
| if (showInShadeBefore != b.showInShade()) { |
| mImpl.mCachedState.updateBubbleSuppressedState(b); |
| } |
| } |
| |
| @VisibleForTesting |
| public void inflateAndAdd(Bubble bubble, boolean suppressFlyout, boolean showInShade) { |
| // Lazy init stack view when a bubble is created |
| ensureBubbleViewsAndWindowCreated(); |
| bubble.setInflateSynchronously(mInflateSynchronously); |
| bubble.inflate(b -> mBubbleData.notificationEntryUpdated(b, suppressFlyout, showInShade), |
| mContext, this, mStackView, mLayerView, |
| mBubbleIconFactory, |
| false /* skipInflation */); |
| } |
| |
| /** |
| * Removes the bubble with the given key. |
| * <p> |
| * Must be called from the main thread. |
| */ |
| @VisibleForTesting |
| @MainThread |
| public void removeBubble(String key, int reason) { |
| if (mBubbleData.hasAnyBubbleWithKey(key)) { |
| mBubbleData.dismissBubbleWithKey(key, reason); |
| } |
| } |
| |
| private void onEntryAdded(BubbleEntry entry) { |
| if (canLaunchInTaskView(mContext, entry)) { |
| updateBubble(entry); |
| } |
| } |
| |
| @VisibleForTesting |
| public void onEntryUpdated(BubbleEntry entry, boolean shouldBubbleUp, boolean fromSystem) { |
| if (!fromSystem) { |
| return; |
| } |
| // shouldBubbleUp checks canBubble & for bubble metadata |
| boolean shouldBubble = shouldBubbleUp && canLaunchInTaskView(mContext, entry); |
| if (!shouldBubble && mBubbleData.hasAnyBubbleWithKey(entry.getKey())) { |
| // It was previously a bubble but no longer a bubble -- lets remove it |
| removeBubble(entry.getKey(), DISMISS_NO_LONGER_BUBBLE); |
| } else if (shouldBubble && entry.isBubble()) { |
| updateBubble(entry); |
| } |
| } |
| |
| private void onEntryRemoved(BubbleEntry entry) { |
| if (isSummaryOfBubbles(entry)) { |
| final String groupKey = entry.getStatusBarNotification().getGroupKey(); |
| mBubbleData.removeSuppressedSummary(groupKey); |
| |
| // Remove any associated bubble children with the summary |
| final List<Bubble> bubbleChildren = getBubblesInGroup(groupKey); |
| for (int i = 0; i < bubbleChildren.size(); i++) { |
| removeBubble(bubbleChildren.get(i).getKey(), DISMISS_GROUP_CANCELLED); |
| } |
| } else { |
| removeBubble(entry.getKey(), DISMISS_NOTIF_CANCEL); |
| } |
| } |
| |
| @VisibleForTesting |
| public void onRankingUpdated(RankingMap rankingMap, |
| HashMap<String, Pair<BubbleEntry, Boolean>> entryDataByKey) { |
| if (mTmpRanking == null) { |
| mTmpRanking = new NotificationListenerService.Ranking(); |
| } |
| String[] orderedKeys = rankingMap.getOrderedKeys(); |
| for (int i = 0; i < orderedKeys.length; i++) { |
| String key = orderedKeys[i]; |
| Pair<BubbleEntry, Boolean> entryData = entryDataByKey.get(key); |
| BubbleEntry entry = entryData.first; |
| boolean shouldBubbleUp = entryData.second; |
| if (entry != null && !isCurrentProfile( |
| entry.getStatusBarNotification().getUser().getIdentifier())) { |
| return; |
| } |
| if (entry != null && (entry.shouldSuppressNotificationList() |
| || entry.getRanking().isSuspended())) { |
| shouldBubbleUp = false; |
| } |
| rankingMap.getRanking(key, mTmpRanking); |
| boolean isActiveOrInOverflow = mBubbleData.hasAnyBubbleWithKey(key); |
| boolean isActive = mBubbleData.hasBubbleInStackWithKey(key); |
| if (isActiveOrInOverflow && !mTmpRanking.canBubble()) { |
| // If this entry is no longer allowed to bubble, dismiss with the BLOCKED reason. |
| // This means that the app or channel's ability to bubble has been revoked. |
| mBubbleData.dismissBubbleWithKey(key, DISMISS_BLOCKED); |
| } else if (isActiveOrInOverflow && !shouldBubbleUp) { |
| // If this entry is allowed to bubble, but cannot currently bubble up or is |
| // suspended, dismiss it. This happens when DND is enabled and configured to hide |
| // bubbles, or focus mode is enabled and the app is designated as distracting. |
| // Dismissing with the reason DISMISS_NO_BUBBLE_UP will retain the underlying |
| // notification, so that the bubble will be re-created if shouldBubbleUp returns |
| // true. |
| mBubbleData.dismissBubbleWithKey(key, DISMISS_NO_BUBBLE_UP); |
| } else if (entry != null && mTmpRanking.isBubble() && !isActiveOrInOverflow) { |
| entry.setFlagBubble(true); |
| onEntryUpdated(entry, shouldBubbleUp, /* fromSystem= */ true); |
| } |
| } |
| } |
| |
| @VisibleForTesting |
| public void onNotificationChannelModified(String pkg, UserHandle user, |
| NotificationChannel channel, int modificationType) { |
| // Only query overflow bubbles here because active bubbles will have an active notification |
| // and channel changes we care about would result in a ranking update. |
| List<Bubble> overflowBubbles = new ArrayList<>(mBubbleData.getOverflowBubbles()); |
| for (int i = 0; i < overflowBubbles.size(); i++) { |
| Bubble b = overflowBubbles.get(i); |
| if (Objects.equals(b.getShortcutId(), channel.getConversationId()) |
| && b.getPackageName().equals(pkg) |
| && b.getUser().getIdentifier() == user.getIdentifier()) { |
| if (!channel.canBubble() || channel.isDeleted()) { |
| mBubbleData.dismissBubbleWithKey(b.getKey(), DISMISS_NO_LONGER_BUBBLE); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Retrieves any bubbles that are part of the notification group represented by the provided |
| * group key. |
| */ |
| private ArrayList<Bubble> getBubblesInGroup(@Nullable String groupKey) { |
| ArrayList<Bubble> bubbleChildren = new ArrayList<>(); |
| if (groupKey == null) { |
| return bubbleChildren; |
| } |
| for (Bubble bubble : mBubbleData.getActiveBubbles()) { |
| if (bubble.getGroupKey() != null && groupKey.equals(bubble.getGroupKey())) { |
| bubbleChildren.add(bubble); |
| } |
| } |
| return bubbleChildren; |
| } |
| |
| private void setIsBubble(@NonNull final BubbleEntry entry, final boolean isBubble, |
| final boolean autoExpand) { |
| Objects.requireNonNull(entry); |
| entry.setFlagBubble(isBubble); |
| try { |
| int flags = 0; |
| if (autoExpand) { |
| flags = Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION; |
| flags |= Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE; |
| } |
| mBarService.onNotificationBubbleChanged(entry.getKey(), isBubble, flags); |
| } catch (RemoteException e) { |
| // Bad things have happened |
| } |
| } |
| |
| private void setIsBubble(@NonNull final Bubble b, final boolean isBubble) { |
| Objects.requireNonNull(b); |
| b.setIsBubble(isBubble); |
| mSysuiProxy.getPendingOrActiveEntry(b.getKey(), (entry) -> { |
| mMainExecutor.execute(() -> { |
| if (entry != null) { |
| // Updating the entry to be a bubble will trigger our normal update flow |
| setIsBubble(entry, isBubble, b.shouldAutoExpand()); |
| } else if (isBubble) { |
| // If bubble doesn't exist, it's a persisted bubble so we need to add it to the |
| // stack ourselves |
| Bubble bubble = mBubbleData.getOrCreateBubble(null, b /* persistedBubble */); |
| inflateAndAdd(bubble, bubble.shouldAutoExpand() /* suppressFlyout */, |
| !bubble.shouldAutoExpand() /* showInShade */); |
| } |
| }); |
| }); |
| } |
| |
| /** When bubbles are floating, this will be used to notify the floating views. */ |
| private final BubbleViewCallback mBubbleStackViewCallback = new BubbleViewCallback() { |
| @Override |
| public void removeBubble(Bubble removedBubble) { |
| if (mStackView != null) { |
| mStackView.removeBubble(removedBubble); |
| } |
| } |
| |
| @Override |
| public void addBubble(Bubble addedBubble) { |
| if (mStackView != null) { |
| mStackView.addBubble(addedBubble); |
| } |
| } |
| |
| @Override |
| public void updateBubble(Bubble updatedBubble) { |
| if (mStackView != null) { |
| mStackView.updateBubble(updatedBubble); |
| } |
| } |
| |
| @Override |
| public void bubbleOrderChanged(List<Bubble> bubbleOrder, boolean updatePointer) { |
| if (mStackView != null) { |
| mStackView.updateBubbleOrder(bubbleOrder, updatePointer); |
| } |
| } |
| |
| @Override |
| public void suppressionChanged(Bubble bubble, boolean isSuppressed) { |
| if (mStackView != null) { |
| mStackView.setBubbleSuppressed(bubble, isSuppressed); |
| } |
| } |
| |
| @Override |
| public void expansionChanged(boolean isExpanded) { |
| if (mStackView != null) { |
| mStackView.setExpanded(isExpanded); |
| } |
| } |
| |
| @Override |
| public void selectionChanged(BubbleViewProvider selectedBubble) { |
| if (mStackView != null) { |
| mStackView.setSelectedBubble(selectedBubble); |
| } |
| |
| } |
| }; |
| |
| /** When bubbles are in the bubble bar, this will be used to notify bubble bar views. */ |
| private final BubbleViewCallback mBubbleBarViewCallback = new BubbleViewCallback() { |
| @Override |
| public void removeBubble(Bubble removedBubble) { |
| if (mLayerView != null) { |
| // TODO: need to check if there's something that needs to happen here, e.g. if |
| // the currently selected & expanded bubble is removed? |
| } |
| } |
| |
| @Override |
| public void addBubble(Bubble addedBubble) { |
| // Nothing to do for adds, these are handled by launcher / in the bubble bar. |
| } |
| |
| @Override |
| public void updateBubble(Bubble updatedBubble) { |
| // Nothing to do for updates, these are handled by launcher / in the bubble bar. |
| } |
| |
| @Override |
| public void bubbleOrderChanged(List<Bubble> bubbleOrder, boolean updatePointer) { |
| // Nothing to do for order changes, these are handled by launcher / in the bubble bar. |
| } |
| |
| @Override |
| public void suppressionChanged(Bubble bubble, boolean isSuppressed) { |
| if (mLayerView != null) { |
| // TODO (b/273316505) handle suppression changes, although might not need to |
| // to do anything on the layerview side for this... |
| } |
| } |
| |
| @Override |
| public void expansionChanged(boolean isExpanded) { |
| if (mLayerView != null) { |
| if (!isExpanded) { |
| mLayerView.collapse(); |
| } else { |
| BubbleViewProvider selectedBubble = mBubbleData.getSelectedBubble(); |
| if (selectedBubble != null) { |
| mLayerView.showExpandedView(selectedBubble); |
| } |
| } |
| } |
| } |
| |
| @Override |
| public void selectionChanged(BubbleViewProvider selectedBubble) { |
| // Only need to update the layer view if we're currently expanded for selection changes. |
| if (mLayerView != null && isStackExpanded()) { |
| mLayerView.showExpandedView(selectedBubble); |
| } |
| } |
| }; |
| |
| @SuppressWarnings("FieldCanBeLocal") |
| private final BubbleData.Listener mBubbleDataListener = new BubbleData.Listener() { |
| |
| @Override |
| public void applyUpdate(BubbleData.Update update) { |
| if (DEBUG_BUBBLE_CONTROLLER) { |
| Log.d(TAG, "applyUpdate:" + " bubbleAdded=" + (update.addedBubble != null) |
| + " bubbleRemoved=" |
| + (update.removedBubbles != null && update.removedBubbles.size() > 0) |
| + " bubbleUpdated=" + (update.updatedBubble != null) |
| + " orderChanged=" + update.orderChanged |
| + " expandedChanged=" + update.expandedChanged |
| + " selectionChanged=" + update.selectionChanged |
| + " suppressed=" + (update.suppressedBubble != null) |
| + " unsuppressed=" + (update.unsuppressedBubble != null)); |
| } |
| |
| ensureBubbleViewsAndWindowCreated(); |
| |
| // Lazy load overflow bubbles from disk |
| loadOverflowBubblesFromDisk(); |
| |
| // If bubbles in the overflow have a dot, make sure the overflow shows a dot |
| updateOverflowButtonDot(); |
| |
| // Update bubbles in overflow. |
| if (mOverflowListener != null) { |
| mOverflowListener.applyUpdate(update); |
| } |
| |
| // Do removals, if any. |
| ArrayList<Pair<Bubble, Integer>> removedBubbles = |
| new ArrayList<>(update.removedBubbles); |
| ArrayList<Bubble> bubblesToBeRemovedFromRepository = new ArrayList<>(); |
| for (Pair<Bubble, Integer> removed : removedBubbles) { |
| final Bubble bubble = removed.first; |
| @Bubbles.DismissReason final int reason = removed.second; |
| |
| mBubbleViewCallback.removeBubble(bubble); |
| |
| // Leave the notification in place if we're dismissing due to user switching, or |
| // because DND is suppressing the bubble. In both of those cases, we need to be able |
| // to restore the bubble from the notification later. |
| if (reason == DISMISS_USER_CHANGED || reason == DISMISS_NO_BUBBLE_UP) { |
| continue; |
| } |
| if (reason == DISMISS_NOTIF_CANCEL |
| || reason == DISMISS_SHORTCUT_REMOVED) { |
| bubblesToBeRemovedFromRepository.add(bubble); |
| } |
| if (!mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) { |
| if (!mBubbleData.hasOverflowBubbleWithKey(bubble.getKey()) |
| && (!bubble.showInShade() |
| || reason == DISMISS_NOTIF_CANCEL |
| || reason == DISMISS_GROUP_CANCELLED)) { |
| // The bubble is now gone & the notification is hidden from the shade, so |
| // time to actually remove it |
| mSysuiProxy.notifyRemoveNotification(bubble.getKey(), REASON_CANCEL); |
| } else { |
| if (bubble.isBubble()) { |
| setIsBubble(bubble, false /* isBubble */); |
| } |
| mSysuiProxy.updateNotificationBubbleButton(bubble.getKey()); |
| } |
| } |
| } |
| mDataRepository.removeBubbles(mCurrentUserId, bubblesToBeRemovedFromRepository); |
| |
| if (update.addedBubble != null) { |
| mDataRepository.addBubble(mCurrentUserId, update.addedBubble); |
| mBubbleViewCallback.addBubble(update.addedBubble); |
| } |
| |
| if (update.updatedBubble != null) { |
| mBubbleViewCallback.updateBubble(update.updatedBubble); |
| } |
| |
| if (update.suppressedBubble != null) { |
| mBubbleViewCallback.suppressionChanged(update.suppressedBubble, true); |
| } |
| |
| if (update.unsuppressedBubble != null) { |
| mBubbleViewCallback.suppressionChanged(update.unsuppressedBubble, false); |
| } |
| |
| boolean collapseStack = update.expandedChanged && !update.expanded; |
| |
| // At this point, the correct bubbles are inflated in the stack. |
| // Make sure the order in bubble data is reflected in bubble row. |
| if (update.orderChanged) { |
| mDataRepository.addBubbles(mCurrentUserId, update.bubbles); |
| // if the stack is going to be collapsed, do not update pointer position |
| // after reordering |
| mBubbleViewCallback.bubbleOrderChanged(update.bubbles, !collapseStack); |
| } |
| |
| if (collapseStack) { |
| mBubbleViewCallback.expansionChanged(/* expanded= */ false); |
| mSysuiProxy.requestNotificationShadeTopUi(false, TAG); |
| } |
| |
| if (update.selectionChanged) { |
| mBubbleViewCallback.selectionChanged(update.selectedBubble); |
| } |
| |
| // Expanding? Apply this last. |
| if (update.expandedChanged && update.expanded) { |
| mBubbleViewCallback.expansionChanged(/* expanded= */ true); |
| mSysuiProxy.requestNotificationShadeTopUi(true, TAG); |
| } |
| |
| mSysuiProxy.notifyInvalidateNotifications("BubbleData.Listener.applyUpdate"); |
| updateBubbleViews(); |
| |
| // Update the cached state for queries from SysUI |
| mImpl.mCachedState.update(update); |
| |
| if (isShowingAsBubbleBar() && mBubbleStateListener != null) { |
| BubbleBarUpdate bubbleBarUpdate = update.toBubbleBarUpdate(); |
| // Some updates aren't relevant to the bubble bar so check first. |
| if (bubbleBarUpdate.anythingChanged()) { |
| mBubbleStateListener.onBubbleStateChange(bubbleBarUpdate); |
| } |
| } |
| } |
| }; |
| |
| private void updateOverflowButtonDot() { |
| BubbleOverflow overflow = mBubbleData.getOverflow(); |
| if (overflow == null) return; |
| |
| for (Bubble b : mBubbleData.getOverflowBubbles()) { |
| if (b.showDot()) { |
| overflow.setShowDot(true); |
| return; |
| } |
| } |
| overflow.setShowDot(false); |
| } |
| |
| private boolean handleDismissalInterception(BubbleEntry entry, |
| @Nullable List<BubbleEntry> children, IntConsumer removeCallback) { |
| if (isSummaryOfBubbles(entry)) { |
| handleSummaryDismissalInterception(entry, children, removeCallback); |
| } else { |
| Bubble bubble = mBubbleData.getBubbleInStackWithKey(entry.getKey()); |
| if (bubble == null || !entry.isBubble()) { |
| bubble = mBubbleData.getOverflowBubbleWithKey(entry.getKey()); |
| } |
| if (bubble == null) { |
| return false; |
| } |
| bubble.setSuppressNotification(true); |
| bubble.setShowDot(false /* show */); |
| } |
| // Update the shade |
| mSysuiProxy.notifyInvalidateNotifications("BubbleController.handleDismissalInterception"); |
| return true; |
| } |
| |
| private boolean isSummaryOfBubbles(BubbleEntry entry) { |
| String groupKey = entry.getStatusBarNotification().getGroupKey(); |
| ArrayList<Bubble> bubbleChildren = getBubblesInGroup(groupKey); |
| boolean isSuppressedSummary = mBubbleData.isSummarySuppressed(groupKey) |
| && mBubbleData.getSummaryKey(groupKey).equals(entry.getKey()); |
| boolean isSummary = entry.getStatusBarNotification().getNotification().isGroupSummary(); |
| return (isSuppressedSummary || isSummary) && !bubbleChildren.isEmpty(); |
| } |
| |
| private void handleSummaryDismissalInterception( |
| BubbleEntry summary, @Nullable List<BubbleEntry> children, IntConsumer removeCallback) { |
| if (children != null) { |
| for (int i = 0; i < children.size(); i++) { |
| BubbleEntry child = children.get(i); |
| if (mBubbleData.hasAnyBubbleWithKey(child.getKey())) { |
| // Suppress the bubbled child |
| // As far as group manager is concerned, once a child is no longer shown |
| // in the shade, it is essentially removed. |
| Bubble bubbleChild = mBubbleData.getAnyBubbleWithkey(child.getKey()); |
| if (bubbleChild != null) { |
| bubbleChild.setSuppressNotification(true); |
| bubbleChild.setShowDot(false /* show */); |
| } |
| } else { |
| // non-bubbled children can be removed |
| removeCallback.accept(i); |
| } |
| } |
| } |
| |
| // And since all children are removed, remove the summary. |
| removeCallback.accept(-1); |
| |
| // TODO: (b/145659174) remove references to mSuppressedGroupKeys once fully migrated |
| mBubbleData.addSummaryToSuppress(summary.getStatusBarNotification().getGroupKey(), |
| summary.getKey()); |
| } |
| |
| /** |
| * Updates the visibility of the bubbles based on current state. |
| * Does not un-bubble, just hides or un-hides the views themselves. |
| * |
| * Updates view description for TalkBack focus. |
| * Updates bubbles' icon views clickable states (when floating). |
| */ |
| public void updateBubbleViews() { |
| if (mStackView == null && mLayerView == null) { |
| return; |
| } |
| |
| if (!mIsStatusBarShade) { |
| // Bubbles don't appear when the device is locked. |
| if (mStackView != null) { |
| mStackView.setVisibility(INVISIBLE); |
| } |
| if (mLayerView != null) { |
| mLayerView.setVisibility(INVISIBLE); |
| } |
| } else if (hasBubbles()) { |
| // If we're unlocked, show the stack if we have bubbles. If we don't have bubbles, the |
| // stack will be set to INVISIBLE in onAllBubblesAnimatedOut after the bubbles animate |
| // out. |
| if (mStackView != null) { |
| mStackView.setVisibility(VISIBLE); |
| } |
| if (mLayerView != null && isStackExpanded()) { |
| mLayerView.setVisibility(VISIBLE); |
| } |
| } |
| |
| if (mStackView != null) { |
| mStackView.updateContentDescription(); |
| mStackView.updateBubblesAcessibillityStates(); |
| } else if (mLayerView != null) { |
| // TODO(b/273313561): handle a11y for BubbleBarLayerView |
| } |
| } |
| |
| @VisibleForTesting |
| public BubbleStackView getStackView() { |
| return mStackView; |
| } |
| |
| /** |
| * Check if notification panel is in an expanded state. |
| * Makes a call to System UI process and delivers the result via {@code callback} on the |
| * WM Shell main thread. |
| * |
| * @param callback callback that has the result of notification panel expanded state |
| */ |
| public void isNotificationPanelExpanded(Consumer<Boolean> callback) { |
| mSysuiProxy.isNotificationPanelExpand(expanded -> |
| mMainExecutor.execute(() -> callback.accept(expanded))); |
| } |
| |
| /** |
| * Description of current bubble state. |
| */ |
| private void dump(PrintWriter pw, String prefix) { |
| pw.println("BubbleController state:"); |
| mBubbleData.dump(pw); |
| pw.println(); |
| if (mStackView != null) { |
| mStackView.dump(pw); |
| } |
| pw.println(); |
| mImpl.mCachedState.dump(pw); |
| } |
| |
| /** |
| * Whether an intent is properly configured to display in a |
| * {@link TaskView}. |
| * |
| * Keep checks in sync with BubbleExtractor#canLaunchInTaskView. Typically |
| * that should filter out any invalid bubbles, but should protect SysUI side just in case. |
| * |
| * @param context the context to use. |
| * @param entry the entry to bubble. |
| */ |
| static boolean canLaunchInTaskView(Context context, BubbleEntry entry) { |
| PendingIntent intent = entry.getBubbleMetadata() != null |
| ? entry.getBubbleMetadata().getIntent() |
| : null; |
| if (entry.getBubbleMetadata() != null |
| && entry.getBubbleMetadata().getShortcutId() != null) { |
| return true; |
| } |
| if (intent == null) { |
| Log.w(TAG, "Unable to create bubble -- no intent: " + entry.getKey()); |
| return false; |
| } |
| PackageManager packageManager = getPackageManagerForUser( |
| context, entry.getStatusBarNotification().getUser().getIdentifier()); |
| return isResizableActivity(intent.getIntent(), packageManager, entry.getKey()); |
| } |
| |
| static boolean isResizableActivity(Intent intent, PackageManager packageManager, String key) { |
| if (intent == null) { |
| Log.w(TAG, "Unable to send as bubble: " + key + " null intent"); |
| return false; |
| } |
| ActivityInfo info = intent.resolveActivityInfo(packageManager, 0); |
| if (info == null) { |
| Log.w(TAG, "Unable to send as bubble: " + key |
| + " couldn't find activity info for intent: " + intent); |
| return false; |
| } |
| if (!ActivityInfo.isResizeableMode(info.resizeMode)) { |
| Log.w(TAG, "Unable to send as bubble: " + key |
| + " activity is not resizable for intent: " + intent); |
| return false; |
| } |
| return true; |
| } |
| |
| static PackageManager getPackageManagerForUser(Context context, int userId) { |
| Context contextForUser = context; |
| // UserHandle defines special userId as negative values, e.g. USER_ALL |
| if (userId >= 0) { |
| try { |
| // Create a context for the correct user so if a package isn't installed |
| // for user 0 we can still load information about the package. |
| contextForUser = |
| context.createPackageContextAsUser(context.getPackageName(), |
| Context.CONTEXT_RESTRICTED, |
| new UserHandle(userId)); |
| } catch (PackageManager.NameNotFoundException e) { |
| // Shouldn't fail to find the package name for system ui. |
| } |
| } |
| return contextForUser.getPackageManager(); |
| } |
| |
| /** PinnedStackListener that dispatches IME visibility updates to the stack. */ |
| //TODO(b/170442945): Better way to do this / insets listener? |
| private class BubblesImeListener extends PinnedStackListenerForwarder.PinnedTaskListener { |
| @Override |
| public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) { |
| mBubblePositioner.setImeVisible(imeVisible, imeHeight); |
| if (mStackView != null) { |
| mStackView.setImeVisible(imeVisible); |
| } |
| } |
| } |
| |
| /** |
| * The interface for calls from outside the host process. |
| */ |
| @BinderThread |
| private class IBubblesImpl extends IBubbles.Stub implements ExternalInterfaceBinder { |
| private BubbleController mController; |
| private final SingleInstanceRemoteListener<BubbleController, IBubblesListener> mListener; |
| private final Bubbles.BubbleStateListener mBubbleListener = |
| new Bubbles.BubbleStateListener() { |
| |
| @Override |
| public void onBubbleStateChange(BubbleBarUpdate update) { |
| Bundle b = new Bundle(); |
| b.setClassLoader(BubbleBarUpdate.class.getClassLoader()); |
| b.putParcelable(BubbleBarUpdate.BUNDLE_KEY, update); |
| mListener.call(l -> l.onBubbleStateChange(b)); |
| } |
| }; |
| |
| IBubblesImpl(BubbleController controller) { |
| mController = controller; |
| mListener = new SingleInstanceRemoteListener<>(mController, |
| c -> c.registerBubbleStateListener(mBubbleListener), |
| c -> c.unregisterBubbleStateListener()); |
| } |
| |
| /** |
| * Invalidates this instance, preventing future calls from updating the controller. |
| */ |
| @Override |
| public void invalidate() { |
| mController = null; |
| } |
| |
| @Override |
| public void registerBubbleListener(IBubblesListener listener) { |
| mMainExecutor.execute(() -> { |
| mListener.register(listener); |
| }); |
| } |
| |
| @Override |
| public void unregisterBubbleListener(IBubblesListener listener) { |
| mMainExecutor.execute(() -> mListener.unregister()); |
| } |
| |
| @Override |
| public void showBubble(String key, boolean onLauncherHome) { |
| mMainExecutor.execute(() -> { |
| mBubblePositioner.setShowingInBubbleBar(onLauncherHome); |
| mController.expandStackAndSelectBubbleFromLauncher(key); |
| }); |
| } |
| |
| @Override |
| public void removeBubble(String key, int reason) { |
| // TODO (b/271466616) allow removals from launcher |
| } |
| |
| @Override |
| public void collapseBubbles() { |
| mMainExecutor.execute(() -> mController.collapseStack()); |
| } |
| |
| @Override |
| public void onTaskbarStateChanged(int newState) { |
| // TODO (b/269670598) |
| } |
| } |
| |
| private class BubblesImpl implements Bubbles { |
| // Up-to-date cached state of bubbles data for SysUI to query from the calling thread |
| @VisibleForTesting |
| public class CachedState { |
| private boolean mIsStackExpanded; |
| private String mSelectedBubbleKey; |
| private HashSet<String> mSuppressedBubbleKeys = new HashSet<>(); |
| private HashMap<String, String> mSuppressedGroupToNotifKeys = new HashMap<>(); |
| private HashMap<String, Bubble> mShortcutIdToBubble = new HashMap<>(); |
| |
| private HashMap<String, Integer> mAppBubbleTaskIds = new HashMap(); |
| |
| private ArrayList<Bubble> mTmpBubbles = new ArrayList<>(); |
| |
| /** |
| * Updates the cached state based on the last full BubbleData change. |
| */ |
| synchronized void update(BubbleData.Update update) { |
| if (update.selectionChanged) { |
| mSelectedBubbleKey = update.selectedBubble != null |
| ? update.selectedBubble.getKey() |
| : null; |
| } |
| if (update.expandedChanged) { |
| mIsStackExpanded = update.expanded; |
| } |
| if (update.suppressedSummaryChanged) { |
| String summaryKey = |
| mBubbleData.getSummaryKey(update.suppressedSummaryGroup); |
| if (summaryKey != null) { |
| mSuppressedGroupToNotifKeys.put(update.suppressedSummaryGroup, summaryKey); |
| } else { |
| mSuppressedGroupToNotifKeys.remove(update.suppressedSummaryGroup); |
| } |
| } |
| |
| mTmpBubbles.clear(); |
| mTmpBubbles.addAll(update.bubbles); |
| mTmpBubbles.addAll(update.overflowBubbles); |
| |
| mSuppressedBubbleKeys.clear(); |
| mShortcutIdToBubble.clear(); |
| mAppBubbleTaskIds.clear(); |
| for (Bubble b : mTmpBubbles) { |
| mShortcutIdToBubble.put(b.getShortcutId(), b); |
| updateBubbleSuppressedState(b); |
| |
| if (b.isAppBubble()) { |
| mAppBubbleTaskIds.put(b.getKey(), b.getTaskId()); |
| } |
| } |
| } |
| |
| /** Sets the app bubble's taskId which is cached for SysUI. */ |
| synchronized void setAppBubbleTaskId(String key, int taskId) { |
| mAppBubbleTaskIds.put(key, taskId); |
| } |
| |
| /** |
| * Updates a specific bubble suppressed state. This is used mainly because notification |
| * suppression changes don't go through the same BubbleData update mechanism. |
| */ |
| synchronized void updateBubbleSuppressedState(Bubble b) { |
| if (!b.showInShade()) { |
| mSuppressedBubbleKeys.add(b.getKey()); |
| } else { |
| mSuppressedBubbleKeys.remove(b.getKey()); |
| } |
| } |
| |
| public synchronized boolean isStackExpanded() { |
| return mIsStackExpanded; |
| } |
| |
| public synchronized boolean isBubbleExpanded(String key) { |
| return mIsStackExpanded && key.equals(mSelectedBubbleKey); |
| } |
| |
| public synchronized boolean isBubbleNotificationSuppressedFromShade(String key, |
| String groupKey) { |
| return mSuppressedBubbleKeys.contains(key) |
| || (mSuppressedGroupToNotifKeys.containsKey(groupKey) |
| && key.equals(mSuppressedGroupToNotifKeys.get(groupKey))); |
| } |
| |
| @Nullable |
| public synchronized Bubble getBubbleWithShortcutId(String id) { |
| return mShortcutIdToBubble.get(id); |
| } |
| |
| synchronized void dump(PrintWriter pw) { |
| pw.println("BubbleImpl.CachedState state:"); |
| |
| pw.println("mIsStackExpanded: " + mIsStackExpanded); |
| pw.println("mSelectedBubbleKey: " + mSelectedBubbleKey); |
| |
| pw.print("mSuppressedBubbleKeys: "); |
| pw.println(mSuppressedBubbleKeys.size()); |
| for (String key : mSuppressedBubbleKeys) { |
| pw.println(" suppressing: " + key); |
| } |
| |
| pw.print("mSuppressedGroupToNotifKeys: "); |
| pw.println(mSuppressedGroupToNotifKeys.size()); |
| for (String key : mSuppressedGroupToNotifKeys.keySet()) { |
| pw.println(" suppressing: " + key); |
| } |
| |
| pw.print("mAppBubbleTaskIds: " + mAppBubbleTaskIds.values()); |
| } |
| } |
| |
| private CachedState mCachedState = new CachedState(); |
| |
| private IBubblesImpl mIBubbles; |
| |
| @Override |
| public IBubbles createExternalInterface() { |
| if (mIBubbles != null) { |
| mIBubbles.invalidate(); |
| } |
| mIBubbles = new IBubblesImpl(BubbleController.this); |
| return mIBubbles; |
| } |
| |
| @Override |
| public boolean isBubbleNotificationSuppressedFromShade(String key, String groupKey) { |
| return mCachedState.isBubbleNotificationSuppressedFromShade(key, groupKey); |
| } |
| |
| @Override |
| public boolean isBubbleExpanded(String key) { |
| return mCachedState.isBubbleExpanded(key); |
| } |
| |
| @Override |
| @Nullable |
| public Bubble getBubbleWithShortcutId(String shortcutId) { |
| return mCachedState.getBubbleWithShortcutId(shortcutId); |
| } |
| |
| @Override |
| public void collapseStack() { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.collapseStack(); |
| }); |
| } |
| |
| @Override |
| public void expandStackAndSelectBubble(BubbleEntry entry) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.expandStackAndSelectBubble(entry); |
| }); |
| } |
| |
| @Override |
| public void expandStackAndSelectBubble(Bubble bubble) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.expandStackAndSelectBubble(bubble); |
| }); |
| } |
| |
| @Override |
| public void showOrHideAppBubble(Intent intent, UserHandle user, @Nullable Icon icon) { |
| mMainExecutor.execute( |
| () -> BubbleController.this.showOrHideAppBubble(intent, user, icon)); |
| } |
| |
| @Override |
| public boolean isAppBubbleTaskId(int taskId) { |
| return mCachedState.mAppBubbleTaskIds.values().contains(taskId); |
| } |
| |
| @Override |
| @Nullable |
| public ScreenshotSync getScreenshotExcludingBubble(int displayId) { |
| Pair<ScreenCaptureListener, ScreenshotSync> screenCaptureListener = |
| ScreenCapture.createSyncCaptureListener(); |
| |
| mMainExecutor.execute( |
| () -> BubbleController.this.getScreenshotExcludingBubble(displayId, |
| screenCaptureListener)); |
| |
| return screenCaptureListener.second; |
| } |
| |
| @Override |
| public boolean handleDismissalInterception(BubbleEntry entry, |
| @Nullable List<BubbleEntry> children, IntConsumer removeCallback, |
| Executor callbackExecutor) { |
| IntConsumer cb = removeCallback != null |
| ? (index) -> callbackExecutor.execute(() -> removeCallback.accept(index)) |
| : null; |
| return mMainExecutor.executeBlockingForResult(() -> { |
| return BubbleController.this.handleDismissalInterception(entry, children, cb); |
| }, Boolean.class); |
| } |
| |
| @Override |
| public void setSysuiProxy(SysuiProxy proxy) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.setSysuiProxy(proxy); |
| }); |
| } |
| |
| @Override |
| public void setExpandListener(BubbleExpandListener listener) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.setExpandListener(listener); |
| }); |
| } |
| |
| @Override |
| public void onEntryAdded(BubbleEntry entry) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onEntryAdded(entry); |
| }); |
| } |
| |
| @Override |
| public void onEntryUpdated(BubbleEntry entry, boolean shouldBubbleUp, boolean fromSystem) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onEntryUpdated(entry, shouldBubbleUp, fromSystem); |
| }); |
| } |
| |
| @Override |
| public void onEntryRemoved(BubbleEntry entry) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onEntryRemoved(entry); |
| }); |
| } |
| |
| @Override |
| public void onRankingUpdated(RankingMap rankingMap, |
| HashMap<String, Pair<BubbleEntry, Boolean>> entryDataByKey) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onRankingUpdated(rankingMap, entryDataByKey); |
| }); |
| } |
| |
| @Override |
| public void onNotificationChannelModified(String pkg, |
| UserHandle user, NotificationChannel channel, int modificationType) { |
| // Bubbles only cares about updates or deletions. |
| if (modificationType == NOTIFICATION_CHANNEL_OR_GROUP_UPDATED |
| || modificationType == NOTIFICATION_CHANNEL_OR_GROUP_DELETED) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onNotificationChannelModified(pkg, user, channel, |
| modificationType); |
| }); |
| } |
| } |
| |
| @Override |
| public void onStatusBarVisibilityChanged(boolean visible) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onStatusBarVisibilityChanged(visible); |
| }); |
| } |
| |
| @Override |
| public void onZenStateChanged() { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onZenStateChanged(); |
| }); |
| } |
| |
| @Override |
| public void onStatusBarStateChanged(boolean isShade) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onStatusBarStateChanged(isShade); |
| }); |
| } |
| |
| @Override |
| public void onUserChanged(int newUserId) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onUserChanged(newUserId); |
| }); |
| } |
| |
| @Override |
| public void onCurrentProfilesChanged(SparseArray<UserInfo> currentProfiles) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onCurrentProfilesChanged(currentProfiles); |
| }); |
| } |
| |
| @Override |
| public void onUserRemoved(int removedUserId) { |
| mMainExecutor.execute(() -> { |
| BubbleController.this.onUserRemoved(removedUserId); |
| }); |
| } |
| |
| @Override |
| public void onNotificationPanelExpandedChanged(boolean expanded) { |
| mMainExecutor.execute( |
| () -> BubbleController.this.onNotificationPanelExpandedChanged(expanded)); |
| } |
| } |
| |
| /** |
| * Bubble data that is stored per user. |
| * Used to store and restore active bubbles during user switching. |
| */ |
| private static class UserBubbleData { |
| private final Map<String, Boolean> mKeyToShownInShadeMap = new HashMap<>(); |
| |
| /** |
| * Add bubble key and whether it should be shown in notification shade |
| */ |
| void add(String key, boolean shownInShade) { |
| mKeyToShownInShadeMap.put(key, shownInShade); |
| } |
| |
| /** |
| * Get all bubble keys stored for this user |
| */ |
| Set<String> getKeys() { |
| return mKeyToShownInShadeMap.keySet(); |
| } |
| |
| /** |
| * Check if this bubble with the given key should be shown in the notification shade |
| */ |
| boolean isShownInShade(String key) { |
| return mKeyToShownInShadeMap.get(key); |
| } |
| } |
| } |