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