1 /*
2  * Copyright (C) 2020 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 
17 package com.android.systemui.screenshot;
18 
19 import android.content.Context;
20 import android.graphics.Bitmap;
21 import android.graphics.Rect;
22 import android.graphics.drawable.Drawable;
23 import android.provider.Settings;
24 import android.util.Log;
25 import android.view.ScrollCaptureResponse;
26 
27 import androidx.concurrent.futures.CallbackToFutureAdapter;
28 import androidx.concurrent.futures.CallbackToFutureAdapter.Completer;
29 
30 import com.android.internal.annotations.VisibleForTesting;
31 import com.android.internal.logging.UiEventLogger;
32 import com.android.systemui.dagger.qualifiers.Background;
33 import com.android.systemui.screenshot.ScrollCaptureClient.CaptureResult;
34 import com.android.systemui.screenshot.ScrollCaptureClient.Session;
35 
36 import com.google.common.util.concurrent.ListenableFuture;
37 
38 import java.util.concurrent.CancellationException;
39 import java.util.concurrent.ExecutionException;
40 import java.util.concurrent.Executor;
41 
42 import javax.inject.Inject;
43 
44 /**
45  * Interaction controller between the UI and ScrollCaptureClient.
46  */
47 public class ScrollCaptureController {
48     private static final String TAG = LogConfig.logTag(ScrollCaptureController.class);
49     private static final float MAX_PAGES_DEFAULT = 3f;
50 
51     private static final String SETTING_KEY_MAX_PAGES = "screenshot.scroll_max_pages";
52     // Portion of the tiles to be acquired above the starting position in infinite scroll
53     // situations. 1.0 means maximize the area above, 0 means just go down.
54     private static final float IDEAL_PORTION_ABOVE = 0.4f;
55 
56     private boolean mScrollingUp = true;
57     // If true, stop acquiring images when no more bitmap data is available in the current direction
58     // or if the desired bitmap size is reached.
59     private boolean mFinishOnBoundary;
60 
61     public static final int MAX_HEIGHT = 12000;
62 
63     private final Context mContext;
64     private final Executor mBgExecutor;
65     private final ImageTileSet mImageTileSet;
66     private final UiEventLogger mEventLogger;
67     private final ScrollCaptureClient mClient;
68 
69     private Completer<LongScreenshot> mCaptureCompleter;
70 
71     private ListenableFuture<Session> mSessionFuture;
72     private Session mSession;
73     private ListenableFuture<CaptureResult> mTileFuture;
74     private ListenableFuture<Void> mEndFuture;
75     private String mWindowOwner;
76     private volatile boolean mCancelled;
77 
78     static class LongScreenshot {
79         private final ImageTileSet mImageTileSet;
80         private final Session mSession;
81         // TODO: Add UserHandle so LongScreenshots can adhere to work profile screenshot policy
82 
LongScreenshot(Session session, ImageTileSet imageTileSet)83         LongScreenshot(Session session, ImageTileSet imageTileSet) {
84             mSession = session;
85             mImageTileSet = imageTileSet;
86         }
87 
88         /** Returns a bitmap containing the combinded result. */
toBitmap()89         public Bitmap toBitmap() {
90             return mImageTileSet.toBitmap();
91         }
92 
toBitmap(Rect bounds)93         public Bitmap toBitmap(Rect bounds) {
94             return mImageTileSet.toBitmap(bounds);
95         }
96 
97         /** Releases image resources from the screenshot. */
release()98         public void release() {
99             if (LogConfig.DEBUG_SCROLL) {
100                 Log.d(TAG, "LongScreenshot :: release()");
101             }
102             mImageTileSet.clear();
103             mSession.release();
104         }
105 
getLeft()106         public int getLeft() {
107             return mImageTileSet.getLeft();
108         }
109 
getTop()110         public int getTop() {
111             return mImageTileSet.getTop();
112         }
113 
getBottom()114         public int getBottom() {
115             return mImageTileSet.getBottom();
116         }
117 
getWidth()118         public int getWidth() {
119             return mImageTileSet.getWidth();
120         }
121 
getHeight()122         public int getHeight() {
123             return mImageTileSet.getHeight();
124         }
125 
126         /** @return the height of the visible area of the scrolling page, in pixels */
getPageHeight()127         public int getPageHeight() {
128             return mSession.getPageHeight();
129         }
130 
131         @Override
toString()132         public String toString() {
133             return "LongScreenshot{"
134                     + "l=" + mImageTileSet.getLeft() + ", "
135                     + "t=" + mImageTileSet.getTop() + ", "
136                     + "r=" + mImageTileSet.getRight() + ", "
137                     + "b=" + mImageTileSet.getBottom() + ", "
138                     + "w=" + mImageTileSet.getWidth() + ", "
139                     + "h=" + mImageTileSet.getHeight()
140                     + "}";
141         }
142 
getDrawable()143         public Drawable getDrawable() {
144             return mImageTileSet.getDrawable();
145         }
146     }
147 
148     @Inject
ScrollCaptureController(Context context, @Background Executor bgExecutor, ScrollCaptureClient client, ImageTileSet imageTileSet, UiEventLogger logger)149     ScrollCaptureController(Context context, @Background Executor bgExecutor,
150             ScrollCaptureClient client, ImageTileSet imageTileSet, UiEventLogger logger) {
151         mContext = context;
152         mBgExecutor = bgExecutor;
153         mClient = client;
154         mImageTileSet = imageTileSet;
155         mEventLogger = logger;
156     }
157 
158     @VisibleForTesting
getTargetTopSizeRatio()159     float getTargetTopSizeRatio() {
160         return IDEAL_PORTION_ABOVE;
161     }
162 
163     /**
164      * Run scroll capture. Performs a batch capture, collecting image tiles.
165      *
166      * @param response a scroll capture response from a previous request which is
167      *                 {@link ScrollCaptureResponse#isConnected() connected}.
168      * @return a future ImageTile set containing the result
169      */
run(ScrollCaptureResponse response)170     ListenableFuture<LongScreenshot> run(ScrollCaptureResponse response) {
171         mCancelled = false;
172         return CallbackToFutureAdapter.getFuture(completer -> {
173             mCaptureCompleter = completer;
174             mWindowOwner = response.getPackageName();
175             mCaptureCompleter.addCancellationListener(this::onCancelled, mBgExecutor);
176             mBgExecutor.execute(() -> {
177                 float maxPages = Settings.Secure.getFloat(mContext.getContentResolver(),
178                         SETTING_KEY_MAX_PAGES, MAX_PAGES_DEFAULT);
179                 mSessionFuture = mClient.start(response, maxPages);
180                 mSessionFuture.addListener(this::onStartComplete, mContext.getMainExecutor());
181             });
182             return "<batch scroll capture>";
183         });
184     }
185 
186     /**
187      * The ListenableFuture for the long screenshot was cancelled. Be sure to cancel all downstream
188      * futures that might be pending.
189      */
onCancelled()190     private void onCancelled() {
191         mCancelled = true;
192         if (mSessionFuture != null) {
193             mSessionFuture.cancel(true);
194         }
195         if (mTileFuture != null) {
196             mTileFuture.cancel(true);
197         }
198         if (mSession != null) {
199             mSession.end();
200         }
201         mEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_FAILURE, 0, mWindowOwner);
202     }
203 
onStartComplete()204     private void onStartComplete() {
205         try {
206             mSession = mSessionFuture.get();
207             if (LogConfig.DEBUG_SCROLL) {
208                 Log.d(TAG, "got session " + mSession);
209             }
210             mEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_STARTED, 0, mWindowOwner);
211             requestNextTile(0);
212         } catch (InterruptedException | ExecutionException e) {
213             // Failure to start, propagate to caller
214             Log.e(TAG, "session start failed!");
215             mCaptureCompleter.setException(e);
216             mEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_FAILURE, 0, mWindowOwner);
217         }
218     }
219 
requestNextTile(int topPx)220     private void requestNextTile(int topPx) {
221         if (LogConfig.DEBUG_SCROLL) {
222             Log.d(TAG, "requestNextTile: " + topPx);
223         }
224         if (mCancelled) {
225             Log.d(TAG, "requestNextTile: CANCELLED");
226             return;
227         }
228         mTileFuture = mSession.requestTile(topPx);
229         mTileFuture.addListener(() -> {
230             try {
231                 onCaptureResult(mTileFuture.get());
232             } catch (CancellationException e) {
233                 Log.e(TAG, "requestTile cancelled");
234             } catch (InterruptedException | ExecutionException e) {
235                 Log.e(TAG, "requestTile failed!", e);
236                 mCaptureCompleter.setException(e);
237             }
238         }, mBgExecutor);
239     }
240 
onCaptureResult(CaptureResult result)241     private void onCaptureResult(CaptureResult result) {
242         if (LogConfig.DEBUG_SCROLL) {
243             Log.d(TAG, "onCaptureResult: " + result + " scrolling " + (mScrollingUp ? "UP" : "DOWN")
244                     + " finish on boundary: " + mFinishOnBoundary);
245         }
246         boolean emptyResult = result.captured.height() == 0;
247 
248         if (emptyResult) {
249             // Potentially reached a vertical boundary. Extend in the other direction.
250             if (mFinishOnBoundary) {
251                 if (LogConfig.DEBUG_SCROLL) {
252                     Log.d(TAG, "Empty: finished!");
253                 }
254                 finishCapture();
255                 return;
256             } else {
257                 // We hit a boundary, clear the tiles, capture everything in the opposite direction,
258                 // then finish.
259                 mImageTileSet.clear();
260                 mFinishOnBoundary = true;
261                 mScrollingUp = !mScrollingUp;
262                 if (LogConfig.DEBUG_SCROLL) {
263                     Log.d(TAG, "Empty: cleared, switch direction to finish");
264                 }
265             }
266         } else {
267             // Got a non-empty result, but may already have enough bitmap data now
268             int expectedTiles = mImageTileSet.size() + 1;
269             if (expectedTiles >= mSession.getMaxTiles()) {
270                 if (LogConfig.DEBUG_SCROLL) {
271                     Log.d(TAG, "Hit max tiles: finished");
272                 }
273                 // If we ever hit the max tiles, we've got enough bitmap data to finish
274                 // (even if we weren't sure we'd finish on this pass).
275                 finishCapture();
276                 return;
277             } else {
278                 if (mScrollingUp && !mFinishOnBoundary) {
279                     // During the initial scroll up, we only want to acquire the portion described
280                     // by IDEAL_PORTION_ABOVE.
281                     if (mImageTileSet.getHeight() + result.captured.height()
282                             >= mSession.getTargetHeight() * IDEAL_PORTION_ABOVE) {
283                         if (LogConfig.DEBUG_SCROLL) {
284                             Log.d(TAG, "Hit ideal portion above: clear and switch direction");
285                         }
286                         // We got enough above the start point, now see how far down it can go.
287                         mImageTileSet.clear();
288                         mScrollingUp = false;
289                     }
290                 }
291             }
292         }
293 
294         if (!emptyResult) {
295             mImageTileSet.addTile(new ImageTile(result.image, result.captured));
296         }
297         if (LogConfig.DEBUG_SCROLL) {
298             Log.d(TAG, "bounds: " + mImageTileSet.getLeft() + "," + mImageTileSet.getTop()
299                     + " - " + mImageTileSet.getRight() + "," + mImageTileSet.getBottom()
300                     + " (" + mImageTileSet.getWidth() + "x" + mImageTileSet.getHeight() + ")");
301         }
302 
303         Rect gapBounds = mImageTileSet.getGaps();
304         if (!gapBounds.isEmpty()) {
305             if (LogConfig.DEBUG_SCROLL) {
306                 Log.d(TAG, "Found gaps in tileset: " + gapBounds + ", requesting " + gapBounds.top);
307             }
308             requestNextTile(gapBounds.top);
309             return;
310         }
311 
312         if (mImageTileSet.getHeight() >= mSession.getTargetHeight()) {
313             if (LogConfig.DEBUG_SCROLL) {
314                 Log.d(TAG, "Target height reached.");
315             }
316             finishCapture();
317             return;
318         }
319 
320         int nextTop;
321         if (emptyResult) {
322             // An empty result caused the direction the flip,
323             // so use the requested edges to determine the next top.
324             nextTop = (mScrollingUp)
325                     ? result.requested.top - mSession.getTileHeight()
326                     : result.requested.bottom;
327         } else {
328             nextTop = (mScrollingUp)
329                     ? mImageTileSet.getTop() - mSession.getTileHeight()
330                     : mImageTileSet.getBottom();
331         }
332         requestNextTile(nextTop);
333     }
334 
finishCapture()335     private void finishCapture() {
336         if (LogConfig.DEBUG_SCROLL) {
337             Log.d(TAG, "finishCapture()");
338         }
339         if (mImageTileSet.getHeight() > 0) {
340             mEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_COMPLETED, 0, mWindowOwner);
341         } else {
342             mEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_FAILURE, 0, mWindowOwner);
343         }
344         mEndFuture = mSession.end();
345         mEndFuture.addListener(() -> {
346             if (LogConfig.DEBUG_SCROLL) {
347                 Log.d(TAG, "endCapture completed");
348             }
349             // Provide result to caller and complete the top-level future
350             // Caller is responsible for releasing this resource (ImageReader/HardwareBuffers)
351             mCaptureCompleter.set(new LongScreenshot(mSession, mImageTileSet));
352         }, mContext.getMainExecutor());
353     }
354 }
355