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