1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.wm.shell.bubbles;
17 
18 import static android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED;
19 import static android.app.ActivityTaskManager.INVALID_TASK_ID;
20 import static android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
21 import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
22 
23 import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_EXPANDED_VIEW;
24 
25 import android.app.ActivityOptions;
26 import android.app.ActivityTaskManager;
27 import android.app.PendingIntent;
28 import android.content.ComponentName;
29 import android.content.Context;
30 import android.content.Intent;
31 import android.graphics.Rect;
32 import android.os.RemoteException;
33 import android.util.Log;
34 import android.view.View;
35 
36 import androidx.annotation.Nullable;
37 
38 import com.android.wm.shell.common.ShellExecutor;
39 import com.android.wm.shell.common.annotations.ShellMainThread;
40 import com.android.wm.shell.taskview.TaskView;
41 import com.android.wm.shell.taskview.TaskViewTaskController;
42 
43 /**
44  * Handles creating and updating the {@link TaskView} associated with a {@link Bubble}.
45  */
46 public class BubbleTaskViewHelper {
47 
48     private static final String TAG = BubbleTaskViewHelper.class.getSimpleName();
49 
50     /**
51      * Listener for users of {@link BubbleTaskViewHelper} to use to be notified of events
52      * on the task.
53      */
54     public interface Listener {
55 
56         /** Called when the task is first created. */
onTaskCreated()57         void onTaskCreated();
58 
59         /** Called when the visibility of the task changes. */
onContentVisibilityChanged(boolean visible)60         void onContentVisibilityChanged(boolean visible);
61 
62         /** Called when back is pressed on the task root. */
onBackPressed()63         void onBackPressed();
64     }
65 
66     private final Context mContext;
67     private final BubbleController mController;
68     private final @ShellMainThread ShellExecutor mMainExecutor;
69     private final BubbleTaskViewHelper.Listener mListener;
70     private final View mParentView;
71 
72     @Nullable
73     private Bubble mBubble;
74     @Nullable
75     private PendingIntent mPendingIntent;
76     private TaskViewTaskController mTaskViewTaskController;
77     @Nullable
78     private TaskView mTaskView;
79     private int mTaskId = INVALID_TASK_ID;
80 
81     private final TaskView.Listener mTaskViewListener = new TaskView.Listener() {
82         private boolean mInitialized = false;
83         private boolean mDestroyed = false;
84 
85         @Override
86         public void onInitialized() {
87             if (DEBUG_BUBBLE_EXPANDED_VIEW) {
88                 Log.d(TAG, "onInitialized: destroyed=" + mDestroyed
89                         + " initialized=" + mInitialized
90                         + " bubble=" + getBubbleKey());
91             }
92 
93             if (mDestroyed || mInitialized) {
94                 return;
95             }
96 
97             // Custom options so there is no activity transition animation
98             ActivityOptions options = ActivityOptions.makeCustomAnimation(mContext,
99                     0 /* enterResId */, 0 /* exitResId */);
100 
101             Rect launchBounds = new Rect();
102             mTaskView.getBoundsOnScreen(launchBounds);
103 
104             // TODO: I notice inconsistencies in lifecycle
105             // Post to keep the lifecycle normal
106             mParentView.post(() -> {
107                 if (DEBUG_BUBBLE_EXPANDED_VIEW) {
108                     Log.d(TAG, "onInitialized: calling startActivity, bubble="
109                             + getBubbleKey());
110                 }
111                 try {
112                     options.setTaskAlwaysOnTop(true);
113                     options.setLaunchedFromBubble(true);
114                     options.setPendingIntentBackgroundActivityStartMode(
115                             MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
116                     options.setPendingIntentBackgroundActivityLaunchAllowedByPermission(true);
117 
118                     Intent fillInIntent = new Intent();
119                     // Apply flags to make behaviour match documentLaunchMode=always.
120                     fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT);
121                     fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
122 
123                     if (mBubble.isAppBubble()) {
124                         Context context =
125                                 mContext.createContextAsUser(
126                                         mBubble.getUser(), Context.CONTEXT_RESTRICTED);
127                         PendingIntent pi = PendingIntent.getActivity(
128                                 context,
129                                 /* requestCode= */ 0,
130                                 mBubble.getAppBubbleIntent()
131                                         .addFlags(FLAG_ACTIVITY_NEW_DOCUMENT)
132                                         .addFlags(FLAG_ACTIVITY_MULTIPLE_TASK),
133                                 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT,
134                                 /* options= */ null);
135                         mTaskView.startActivity(pi, /* fillInIntent= */ null, options,
136                                 launchBounds);
137                     } else if (mBubble.hasMetadataShortcutId()) {
138                         options.setApplyActivityFlagsForBubbles(true);
139                         mTaskView.startShortcutActivity(mBubble.getShortcutInfo(),
140                                 options, launchBounds);
141                     } else {
142                         if (mBubble != null) {
143                             mBubble.setIntentActive();
144                         }
145                         mTaskView.startActivity(mPendingIntent, fillInIntent, options,
146                                 launchBounds);
147                     }
148                 } catch (RuntimeException e) {
149                     // If there's a runtime exception here then there's something
150                     // wrong with the intent, we can't really recover / try to populate
151                     // the bubble again so we'll just remove it.
152                     Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey()
153                             + ", " + e.getMessage() + "; removing bubble");
154                     mController.removeBubble(getBubbleKey(), Bubbles.DISMISS_INVALID_INTENT);
155                 }
156                 mInitialized = true;
157             });
158         }
159 
160         @Override
161         public void onReleased() {
162             mDestroyed = true;
163         }
164 
165         @Override
166         public void onTaskCreated(int taskId, ComponentName name) {
167             if (DEBUG_BUBBLE_EXPANDED_VIEW) {
168                 Log.d(TAG, "onTaskCreated: taskId=" + taskId
169                         + " bubble=" + getBubbleKey());
170             }
171             // The taskId is saved to use for removeTask, preventing appearance in recent tasks.
172             mTaskId = taskId;
173 
174             // With the task org, the taskAppeared callback will only happen once the task has
175             // already drawn
176             mListener.onTaskCreated();
177         }
178 
179         @Override
180         public void onTaskVisibilityChanged(int taskId, boolean visible) {
181             mListener.onContentVisibilityChanged(visible);
182         }
183 
184         @Override
185         public void onTaskRemovalStarted(int taskId) {
186             if (DEBUG_BUBBLE_EXPANDED_VIEW) {
187                 Log.d(TAG, "onTaskRemovalStarted: taskId=" + taskId
188                         + " bubble=" + getBubbleKey());
189             }
190             if (mBubble != null) {
191                 mController.removeBubble(mBubble.getKey(), Bubbles.DISMISS_TASK_FINISHED);
192             }
193         }
194 
195         @Override
196         public void onBackPressedOnTaskRoot(int taskId) {
197             if (mTaskId == taskId && mController.isStackExpanded()) {
198                 mListener.onBackPressed();
199             }
200         }
201     };
202 
BubbleTaskViewHelper(Context context, BubbleController controller, BubbleTaskViewHelper.Listener listener, View parent)203     public BubbleTaskViewHelper(Context context,
204             BubbleController controller,
205             BubbleTaskViewHelper.Listener listener,
206             View parent) {
207         mContext = context;
208         mController = controller;
209         mMainExecutor = mController.getMainExecutor();
210         mListener = listener;
211         mParentView = parent;
212         mTaskViewTaskController = new TaskViewTaskController(mContext,
213                 mController.getTaskOrganizer(),
214                 mController.getTaskViewTransitions(), mController.getSyncTransactionQueue());
215         mTaskView = new TaskView(mContext, mTaskViewTaskController);
216         mTaskView.setListener(mMainExecutor, mTaskViewListener);
217     }
218 
219     /**
220      * Sets the bubble or updates the bubble used to populate the view.
221      *
222      * @return true if the bubble is new, false if it was an update to the same bubble.
223      */
update(Bubble bubble)224     public boolean update(Bubble bubble) {
225         boolean isNew = mBubble == null || didBackingContentChange(bubble);
226         mBubble = bubble;
227         if (isNew) {
228             mPendingIntent = mBubble.getBubbleIntent();
229             return true;
230         }
231         return false;
232     }
233 
234     /** Cleans up anything related to the task and {@code TaskView}. */
cleanUpTaskView()235     public void cleanUpTaskView() {
236         if (DEBUG_BUBBLE_EXPANDED_VIEW) {
237             Log.d(TAG, "cleanUpExpandedState: bubble=" + getBubbleKey() + " task=" + mTaskId);
238         }
239         if (mTaskId != INVALID_TASK_ID) {
240             try {
241                 ActivityTaskManager.getService().removeTask(mTaskId);
242             } catch (RemoteException e) {
243                 Log.w(TAG, e.getMessage());
244             }
245         }
246         if (mTaskView != null) {
247             mTaskView.release();
248             mTaskView = null;
249         }
250     }
251 
252     /** Returns the bubble key associated with this view. */
253     @Nullable
getBubbleKey()254     public String getBubbleKey() {
255         return mBubble != null ? mBubble.getKey() : null;
256     }
257 
258     /** Returns the TaskView associated with this view. */
259     @Nullable
getTaskView()260     public TaskView getTaskView() {
261         return mTaskView;
262     }
263 
264     /**
265      * Returns the task id associated with the task in this view. If the task doesn't exist then
266      * {@link ActivityTaskManager#INVALID_TASK_ID}.
267      */
getTaskId()268     public int getTaskId() {
269         return mTaskId;
270     }
271 
272     /** Returns whether the bubble set on the helper is valid to populate the task view. */
isValidBubble()273     public boolean isValidBubble() {
274         return mBubble != null && (mPendingIntent != null || mBubble.hasMetadataShortcutId());
275     }
276 
277     // TODO (b/274980695): Is this still relevant?
278     /**
279      * Bubbles are backed by a pending intent or a shortcut, once the activity is
280      * started we never change it / restart it on notification updates -- unless the bubble's
281      * backing data switches.
282      *
283      * This indicates if the new bubble is backed by a different data source than what was
284      * previously shown here (e.g. previously a pending intent & now a shortcut).
285      *
286      * @param newBubble the bubble this view is being updated with.
287      * @return true if the backing content has changed.
288      */
didBackingContentChange(Bubble newBubble)289     private boolean didBackingContentChange(Bubble newBubble) {
290         boolean prevWasIntentBased = mBubble != null && mPendingIntent != null;
291         boolean newIsIntentBased = newBubble.getBubbleIntent() != null;
292         return prevWasIntentBased != newIsIntentBased;
293     }
294 }
295