1 /*
2  * Copyright 2019 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 @file:Suppress("DEPRECATION")
18 
19 package androidx.compose.ui.text.input
20 
21 import androidx.compose.ui.text.TextRange
22 import androidx.compose.ui.text.emptyAnnotatedString
23 import androidx.compose.ui.util.fastForEach
24 
25 /**
26  * Helper class to apply [EditCommand]s on an internal buffer. Used by TextField Composable to
27  * combine TextFieldValue lifecycle with the editing operations.
28  * * When a [TextFieldValue] is suggested by the developer, [reset] should be called.
29  * * When [TextInputService] provides [EditCommand]s, they should be applied to the internal buffer
30  *   using [apply].
31  */
32 class EditProcessor {
33 
34     /** The current state of the internal editing buffer as a [TextFieldValue]. */
35     /*@VisibleForTesting*/
36     internal var mBufferState: TextFieldValue =
37         TextFieldValue(emptyAnnotatedString(), TextRange.Zero, null)
38         private set
39 
40     // The editing buffer used for applying editor commands from IME.
41     /*@VisibleForTesting*/
42     internal var mBuffer: EditingBuffer =
43         EditingBuffer(text = mBufferState.annotatedString, selection = mBufferState.selection)
44         private set
45 
46     /**
47      * Must be called whenever new editor model arrives.
48      *
49      * This method updates the internal editing buffer with the given editor model. This method may
50      * tell the IME about the selection offset changes or extracted text changes.
51      */
52     @Suppress("ReferencesDeprecated")
resetnull53     fun reset(
54         value: TextFieldValue,
55         textInputSession: TextInputSession?,
56     ) {
57         var textChanged = false
58         var selectionChanged = false
59         val compositionChanged = value.composition != mBuffer.composition
60 
61         if (mBufferState.annotatedString.text != value.annotatedString.text) {
62             mBuffer = EditingBuffer(text = value.annotatedString, selection = value.selection)
63             textChanged = true
64         } else if (mBufferState.selection != value.selection) {
65             mBuffer.setSelection(value.selection.min, value.selection.max)
66             selectionChanged = true
67         }
68 
69         if (value.composition == null) {
70             mBuffer.commitComposition()
71         } else if (!value.composition.collapsed) {
72             mBuffer.setComposition(value.composition.min, value.composition.max)
73         }
74 
75         // this is the same code as in TextInputServiceAndroid class where restartInput is decided
76         // if restartInput is going to be called the composition has to be cleared otherwise it
77         // results in keyboards behaving strangely.
78         val newValue =
79             if (textChanged || (!selectionChanged && compositionChanged)) {
80                 mBuffer.commitComposition()
81                 value.copy(composition = null)
82             } else {
83                 value
84             }
85 
86         val oldValue = mBufferState
87         mBufferState = newValue
88 
89         textInputSession?.updateState(oldValue, newValue)
90     }
91 
92     /**
93      * Applies a set of [editCommands] to the internal text editing buffer.
94      *
95      * After applying the changes, returns the final state of the editing buffer as a
96      * [TextFieldValue]
97      *
98      * @param editCommands [EditCommand]s to be applied to the editing buffer.
99      * @return the [TextFieldValue] representation of the final buffer state.
100      */
applynull101     fun apply(editCommands: List<EditCommand>): TextFieldValue {
102         var lastCommand: EditCommand? = null
103         try {
104             editCommands.fastForEach {
105                 lastCommand = it
106                 it.applyTo(mBuffer)
107             }
108         } catch (e: Exception) {
109             throw RuntimeException(generateBatchErrorMessage(editCommands, lastCommand), e)
110         }
111 
112         val newState =
113             TextFieldValue(
114                 annotatedString = mBuffer.toAnnotatedString(),
115                 // preserve original reversed selection when creating new state.
116                 // otherwise the text range may flicker to un-reversed for a frame,
117                 // which can cause haptics and handles to be crossed.
118                 selection =
119                     mBuffer.selection.run {
120                         takeUnless { mBufferState.selection.reversed } ?: TextRange(max, min)
121                     },
122                 composition = mBuffer.composition
123             )
124 
125         mBufferState = newState
126         return newState
127     }
128 
129     /** Returns the current state of the internal editing buffer as a [TextFieldValue]. */
toTextFieldValuenull130     fun toTextFieldValue(): TextFieldValue = mBufferState
131 
132     private fun generateBatchErrorMessage(
133         editCommands: List<EditCommand>,
134         failedCommand: EditCommand?,
135     ): String = buildString {
136         appendLine(
137             "Error while applying EditCommand batch to buffer (" +
138                 "length=${mBuffer.length}, " +
139                 "composition=${mBuffer.composition}, " +
140                 "selection=${mBuffer.selection}):"
141         )
142         @Suppress("ListIterator")
143         editCommands.joinTo(this, separator = "\n") {
144             val prefix = if (failedCommand === it) " > " else "   "
145             prefix + it.toStringForLog()
146         }
147     }
148 
149     /**
150      * Generate a description of the command that is suitable for logging – this should not include
151      * any user-entered text, which may be sensitive.
152      */
toStringForLognull153     private fun EditCommand.toStringForLog(): String =
154         when (this) {
155             is CommitTextCommand ->
156                 "CommitTextCommand(text.length=${text.length}, newCursorPosition=$newCursorPosition)"
157             is SetComposingTextCommand ->
158                 "SetComposingTextCommand(text.length=${text.length}, " +
159                     "newCursorPosition=$newCursorPosition)"
160             is SetComposingRegionCommand -> toString()
161             is DeleteSurroundingTextCommand -> toString()
162             is DeleteSurroundingTextInCodePointsCommand -> toString()
163             is SetSelectionCommand -> toString()
164             is FinishComposingTextCommand -> toString()
165             is BackspaceCommand -> toString()
166             is MoveCursorCommand -> toString()
167             is DeleteAllCommand -> toString()
168             // Do not return toString() by default, since that might contain sensitive text.
169             else -> "Unknown EditCommand: " + (this::class.simpleName ?: "{anonymous EditCommand}")
170         }
171 }
172