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.contentcapture
18 
19 import android.os.Build
20 import android.os.Handler
21 import android.os.Looper
22 import android.util.LongSparseArray
23 import android.view.View
24 import android.view.translation.TranslationRequestValue
25 import android.view.translation.ViewTranslationRequest
26 import android.view.translation.ViewTranslationResponse
27 import androidx.annotation.RequiresApi
28 import androidx.annotation.VisibleForTesting
29 import androidx.collection.IntObjectMap
30 import androidx.collection.MutableIntObjectMap
31 import androidx.collection.intObjectMapOf
32 import androidx.collection.mutableIntObjectMapOf
33 import androidx.compose.ui.ExperimentalComposeUiApi
34 import androidx.compose.ui.internal.checkPreconditionNotNull
35 import androidx.compose.ui.platform.AndroidComposeView
36 import androidx.compose.ui.platform.SemanticsNodeCopy
37 import androidx.compose.ui.platform.coreshims.ContentCaptureSessionCompat
38 import androidx.compose.ui.platform.coreshims.ViewCompatShims
39 import androidx.compose.ui.platform.coreshims.ViewStructureCompat
40 import androidx.compose.ui.platform.getTextLayoutResult
41 import androidx.compose.ui.platform.toLegacyClassName
42 import androidx.compose.ui.semantics.SemanticsActions
43 import androidx.compose.ui.semantics.SemanticsNode
44 import androidx.compose.ui.semantics.SemanticsNodeWithAdjustedBounds
45 import androidx.compose.ui.semantics.SemanticsProperties
46 import androidx.compose.ui.semantics.getAllUncoveredSemanticsNodesToIntObjectMap
47 import androidx.compose.ui.semantics.getOrNull
48 import androidx.compose.ui.text.AnnotatedString
49 import androidx.compose.ui.util.fastForEach
50 import androidx.compose.ui.util.fastJoinToString
51 import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
52 import androidx.lifecycle.DefaultLifecycleObserver
53 import androidx.lifecycle.LifecycleOwner
54 import java.util.function.Consumer
55 import kotlinx.coroutines.channels.Channel
56 import kotlinx.coroutines.delay
57 
58 // TODO(b/272068594): Fix the primitive usage after completing the semantics refactor.
59 // TODO(b/318748747): Add an interface for ContentCaptureManager to the common module, and then this
60 //  would be the AndroidImplementation. When we create a LocalContentCaptureManager in the future,
61 //  we would expose the interface but not this implementation.
62 @OptIn(ExperimentalComposeUiApi::class)
63 @Suppress("NullAnnotationGroup")
64 internal class AndroidContentCaptureManager(
65     val view: AndroidComposeView,
66     var onContentCaptureSession: () -> ContentCaptureSessionCompat?
67 ) : ContentCaptureManager, DefaultLifecycleObserver, View.OnAttachStateChangeListener {
68 
69     @VisibleForTesting internal var contentCaptureSession: ContentCaptureSessionCompat? = null
70 
71     /** An ordered list of buffered content capture events. */
72     private val bufferedEvents = mutableListOf<ContentCaptureEvent>()
73 
74     /**
75      * Delay before dispatching a recurring accessibility event in milliseconds. This delay
76      * guarantees that a recurring event will be send at most once during the
77      * [SendRecurringContentCaptureEventsIntervalMillis] time frame.
78      */
79     private var SendRecurringContentCaptureEventsIntervalMillis = 100L
80 
81     /**
82      * Indicates whether the translated information is show or hide in the [AndroidComposeView].
83      *
84      * See
85      * [ViewTranslationCallback](https://cs.android.com/android/platform/superproject/+/refs/heads/master:frameworks/base/core/java/android/view/translation/ViewTranslationCallback.java)
86      * for more details of the View translation API.
87      */
88     private enum class TranslateStatus {
89         SHOW_ORIGINAL,
90         SHOW_TRANSLATED
91     }
92 
93     private var translateStatus = TranslateStatus.SHOW_ORIGINAL
94 
95     private var currentSemanticsNodesInvalidated = true
96     private val boundsUpdateChannel = Channel<Unit>(1)
97     internal val handler = Handler(Looper.getMainLooper())
98 
99     /**
100      * Up to date semantics nodes in pruned semantics tree. It always reflects the current semantics
101      * tree. They key is the virtual view id(the root node has a key of
102      * AccessibilityNodeProviderCompat.HOST_VIEW_ID and other node has a key of its id).
103      */
104     internal var currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds> =
105         intObjectMapOf()
106         get() {
107             if (currentSemanticsNodesInvalidated) { // first instance of retrieving all nodes
108                 currentSemanticsNodesInvalidated = false
109                 field =
110                     view.semanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap(
111                         customRootNodeId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
112                     )
113                 currentSemanticsNodesSnapshotTimestampMillis = System.currentTimeMillis()
114             }
115             return field
116         }
117 
118     private var currentSemanticsNodesSnapshotTimestampMillis = 0L
119 
120     // previousSemanticsNodes holds the previous pruned semantics tree so that we can compare the
121     // current and previous trees in onSemanticsChange(). We use SemanticsNodeCopy here because
122     // SemanticsNode's children are dynamically generated and always reflect the current children.
123     // We need to keep a copy of its old structure for comparison.
124     private var previousSemanticsNodes: MutableIntObjectMap<SemanticsNodeCopy> =
125         mutableIntObjectMapOf()
126     private var previousSemanticsRoot =
127         SemanticsNodeCopy(view.semanticsOwner.unmergedRootSemanticsNode, intObjectMapOf())
128     private var checkingForSemanticsChanges = false
129 
130     private val contentCaptureChangeChecker = Runnable {
131         if (!isEnabled) return@Runnable
132 
133         // TODO(mnuzen): there might be a case where `view.measureAndLayout()` is called twice --
134         // once by the CC checker and once by the a11y checker.
135         view.measureAndLayout()
136 
137         // Semantics structural change
138         // Always send disappear event first.
139         sendContentCaptureDisappearEvents()
140         sendContentCaptureAppearEvents(
141             view.semanticsOwner.unmergedRootSemanticsNode,
142             previousSemanticsRoot
143         )
144 
145         // Property change
146         checkForContentCapturePropertyChanges(currentSemanticsNodes)
147         updateSemanticsCopy()
148 
149         checkingForSemanticsChanges = false
150     }
151 
152     override fun onViewAttachedToWindow(v: View) {}
153 
154     override fun onViewDetachedFromWindow(v: View) {
155         handler.removeCallbacks(contentCaptureChangeChecker)
156         contentCaptureSession = null
157     }
158 
159     /** True if any content capture service enabled in the system. */
160     internal val isEnabled: Boolean
161         get() = ContentCaptureManager.isEnabled && contentCaptureSession != null
162 
163     override fun onStart(owner: LifecycleOwner) {
164         contentCaptureSession = onContentCaptureSession()
165         updateBuffersOnAppeared(index = -1, view.semanticsOwner.unmergedRootSemanticsNode)
166         notifyContentCaptureChanges()
167     }
168 
169     override fun onStop(owner: LifecycleOwner) {
170         updateBuffersOnDisappeared(view.semanticsOwner.unmergedRootSemanticsNode)
171         notifyContentCaptureChanges()
172         contentCaptureSession = null
173     }
174 
175     /**
176      * This suspend function loops for the entire lifetime of the Compose instance: it consumes
177      * recent layout changes and sends events to the accessibility and content capture framework in
178      * batches separated by a 100ms delay.
179      */
180     internal suspend fun boundsUpdatesEventLoop() {
181         for (notification in boundsUpdateChannel) {
182             if (isEnabled) {
183                 notifyContentCaptureChanges()
184             }
185             if (!checkingForSemanticsChanges) {
186                 checkingForSemanticsChanges = true
187                 handler.post(contentCaptureChangeChecker)
188             }
189 
190             delay(SendRecurringContentCaptureEventsIntervalMillis)
191         }
192     }
193 
194     internal fun onSemanticsChange() {
195         // When content capture is turned off, we still want to keep
196         // currentSemanticsNodesInvalidated up to date so that when content capture is turned on
197         // later, we can refresh currentSemanticsNodes if currentSemanticsNodes is stale.
198         currentSemanticsNodesInvalidated = true
199 
200         if (isEnabled && !checkingForSemanticsChanges) {
201             checkingForSemanticsChanges = true
202 
203             handler.post(contentCaptureChangeChecker)
204         }
205     }
206 
207     internal fun onLayoutChange() {
208         // When content capture is turned off, we still want to keep
209         // currentSemanticsNodesInvalidated up to date so that when content capture is turned on
210         // later, we can refresh currentSemanticsNodes if currentSemanticsNodes is stale.
211         currentSemanticsNodesInvalidated = true
212 
213         // The layout change of a LayoutNode will also affect its children, so even if it doesn't
214         // have semantics attached, we should process it.
215         if (isEnabled) notifySubtreeStateChangeIfNeeded()
216     }
217 
218     private fun sendContentCaptureDisappearEvents() {
219         previousSemanticsNodes.forEachKey { key ->
220             if (!currentSemanticsNodes.contains(key)) {
221                 bufferContentCaptureViewDisappeared(key)
222                 notifySubtreeStateChangeIfNeeded()
223             }
224         }
225     }
226 
227     private fun sendContentCaptureAppearEvents(newNode: SemanticsNode, oldNode: SemanticsNodeCopy) {
228         // Iterate the new tree to notify content capture appear
229         newNode.fastForEachReplacedVisibleChildren { index, child ->
230             if (!oldNode.children.contains(child.id)) {
231                 updateBuffersOnAppeared(index, child)
232                 notifySubtreeStateChangeIfNeeded()
233             }
234         }
235 
236         newNode.replacedChildren.fastForEach { child ->
237             if (
238                 currentSemanticsNodes.contains(child.id) &&
239                     previousSemanticsNodes.contains(child.id)
240             ) {
241                 val prevNodeCopy =
242                     checkPreconditionNotNull(previousSemanticsNodes[child.id]) {
243                         "node not present in pruned tree before this change"
244                     }
245                 sendContentCaptureAppearEvents(child, prevNodeCopy)
246             }
247         }
248     }
249 
250     // Analogous to `sendSemanticsPropertyChangeEvents`
251     private fun checkForContentCapturePropertyChanges(
252         newSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>
253     ) {
254         newSemanticsNodes.forEachKey { id ->
255             // We do doing this search because the new configuration is set as a whole, so we
256             // can't indicate which property is changed when setting the new configuration.
257             val oldNode = previousSemanticsNodes[id]
258             val newNode =
259                 checkPreconditionNotNull(newSemanticsNodes[id]?.semanticsNode) {
260                     "no value for specified key"
261                 }
262 
263             // Content capture requires events to be sent when an item is added/removed.
264             if (oldNode == null) {
265                 newNode.unmergedConfig.props.forEachKey { key ->
266                     @Suppress("LABEL_NAME_CLASH")
267                     if (key != SemanticsProperties.Text) return@forEachKey
268                     val newText =
269                         newNode.unmergedConfig.getOrNull(SemanticsProperties.Text)?.firstOrNull()
270                     sendContentCaptureTextUpdateEvent(newNode.id, newText.toString())
271                 }
272                 return@forEachKey
273             }
274 
275             newNode.unmergedConfig.props.forEachKey { key ->
276                 when (key) {
277                     SemanticsProperties.Text -> {
278                         val oldText =
279                             oldNode.unmergedConfig
280                                 .getOrNull(SemanticsProperties.Text)
281                                 ?.firstOrNull()
282                         val newText =
283                             newNode.unmergedConfig
284                                 .getOrNull(SemanticsProperties.Text)
285                                 ?.firstOrNull()
286                         if (oldText != newText) {
287                             sendContentCaptureTextUpdateEvent(newNode.id, newText.toString())
288                         }
289                     }
290                 }
291             }
292         }
293     }
294 
295     private fun sendContentCaptureTextUpdateEvent(id: Int, newText: String) {
296         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
297             return
298         }
299         val session = contentCaptureSession ?: return
300         // TODO: consider having a `newContentCaptureId` function to improve readability.
301         val autofillId = session.newAutofillId(id.toLong())
302         checkPreconditionNotNull(autofillId) { "Invalid content capture ID" }
303         session.notifyViewTextChanged(autofillId, newText)
304     }
305 
306     private fun updateSemanticsCopy() {
307         previousSemanticsNodes.clear()
308 
309         currentSemanticsNodes.forEach { key, value ->
310             previousSemanticsNodes[key] =
311                 SemanticsNodeCopy(value.semanticsNode, currentSemanticsNodes)
312         }
313         previousSemanticsRoot =
314             SemanticsNodeCopy(view.semanticsOwner.unmergedRootSemanticsNode, currentSemanticsNodes)
315     }
316 
317     private fun notifySubtreeStateChangeIfNeeded() {
318         boundsUpdateChannel.trySend(Unit)
319     }
320 
321     private fun SemanticsNode.toViewStructure(index: Int): ViewStructureCompat? {
322         val session = contentCaptureSession ?: return null
323         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
324             return null
325         }
326 
327         val rootAutofillId = ViewCompatShims.getAutofillId(view) ?: return null
328         val parentNode = parent
329         val parentAutofillId =
330             if (parentNode != null) {
331                 session.newAutofillId(parentNode.id.toLong()) ?: return null
332             } else {
333                 rootAutofillId.toAutofillId()
334             }
335         val structure =
336             session.newVirtualViewStructure(parentAutofillId, id.toLong()) ?: return null
337 
338         val configuration = this.unmergedConfig
339         if (configuration.contains(SemanticsProperties.Password)) {
340             return null
341         }
342 
343         structure.extras?.let {
344             // Due to the batching strategy, the ContentCaptureEvent.eventTimestamp is inaccurate.
345             // This timestamp in the extra bundle is the equivalent substitution.
346             it.putLong(
347                 VIEW_STRUCTURE_BUNDLE_KEY_TIMESTAMP,
348                 currentSemanticsNodesSnapshotTimestampMillis
349             )
350             // An additional index to help the System Intelligence to rebuild hierarchy with order.
351             it.putInt(VIEW_STRUCTURE_BUNDLE_KEY_ADDITIONAL_INDEX, index)
352         }
353 
354         configuration.getOrNull(SemanticsProperties.TestTag)?.let {
355             // Treat test tag as resourceId
356             structure.setId(id, null, null, it)
357         }
358         configuration.getOrNull(SemanticsProperties.IsTraversalGroup)?.let {
359             structure.setClassName("android.widget.ViewGroup")
360         }
361         configuration.getOrNull(SemanticsProperties.Text)?.let {
362             structure.setClassName("android.widget.TextView")
363             structure.setText(it.fastJoinToString("\n"))
364         }
365         configuration.getOrNull(SemanticsProperties.EditableText)?.let {
366             structure.setClassName("android.widget.EditText")
367             structure.setText(it)
368         }
369         configuration.getOrNull(SemanticsProperties.ContentDescription)?.let {
370             structure.setContentDescription(it.fastJoinToString("\n"))
371         }
372         configuration.getOrNull(SemanticsProperties.Role)?.toLegacyClassName()?.let {
373             structure.setClassName(it)
374         }
375 
376         getTextLayoutResult(configuration)?.let {
377             val input = it.layoutInput
378             val px = input.style.fontSize.value * input.density.density * input.density.fontScale
379             structure.setTextStyle(px, 0, 0, 0)
380         }
381 
382         with(boundsInParent) {
383             structure.setDimens(left.toInt(), top.toInt(), 0, 0, width.toInt(), height.toInt())
384         }
385         return structure
386     }
387 
388     private fun SemanticsNode.fastForEachReplacedVisibleChildren(
389         action: (Int, SemanticsNode) -> Unit
390     ) =
391         this.replacedChildren.fastForEachIndexedWithFilter(action) {
392             currentSemanticsNodes.contains(it.id)
393         }
394 
395     private inline fun <T> List<T>.fastForEachIndexedWithFilter(
396         action: (Int, T) -> Unit,
397         predicate: (T) -> Boolean
398     ) {
399         var i = 0
400         for (index in indices) {
401             val item = get(index)
402             if (predicate(item)) {
403                 action(i, item)
404                 i++
405             }
406         }
407     }
408 
409     private fun bufferContentCaptureViewAppeared(
410         virtualId: Int,
411         viewStructure: ViewStructureCompat?
412     ) {
413         if (viewStructure == null) {
414             return
415         }
416 
417         bufferedEvents.add(
418             ContentCaptureEvent(
419                 virtualId,
420                 currentSemanticsNodesSnapshotTimestampMillis,
421                 ContentCaptureEventType.VIEW_APPEAR,
422                 viewStructure
423             )
424         )
425     }
426 
427     private fun bufferContentCaptureViewDisappeared(virtualId: Int) {
428         bufferedEvents.add(
429             ContentCaptureEvent(
430                 virtualId,
431                 currentSemanticsNodesSnapshotTimestampMillis,
432                 ContentCaptureEventType.VIEW_DISAPPEAR,
433                 null
434             )
435         )
436     }
437 
438     private fun notifyContentCaptureChanges() {
439         val session = contentCaptureSession ?: return
440         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
441             return
442         }
443 
444         if (bufferedEvents.isNotEmpty()) {
445             bufferedEvents.fastForEach { event ->
446                 when (event.type) {
447                     ContentCaptureEventType.VIEW_APPEAR -> {
448                         event.structureCompat?.let { node ->
449                             session.notifyViewAppeared(node.toViewStructure())
450                         }
451                     }
452                     ContentCaptureEventType.VIEW_DISAPPEAR -> {
453                         session.newAutofillId(event.id.toLong())?.let { autofillId ->
454                             session.notifyViewDisappeared(autofillId)
455                         }
456                     }
457                 }
458             }
459             session.flush()
460             bufferedEvents.clear()
461         }
462     }
463 
464     private fun updateBuffersOnAppeared(index: Int, node: SemanticsNode) {
465         if (!isEnabled) {
466             return
467         }
468 
469         updateTranslationOnAppeared(node)
470 
471         bufferContentCaptureViewAppeared(node.id, node.toViewStructure(index))
472         node.fastForEachReplacedVisibleChildren { i, child -> updateBuffersOnAppeared(i, child) }
473     }
474 
475     private fun updateBuffersOnDisappeared(node: SemanticsNode) {
476         if (!isEnabled) {
477             return
478         }
479         bufferContentCaptureViewDisappeared(node.id)
480         node.replacedChildren.fastForEach { child -> updateBuffersOnDisappeared(child) }
481     }
482 
483     private fun updateTranslationOnAppeared(node: SemanticsNode) {
484         val config = node.unmergedConfig
485         val isShowingTextSubstitution =
486             config.getOrNull(SemanticsProperties.IsShowingTextSubstitution)
487 
488         if (translateStatus == TranslateStatus.SHOW_ORIGINAL && isShowingTextSubstitution == true) {
489             config.getOrNull(SemanticsActions.ShowTextSubstitution)?.action?.invoke(false)
490         } else if (
491             translateStatus == TranslateStatus.SHOW_TRANSLATED && isShowingTextSubstitution == false
492         ) {
493             config.getOrNull(SemanticsActions.ShowTextSubstitution)?.action?.invoke(true)
494         }
495     }
496 
497     // TODO(b/272068594): Find a way to use Public API instead of using this in tests.
498     internal fun onShowTranslation() {
499         translateStatus = TranslateStatus.SHOW_TRANSLATED
500         showTranslatedText()
501     }
502 
503     // TODO(b/272068594): Find a way to use Public API instead of using this in tests.
504     internal fun onHideTranslation() {
505         translateStatus = TranslateStatus.SHOW_ORIGINAL
506         hideTranslatedText()
507     }
508 
509     // TODO(b/272068594): Find a way to use Public API instead of using this in tests.
510     internal fun onClearTranslation() {
511         translateStatus = TranslateStatus.SHOW_ORIGINAL
512         clearTranslatedText()
513     }
514 
515     private fun showTranslatedText() {
516         currentSemanticsNodes.forEachValue { node ->
517             val config = node.semanticsNode.unmergedConfig
518             if (config.getOrNull(SemanticsProperties.IsShowingTextSubstitution) == false) {
519                 config.getOrNull(SemanticsActions.ShowTextSubstitution)?.action?.invoke(true)
520             }
521         }
522     }
523 
524     private fun hideTranslatedText() {
525         currentSemanticsNodes.forEachValue { node ->
526             val config = node.semanticsNode.unmergedConfig
527             if (config.getOrNull(SemanticsProperties.IsShowingTextSubstitution) == true) {
528                 config.getOrNull(SemanticsActions.ShowTextSubstitution)?.action?.invoke(false)
529             }
530         }
531     }
532 
533     private fun clearTranslatedText() {
534         currentSemanticsNodes.forEachValue { node ->
535             val config = node.semanticsNode.unmergedConfig
536             if (config.getOrNull(SemanticsProperties.IsShowingTextSubstitution) != null) {
537                 config.getOrNull(SemanticsActions.ClearTextSubstitution)?.action?.invoke()
538             }
539         }
540     }
541 
542     @RequiresApi(Build.VERSION_CODES.S)
543     private object ViewTranslationHelperMethods {
544         @Suppress("UNUSED_PARAMETER")
545         @RequiresApi(Build.VERSION_CODES.S)
546         fun onCreateVirtualViewTranslationRequests(
547             contentCaptureManager: AndroidContentCaptureManager,
548             virtualIds: LongArray,
549             supportedFormats: IntArray,
550             requestsCollector: Consumer<ViewTranslationRequest?>
551         ) {
552 
553             virtualIds.forEach {
554                 val node =
555                     contentCaptureManager.currentSemanticsNodes[it.toInt()]?.semanticsNode
556                         ?: return@forEach
557                 val requestBuilder =
558                     ViewTranslationRequest.Builder(
559                         contentCaptureManager.view.autofillId,
560                         node.id.toLong()
561                     )
562 
563                 val text =
564                     AnnotatedString(
565                         node.unmergedConfig
566                             .getOrNull(SemanticsProperties.Text)
567                             ?.fastJoinToString("\n") ?: return@forEach
568                     )
569 
570                 requestBuilder.setValue(
571                     ViewTranslationRequest.ID_TEXT,
572                     TranslationRequestValue.forText(text)
573                 )
574                 requestsCollector.accept(requestBuilder.build())
575             }
576         }
577 
578         @RequiresApi(Build.VERSION_CODES.S)
579         fun onVirtualViewTranslationResponses(
580             contentCaptureManager: AndroidContentCaptureManager,
581             response: LongSparseArray<ViewTranslationResponse?>
582         ) {
583             if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
584                 return
585             }
586 
587             // TODO(mnuzen): move post into `AndroidComposeView`
588             // This callback can be invoked from non UI thread.
589             if (Looper.getMainLooper().thread == Thread.currentThread()) {
590                 doTranslation(contentCaptureManager, response)
591             } else {
592                 contentCaptureManager.view.post { doTranslation(contentCaptureManager, response) }
593             }
594         }
595 
596         private fun doTranslation(
597             contentCaptureManager: AndroidContentCaptureManager,
598             response: LongSparseArray<ViewTranslationResponse?>
599         ) {
600             val size = response.size()
601             for (i in 0 until size) {
602                 val key = response.keyAt(i)
603                 response.get(key)?.getValue(ViewTranslationRequest.ID_TEXT)?.text?.let {
604                     contentCaptureManager.currentSemanticsNodes[key.toInt()]?.semanticsNode?.let {
605                         semanticsNode ->
606                         semanticsNode.unmergedConfig
607                             .getOrNull(SemanticsActions.SetTextSubstitution)
608                             ?.action
609                             ?.invoke(AnnotatedString(it.toString()))
610                     }
611                 }
612             }
613         }
614     }
615 
616     @RequiresApi(Build.VERSION_CODES.S)
617     internal fun onCreateVirtualViewTranslationRequests(
618         virtualIds: LongArray,
619         supportedFormats: IntArray,
620         requestsCollector: Consumer<ViewTranslationRequest?>
621     ) {
622         ViewTranslationHelperMethods.onCreateVirtualViewTranslationRequests(
623             this,
624             virtualIds,
625             supportedFormats,
626             requestsCollector
627         )
628     }
629 
630     @RequiresApi(Build.VERSION_CODES.S)
631     internal fun onVirtualViewTranslationResponses(
632         contentCaptureManager: AndroidContentCaptureManager,
633         response: LongSparseArray<ViewTranslationResponse?>
634     ) {
635         ViewTranslationHelperMethods.onVirtualViewTranslationResponses(
636             contentCaptureManager,
637             response
638         )
639     }
640 
641     companion object {
642         const val VIEW_STRUCTURE_BUNDLE_KEY_TIMESTAMP = "android.view.contentcapture.EventTimestamp"
643         const val VIEW_STRUCTURE_BUNDLE_KEY_ADDITIONAL_INDEX =
644             "android.view.ViewStructure.extra.EXTRA_VIEW_NODE_INDEX"
645     }
646 }
647 
648 private enum class ContentCaptureEventType {
649     VIEW_APPEAR,
650     VIEW_DISAPPEAR,
651 }
652 
653 private data class ContentCaptureEvent(
654     val id: Int,
655     val timestamp: Long,
656     val type: ContentCaptureEventType,
657     val structureCompat: ViewStructureCompat?,
658 )
659