Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit b42ce666 authored by Matt Casey's avatar Matt Casey
Browse files

Properly handle empty response rect in ScrollCaptureController

Add tests for ScrollCaptureController for this bug and other typical
flows.

Bug: 182926096
Test: atest ScrollCaptureControllerTest
Change-Id: Ica2104c7c98b99b9322a6efe6bbc5b33c01d8b86
parent 82769e00
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -34,6 +34,8 @@ import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import javax.inject.Inject;

/**
 * Owns a series of partial screen captures (tiles).
 * <p>
@@ -47,6 +49,7 @@ class ImageTileSet {
    private CallbackRegistry<OnBoundsChangedListener, ImageTileSet, Rect> mOnBoundsListeners;
    private CallbackRegistry<OnContentChangedListener, ImageTileSet, Rect> mContentListeners;

    @Inject
    ImageTileSet(@UiThread Handler handler) {
        mHandler = handler;
    }
+2 −4
Original line number Diff line number Diff line
@@ -29,11 +29,9 @@ import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.RemoteException;
import android.os.UserHandle;
import android.text.TextUtils;
import android.util.Log;
import android.view.IScrollCaptureConnection;
import android.view.IWindowManager;
import android.view.ScrollCaptureResponse;
import android.view.View;
@@ -101,12 +99,12 @@ public class LongScreenshotActivity extends Activity {
    @Inject
    public LongScreenshotActivity(UiEventLogger uiEventLogger, ImageExporter imageExporter,
            @Main Executor mainExecutor, @Background Executor bgExecutor, IWindowManager wms,
            Context context) {
            Context context, ScrollCaptureController scrollCaptureController) {
        mUiEventLogger = uiEventLogger;
        mUiExecutor = mainExecutor;
        mBackgroundExecutor = bgExecutor;
        mImageExporter = imageExporter;
        mScrollCaptureController = new ScrollCaptureController(context, bgExecutor, wms);
        mScrollCaptureController = scrollCaptureController;
    }


+12 −9
Original line number Diff line number Diff line
@@ -18,19 +18,16 @@ package com.android.systemui.screenshot;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.HardwareRenderer;
import android.graphics.RecordingCanvas;
import android.graphics.Rect;
import android.graphics.RenderNode;
import android.graphics.drawable.Drawable;
import android.provider.Settings;
import android.util.Log;
import android.view.IWindowManager;
import android.view.ScrollCaptureResponse;

import androidx.concurrent.futures.CallbackToFutureAdapter;
import androidx.concurrent.futures.CallbackToFutureAdapter.Completer;

import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.screenshot.ScrollCaptureClient.CaptureResult;
import com.android.systemui.screenshot.ScrollCaptureClient.Session;

@@ -39,6 +36,8 @@ 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.
 */
