diff options
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 */ |