1 /*
2  * Copyright (C) 2024 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.scroll;
18 
19 import static android.util.MathUtils.constrain;
20 
21 import static com.google.common.util.concurrent.Futures.immediateFuture;
22 
23 import static org.mockito.Mockito.mock;
24 import static org.mockito.Mockito.when;
25 
26 import static java.lang.Math.abs;
27 import static java.lang.Math.max;
28 import static java.lang.Math.min;
29 
30 import android.graphics.Rect;
31 import android.hardware.HardwareBuffer;
32 import android.media.Image;
33 import android.util.Log;
34 
35 import com.google.common.util.concurrent.Futures;
36 import com.google.common.util.concurrent.ListenableFuture;
37 
38 /**
39  * A flexible test double for {@link ScrollCaptureClient.Session}.
40  * <p>
41  * FakeSession provides the ability to emulate both the available scrollable content range as well
42  * as the current visible bounds. Visible bounds may vary because the target view itself may be
43  * slid vertically during capture, with portions may become clipped by parent views. This scenario
44  * frequently occurs with UIs constructed from nested scrolling views or collapsing headers.
45  */
46 class FakeSession implements ScrollCaptureClient.Session {
47     private static final String TAG = "FakeSession";
48     // Available range of content
49     private final Rect mAvailable;
50 
51     /** bounds for scrollDelta (y), range with bottom adjusted to account for page height. */
52     private final Rect mAvailableTop;
53 
54     private final Rect mVisiblePage;
55     private final int mTileHeight;
56     private final int mMaxTiles;
57 
58     private int mScrollDelta;
59     private int mPageHeight;
60     private int mTargetHeight;
61 
FakeSession(int pageHeight, float maxPages, int tileHeight, int visiblePageTop, int visiblePageBottom, int availableTop, int availableBottom, int maxTiles)62     FakeSession(int pageHeight, float maxPages, int tileHeight, int visiblePageTop,
63             int visiblePageBottom, int availableTop, int availableBottom,
64             int maxTiles) {
65         mPageHeight = pageHeight;
66         mTileHeight = tileHeight;
67         mAvailable = new Rect(0, availableTop, getPageWidth(), availableBottom);
68         mAvailableTop = new Rect(mAvailable);
69         mAvailableTop.inset(0, 0, 0, pageHeight);
70         mVisiblePage = new Rect(0, visiblePageTop, getPageWidth(), visiblePageBottom);
71         mTargetHeight = (int) (pageHeight * maxPages);
72         mMaxTiles = maxTiles;
73     }
74 
mockImage()75     private static Image mockImage() {
76         Image image = mock(Image.class);
77         when(image.getHardwareBuffer()).thenReturn(mock(HardwareBuffer.class));
78         return image;
79     }
80 
getScrollDelta()81     public int getScrollDelta() {
82         return mScrollDelta;
83     }
84 
85     @Override
requestTile(int requestedTop)86     public ListenableFuture<ScrollCaptureClient.CaptureResult> requestTile(int requestedTop) {
87         Rect requested = new Rect(0, requestedTop, getPageWidth(), requestedTop + getTileHeight());
88         Log.d(TAG, "requested: " + requested);
89         Rect page = new Rect(0, 0, getPageWidth(), mPageHeight);
90         page.offset(0, mScrollDelta);
91         Log.d(TAG, "page: " + page);
92         // Simulate behavior from lower levels by replicating 'requestChildRectangleOnScreen'
93         if (!page.contains(requested)) {
94             Log.d(TAG, "requested not within page, scrolling");
95             // distance+direction needed to scroll to align each edge of request with
96             // corresponding edge of the page
97             int distTop = requested.top - page.top; // positive means already visible
98             int distBottom = requested.bottom - page.bottom; // negative means already visible
99             Log.d(TAG, "distTop = " + distTop);
100             Log.d(TAG, "distBottom = " + distBottom);
101 
102             boolean scrollUp = false;
103             if (distTop < 0  && distBottom > 0) {
104                 scrollUp = abs(distTop) < distBottom;
105             } else if (distTop < 0) {
106                 scrollUp = true;
107             }
108 
109             // determine which edges are currently clipped
110             if (scrollUp) {
111                 Log.d(TAG, "trying to scroll up by " + -distTop + " px");
112                 // need to scroll up to align top edge to visible-top
113                 mScrollDelta += distTop;
114                 Log.d(TAG, "new scrollDelta = " + mScrollDelta);
115             } else {
116                 Log.d(TAG, "trying to scroll down by " + distBottom + " px");
117                 // scroll down to align bottom edge with visible bottom, but keep top visible
118                 int topEdgeDistance = max(0, requestedTop - page.top);
119                 mScrollDelta += min(distBottom, topEdgeDistance);
120                 Log.d(TAG, "new scrollDelta = " + mScrollDelta);
121             }
122 
123             // Clamp to available content
124             mScrollDelta = constrain(mScrollDelta, mAvailableTop.top, mAvailableTop.bottom);
125             Log.d(TAG, "scrollDelta, adjusted to available range = " + mScrollDelta);
126 
127             // Reset to apply a changed scroll delta possibly.
128             page.offsetTo(0, 0);
129             page.offset(0, mScrollDelta);
130 
131             Log.d(TAG, "page (after scroll): " + page);
132             Log.d(TAG, "requested (after scroll): " + requested);
133         }
134         Log.d(TAG, "mVisiblePage = " + mVisiblePage);
135         Log.d(TAG, "scrollDelta = " + mScrollDelta);
136 
137         Rect target = new Rect(requested);
138         Rect visible = new Rect(mVisiblePage);
139         visible.offset(0, mScrollDelta);
140 
141         Log.d(TAG, "target:  " + target);
142         Log.d(TAG, "visible:  " + visible);
143 
144         // if any of the requested rect is available to scroll into the view:
145         if (target.intersect(page) && target.intersect(visible)) {
146             Log.d(TAG, "returning captured = " + target);
147             ScrollCaptureClient.CaptureResult result =
148                     new ScrollCaptureClient.CaptureResult(mockImage(), requested, target);
149             return immediateFuture(result);
150         }
151         Log.d(TAG, "no part of requested rect is within page, returning empty");
152         ScrollCaptureClient.CaptureResult result =
153                 new ScrollCaptureClient.CaptureResult(null, requested, new Rect());
154         return immediateFuture(result);
155     }
156 
157 
158     @Override
159     public int getMaxTiles() {
160         return mMaxTiles;
161     }
162 
163     @Override
164     public int getTargetHeight() {
165         return mTargetHeight;
166     }
167 
168     @Override
169     public int getTileHeight() {
170         return mTileHeight;
171     }
172 
173     @Override
174     public int getPageWidth() {
175         return 100;
176     }
177 
178     @Override
179     public int getPageHeight() {
180         return mPageHeight;
181     }
182 
183     @Override
184     public Rect getWindowBounds() {
185         throw new IllegalStateException("Not implemented");
186     }
187 
188     @Override
189     public ListenableFuture<Void> end() {
190         return Futures.immediateVoidFuture();
191     }
192 
193     @Override
194     public void release() {
195     }
196 }
197