Use offset mapping when building CursorAnchorInfo

CursorAnchorInfoBuilder was not accounting for offset mapping, which
could cause an offset out of bounds exception.

Relnote: N/A

Test: CursorAnchorInfoBuilderTest
Test: TextFieldVisualTransformationMagnifierTest
Bug: 143556460
Bug: 287523357
Change-Id: Id538760c08cd6e8efaad23c699734fa6b1ada776
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/CoreTextFieldInputServiceIntegrationTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/CoreTextFieldInputServiceIntegrationTest.kt
index 3d3b62e..6e5cdb7 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/CoreTextFieldInputServiceIntegrationTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/CoreTextFieldInputServiceIntegrationTest.kt
@@ -45,6 +45,7 @@
 import androidx.compose.ui.text.input.ImeOptions
 import androidx.compose.ui.text.input.KeyboardCapitalization
 import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.OffsetMapping
 import androidx.compose.ui.text.input.PlatformTextInputService
 import androidx.compose.ui.text.input.TextFieldValue
 import androidx.compose.ui.text.input.TextInputService
@@ -464,6 +465,7 @@
 
         rule.runOnIdle {
             assertThat(platformTextInputService.lastInputValue).isNull()
+            assertThat(platformTextInputService.offsetMapping).isNull()
             assertThat(platformTextInputService.textLayoutResult).isNull()
             assertThat(platformTextInputService.textLayoutPositionInWindow).isNull()
             assertThat(platformTextInputService.innerTextFieldBounds).isNull()
@@ -476,6 +478,7 @@
 
         rule.runOnIdle {
             assertThat(platformTextInputService.lastInputValue).isEqualTo(value)
+            assertThat(platformTextInputService.offsetMapping).isNotNull()
             assertThat(platformTextInputService.textLayoutResult).isNotNull()
             assertThat(platformTextInputService.textLayoutResult).isEqualTo(textLayoutResult)
             assertThat(platformTextInputService.textLayoutPositionInWindow)
@@ -512,6 +515,7 @@
         var lastInputValue: TextFieldValue? = null
         var lastInputImeOptions: ImeOptions? = null
 
+        var offsetMapping: OffsetMapping? = null
         var textLayoutResult: TextLayoutResult? = null
         var textLayoutPositionInWindow: Offset? = null
         var innerTextFieldBounds: Rect? = null
@@ -554,12 +558,14 @@
 
         override fun updateTextLayoutResult(
             textFieldValue: TextFieldValue,
+            offsetMapping: OffsetMapping,
             textLayoutResult: TextLayoutResult,
             textLayoutPositionInWindow: Offset,
             innerTextFieldBounds: Rect,
             decorationBoxBounds: Rect
         ) {
             lastInputValue = textFieldValue
+            this.offsetMapping = offsetMapping
             this.textLayoutResult = textLayoutResult
             this.textLayoutPositionInWindow = textLayoutPositionInWindow
             this.innerTextFieldBounds = innerTextFieldBounds
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
index 06f02e7..2967ba6 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
@@ -405,7 +405,12 @@
             state.layoutResult?.let { layoutResult ->
                 state.inputSession?.let { inputSession ->
                     if (state.hasFocus) {
-                        TextFieldDelegate.updateTextLayoutResult(inputSession, value, layoutResult)
+                        TextFieldDelegate.updateTextLayoutResult(
+                            inputSession,
+                            value,
+                            offsetMapping,
+                            layoutResult
+                        )
                     }
                 }
             }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
index 8f8a6d6..2df1762 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
@@ -184,12 +184,14 @@
          *
          * @param textInputSession the current input session
          * @param textFieldValue the editor state
+         * @param offsetMapping the offset mapping for the visual transformation
          * @param textLayoutResult the layout result
          */
         @JvmStatic
         internal fun updateTextLayoutResult(
             textInputSession: TextInputSession,
             textFieldValue: TextFieldValue,
+            offsetMapping: OffsetMapping,
             textLayoutResult: TextLayoutResultProxy
         ) {
             textLayoutResult.innerTextFieldCoordinates?.let { innerTextFieldCoordinates ->
@@ -197,6 +199,7 @@
                 textLayoutResult.decorationBoxCoordinates?.let { decorationBoxCoordinates ->
                     textInputSession.updateTextLayoutResult(
                         textFieldValue,
+                        offsetMapping,
                         textLayoutResult.value,
                         innerTextFieldCoordinates.positionInWindow(),
                         innerTextFieldCoordinates.visibleBounds(),
diff --git a/compose/ui/ui-text/api/current.txt b/compose/ui/ui-text/api/current.txt
index ba3232f..72ecd80b 100644
--- a/compose/ui/ui-text/api/current.txt
+++ b/compose/ui/ui-text/api/current.txt
@@ -1125,7 +1125,7 @@
     method public void startInput(androidx.compose.ui.text.input.TextFieldValue value, androidx.compose.ui.text.input.ImeOptions imeOptions, kotlin.jvm.functions.Function1<? super java.util.List<? extends androidx.compose.ui.text.input.EditCommand>,kotlin.Unit> onEditCommand, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.ImeAction,kotlin.Unit> onImeActionPerformed);
     method public void stopInput();
     method public void updateState(androidx.compose.ui.text.input.TextFieldValue? oldValue, androidx.compose.ui.text.input.TextFieldValue newValue);
-    method public default void updateTextLayoutResult(androidx.compose.ui.text.input.TextFieldValue textFieldValue, androidx.compose.ui.text.TextLayoutResult textLayoutResult, long textLayoutPositionInWindow, androidx.compose.ui.geometry.Rect innerTextFieldBounds, androidx.compose.ui.geometry.Rect decorationBoxBounds);
+    method public default void updateTextLayoutResult(androidx.compose.ui.text.input.TextFieldValue textFieldValue, androidx.compose.ui.text.input.OffsetMapping offsetMapping, androidx.compose.ui.text.TextLayoutResult textLayoutResult, long textLayoutPositionInWindow, androidx.compose.ui.geometry.Rect innerTextFieldBounds, androidx.compose.ui.geometry.Rect decorationBoxBounds);
   }
 
   public final class SetComposingRegionCommand implements androidx.compose.ui.text.input.EditCommand {
@@ -1201,7 +1201,7 @@
     method public boolean notifyFocusedRect(androidx.compose.ui.geometry.Rect rect);
     method public boolean showSoftwareKeyboard();
     method public boolean updateState(androidx.compose.ui.text.input.TextFieldValue? oldValue, androidx.compose.ui.text.input.TextFieldValue newValue);
-    method public boolean updateTextLayoutResult(androidx.compose.ui.text.input.TextFieldValue textFieldValue, androidx.compose.ui.text.TextLayoutResult textLayoutResult, long textLayoutPositionInWindow, androidx.compose.ui.geometry.Rect innerTextFieldBounds, androidx.compose.ui.geometry.Rect decorationBoxBounds);
+    method public boolean updateTextLayoutResult(androidx.compose.ui.text.input.TextFieldValue textFieldValue, androidx.compose.ui.text.input.OffsetMapping offsetMapping, androidx.compose.ui.text.TextLayoutResult textLayoutResult, long textLayoutPositionInWindow, androidx.compose.ui.geometry.Rect innerTextFieldBounds, androidx.compose.ui.geometry.Rect decorationBoxBounds);
     property public final boolean isOpen;
   }
 
diff --git a/compose/ui/ui-text/api/restricted_current.txt b/compose/ui/ui-text/api/restricted_current.txt
index ba3232f..72ecd80b 100644
--- a/compose/ui/ui-text/api/restricted_current.txt
+++ b/compose/ui/ui-text/api/restricted_current.txt
@@ -1125,7 +1125,7 @@
     method public void startInput(androidx.compose.ui.text.input.TextFieldValue value, androidx.compose.ui.text.input.ImeOptions imeOptions, kotlin.jvm.functions.Function1<? super java.util.List<? extends androidx.compose.ui.text.input.EditCommand>,kotlin.Unit> onEditCommand, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.ImeAction,kotlin.Unit> onImeActionPerformed);
     method public void stopInput();
     method public void updateState(androidx.compose.ui.text.input.TextFieldValue? oldValue, androidx.compose.ui.text.input.TextFieldValue newValue);
-    method public default void updateTextLayoutResult(androidx.compose.ui.text.input.TextFieldValue textFieldValue, androidx.compose.ui.text.TextLayoutResult textLayoutResult, long textLayoutPositionInWindow, androidx.compose.ui.geometry.Rect innerTextFieldBounds, androidx.compose.ui.geometry.Rect decorationBoxBounds);
+    method public default void updateTextLayoutResult(androidx.compose.ui.text.input.TextFieldValue textFieldValue, androidx.compose.ui.text.input.OffsetMapping offsetMapping, androidx.compose.ui.text.TextLayoutResult textLayoutResult, long textLayoutPositionInWindow, androidx.compose.ui.geometry.Rect innerTextFieldBounds, androidx.compose.ui.geometry.Rect decorationBoxBounds);
   }
 
   public final class SetComposingRegionCommand implements androidx.compose.ui.text.input.EditCommand {
@@ -1201,7 +1201,7 @@
     method public boolean notifyFocusedRect(androidx.compose.ui.geometry.Rect rect);
     method public boolean showSoftwareKeyboard();
     method public boolean updateState(androidx.compose.ui.text.input.TextFieldValue? oldValue, androidx.compose.ui.text.input.TextFieldValue newValue);
-    method public boolean updateTextLayoutResult(androidx.compose.ui.text.input.TextFieldValue textFieldValue, androidx.compose.ui.text.TextLayoutResult textLayoutResult, long textLayoutPositionInWindow, androidx.compose.ui.geometry.Rect innerTextFieldBounds, androidx.compose.ui.geometry.Rect decorationBoxBounds);
+    method public boolean updateTextLayoutResult(androidx.compose.ui.text.input.TextFieldValue textFieldValue, androidx.compose.ui.text.input.OffsetMapping offsetMapping, androidx.compose.ui.text.TextLayoutResult textLayoutResult, long textLayoutPositionInWindow, androidx.compose.ui.geometry.Rect innerTextFieldBounds, androidx.compose.ui.geometry.Rect decorationBoxBounds);
     property public final boolean isOpen;
   }
 
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/TextInputService.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/TextInputService.kt
index f7d277d..9d6af43 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/TextInputService.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/TextInputService.kt
@@ -180,6 +180,7 @@
      * Notify the input service of layout and position changes.
      *
      * @param textFieldValue the text field's [TextFieldValue]
+     * @param offsetMapping the offset mapping for the visual transformation
      * @param textLayoutResult the text field's [TextLayoutResult]
      * @param textLayoutPositionInWindow position of the text field relative to the window
      * @param innerTextFieldBounds visible bounds of the text field in local coordinates, or an
@@ -189,6 +190,7 @@
      */
     fun updateTextLayoutResult(
         textFieldValue: TextFieldValue,
+        offsetMapping: OffsetMapping,
         textLayoutResult: TextLayoutResult,
         textLayoutPositionInWindow: Offset,
         innerTextFieldBounds: Rect,
@@ -196,6 +198,7 @@
     ) = ensureOpenSession {
         platformTextInputService.updateTextLayoutResult(
             textFieldValue,
+            offsetMapping,
             textLayoutResult,
             textLayoutPositionInWindow,
             innerTextFieldBounds,
@@ -323,6 +326,7 @@
      */
     fun updateTextLayoutResult(
         textFieldValue: TextFieldValue,
+        offsetMapping: OffsetMapping,
         textLayoutResult: TextLayoutResult,
         textLayoutPositionInWindow: Offset,
         innerTextFieldBounds: Rect,
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/CursorAnchorInfoBuilderTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/CursorAnchorInfoBuilderTest.kt
index b7dc4cc..af70cf4 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/CursorAnchorInfoBuilderTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/CursorAnchorInfoBuilderTest.kt
@@ -35,6 +35,7 @@
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.font.createFontFamilyResolver
 import androidx.compose.ui.text.font.toFontFamily
+import androidx.compose.ui.text.input.OffsetMapping
 import androidx.compose.ui.text.input.TextFieldValue
 import androidx.compose.ui.text.input.build
 import androidx.compose.ui.text.style.TextOverflow
@@ -252,6 +253,28 @@
     }
 
     @Test
+    fun testInsertionMarkerWithVisualTransformation() {
+        val fontSize = 10.sp
+        val textFieldValue = TextFieldValue("abcde", selection = TextRange(2))
+        val offsetMapping = object : OffsetMapping {
+            override fun originalToTransformed(offset: Int) = if (offset < 2) offset else offset + 3
+            override fun transformedToOriginal(offset: Int) = throw NotImplementedError()
+        }
+        val textLayoutResult = getTextLayoutResult("ab---cde", fontSize = fontSize)
+
+        val cursorAnchorInfo =
+            CursorAnchorInfo.Builder()
+                .build(textFieldValue, textLayoutResult, matrix, offsetMapping = offsetMapping)
+
+        val fontSizeInPx = with(defaultDensity) { fontSize.toPx() }
+        assertThat(cursorAnchorInfo.insertionMarkerHorizontal).isEqualTo(5 * fontSizeInPx)
+        assertThat(cursorAnchorInfo.insertionMarkerTop).isEqualTo(0f)
+        assertThat(cursorAnchorInfo.insertionMarkerBottom).isEqualTo(fontSizeInPx)
+        assertThat(cursorAnchorInfo.insertionMarkerBaseline).isEqualTo(fontSizeInPx)
+        assertThat(cursorAnchorInfo.insertionMarkerFlags).isEqualTo(FLAG_HAS_VISIBLE_REGION)
+    }
+
+    @Test
     fun testInsertionMarkerNotVisible() {
         val fontSize = 10.sp
         val textFieldValue = TextFieldValue("abc", selection = TextRange(1))
@@ -266,6 +289,7 @@
             CursorAnchorInfo.Builder()
                 .build(
                     textFieldValue,
+                    OffsetMapping.Identity,
                     textLayoutResult,
                     matrix,
                     innerTextFieldBounds = innerTextFieldBounds,
@@ -293,6 +317,7 @@
             CursorAnchorInfo.Builder()
                 .build(
                     textFieldValue,
+                    OffsetMapping.Identity,
                     textLayoutResult,
                     matrix,
                     innerTextFieldBounds = innerTextFieldBounds,
@@ -390,6 +415,47 @@
     }
 
     @Test
+    fun testCharacterBoundsWithVisualTransformation() {
+        val fontSize = 10.sp
+        val fontSizeInPx = with(defaultDensity) { fontSize.toPx() }
+        val text = "abcd"
+        // Composition is on "bc"
+        val composition = TextRange(2, 4)
+        val offsetMapping = object : OffsetMapping {
+            override fun originalToTransformed(offset: Int) = 2 * offset
+            override fun transformedToOriginal(offset: Int) = throw NotImplementedError()
+        }
+        val transformedText = "a-b-c-d-"
+        val textFieldValue = TextFieldValue(text, composition = composition)
+        val width = transformedText.length * fontSizeInPx
+        val textLayoutResult =
+            getTextLayoutResult(transformedText, fontSize = fontSize, width = width)
+
+        val cursorAnchorInfo =
+            CursorAnchorInfo.Builder()
+                .build(textFieldValue, textLayoutResult, matrix, offsetMapping = offsetMapping)
+
+        for (index in text.indices) {
+            if (index in composition) {
+                assertThat(cursorAnchorInfo.getCharacterBounds(index))
+                    .isEqualTo(
+                        RectF(
+                            2 * index * fontSizeInPx,
+                            0f,
+                            (2 * index + 1) * fontSizeInPx,
+                            fontSizeInPx
+                        )
+                    )
+                assertThat(cursorAnchorInfo.getCharacterBoundsFlags(index))
+                    .isEqualTo(FLAG_HAS_VISIBLE_REGION)
+            } else {
+                assertThat(cursorAnchorInfo.getCharacterBounds(index)).isNull()
+                assertThat(cursorAnchorInfo.getCharacterBoundsFlags(index)).isEqualTo(0)
+            }
+        }
+    }
+
+    @Test
     fun testCharacterBoundsVisibility() {
         val fontSize = 10.sp
         val fontSizeInPx = with(defaultDensity) { fontSize.toPx() }
@@ -406,6 +472,7 @@
             CursorAnchorInfo.Builder()
                 .build(
                     textFieldValue,
+                    OffsetMapping.Identity,
                     textLayoutResult,
                     matrix,
                     innerTextFieldBounds = innerTextFieldBounds,
@@ -452,6 +519,7 @@
             CursorAnchorInfo.Builder()
                 .build(
                     textFieldValue,
+                    OffsetMapping.Identity,
                     getTextLayoutResult(textFieldValue.text),
                     matrix,
                     innerTextFieldBounds = Rect(1f, 2f, 3f, 4f),
@@ -472,6 +540,7 @@
             CursorAnchorInfo.Builder()
                 .build(
                     textFieldValue,
+                    OffsetMapping.Identity,
                     getTextLayoutResult(textFieldValue.text),
                     matrix,
                     innerTextFieldBounds = Rect(1f, 2f, 3f, 4f),
@@ -503,6 +572,7 @@
             CursorAnchorInfo.Builder()
                 .build(
                     textFieldValue,
+                    OffsetMapping.Identity,
                     textLayoutResult,
                     matrix,
                     innerTextFieldBounds = innerTextFieldBounds,
@@ -563,6 +633,7 @@
             CursorAnchorInfo.Builder()
                 .build(
                     textFieldValue,
+                    OffsetMapping.Identity,
                     textLayoutResult,
                     matrix,
                     innerTextFieldBounds = innerTextFieldBounds,
@@ -613,6 +684,7 @@
     textFieldValue: TextFieldValue,
     textLayoutResult: TextLayoutResult,
     matrix: Matrix,
+    offsetMapping: OffsetMapping = OffsetMapping.Identity,
     includeInsertionMarker: Boolean = true,
     includeCharacterBounds: Boolean = true,
     includeEditorBounds: Boolean = true,
@@ -622,6 +694,7 @@
         Rect(0f, 0f, textLayoutResult.size.width.toFloat(), textLayoutResult.size.height.toFloat())
     return build(
         textFieldValue,
+        offsetMapping,
         textLayoutResult,
         matrix,
         innerTextFieldBounds = innerTextFieldBounds,
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidCursorAnchorInfoTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidCursorAnchorInfoTest.kt
index d4a44fb..f205503 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidCursorAnchorInfoTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidCursorAnchorInfoTest.kt
@@ -37,6 +37,7 @@
 import androidx.compose.ui.text.font.toFontFamily
 import androidx.compose.ui.text.input.ImeOptions
 import androidx.compose.ui.text.input.InputMethodManager
+import androidx.compose.ui.text.input.OffsetMapping
 import androidx.compose.ui.text.input.RecordingInputConnection
 import androidx.compose.ui.text.input.TextFieldValue
 import androidx.compose.ui.text.input.TextInputServiceAndroid
@@ -108,12 +109,14 @@
             TextFieldValue("abc", selection = TextRange(2), composition = TextRange(1, 2))
         textInputService.updateState(oldValue = textFieldValue, newValue = textFieldValue)
 
+        val offsetMapping = OffsetMapping.Identity
         val textLayoutResult = getTextLayoutResult(textFieldValue.text)
         var textLayoutPositionInWindow = Offset(1f, 1f)
         val innerTextFieldBounds = Rect.Zero
         val decorationBoxBounds = Rect.Zero
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
@@ -128,6 +131,7 @@
         val expected =
             builder.build(
                 textFieldValue,
+                offsetMapping,
                 textLayoutResult,
                 matrix,
                 innerTextFieldBounds,
@@ -139,6 +143,7 @@
         textLayoutPositionInWindow = Offset(2f, 2f)
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
@@ -160,12 +165,14 @@
         // No immediate update until updateTextLayoutResult call
         verify(inputMethodManager, never()).updateCursorAnchorInfo(any())
 
+        val offsetMapping = OffsetMapping.Identity
         val textLayoutResult = getTextLayoutResult(textFieldValue.text)
         var textLayoutPositionInWindow = Offset(1f, 1f)
         val innerTextFieldBounds = Rect.Zero
         val decorationBoxBounds = Rect.Zero
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
@@ -178,6 +185,7 @@
         val expected =
             builder.build(
                 textFieldValue,
+                offsetMapping,
                 textLayoutResult,
                 matrix,
                 innerTextFieldBounds,
@@ -189,6 +197,7 @@
         textLayoutPositionInWindow = Offset(2f, 2f)
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
@@ -205,12 +214,14 @@
             TextFieldValue("abc", selection = TextRange(2), composition = TextRange(1, 2))
         textInputService.updateState(oldValue = textFieldValue, newValue = textFieldValue)
 
+        val offsetMapping = OffsetMapping.Identity
         val textLayoutResult = getTextLayoutResult(textFieldValue.text)
         var textLayoutPositionInWindow = Offset(1f, 1f)
         val innerTextFieldBounds = Rect.Zero
         val decorationBoxBounds = Rect.Zero
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
@@ -226,6 +237,7 @@
         textLayoutPositionInWindow = Offset(2f, 2f)
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
@@ -238,6 +250,7 @@
         val expected =
             builder.build(
                 textFieldValue,
+                offsetMapping,
                 textLayoutResult,
                 matrix,
                 innerTextFieldBounds,
@@ -256,6 +269,7 @@
         textLayoutPositionInWindow = Offset(3f, 3f)
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
@@ -268,6 +282,7 @@
         val expected2 =
             builder.build(
                 textFieldValue,
+                offsetMapping,
                 textLayoutResult,
                 matrix,
                 innerTextFieldBounds,
@@ -282,12 +297,14 @@
             TextFieldValue("abc", selection = TextRange(2), composition = TextRange(1, 2))
         textInputService.updateState(oldValue = textFieldValue, newValue = textFieldValue)
 
+        val offsetMapping = OffsetMapping.Identity
         val textLayoutResult = getTextLayoutResult(textFieldValue.text)
         var textLayoutPositionInWindow = Offset(1f, 1f)
         val innerTextFieldBounds = Rect.Zero
         val decorationBoxBounds = Rect.Zero
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
@@ -304,6 +321,7 @@
         val expected =
             builder.build(
                 textFieldValue,
+                offsetMapping,
                 textLayoutResult,
                 matrix,
                 innerTextFieldBounds,
@@ -315,6 +333,7 @@
         textLayoutPositionInWindow = Offset(2f, 2f)
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
@@ -327,6 +346,7 @@
         val expected2 =
             builder.build(
                 textFieldValue,
+                offsetMapping,
                 textLayoutResult,
                 matrix,
                 innerTextFieldBounds,
@@ -341,12 +361,14 @@
             TextFieldValue("abc", selection = TextRange(2), composition = TextRange(1, 2))
         textInputService.updateState(oldValue = textFieldValue, newValue = textFieldValue)
 
+        val offsetMapping = OffsetMapping.Identity
         val textLayoutResult = getTextLayoutResult(textFieldValue.text)
         var textLayoutPositionInWindow = Offset(1f, 1f)
         val innerTextFieldBounds = Rect.Zero
         val decorationBoxBounds = Rect.Zero
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
@@ -369,6 +391,7 @@
         textLayoutPositionInWindow = Offset(2f, 2f)
         textInputService.updateTextLayoutResult(
             textFieldValue = textFieldValue,
+            offsetMapping = offsetMapping,
             textLayoutResult = textLayoutResult,
             textLayoutPositionInWindow = textLayoutPositionInWindow,
             innerTextFieldBounds = innerTextFieldBounds,
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/CursorAnchorInfoBuilder.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/CursorAnchorInfoBuilder.kt
index bb53826..707e9a6 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/CursorAnchorInfoBuilder.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/CursorAnchorInfoBuilder.kt
@@ -34,6 +34,7 @@
  * [CursorAnchorInfo](https://developer.android.com/reference/android/view/inputmethod/CursorAnchorInfo).
  *
  * @param textFieldValue the text field's [TextFieldValue]
+ * @param offsetMapping the offset mapping for the text field's visual transformation
  * @param textLayoutResult the text field's [TextLayoutResult]
  * @param matrix matrix that transforms local coordinates into screen coordinates
  * @param innerTextFieldBounds visible bounds of the text field in local coordinates, or an empty
@@ -47,6 +48,7 @@
  */
 internal fun CursorAnchorInfo.Builder.build(
     textFieldValue: TextFieldValue,
+    offsetMapping: OffsetMapping,
     textLayoutResult: TextLayoutResult,
     matrix: Matrix,
     innerTextFieldBounds: Rect,
@@ -65,7 +67,7 @@
     setSelectionRange(selectionStart, selectionEnd)
 
     if (includeInsertionMarker) {
-        setInsertionMarker(selectionStart, textLayoutResult, innerTextFieldBounds)
+        setInsertionMarker(selectionStart, offsetMapping, textLayoutResult, innerTextFieldBounds)
     }
 
     if (includeCharacterBounds) {
@@ -80,6 +82,7 @@
             addCharacterBounds(
                 compositionStart,
                 compositionEnd,
+                offsetMapping,
                 textLayoutResult,
                 innerTextFieldBounds
             )
@@ -103,15 +106,18 @@
 
 private fun CursorAnchorInfo.Builder.setInsertionMarker(
     selectionStart: Int,
+    offsetMapping: OffsetMapping,
     textLayoutResult: TextLayoutResult,
     innerTextFieldBounds: Rect
 ): CursorAnchorInfo.Builder {
     if (selectionStart < 0) return this
 
-    val cursorRect = textLayoutResult.getCursorRect(selectionStart)
+    val selectionStartTransformed = offsetMapping.originalToTransformed(selectionStart)
+    val cursorRect = textLayoutResult.getCursorRect(selectionStartTransformed)
     val isTopVisible = innerTextFieldBounds.containsInclusive(cursorRect.topLeft)
     val isBottomVisible = innerTextFieldBounds.containsInclusive(cursorRect.bottomLeft)
-    val isRtl = textLayoutResult.getBidiRunDirection(selectionStart) == ResolvedTextDirection.Rtl
+    val isRtl =
+        textLayoutResult.getBidiRunDirection(selectionStartTransformed) == ResolvedTextDirection.Rtl
 
     var flags = 0
     if (isTopVisible || isBottomVisible) flags = flags or CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION
@@ -135,14 +141,28 @@
 private fun CursorAnchorInfo.Builder.addCharacterBounds(
     startOffset: Int,
     endOffset: Int,
+    offsetMapping: OffsetMapping,
     textLayoutResult: TextLayoutResult,
     innerTextFieldBounds: Rect
 ): CursorAnchorInfo.Builder {
-    val array = FloatArray((endOffset - startOffset) * 4)
-    textLayoutResult.multiParagraph.fillBoundingBoxes(TextRange(startOffset, endOffset), array, 0)
+    val startOffsetTransformed = offsetMapping.originalToTransformed(startOffset)
+    val endOffsetTransformed = offsetMapping.originalToTransformed(endOffset)
+    val array = FloatArray((endOffsetTransformed - startOffsetTransformed) * 4)
+    textLayoutResult.multiParagraph.fillBoundingBoxes(
+        TextRange(
+            startOffsetTransformed,
+            endOffsetTransformed
+        ), array, 0
+    )
 
     for (offset in startOffset until endOffset) {
-        val arrayIndex = 4 * (offset - startOffset)
+        // It's possible for a visual transformation to hide some characters. If the character at
+        // the offset is hidden, then offsetTransformed points to the last preceding character that
+        // is not hidden. Since the CursorAnchorInfo API doesn't define what to return in this case,
+        // and visual transformations hiding characters should be rare, returning the bounds for the
+        // last preceding character is the simplest behavior.
+        val offsetTransformed = offsetMapping.originalToTransformed(offset)
+        val arrayIndex = 4 * (offsetTransformed - startOffsetTransformed)
         val rect =
             Rect(
                 array[arrayIndex] /* left */,
@@ -157,11 +177,11 @@
         }
         if (
             !innerTextFieldBounds.containsInclusive(rect.topLeft) ||
-                !innerTextFieldBounds.containsInclusive(rect.bottomRight)
+            !innerTextFieldBounds.containsInclusive(rect.bottomRight)
         ) {
             flags = flags or CursorAnchorInfo.FLAG_HAS_INVISIBLE_REGION
         }
-        if (textLayoutResult.getBidiRunDirection(offset) == ResolvedTextDirection.Rtl) {
+        if (textLayoutResult.getBidiRunDirection(offsetTransformed) == ResolvedTextDirection.Rtl) {
             flags = flags or CursorAnchorInfo.FLAG_IS_RTL
         }
 
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/CursorAnchorInfoController.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/CursorAnchorInfoController.kt
index e386aa98..1271afa 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/CursorAnchorInfoController.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/CursorAnchorInfoController.kt
@@ -33,6 +33,7 @@
 
     private var textFieldValue: TextFieldValue? = null
     private var textLayoutResult: TextLayoutResult? = null
+    private var offsetMapping: OffsetMapping? = null
     private var textLayoutPositionInWindow: Offset? = null
     private var innerTextFieldBounds: Rect? = null
     private var decorationBoxBounds: Rect? = null
@@ -83,6 +84,7 @@
      * Notify the controller of layout and position changes.
      *
      * @param textFieldValue the text field's [TextFieldValue]
+     * @param offsetMapping the offset mapping for the visual transformation
      * @param textLayoutResult the text field's [TextLayoutResult]
      * @param textLayoutPositionInWindow position of the text field relative to the window
      * @param innerTextFieldBounds visible bounds of the text field in local coordinates, or an
@@ -92,12 +94,14 @@
      */
     fun updateTextLayoutResult(
         textFieldValue: TextFieldValue,
+        offsetMapping: OffsetMapping,
         textLayoutResult: TextLayoutResult,
         textLayoutPositionInWindow: Offset,
         innerTextFieldBounds: Rect,
         decorationBoxBounds: Rect
     ) {
         this.textFieldValue = textFieldValue
+        this.offsetMapping = offsetMapping
         this.textLayoutResult = textLayoutResult
         this.textLayoutPositionInWindow = textLayoutPositionInWindow
         this.innerTextFieldBounds = innerTextFieldBounds
@@ -117,6 +121,7 @@
      */
     fun invalidate() {
         textFieldValue = null
+        offsetMapping = null
         textLayoutResult = null
         textLayoutPositionInWindow = null
         innerTextFieldBounds = null
@@ -132,6 +137,7 @@
         inputMethodManager.updateCursorAnchorInfo(
             builder.build(
                 textFieldValue!!,
+                offsetMapping!!,
                 textLayoutResult!!,
                 matrix,
                 innerTextFieldBounds!!,
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroid.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroid.android.kt
index 448968d..a7b9119 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroid.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroid.android.kt
@@ -407,6 +407,7 @@
 
     override fun updateTextLayoutResult(
         textFieldValue: TextFieldValue,
+        offsetMapping: OffsetMapping,
         textLayoutResult: TextLayoutResult,
         textLayoutPositionInWindow: Offset,
         innerTextFieldBounds: Rect,
@@ -414,6 +415,7 @@
     ) {
         cursorAnchorInfoController.updateTextLayoutResult(
             textFieldValue,
+            offsetMapping,
             textLayoutResult,
             textLayoutPositionInWindow,
             innerTextFieldBounds,