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