summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleTaskViewListenerTest.kt491
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java292
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewListener.java279
-rw-r--r--libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java19
4 files changed, 781 insertions, 300 deletions
diff --git a/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleTaskViewListenerTest.kt b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleTaskViewListenerTest.kt
new file mode 100644
index 000000000000..9ebc3d78b3a7
--- /dev/null
+++ b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleTaskViewListenerTest.kt
@@ -0,0 +1,491 @@
+/*
+ * Copyright (C) 2025 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 android.app.Notification
+import android.app.PendingIntent
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutInfo
+import android.graphics.drawable.Icon
+import android.os.UserHandle
+import android.service.notification.NotificationListenerService.Ranking
+import android.service.notification.StatusBarNotification
+import android.view.View
+import android.widget.FrameLayout
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import com.android.internal.protolog.ProtoLog
+import com.android.wm.shell.R
+import com.android.wm.shell.bubbles.Bubbles.BubbleMetadataFlagListener
+import com.android.wm.shell.common.TestShellExecutor
+import com.android.wm.shell.taskview.TaskView
+import com.android.wm.shell.taskview.TaskViewController
+import com.android.wm.shell.taskview.TaskViewTaskController
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+/**
+ * Tests for [BubbleTaskViewListener].
+ */
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class BubbleTaskViewListenerTest {
+
+ private val context = ApplicationProvider.getApplicationContext<Context>()
+
+ private var taskViewController = mock<TaskViewController>()
+ private var listenerCallback = mock<BubbleTaskViewListener.Callback>()
+ private var expandedViewManager = mock<BubbleExpandedViewManager>()
+
+ private lateinit var bubbleTaskViewListener: BubbleTaskViewListener
+ private lateinit var taskView: TaskView
+ private lateinit var bubbleTaskView: BubbleTaskView
+ private lateinit var parentView: ViewPoster
+ private lateinit var mainExecutor: TestShellExecutor
+ private lateinit var bgExecutor: TestShellExecutor
+
+ @Before
+ fun setUp() {
+ ProtoLog.REQUIRE_PROTOLOGTOOL = false
+ ProtoLog.init()
+
+ parentView = ViewPoster(context)
+ mainExecutor = TestShellExecutor()
+ bgExecutor = TestShellExecutor()
+
+ taskView = TaskView(context, taskViewController, mock<TaskViewTaskController>())
+ bubbleTaskView = BubbleTaskView(taskView, mainExecutor)
+
+ bubbleTaskViewListener =
+ BubbleTaskViewListener(
+ context,
+ bubbleTaskView,
+ parentView,
+ expandedViewManager,
+ listenerCallback
+ )
+ }
+
+ @Test
+ fun createBubbleTaskViewListener_withCreatedTaskView() {
+ // Make the bubbleTaskView look like it's been created
+ val taskId = 123
+ bubbleTaskView.listener.onTaskCreated(taskId, mock<ComponentName>())
+ reset(listenerCallback)
+
+ bubbleTaskViewListener =
+ BubbleTaskViewListener(
+ context,
+ bubbleTaskView,
+ parentView,
+ expandedViewManager,
+ listenerCallback
+ )
+
+ assertThat(bubbleTaskView.delegateListener).isEqualTo(bubbleTaskViewListener)
+ assertThat(bubbleTaskViewListener.taskView).isEqualTo(bubbleTaskView.taskView)
+
+ verify(listenerCallback).onTaskCreated()
+ assertThat(bubbleTaskViewListener.taskId).isEqualTo(taskId)
+ }
+
+ @Test
+ fun createBubbleTaskViewListener() {
+ bubbleTaskViewListener =
+ BubbleTaskViewListener(
+ context,
+ bubbleTaskView,
+ parentView,
+ expandedViewManager,
+ listenerCallback
+ )
+
+ assertThat(bubbleTaskView.delegateListener).isEqualTo(bubbleTaskViewListener)
+ assertThat(bubbleTaskViewListener.taskView).isEqualTo(bubbleTaskView.taskView)
+ verify(listenerCallback, never()).onTaskCreated()
+ }
+
+ @Test
+ fun onInitialized_pendingIntentChatBubble() {
+ val target = Intent(context, TestActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(context, 0, target,
+ PendingIntent.FLAG_MUTABLE)
+
+ val b = createChatBubble("key", pendingIntent)
+ bubbleTaskViewListener.setBubble(b)
+
+ assertThat(b.isChat).isTrue()
+ // Has shortcut info
+ assertThat(b.shortcutInfo).isNotNull()
+ // But it didn't use that on bubble metadata
+ assertThat(b.metadataShortcutId).isNull()
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onInitialized()
+ }
+ getInstrumentation().waitForIdleSync()
+
+ // ..so it's pending intent-based, and launches that
+ assertThat(b.isPendingIntentActive).isTrue()
+ verify(taskViewController).startActivity(any(), eq(pendingIntent), any(), any(), any())
+ }
+
+ @Test
+ fun onInitialized_shortcutChatBubble() {
+ val shortcutInfo = ShortcutInfo.Builder(context)
+ .setId("mockShortcutId")
+ .build()
+ val b = createChatBubble("key", shortcutInfo)
+ bubbleTaskViewListener.setBubble(b)
+
+ assertThat(b.isChat).isTrue()
+ assertThat(b.shortcutInfo).isNotNull()
+ // Chat bubble using a shortcut
+ assertThat(b.metadataShortcutId).isNotNull()
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onInitialized()
+ }
+ getInstrumentation().waitForIdleSync()
+
+ assertThat(b.isPendingIntentActive).isFalse()
+ verify(taskViewController).startShortcutActivity(any(), eq(shortcutInfo), any(), any())
+ }
+
+ @Test
+ fun onInitialized_appBubble() {
+ val b = createAppBubble()
+ bubbleTaskViewListener.setBubble(b)
+
+ assertThat(b.isApp).isTrue()
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onInitialized()
+ }
+ getInstrumentation().waitForIdleSync()
+
+ assertThat(b.isPendingIntentActive).isFalse()
+ verify(taskViewController).startActivity(any(), any(), anyOrNull(), any(), any())
+ }
+
+ @Test
+ fun onInitialized_preparingTransition() {
+ val b = createAppBubble()
+ bubbleTaskViewListener.setBubble(b)
+ taskView = Mockito.spy(taskView)
+ val preparingTransition = mock<BubbleTransitions.BubbleTransition>()
+ b.preparingTransition = preparingTransition
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onInitialized()
+ }
+ getInstrumentation().waitForIdleSync()
+
+ verify(preparingTransition).surfaceCreated()
+ }
+
+ @Test
+ fun onInitialized_destroyed() {
+ val b = createAppBubble()
+ bubbleTaskViewListener.setBubble(b)
+
+ assertThat(b.isApp).isTrue()
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onReleased()
+ bubbleTaskViewListener.onInitialized()
+ }
+ getInstrumentation().waitForIdleSync()
+
+ verify(taskViewController, never()).startActivity(any(), any(), anyOrNull(), any(), any())
+ }
+
+ @Test
+ fun onInitialized_initialized() {
+ val b = createAppBubble()
+ bubbleTaskViewListener.setBubble(b)
+
+ assertThat(b.isApp).isTrue()
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onInitialized()
+ }
+ getInstrumentation().waitForIdleSync()
+
+ reset(taskViewController)
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onInitialized()
+ }
+ // Already initialized, so no activity should be started.
+ verify(taskViewController, never()).startActivity(any(), any(), anyOrNull(), any(), any())
+ }
+
+ @Test
+ fun onTaskCreated() {
+ val b = createAppBubble()
+ bubbleTaskViewListener.setBubble(b)
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onInitialized()
+ }
+ getInstrumentation().waitForIdleSync()
+ verify(taskViewController).startActivity(any(), any(), anyOrNull(), any(), any())
+
+ val taskId = 123
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onTaskCreated(taskId, mock<ComponentName>())
+ }
+ getInstrumentation().waitForIdleSync()
+
+ verify(listenerCallback).onTaskCreated()
+ verify(expandedViewManager, never()).setNoteBubbleTaskId(any(), any())
+ assertThat(bubbleTaskViewListener.taskId).isEqualTo(taskId)
+ }
+
+ @Test
+ fun onTaskCreated_noteBubble() {
+ val b = createNoteBubble()
+ bubbleTaskViewListener.setBubble(b)
+ assertThat(b.isNote).isTrue()
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onInitialized()
+ }
+ getInstrumentation().waitForIdleSync()
+ verify(taskViewController).startActivity(any(), any(), anyOrNull(), any(), any())
+
+ val taskId = 123
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onTaskCreated(taskId, mock<ComponentName>())
+ }
+ getInstrumentation().waitForIdleSync()
+
+ verify(listenerCallback).onTaskCreated()
+ verify(expandedViewManager).setNoteBubbleTaskId(eq(b.key), eq(taskId))
+ assertThat(bubbleTaskViewListener.taskId).isEqualTo(taskId)
+ }
+
+ @Test
+ fun onTaskVisibilityChanged_true() {
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onTaskVisibilityChanged(1, true)
+ }
+ verify(listenerCallback).onContentVisibilityChanged(eq(true))
+ }
+
+ @Test
+ fun onTaskVisibilityChanged_false() {
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onTaskVisibilityChanged(1, false)
+ }
+ verify(listenerCallback).onContentVisibilityChanged(eq(false))
+ }
+
+ @Test
+ fun onTaskRemovalStarted() {
+ val mockTaskView = mock<TaskView>()
+ bubbleTaskView = BubbleTaskView(mockTaskView, mainExecutor)
+
+ bubbleTaskViewListener =
+ BubbleTaskViewListener(
+ context,
+ bubbleTaskView,
+ parentView,
+ expandedViewManager,
+ listenerCallback
+ )
+
+ val b = createAppBubble()
+ bubbleTaskViewListener.setBubble(b)
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onInitialized()
+ }
+ getInstrumentation().waitForIdleSync()
+ verify(mockTaskView).startActivity(any(), anyOrNull(), any(), any())
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onTaskRemovalStarted(1)
+ }
+
+ verify(expandedViewManager).removeBubble(eq(b.key), eq(Bubbles.DISMISS_TASK_FINISHED))
+ verify(mockTaskView).release()
+ assertThat(parentView.lastRemovedView).isEqualTo(mockTaskView)
+ assertThat(bubbleTaskViewListener.taskView).isNull()
+ verify(listenerCallback).onTaskRemovalStarted()
+ }
+
+ @Test
+ fun onBackPressedOnTaskRoot_expanded() {
+ val taskId = 123
+ whenever(expandedViewManager.isStackExpanded()).doReturn(true)
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onTaskCreated(taskId, mock<ComponentName>())
+ bubbleTaskViewListener.onBackPressedOnTaskRoot(taskId)
+ }
+ verify(listenerCallback).onBackPressed()
+ }
+
+ @Test
+ fun onBackPressedOnTaskRoot_notExpanded() {
+ val taskId = 123
+ whenever(expandedViewManager.isStackExpanded()).doReturn(false)
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onTaskCreated(taskId, mock<ComponentName>())
+ bubbleTaskViewListener.onBackPressedOnTaskRoot(taskId)
+ }
+ verify(listenerCallback, never()).onBackPressed()
+ }
+
+ @Test
+ fun onBackPressedOnTaskRoot_taskIdMissMatch() {
+ val taskId = 123
+ whenever(expandedViewManager.isStackExpanded()).doReturn(true)
+
+ getInstrumentation().runOnMainSync {
+ bubbleTaskViewListener.onTaskCreated(taskId, mock<ComponentName>())
+ bubbleTaskViewListener.onBackPressedOnTaskRoot(42)
+ }
+ verify(listenerCallback, never()).onBackPressed()
+ }
+
+ @Test
+ fun setBubble_isNew() {
+ val b = createAppBubble()
+ val isNew = bubbleTaskViewListener.setBubble(b)
+ assertThat(isNew).isTrue()
+ }
+
+ @Test
+ fun setBubble_launchContentChanged() {
+ val target = Intent(context, TestActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(
+ context, 0, target,
+ PendingIntent.FLAG_MUTABLE
+ )
+
+ val b = createChatBubble("key", pendingIntent)
+ var isNew = bubbleTaskViewListener.setBubble(b)
+ // First time bubble is set, so it is "new"
+ assertThat(isNew).isTrue()
+
+ val b2 = createChatBubble("key", pendingIntent)
+ isNew = bubbleTaskViewListener.setBubble(b2)
+ // Second time bubble is set & it uses same type of launch content, not "new"
+ assertThat(isNew).isFalse()
+
+ val shortcutInfo = ShortcutInfo.Builder(context)
+ .setId("mockShortcutId")
+ .build()
+ val b3 = createChatBubble("key", shortcutInfo)
+ // bubble is using different content, so it is "new"
+ isNew = bubbleTaskViewListener.setBubble(b3)
+ assertThat(isNew).isTrue()
+ }
+
+ private fun createAppBubble(): Bubble {
+ val target = Intent(context, TestActivity::class.java)
+ target.setPackage(context.packageName)
+ return Bubble.createAppBubble(target, mock<UserHandle>(), mock<Icon>(),
+ mainExecutor, bgExecutor)
+ }
+
+ private fun createNoteBubble(): Bubble {
+ val target = Intent(context, TestActivity::class.java)
+ target.setPackage(context.packageName)
+ return Bubble.createNotesBubble(target, mock<UserHandle>(), mock<Icon>(),
+ mainExecutor, bgExecutor)
+ }
+
+ private fun createChatBubble(key: String, shortcutInfo: ShortcutInfo): Bubble {
+ return Bubble(
+ key,
+ shortcutInfo,
+ 0 /* desiredHeight */,
+ 0 /* desiredHeightResId */,
+ "title",
+ -1 /*taskId */,
+ null /* locusId */, true /* isdismissabel */,
+ mainExecutor, bgExecutor, mock<BubbleMetadataFlagListener>()
+ )
+ }
+
+ private fun createChatBubble(key: String, pendingIntent: PendingIntent): Bubble {
+ val metadata = Notification.BubbleMetadata.Builder(
+ pendingIntent,
+ Icon.createWithResource(context, R.drawable.bubble_ic_create_bubble)
+ ).build()
+ val shortcutInfo = ShortcutInfo.Builder(context)
+ .setId("shortcutId")
+ .build()
+ val notification: Notification =
+ Notification.Builder(context, key)
+ .setSmallIcon(mock<Icon>())
+ .setWhen(System.currentTimeMillis())
+ .setContentTitle("title")
+ .setContentText("content")
+ .setBubbleMetadata(metadata)
+ .build()
+ val sbn = mock<StatusBarNotification>()
+ val ranking = mock<Ranking>()
+ whenever(sbn.getNotification()).thenReturn(notification)
+ whenever(sbn.getKey()).thenReturn(key)
+ whenever(ranking.getConversationShortcutInfo()).thenReturn(shortcutInfo)
+ val entry = BubbleEntry(sbn, ranking, true, false, false, false)
+ return Bubble(
+ entry, mock<BubbleMetadataFlagListener>(), null, mainExecutor,
+ bgExecutor
+ )
+ }
+
+ /**
+ * FrameLayout that immediately runs any runnables posted to it and tracks view removals.
+ */
+ class ViewPoster(context: Context) : FrameLayout(context) {
+
+ lateinit var lastRemovedView: View
+
+ override fun post(r: Runnable): Boolean {
+ r.run()
+ return true
+ }
+
+ override fun removeView(v: View) {
+ super.removeView(v)
+ lastRemovedView = v
+ }
+ }
+} \ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java
deleted file mode 100644
index e47ac61a53dd..000000000000
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java
+++ /dev/null
@@ -1,292 +0,0 @@
-/*
- * Copyright (C) 2023 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.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS;
-import static android.app.ActivityTaskManager.INVALID_TASK_ID;
-import static android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
-import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
-
-import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
-
-import android.app.ActivityOptions;
-import android.app.ActivityTaskManager;
-import android.app.PendingIntent;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.Rect;
-import android.util.Log;
-import android.view.View;
-import android.view.ViewGroup;
-
-import androidx.annotation.Nullable;
-
-import com.android.internal.protolog.ProtoLog;
-import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
-import com.android.wm.shell.taskview.TaskView;
-
-/**
- * Handles creating and updating the {@link TaskView} associated with a {@link Bubble}.
- */
-public class BubbleTaskViewHelper {
-
- private static final String TAG = BubbleTaskViewHelper.class.getSimpleName();
-
- /**
- * Listener for users of {@link BubbleTaskViewHelper} to use to be notified of events
- * on the task.
- */
- public interface Listener {
-
- /** Called when the task is first created. */
- void onTaskCreated();
-
- /** Called when the visibility of the task changes. */
- void onContentVisibilityChanged(boolean visible);
-
- /** Called when back is pressed on the task root. */
- void onBackPressed();
-
- /** Called when task removal has started. */
- void onTaskRemovalStarted();
- }
-
- private final Context mContext;
- private final BubbleExpandedViewManager mExpandedViewManager;
- private final BubbleTaskViewHelper.Listener mListener;
- private final View mParentView;
-
- @Nullable
- private Bubble mBubble;
- @Nullable
- private PendingIntent mPendingIntent;
- @Nullable
- private TaskView mTaskView;
- private int mTaskId = INVALID_TASK_ID;
-
- private final TaskView.Listener mTaskViewListener = new TaskView.Listener() {
- private boolean mInitialized = false;
- private boolean mDestroyed = false;
-
- @Override
- public void onInitialized() {
- ProtoLog.d(WM_SHELL_BUBBLES, "onInitialized: destroyed=%b initialized=%b bubble=%s",
- mDestroyed, mInitialized, getBubbleKey());
-
- if (mDestroyed || mInitialized) {
- return;
- }
-
- // Custom options so there is no activity transition animation
- ActivityOptions options = ActivityOptions.makeCustomAnimation(mContext,
- 0 /* enterResId */, 0 /* exitResId */);
-
- Rect launchBounds = new Rect();
- mTaskView.getBoundsOnScreen(launchBounds);
-
- // TODO: I notice inconsistencies in lifecycle
- // Post to keep the lifecycle normal
- // TODO - currently based on type, really it's what the "launch item" is.
- mParentView.post(() -> {
- ProtoLog.d(WM_SHELL_BUBBLES, "onInitialized: calling startActivity, bubble=%s",
- getBubbleKey());
- try {
- options.setTaskAlwaysOnTop(true);
- options.setPendingIntentBackgroundActivityStartMode(
- MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS);
- final boolean isShortcutBubble = (mBubble.hasMetadataShortcutId()
- || (mBubble.isShortcut()
- && BubbleAnythingFlagHelper.enableCreateAnyBubble()));
- if (mBubble.getPreparingTransition() != null) {
- mBubble.getPreparingTransition().surfaceCreated();
- } else if (mBubble.isApp() || mBubble.isNote()) {
- Context context =
- mContext.createContextAsUser(
- mBubble.getUser(), Context.CONTEXT_RESTRICTED);
- Intent fillInIntent = null;
- //first try get pending intent from the bubble
- PendingIntent pi = mBubble.getPendingIntent();
- if (pi == null) {
- // if null - create new one
- pi = PendingIntent.getActivity(
- context,
- /* requestCode= */ 0,
- mBubble.getIntent()
- .addFlags(FLAG_ACTIVITY_MULTIPLE_TASK),
- PendingIntent.FLAG_IMMUTABLE
- | PendingIntent.FLAG_UPDATE_CURRENT,
- /* options= */ null);
- } else {
- fillInIntent = new Intent(pi.getIntent());
- fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
- }
- mTaskView.startActivity(pi, fillInIntent, options, launchBounds);
- } else if (isShortcutBubble) {
- options.setLaunchedFromBubble(true);
- options.setApplyActivityFlagsForBubbles(true);
- mTaskView.startShortcutActivity(mBubble.getShortcutInfo(),
- options, launchBounds);
- } else {
- options.setLaunchedFromBubble(true);
- if (mBubble != null) {
- mBubble.setPendingIntentActive();
- }
- final Intent fillInIntent = new Intent();
- // Apply flags to make behaviour match documentLaunchMode=always.
- fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT);
- fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
- mTaskView.startActivity(mPendingIntent, fillInIntent, options,
- launchBounds);
- }
- } catch (RuntimeException e) {
- // If there's a runtime exception here then there's something
- // wrong with the intent, we can't really recover / try to populate
- // the bubble again so we'll just remove it.
- Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey()
- + ", " + e.getMessage() + "; removing bubble");
- mExpandedViewManager.removeBubble(
- getBubbleKey(), Bubbles.DISMISS_INVALID_INTENT);
- }
- mInitialized = true;
- });
- }
-
- @Override
- public void onReleased() {
- mDestroyed = true;
- }
-
- @Override
- public void onTaskCreated(int taskId, ComponentName name) {
- ProtoLog.d(WM_SHELL_BUBBLES, "onTaskCreated: taskId=%d bubble=%s",
- taskId, getBubbleKey());
- // The taskId is saved to use for removeTask, preventing appearance in recent tasks.
- mTaskId = taskId;
-
- if (mBubble != null && mBubble.isNote()) {
- // Let the controller know sooner what the taskId is.
- mExpandedViewManager.setNoteBubbleTaskId(mBubble.getKey(), mTaskId);
- }
-
- // With the task org, the taskAppeared callback will only happen once the task has
- // already drawn
- mListener.onTaskCreated();
- }
-
- @Override
- public void onTaskVisibilityChanged(int taskId, boolean visible) {
- mListener.onContentVisibilityChanged(visible);
- }
-
- @Override
- public void onTaskRemovalStarted(int taskId) {
- ProtoLog.d(WM_SHELL_BUBBLES, "onTaskRemovalStarted: taskId=%d bubble=%s",
- taskId, getBubbleKey());
- if (mBubble != null) {
- mExpandedViewManager.removeBubble(mBubble.getKey(), Bubbles.DISMISS_TASK_FINISHED);
- }
- if (mTaskView != null) {
- mTaskView.release();
- ((ViewGroup) mParentView).removeView(mTaskView);
- mTaskView = null;
- }
- mListener.onTaskRemovalStarted();
- }
-
- @Override
- public void onBackPressedOnTaskRoot(int taskId) {
- if (mTaskId == taskId && mExpandedViewManager.isStackExpanded()) {
- mListener.onBackPressed();
- }
- }
- };
-
- public BubbleTaskViewHelper(Context context,
- BubbleExpandedViewManager expandedViewManager,
- BubbleTaskViewHelper.Listener listener,
- BubbleTaskView bubbleTaskView,
- View parent) {
- mContext = context;
- mExpandedViewManager = expandedViewManager;
- mListener = listener;
- mParentView = parent;
- mTaskView = bubbleTaskView.getTaskView();
- bubbleTaskView.setDelegateListener(mTaskViewListener);
- if (bubbleTaskView.isCreated()) {
- mTaskId = bubbleTaskView.getTaskId();
- mListener.onTaskCreated();
- }
- }
-
- /**
- * Sets the bubble or updates the bubble used to populate the view.
- *
- * @return true if the bubble is new, false if it was an update to the same bubble.
- */
- public boolean update(Bubble bubble) {
- boolean isNew = mBubble == null || didBackingContentChange(bubble);
- mBubble = bubble;
- if (isNew) {
- mPendingIntent = mBubble.getPendingIntent();
- return true;
- }
- return false;
- }
-
- /** Returns the bubble key associated with this view. */
- @Nullable
- public String getBubbleKey() {
- return mBubble != null ? mBubble.getKey() : null;
- }
-
- /** Returns the TaskView associated with this view. */
- @Nullable
- public TaskView getTaskView() {
- return mTaskView;
- }
-
- /**
- * Returns the task id associated with the task in this view. If the task doesn't exist then
- * {@link ActivityTaskManager#INVALID_TASK_ID}.
- */
- public int getTaskId() {
- return mTaskId;
- }
-
- /** Returns whether the bubble set on the helper is valid to populate the task view. */
- public boolean isValidBubble() {
- return mBubble != null && (mPendingIntent != null || mBubble.hasMetadataShortcutId());
- }
-
- // TODO (b/274980695): Is this still relevant?
- /**
- * Bubbles are backed by a pending intent or a shortcut, once the activity is
- * started we never change it / restart it on notification updates -- unless the bubble's
- * backing data switches.
- *
- * This indicates if the new bubble is backed by a different data source than what was
- * previously shown here (e.g. previously a pending intent & now a shortcut).
- *
- * @param newBubble the bubble this view is being updated with.
- * @return true if the backing content has changed.
- */
- private boolean didBackingContentChange(Bubble newBubble) {
- boolean prevWasIntentBased = mBubble != null && mPendingIntent != null;
- boolean newIsIntentBased = newBubble.getPendingIntent() != null;
- return prevWasIntentBased != newIsIntentBased;
- }
-}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewListener.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewListener.java
new file mode 100644
index 000000000000..a38debb702dc
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewListener.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright (C) 2025 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.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS;
+import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+import static android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
+import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
+
+import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
+
+import android.app.ActivityOptions;
+import android.app.ActivityTaskManager;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.protolog.ProtoLog;
+import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper;
+import com.android.wm.shell.taskview.TaskView;
+
+/**
+ * A listener that works with task views for bubbles, manages launching the appropriate
+ * content into the task view from the bubble and sends updates of task view events back to
+ * the parent view via {@link BubbleTaskViewListener.Callback}.
+ */
+public class BubbleTaskViewListener implements TaskView.Listener {
+ private static final String TAG = BubbleTaskViewListener.class.getSimpleName();
+
+ /**
+ * Callback to let the view parent of TaskView to be notified of different events.
+ */
+ public interface Callback {
+
+ /** Called when the task is first created. */
+ void onTaskCreated();
+
+ /** Called when the visibility of the task changes. */
+ void onContentVisibilityChanged(boolean visible);
+
+ /** Called when back is pressed on the task root. */
+ void onBackPressed();
+
+ /** Called when task removal has started. */
+ void onTaskRemovalStarted();
+ }
+
+ private final Context mContext;
+ private final BubbleExpandedViewManager mExpandedViewManager;
+ private final BubbleTaskViewListener.Callback mCallback;
+ private final View mParentView;
+
+ private Bubble mBubble;
+ @Nullable
+ private PendingIntent mPendingIntent;
+ private int mTaskId = INVALID_TASK_ID;
+ private TaskView mTaskView;
+
+ private boolean mInitialized = false;
+ private boolean mDestroyed = false;
+
+ public BubbleTaskViewListener(Context context, BubbleTaskView bubbleTaskView, View parentView,
+ BubbleExpandedViewManager manager, BubbleTaskViewListener.Callback callback) {
+ mContext = context;
+ mTaskView = bubbleTaskView.getTaskView();
+ mParentView = parentView;
+ mExpandedViewManager = manager;
+ mCallback = callback;
+ bubbleTaskView.setDelegateListener(this);
+ if (bubbleTaskView.isCreated()) {
+ mTaskId = bubbleTaskView.getTaskId();
+ callback.onTaskCreated();
+ }
+ }
+
+ @Override
+ public void onInitialized() {
+ ProtoLog.d(WM_SHELL_BUBBLES, "onInitialized: destroyed=%b initialized=%b bubble=%s",
+ mDestroyed, mInitialized, getBubbleKey());
+
+ if (mDestroyed || mInitialized) {
+ return;
+ }
+
+ // Custom options so there is no activity transition animation
+ ActivityOptions options = ActivityOptions.makeCustomAnimation(mContext,
+ 0 /* enterResId */, 0 /* exitResId */);
+
+ Rect launchBounds = new Rect();
+ mTaskView.getBoundsOnScreen(launchBounds);
+
+ // TODO: I notice inconsistencies in lifecycle
+ // Post to keep the lifecycle normal
+ // TODO - currently based on type, really it's what the "launch item" is.
+ mParentView.post(() -> {
+ ProtoLog.d(WM_SHELL_BUBBLES, "onInitialized: calling startActivity, bubble=%s",
+ getBubbleKey());
+ try {
+ options.setTaskAlwaysOnTop(true);
+ options.setPendingIntentBackgroundActivityStartMode(
+ MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS);
+ final boolean isShortcutBubble = (mBubble.hasMetadataShortcutId()
+ || (mBubble.isShortcut()
+ && BubbleAnythingFlagHelper.enableCreateAnyBubble()));
+ if (mBubble.getPreparingTransition() != null) {
+ mBubble.getPreparingTransition().surfaceCreated();
+ } else if (mBubble.isApp() || mBubble.isNote()) {
+ Context context =
+ mContext.createContextAsUser(
+ mBubble.getUser(), Context.CONTEXT_RESTRICTED);
+ Intent fillInIntent = null;
+ // First try get pending intent from the bubble
+ PendingIntent pi = mBubble.getPendingIntent();
+ if (pi == null) {
+ // If null - create new one
+ pi = PendingIntent.getActivity(
+ context,
+ /* requestCode= */ 0,
+ mBubble.getIntent()
+ .addFlags(FLAG_ACTIVITY_MULTIPLE_TASK),
+ PendingIntent.FLAG_IMMUTABLE
+ | PendingIntent.FLAG_UPDATE_CURRENT,
+ /* options= */ null);
+ } else {
+ fillInIntent = new Intent(pi.getIntent());
+ fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
+ }
+ mTaskView.startActivity(pi, fillInIntent, options, launchBounds);
+ } else if (isShortcutBubble) {
+ options.setLaunchedFromBubble(true);
+ options.setApplyActivityFlagsForBubbles(true);
+ mTaskView.startShortcutActivity(mBubble.getShortcutInfo(),
+ options, launchBounds);
+ } else {
+ options.setLaunchedFromBubble(true);
+ if (mBubble != null) {
+ mBubble.setPendingIntentActive();
+ }
+ final Intent fillInIntent = new Intent();
+ // Apply flags to make behaviour match documentLaunchMode=always.
+ fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT);
+ fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
+ mTaskView.startActivity(mPendingIntent, fillInIntent, options,
+ launchBounds);
+ }
+ } catch (RuntimeException e) {
+ // If there's a runtime exception here then there's something
+ // wrong with the intent, we can't really recover / try to populate
+ // the bubble again so we'll just remove it.
+ Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey()
+ + ", " + e.getMessage() + "; removing bubble");
+ mExpandedViewManager.removeBubble(
+ getBubbleKey(), Bubbles.DISMISS_INVALID_INTENT);
+ }
+ mInitialized = true;
+ });
+ }
+
+ @Override
+ public void onReleased() {
+ mDestroyed = true;
+ }
+
+ @Override
+ public void onTaskCreated(int taskId, ComponentName name) {
+ ProtoLog.d(WM_SHELL_BUBBLES, "onTaskCreated: taskId=%d bubble=%s",
+ taskId, getBubbleKey());
+ // The taskId is saved to use for removeTask, preventing appearance in recent tasks.
+ mTaskId = taskId;
+
+ if (mBubble != null && mBubble.isNote()) {
+ // Let the controller know sooner what the taskId is.
+ mExpandedViewManager.setNoteBubbleTaskId(mBubble.getKey(), mTaskId);
+ }
+
+ // With the task org, the taskAppeared callback will only happen once the task has
+ // already drawn
+ mCallback.onTaskCreated();
+ }
+
+ @Override
+ public void onTaskVisibilityChanged(int taskId, boolean visible) {
+ mCallback.onContentVisibilityChanged(visible);
+ }
+
+ @Override
+ public void onTaskRemovalStarted(int taskId) {
+ ProtoLog.d(WM_SHELL_BUBBLES, "onTaskRemovalStarted: taskId=%d bubble=%s",
+ taskId, getBubbleKey());
+ if (mBubble != null) {
+ mExpandedViewManager.removeBubble(mBubble.getKey(), Bubbles.DISMISS_TASK_FINISHED);
+ }
+ if (mTaskView != null) {
+ mTaskView.release();
+ ((ViewGroup) mParentView).removeView(mTaskView);
+ mTaskView = null;
+ }
+ mCallback.onTaskRemovalStarted();
+ }
+
+ @Override
+ public void onBackPressedOnTaskRoot(int taskId) {
+ if (mTaskId == taskId && mExpandedViewManager.isStackExpanded()) {
+ mCallback.onBackPressed();
+ }
+ }
+
+ /**
+ * Sets the bubble or updates the bubble used to populate the view.
+ *
+ * @return true if the bubble is new or if the launch content of the bubble changed from the
+ * previous bubble.
+ */
+ public boolean setBubble(Bubble bubble) {
+ boolean isNew = mBubble == null || didBackingContentChange(bubble);
+ mBubble = bubble;
+ if (isNew) {
+ mPendingIntent = mBubble.getPendingIntent();
+ }
+ return isNew;
+ }
+
+ /** Returns the TaskView associated with this view. */
+ @Nullable
+ public TaskView getTaskView() {
+ return mTaskView;
+ }
+
+ /**
+ * Returns the task id associated with the task in this view. If the task doesn't exist then
+ * {@link ActivityTaskManager#INVALID_TASK_ID}.
+ */
+ public int getTaskId() {
+ return mTaskId;
+ }
+
+ private String getBubbleKey() {
+ return mBubble != null ? mBubble.getKey() : "";
+ }
+
+ // TODO (b/274980695): Is this still relevant?
+ /**
+ * Bubbles are backed by a pending intent or a shortcut, once the activity is
+ * started we never change it / restart it on notification updates -- unless the bubble's
+ * backing data switches.
+ *
+ * This indicates if the new bubble is backed by a different data source than what was
+ * previously shown here (e.g. previously a pending intent & now a shortcut).
+ *
+ * @param newBubble the bubble this view is being updated with.
+ * @return true if the backing content has changed.
+ */
+ private boolean didBackingContentChange(Bubble newBubble) {
+ boolean prevWasIntentBased = mBubble != null && mPendingIntent != null;
+ boolean newIsIntentBased = newBubble.getPendingIntent() != null;
+ return prevWasIntentBased != newIsIntentBased;
+ }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java
index 35bd07d9fd30..d93dbc3c15d9 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java
@@ -43,7 +43,7 @@ import com.android.wm.shell.bubbles.BubbleLogger;
import com.android.wm.shell.bubbles.BubbleOverflowContainerView;
import com.android.wm.shell.bubbles.BubblePositioner;
import com.android.wm.shell.bubbles.BubbleTaskView;
-import com.android.wm.shell.bubbles.BubbleTaskViewHelper;
+import com.android.wm.shell.bubbles.BubbleTaskViewListener;
import com.android.wm.shell.bubbles.Bubbles;
import com.android.wm.shell.bubbles.RegionSamplingProvider;
import com.android.wm.shell.dagger.HasWMComponent;
@@ -57,7 +57,7 @@ import java.util.function.Supplier;
import javax.inject.Inject;
/** Expanded view of a bubble when it's part of the bubble bar. */
-public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskViewHelper.Listener {
+public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskViewListener.Callback {
/**
* The expanded view listener notifying the {@link BubbleBarLayerView} about the internal
* actions and events
@@ -111,7 +111,7 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
private BubbleExpandedViewManager mManager;
private BubblePositioner mPositioner;
private boolean mIsOverflow;
- private BubbleTaskViewHelper mBubbleTaskViewHelper;
+ private BubbleTaskViewListener mBubbleTaskViewListener;
private BubbleBarMenuViewController mMenuViewController;
@Nullable
private Supplier<Rect> mLayerBoundsSupplier;
@@ -250,9 +250,10 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
mHandleView.setVisibility(View.GONE);
} else {
mTaskView = bubbleTaskView.getTaskView();
- mBubbleTaskViewHelper = new BubbleTaskViewHelper(mContext, expandedViewManager,
- /* listener= */ this, bubbleTaskView,
- /* viewParent= */ this);
+ mBubbleTaskViewListener = new BubbleTaskViewListener(mContext, bubbleTaskView,
+ /* viewParent= */ this,
+ expandedViewManager,
+ /* callback= */ this);
// if the task view is already attached to a parent we need to remove it
if (mTaskView.getParent() != null) {
@@ -539,13 +540,15 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView
/** Updates the bubble shown in the expanded view. */
public void update(Bubble bubble) {
mBubble = bubble;
- mBubbleTaskViewHelper.update(bubble);
+ mBubbleTaskViewListener.setBubble(bubble);
mMenuViewController.updateMenu(bubble);
}
/** The task id of the activity shown in the task view, if it exists. */
public int getTaskId() {
- return mBubbleTaskViewHelper != null ? mBubbleTaskViewHelper.getTaskId() : INVALID_TASK_ID;
+ return mBubbleTaskViewListener != null
+ ? mBubbleTaskViewListener.getTaskId()
+ : INVALID_TASK_ID;
}
/** Sets layer bounds supplier used for obscured touchable region of task view */