blob: ef7355a09fbf3ffff4b58e26f1cc10f177ae482b [file] [log] [blame]
/*
* Copyright (C) 2020 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.systemui.screenshot;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.provider.Settings;
import android.util.Log;
import android.view.ScrollCaptureResponse;
import androidx.concurrent.futures.CallbackToFutureAdapter;
import androidx.concurrent.futures.CallbackToFutureAdapter.Completer;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.UiEventLogger;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.screenshot.ScrollCaptureClient.CaptureResult;
import com.android.systemui.screenshot.ScrollCaptureClient.Session;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import javax.inject.Inject;
/**
* Interaction controller between the UI and ScrollCaptureClient.
*/
public class ScrollCaptureController {
private static final String TAG = LogConfig.logTag(ScrollCaptureController.class);
private static final float MAX_PAGES_DEFAULT = 3f;
private static final String SETTING_KEY_MAX_PAGES = "screenshot.scroll_max_pages";
// Portion of the tiles to be acquired above the starting position in infinite scroll
// situations. 1.0 means maximize the area above, 0 means just go down.
private static final float IDEAL_PORTION_ABOVE = 0.4f;
private boolean mScrollingUp = true;
// If true, stop acquiring images when no more bitmap data is available in the current direction
// or if the desired bitmap size is reached.
private boolean mFinishOnBoundary;
public static final int MAX_HEIGHT = 12000;
private final Context mContext;
private final Executor mBgExecutor;
private final ImageTileSet mImageTileSet;
private final UiEventLogger mEventLogger;
private final ScrollCaptureClient mClient;
private Completer<LongScreenshot> mCaptureCompleter;
private ListenableFuture<Session> mSessionFuture;
private Session mSession;
private ListenableFuture<CaptureResult> mTileFuture;
private ListenableFuture<Void> mEndFuture;
private String mWindowOwner;
static class LongScreenshot {
private final ImageTileSet mImageTileSet;
private final Session mSession;
LongScreenshot(Session session, ImageTileSet imageTileSet) {
mSession = session;
mImageTileSet = imageTileSet;
}
/** Returns a bitmap containing the combinded result. */
public Bitmap toBitmap() {
return mImageTileSet.toBitmap();
}
public Bitmap toBitmap(Rect bounds) {
return mImageTileSet.toBitmap(bounds);
}
/** Releases image resources from the screenshot. */
public void release() {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "LongScreenshot :: release()");
}
mImageTileSet.clear();
mSession.release();
}
public int getLeft() {
return mImageTileSet.getLeft();
}
public int getTop() {
return mImageTileSet.getTop();
}
public int getBottom() {
return mImageTileSet.getBottom();
}
public int getWidth() {
return mImageTileSet.getWidth();
}
public int getHeight() {
return mImageTileSet.getHeight();
}
/** @return the height of the visible area of the scrolling page, in pixels */
public int getPageHeight() {
return mSession.getPageHeight();
}
@Override
public String toString() {
return "LongScreenshot{w=" + mImageTileSet.getWidth()
+ ", h=" + mImageTileSet.getHeight() + "}";
}
public Drawable getDrawable() {
return mImageTileSet.getDrawable();
}
}
@Inject
ScrollCaptureController(Context context, @Background Executor bgExecutor,
ScrollCaptureClient client, ImageTileSet imageTileSet, UiEventLogger logger) {
mContext = context;
mBgExecutor = bgExecutor;
mClient = client;
mImageTileSet = imageTileSet;
mEventLogger = logger;
}
@VisibleForTesting
float getTargetTopSizeRatio() {
return IDEAL_PORTION_ABOVE;
}
/**
* Run scroll capture. Performs a batch capture, collecting image tiles.
*
* @param response a scroll capture response from a previous request which is
* {@link ScrollCaptureResponse#isConnected() connected}.
* @return a future ImageTile set containing the result
*/
ListenableFuture<LongScreenshot> run(ScrollCaptureResponse response) {
return CallbackToFutureAdapter.getFuture(completer -> {
mCaptureCompleter = completer;
mWindowOwner = response.getPackageName();
mBgExecutor.execute(() -> {
float maxPages = Settings.Secure.getFloat(mContext.getContentResolver(),
SETTING_KEY_MAX_PAGES, MAX_PAGES_DEFAULT);
mSessionFuture = mClient.start(response, maxPages);
mSessionFuture.addListener(this::onStartComplete, mContext.getMainExecutor());
});
return "<batch scroll capture>";
});
}
private void onStartComplete() {
try {
mSession = mSessionFuture.get();
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "got session " + mSession);
}
mEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_STARTED, 0, mWindowOwner);
requestNextTile(0);
} catch (InterruptedException | ExecutionException e) {
// Failure to start, propagate to caller
Log.e(TAG, "session start failed!");
mCaptureCompleter.setException(e);
mEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_FAILURE, 0, mWindowOwner);
}
}
private void requestNextTile(int topPx) {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "requestNextTile: " + topPx);
}
mTileFuture = mSession.requestTile(topPx);
mTileFuture.addListener(() -> {
try {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "onCaptureResult");
}
onCaptureResult(mTileFuture.get());
} catch (InterruptedException | ExecutionException e) {
Log.e(TAG, "requestTile failed!", e);
mCaptureCompleter.setException(e);
}
}, mContext.getMainExecutor());
}
private void onCaptureResult(CaptureResult result) {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "onCaptureResult: " + result + " scrolling " + (mScrollingUp ? "UP" : "DOWN")
+ " finish on boundary: " + mFinishOnBoundary);
}
boolean emptyResult = result.captured.height() == 0;
if (emptyResult) {
// Potentially reached a vertical boundary. Extend in the other direction.
if (mFinishOnBoundary) {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "Empty: finished!");
}
finishCapture();
return;
} else {
// We hit a boundary, clear the tiles, capture everything in the opposite direction,
// then finish.
mImageTileSet.clear();
mFinishOnBoundary = true;
mScrollingUp = !mScrollingUp;
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "Empty: cleared, switch direction to finish");
}
}
} else {
// Got a non-empty result, but may already have enough bitmap data now
int expectedTiles = mImageTileSet.size() + 1;
if (expectedTiles >= mSession.getMaxTiles()) {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "Hit max tiles: finished");
}
// If we ever hit the max tiles, we've got enough bitmap data to finish
// (even if we weren't sure we'd finish on this pass).
finishCapture();
return;
} else {
if (mScrollingUp && !mFinishOnBoundary) {
// During the initial scroll up, we only want to acquire the portion described
// by IDEAL_PORTION_ABOVE.
if (mImageTileSet.getHeight() + result.captured.height()
>= mSession.getTargetHeight() * IDEAL_PORTION_ABOVE) {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "Hit ideal portion above: clear and switch direction");
}
// We got enough above the start point, now see how far down it can go.
mImageTileSet.clear();
mScrollingUp = false;
}
}
}
}
if (!emptyResult) {
mImageTileSet.addTile(new ImageTile(result.image, result.captured));
}
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "bounds: " + mImageTileSet.getLeft() + "," + mImageTileSet.getTop()
+ " - " + mImageTileSet.getRight() + "," + mImageTileSet.getBottom()
+ " (" + mImageTileSet.getWidth() + "x" + mImageTileSet.getHeight() + ")");
}
Rect gapBounds = mImageTileSet.getGaps();
if (!gapBounds.isEmpty()) {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "Found gaps in tileset: " + gapBounds + ", requesting " + gapBounds.top);
}
requestNextTile(gapBounds.top);
return;
}
if (mImageTileSet.getHeight() >= mSession.getTargetHeight()) {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "Target height reached.");
}
finishCapture();
return;
}
int nextTop;
if (emptyResult) {
// An empty result caused the direction the flip,
// so use the requested edges to determine the next top.
nextTop = (mScrollingUp)
? result.requested.top - mSession.getTileHeight()
: result.requested.bottom;
} else {
nextTop = (mScrollingUp)
? mImageTileSet.getTop() - mSession.getTileHeight()
: mImageTileSet.getBottom();
}
requestNextTile(nextTop);
}
private void finishCapture() {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "finishCapture()");
}
if (mImageTileSet.getHeight() > 0) {
mEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_COMPLETED, 0, mWindowOwner);
} else {
mEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_FAILURE, 0, mWindowOwner);
}
mEndFuture = mSession.end();
mEndFuture.addListener(() -> {
if (LogConfig.DEBUG_SCROLL) {
Log.d(TAG, "endCapture completed");
}
// Provide result to caller and complete the top-level future
// Caller is responsible for releasing this resource (ImageReader/HardwareBuffers)
mCaptureCompleter.set(new LongScreenshot(mSession, mImageTileSet));
}, mContext.getMainExecutor());
}
}