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