@@ -131,11 +130,13 @@ public class ScrollCaptureController {
        }
    }

    ScrollCaptureController(Context context, Executor bgExecutor, IWindowManager wms) {
    @Inject
    ScrollCaptureController(Context context, @Background Executor bgExecutor,
            ScrollCaptureClient client, ImageTileSet imageTileSet) {
        mContext = context;
        mBgExecutor = bgExecutor;
        mImageTileSet = new ImageTileSet(context.getMainThreadHandler());
        mClient = new ScrollCaptureClient(mContext, wms);
        mClient = client;
        mImageTileSet = imageTileSet;
    }

    /**
@@ -252,8 +253,10 @@ public class ScrollCaptureController {
            return;
        }

        int nextTop = (mScrollingUp)
                ? result.captured.top - mSession.getTileHeight() : result.captured.bottom;
        // Partial or empty results caused the direction the flip, so we can reliably use the
        // requested edges to determine the next top.
        int nextTop = (mScrollingUp) ? result.requested.top - mSession.getTileHeight()
                : result.requested.bottom;
        requestNextTile(nextTop);
    }

+216 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2021 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 static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyFloat;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import android.content.Context;
import android.graphics.Rect;
import android.hardware.HardwareBuffer;
import android.media.Image;
import android.testing.AndroidTestingRunner;
import android.view.ScrollCaptureResponse;

import androidx.test.filters.SmallTest;
import androidx.test.platform.app.InstrumentationRegistry;

import com.android.systemui.SysuiTestCase;

import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.util.concurrent.ExecutionException;

/**
 * Tests for ScrollCaptureController which manages sequential image acquisition for long
 * screenshots.
 */
@SmallTest
@RunWith(AndroidTestingRunner.class)
public class ScrollCaptureControllerTest extends SysuiTestCase {

    private static class FakeSession implements ScrollCaptureClient.Session {
        public int availableTop = Integer.MIN_VALUE;
        public int availableBottom = Integer.MAX_VALUE;
        // If true, return an empty rect any time a partial result would have been returned.
        public boolean emptyInsteadOfPartial = false;

        @Override
        public ListenableFuture<ScrollCaptureClient.CaptureResult> requestTile(int top) {
            Rect requested = new Rect(0, top, getPageWidth(), top + getTileHeight());
            Rect fullContent = new Rect(0, availableTop, getPageWidth(), availableBottom);
            Rect captured = new Rect(requested);
            captured.intersect(fullContent);
            if (emptyInsteadOfPartial && captured.height() != getTileHeight()) {
                captured = new Rect();
            }
            Image image = mock(Image.class);
            when(image.getHardwareBuffer()).thenReturn(mock(HardwareBuffer.class));
            ScrollCaptureClient.CaptureResult result =
                    new ScrollCaptureClient.CaptureResult(image, requested, captured);
            return Futures.immediateFuture(result);
        }

        public int getMaxHeight() {
            return getTileHeight() * getMaxTiles();
        }

        @Override
        public int getMaxTiles() {
            return 10;
        }

        @Override
        public int getTileHeight() {
            return 50;
        }

        @Override
        public int getPageHeight() {
            return 100;
        }

        @Override
        public int getPageWidth() {
            return 100;
        }

        @Override
        public Rect getWindowBounds() {
            return null;
        }

        @Override
        public ListenableFuture<Void> end() {
            return Futures.immediateVoidFuture();
        }

        @Override
        public void release() {
        }
    }

    private ScrollCaptureController mController;
    private FakeSession mSession;
    private ScrollCaptureClient mScrollCaptureClient;

    @Before
    public void setUp() {
        Context context = InstrumentationRegistry.getInstrumentation().getContext();
        mSession = new FakeSession();
        mScrollCaptureClient = mock(ScrollCaptureClient.class);
        when(mScrollCaptureClient.request(anyInt(), anyInt())).thenReturn(
                Futures.immediateFuture(new ScrollCaptureResponse.Builder().build()));
        when(mScrollCaptureClient.start(any(), anyFloat())).thenReturn(
                Futures.immediateFuture(mSession));
        mController = new ScrollCaptureController(context, context.getMainExecutor(),
                mScrollCaptureClient, new ImageTileSet(context.getMainThreadHandler()));
    }

    @Test
    public void testInfinite() throws ExecutionException, InterruptedException {
        ScrollCaptureController.LongScreenshot screenshot =
                mController.run(new ScrollCaptureResponse.Builder().build()).get();
        assertEquals(mSession.getMaxHeight(), screenshot.getHeight());
        // TODO: the top and bottom ratio in the infinite case should be extracted and tested.
        assertEquals(-150, screenshot.getTop());
        assertEquals(350, screenshot.getBottom());
    }

    @Test
    public void testLimitedBottom() throws ExecutionException, InterruptedException {
        // We hit the bottom of the content, so expect it to scroll back up and go above the -150
        // default top position
        mSession.availableBottom = 275;
        ScrollCaptureController.LongScreenshot screenshot =
                mController.run(new ScrollCaptureResponse.Builder().build()).get();
        // Bottom tile will be 25px tall, 10 tiles total
        assertEquals(mSession.getMaxHeight() - 25, screenshot.getHeight());
        assertEquals(-200, screenshot.getTop());
        assertEquals(mSession.availableBottom, screenshot.getBottom());
    }

    @Test
    public void testLimitedTopAndBottom() throws ExecutionException, InterruptedException {
        mSession.availableBottom = 275;
        mSession.availableTop = -200;
        ScrollCaptureController.LongScreenshot screenshot =
                mController.run(new ScrollCaptureResponse.Builder().build()).get();
        assertEquals(mSession.availableBottom - mSession.availableTop, screenshot.getHeight());
        assertEquals(mSession.availableTop, screenshot.getTop());
        assertEquals(mSession.availableBottom, screenshot.getBottom());
    }

    @Test
    public void testVeryLimitedTopInfiniteBottom() throws ExecutionException, InterruptedException {
        // Hit the boundary before the "headroom" is hit in the up direction, then go down
        // infinitely.
        mSession.availableTop = -55;
        ScrollCaptureController.LongScreenshot screenshot =
                mController.run(new ScrollCaptureResponse.Builder().build()).get();
        // The top tile will be 5px tall, so subtract 45px from the theoretical max.
        assertEquals(mSession.getMaxHeight() - 45, screenshot.getHeight());
        assertEquals(mSession.availableTop, screenshot.getTop());
        assertEquals(mSession.availableTop + mSession.getMaxHeight() - 45, screenshot.getBottom());
    }

    @Test
    public void testVeryLimitedTopLimitedBottom() throws ExecutionException, InterruptedException {
        mSession.availableBottom = 275;
        mSession.availableTop = -55;
        ScrollCaptureController.LongScreenshot screenshot =
                mController.run(new ScrollCaptureResponse.Builder().build()).get();
        assertEquals(mSession.availableBottom - mSession.availableTop, screenshot.getHeight());
        assertEquals(mSession.availableTop, screenshot.getTop());
        assertEquals(mSession.availableBottom, screenshot.getBottom());
    }

    @Test
    public void testLimitedTopAndBottomWithEmpty() throws ExecutionException, InterruptedException {
        mSession.emptyInsteadOfPartial = true;
        mSession.availableBottom = 275;
        mSession.availableTop = -167;
        ScrollCaptureController.LongScreenshot screenshot =
                mController.run(new ScrollCaptureResponse.Builder().build()).get();
        // Expecting output from -150 to 250
        assertEquals(400, screenshot.getHeight());
        assertEquals(-150, screenshot.getTop());
        assertEquals(250, screenshot.getBottom());
    }

    @Test
    public void testVeryLimitedTopWithEmpty() throws ExecutionException, InterruptedException {
        // Hit the boundary before the "headroom" is hit in the up direction, then go down
        // infinitely.
        mSession.availableTop = -55;
        mSession.emptyInsteadOfPartial = true;
        ScrollCaptureController.LongScreenshot screenshot =
                mController.run(new ScrollCaptureResponse.Builder().build()).get();
        assertEquals(mSession.getMaxHeight(), screenshot.getHeight());
        assertEquals(-50, screenshot.getTop());
        assertEquals(-50 + mSession.getMaxHeight(), screenshot.getBottom());
    }
}