1 /*
<lambda>null2  * Copyright 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 androidx.compose.ui.scrollcapture
18 
19 import android.graphics.Point
20 import android.view.ScrollCaptureCallback
21 import android.view.ScrollCaptureTarget
22 import android.view.View
23 import androidx.annotation.RequiresApi
24 import androidx.compose.runtime.collection.mutableVectorOf
25 import androidx.compose.runtime.getValue
26 import androidx.compose.runtime.mutableStateOf
27 import androidx.compose.runtime.setValue
28 import androidx.compose.ui.graphics.toAndroidRect
29 import androidx.compose.ui.internal.checkPreconditionNotNull
30 import androidx.compose.ui.layout.LayoutCoordinates
31 import androidx.compose.ui.layout.boundsInRoot
32 import androidx.compose.ui.layout.boundsInWindow
33 import androidx.compose.ui.semantics.SemanticsActions.ScrollByOffset
34 import androidx.compose.ui.semantics.SemanticsNode
35 import androidx.compose.ui.semantics.SemanticsOwner
36 import androidx.compose.ui.semantics.SemanticsProperties.Disabled
37 import androidx.compose.ui.semantics.SemanticsProperties.VerticalScrollAxisRange
38 import androidx.compose.ui.semantics.getOrNull
39 import androidx.compose.ui.semantics.isHidden
40 import androidx.compose.ui.unit.IntRect
41 import androidx.compose.ui.unit.roundToIntRect
42 import java.util.function.Consumer
43 import kotlin.coroutines.CoroutineContext
44 import kotlinx.coroutines.CoroutineScope
45 
46 /** Separate class to host the implementation of scroll capture for dex verification. */
47 @RequiresApi(31)
48 internal class ScrollCapture : ComposeScrollCaptureCallback.ScrollCaptureSessionListener {
49 
50     var scrollCaptureInProgress: Boolean by mutableStateOf(false)
51         private set
52 
53     /**
54      * Implements scroll capture (long screenshots) support for a composition. Finds a single
55      * [ScrollCaptureTarget] to propose to the platform. Searches over the semantics tree to find
56      * nodes that publish vertical scroll semantics (namely [ScrollByOffset] and
57      * [VerticalScrollAxisRange]) and then uses logic similar to how the platform searches [View]
58      * targets to select the deepest, largest scroll container. If a target is found, an
59      * implementation of [ScrollCaptureCallback] is created for it (see
60      * [ComposeScrollCaptureCallback]) and given to the platform.
61      *
62      * The platform currently only supports scroll capture for containers that scroll vertically.
63      * The API supports horizontal as well, but it's not used. To keep this code simpler and avoid
64      * having dead code, we only implement vertical scroll capture as well.
65      *
66      * See go/compose-long-screenshots for more background.
67      */
68     // Required not to be inlined for class verification.
69     fun onScrollCaptureSearch(
70         view: View,
71         semanticsOwner: SemanticsOwner,
72         coroutineContext: CoroutineContext,
73         targets: Consumer<ScrollCaptureTarget>
74     ) {
75         // Search the semantics tree for scroll containers.
76         val candidates = mutableVectorOf<ScrollCaptureCandidate>()
77         visitScrollCaptureCandidates(
78             fromNode = semanticsOwner.unmergedRootSemanticsNode,
79             onCandidate = candidates::add
80         )
81 
82         // Sort to find the deepest node with the biggest bounds in the dimension(s) that the node
83         // supports scrolling in.
84         candidates.sortWith(
85             compareBy(
86                 { it.depth },
87                 { it.viewportBoundsInWindow.height },
88             )
89         )
90         val candidate = candidates.lastOrNull() ?: return
91 
92         // If we found a candidate, create a capture callback for it and give it to the system.
93         val coroutineScope = CoroutineScope(coroutineContext)
94         val callback =
95             ComposeScrollCaptureCallback(
96                 node = candidate.node,
97                 viewportBoundsInWindow = candidate.viewportBoundsInWindow,
98                 coroutineScope = coroutineScope,
99                 listener = this,
100                 view
101             )
102         val localVisibleRectOfCandidate = candidate.coordinates.boundsInRoot()
103         val windowOffsetOfCandidate = candidate.viewportBoundsInWindow.topLeft
104         targets.accept(
105             ScrollCaptureTarget(
106                     view,
107                     localVisibleRectOfCandidate.roundToIntRect().toAndroidRect(),
108                     windowOffsetOfCandidate.let { Point(it.x, it.y) },
109                     callback
110                 )
111                 .apply { scrollBounds = candidate.viewportBoundsInWindow.toAndroidRect() }
112         )
113     }
114 
115     override fun onSessionStarted() {
116         scrollCaptureInProgress = true
117     }
118 
119     override fun onSessionEnded() {
120         scrollCaptureInProgress = false
121     }
122 }
123 
124 /**
125  * Walks the tree of [SemanticsNode]s rooted at [fromNode] to find nodes that look scrollable and
126  * calculate their nesting depth.
127  */
visitScrollCaptureCandidatesnull128 private fun visitScrollCaptureCandidates(
129     fromNode: SemanticsNode,
130     depth: Int = 0,
131     onCandidate: (ScrollCaptureCandidate) -> Unit
132 ) {
133     fromNode.visitDescendants { node ->
134         // TODO(mnuzen): Verify `isHidden` is needed here.
135         //  See b/354723415 for more details.
136         // Transparent, unimportant for accessibility, and disabled nodes can't be candidates, nor
137         // can any of their descendants.
138         if (node.isHidden || Disabled in node.unmergedConfig) {
139             return@visitDescendants false
140         }
141 
142         val nodeCoordinates =
143             checkPreconditionNotNull(node.findCoordinatorToGetBounds()) {
144                     "Expected semantics node to have a coordinator."
145                 }
146                 .coordinates
147 
148         // Zero-sized nodes can't be candidates, and by definition would clip all their children so
149         // they and their descendants can't be candidates either.
150         val viewportBoundsInWindow = nodeCoordinates.boundsInWindow().roundToIntRect()
151         if (viewportBoundsInWindow.isEmpty) {
152             return@visitDescendants false
153         }
154 
155         // If the node is visible, we need to check if it's scrollable.
156         // TODO(b/329295945) Support explicit opt-in/-out.
157         // Don't care about horizontal scroll containers.
158         if (!node.canScrollVertically) {
159             // Not a scrollable, so can't be a candidate, but its descendants might be.
160             return@visitDescendants true
161         }
162 
163         // We found a node that looks scrollable! Report it, then visit its children with an
164         // incremented depth counter.
165         val candidateDepth = depth + 1
166         onCandidate(
167             ScrollCaptureCandidate(
168                 node = node,
169                 depth = candidateDepth,
170                 viewportBoundsInWindow = viewportBoundsInWindow,
171                 coordinates = nodeCoordinates,
172             )
173         )
174         visitScrollCaptureCandidates(
175             fromNode = node,
176             depth = candidateDepth,
177             onCandidate = onCandidate
178         )
179         // We've just visited descendants ourselves, don't need this visit call to do it.
180         return@visitDescendants false
181     }
182 }
183 
184 internal val SemanticsNode.scrollCaptureScrollByAction
185     get() = unmergedConfig.getOrNull(ScrollByOffset)
186 
187 private val SemanticsNode.canScrollVertically: Boolean
188     get() {
189         val scrollByOffset = scrollCaptureScrollByAction
190         val verticalScrollAxisRange = unmergedConfig.getOrNull(VerticalScrollAxisRange)
191         return scrollByOffset != null &&
192             verticalScrollAxisRange != null &&
193             verticalScrollAxisRange.maxValue() > 0f
194     }
195 
196 /**
197  * Visits all the descendants of this [SemanticsNode].
198  *
199  * @param onNode Function called for each [SemanticsNode]. Iff this function returns true, the
200  *   children of the current node will be visited.
201  */
visitDescendantsnull202 private inline fun SemanticsNode.visitDescendants(onNode: (SemanticsNode) -> Boolean) {
203     val nodes = mutableVectorOf<SemanticsNode>()
204     nodes.addAll(getChildrenForSearch())
205     while (nodes.isNotEmpty()) {
206         val node = nodes.removeAt(nodes.lastIndex)
207         val visitChildren = onNode(node)
208         if (visitChildren) {
209             nodes.addAll(node.getChildrenForSearch())
210         }
211     }
212 }
213 
SemanticsNodenull214 private fun SemanticsNode.getChildrenForSearch() =
215     getChildren(
216         includeDeactivatedNodes = false,
217         includeReplacedSemantics = false,
218         includeFakeNodes = false
219     )
220 
221 /**
222  * Information about a potential [ScrollCaptureTarget] needed to both select the final candidate and
223  * create its [ComposeScrollCaptureCallback].
224  */
225 private class ScrollCaptureCandidate(
226     val node: SemanticsNode,
227     val depth: Int,
228     val viewportBoundsInWindow: IntRect,
229     val coordinates: LayoutCoordinates,
230 ) {
231     override fun toString(): String =
232         "ScrollCaptureCandidate(node=$node, " +
233             "depth=$depth, " +
234             "viewportBoundsInWindow=$viewportBoundsInWindow, " +
235             "coordinates=$coordinates)"
236 }
237