1 /*
<lambda>null2 * Copyright 2023 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.foundation.text
18
19 import androidx.compose.foundation.ExperimentalFoundationApi
20 import androidx.compose.foundation.interaction.Interaction
21 import androidx.compose.foundation.interaction.MutableInteractionSource
22 import androidx.compose.foundation.text.input.InputTransformation
23 import androidx.compose.foundation.text.input.KeyboardActionHandler
24 import androidx.compose.foundation.text.input.TextFieldBuffer
25 import androidx.compose.foundation.text.input.TextFieldDecorator
26 import androidx.compose.foundation.text.input.TextFieldLineLimits
27 import androidx.compose.foundation.text.input.TextFieldState
28 import androidx.compose.foundation.text.input.TextObfuscationMode
29 import androidx.compose.foundation.text.input.internal.CodepointTransformation
30 import androidx.compose.foundation.text.input.then
31 import androidx.compose.runtime.Composable
32 import androidx.compose.runtime.CompositionLocalProvider
33 import androidx.compose.runtime.LaunchedEffect
34 import androidx.compose.runtime.State
35 import androidx.compose.runtime.getValue
36 import androidx.compose.runtime.mutableIntStateOf
37 import androidx.compose.runtime.remember
38 import androidx.compose.runtime.rememberUpdatedState
39 import androidx.compose.runtime.setValue
40 import androidx.compose.ui.Modifier
41 import androidx.compose.ui.autofill.ContentType
42 import androidx.compose.ui.focus.onFocusChanged
43 import androidx.compose.ui.geometry.Rect
44 import androidx.compose.ui.graphics.Brush
45 import androidx.compose.ui.graphics.Color
46 import androidx.compose.ui.graphics.SolidColor
47 import androidx.compose.ui.input.key.onPreviewKeyEvent
48 import androidx.compose.ui.platform.LocalTextToolbar
49 import androidx.compose.ui.platform.TextToolbar
50 import androidx.compose.ui.semantics.contentType
51 import androidx.compose.ui.semantics.semantics
52 import androidx.compose.ui.text.TextLayoutResult
53 import androidx.compose.ui.text.TextStyle
54 import androidx.compose.ui.text.input.ImeAction
55 import androidx.compose.ui.text.input.KeyboardType
56 import androidx.compose.ui.unit.Density
57 import kotlinx.coroutines.channels.Channel
58 import kotlinx.coroutines.delay
59 import kotlinx.coroutines.flow.collectLatest
60 import kotlinx.coroutines.flow.consumeAsFlow
61
62 /**
63 * BasicSecureTextField is specifically designed for password entry fields and is a preconfigured
64 * alternative to [BasicTextField]. It only supports a single line of content and comes with default
65 * settings for [KeyboardOptions], [InputTransformation], and [CodepointTransformation] that are
66 * appropriate for entering secure content. Additionally, some context menu actions like cut, copy,
67 * and drag are disabled for added security.
68 *
69 * @param state [TextFieldState] object that holds the internal state of a [BasicSecureTextField].
70 * @param modifier optional [Modifier] for this text field.
71 * @param enabled controls the enabled state of the [BasicSecureTextField]. When `false`, the text
72 * field will be neither editable nor focusable, the input of the text field will not be
73 * selectable.
74 * @param readOnly controls the editable state of the [BasicSecureTextField]. When `true`, the text
75 * field can not be modified, however, a user can focus on it. Read-only text fields are usually
76 * used to display pre-filled forms that a user can not edit.
77 * @param inputTransformation Optional [InputTransformation] that will be used to transform changes
78 * to the [TextFieldState] made by the user. The transformation will be applied to changes made by
79 * hardware and software keyboard events, pasting or dropping text, accessibility services, and
80 * tests. The transformation will _not_ be applied when changing the [state] programmatically, or
81 * when the transformation is changed. If the transformation is changed on an existing text field,
82 * it will be applied to the next user edit. The transformation will not immediately affect the
83 * current [state].
84 * @param textStyle Style configuration for text content that's displayed in the editor.
85 * @param keyboardOptions Software keyboard options that contain configurations such as
86 * [KeyboardType] and [ImeAction]. This composable by default configures [KeyboardOptions] for a
87 * secure text field by disabling auto correct and setting [KeyboardType] to
88 * [KeyboardType.Password].
89 * @param onKeyboardAction Called when the user presses the action button in the input method editor
90 * (IME), or by pressing the enter key on a hardware keyboard. By default this parameter is null,
91 * and would execute the default behavior for a received IME Action e.g., [ImeAction.Done] would
92 * close the keyboard, [ImeAction.Next] would switch the focus to the next focusable item on the
93 * screen.
94 * @param onTextLayout Callback that is executed when the text layout becomes queryable. The
95 * callback receives a function that returns a [TextLayoutResult] if the layout can be calculated,
96 * or null if it cannot. The function reads the layout result from a snapshot state object, and
97 * will invalidate its caller when the layout result changes. A [TextLayoutResult] object contains
98 * paragraph information, size of the text, baselines and other details. The callback can be used
99 * to add additional decoration or functionality to the text. For example, to draw a cursor or
100 * selection around the text. [Density] scope is the one that was used while creating the given
101 * text layout.
102 * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
103 * for this TextField. You can create and pass in your own remembered [MutableInteractionSource]
104 * if you want to observe [Interaction]s and customize the appearance / behavior of this TextField
105 * for different [Interaction]s.
106 * @param cursorBrush [Brush] to paint cursor with. If [SolidColor] with [Color.Unspecified]
107 * provided, there will be no cursor drawn.
108 * @param decorator Allows to add decorations around text field, such as icon, placeholder, helper
109 * messages or similar, and automatically increase the hit target area of the text field.
110 * @param textObfuscationMode Determines the method used to obscure the input text.
111 * @param textObfuscationCharacter Which character to use while obfuscating the text. It doesn't
112 * have an effect when [textObfuscationMode] is set to [TextObfuscationMode.Visible].
113 */
114 // This takes a composable lambda, but it is not primarily a container.
115 @Suppress("ComposableLambdaParameterPosition")
116 @Composable
117 fun BasicSecureTextField(
118 state: TextFieldState,
119 modifier: Modifier = Modifier,
120 enabled: Boolean = true,
121 readOnly: Boolean = false,
122 inputTransformation: InputTransformation? = null,
123 textStyle: TextStyle = TextStyle.Default,
124 keyboardOptions: KeyboardOptions = KeyboardOptions.SecureTextField,
125 onKeyboardAction: KeyboardActionHandler? = null,
126 onTextLayout: (Density.(getResult: () -> TextLayoutResult?) -> Unit)? = null,
127 interactionSource: MutableInteractionSource? = null,
128 cursorBrush: Brush = SolidColor(Color.Black),
129 decorator: TextFieldDecorator? = null,
130 // Last parameter must not be a function unless it's intended to be commonly used as a trailing
131 // lambda.
132 textObfuscationMode: TextObfuscationMode = TextObfuscationMode.RevealLastTyped,
133 textObfuscationCharacter: Char = DefaultObfuscationCharacter,
134 ) {
135 val obfuscationMaskState = rememberUpdatedState(textObfuscationCharacter)
136 val secureTextFieldController = remember { SecureTextFieldController(obfuscationMaskState) }
137 LaunchedEffect(secureTextFieldController) {
138 // start a coroutine that listens for scheduled hide events.
139 secureTextFieldController.observeHideEvents()
140 }
141
142 // revealing last typed character depends on two conditions;
143 // 1 - Requested Obfuscation method
144 // 2 - if the system allows it
145 val revealLastTypedEnabled = textObfuscationMode == TextObfuscationMode.RevealLastTyped
146
147 // while toggling between obfuscation methods if the revealing gets disabled, reset the reveal.
148 LaunchedEffect(revealLastTypedEnabled) {
149 if (!revealLastTypedEnabled) {
150 secureTextFieldController.passwordInputTransformation.hide()
151 }
152 }
153
154 val codepointTransformation =
155 remember(textObfuscationMode) {
156 when (textObfuscationMode) {
157 TextObfuscationMode.RevealLastTyped -> {
158 secureTextFieldController.codepointTransformation
159 }
160 TextObfuscationMode.Hidden -> {
161 CodepointTransformation { _, _ -> obfuscationMaskState.value.code }
162 }
163 else -> null
164 }
165 }
166
167 val secureTextFieldModifier =
168 modifier
169 .semantics { contentType = ContentType.Password }
170 .onPreviewKeyEvent { keyEvent ->
171 // BasicTextField uses this static mapping
172 val command = platformDefaultKeyMapping.map(keyEvent)
173 // do not propagate copy and cut operations
174 command == KeyCommand.COPY || command == KeyCommand.CUT
175 }
176 .then(
177 if (revealLastTypedEnabled) {
178 secureTextFieldController.focusChangeModifier
179 } else {
180 Modifier
181 }
182 )
183
184 DisableCutCopy {
185 BasicTextField(
186 state = state,
187 modifier = secureTextFieldModifier,
188 enabled = enabled,
189 readOnly = readOnly,
190 inputTransformation =
191 if (revealLastTypedEnabled) {
192 inputTransformation.then(secureTextFieldController.passwordInputTransformation)
193 } else inputTransformation,
194 textStyle = textStyle,
195 keyboardOptions = keyboardOptions,
196 onKeyboardAction = onKeyboardAction,
197 lineLimits = TextFieldLineLimits.SingleLine,
198 onTextLayout = onTextLayout,
199 interactionSource = interactionSource,
200 cursorBrush = cursorBrush,
201 codepointTransformation = codepointTransformation,
202 decorator = decorator,
203 isPassword = true,
204 )
205 }
206 }
207
208 /** Enables chaining nullable transformations with the regular chaining order. */
InputTransformationnull209 private fun InputTransformation?.then(next: InputTransformation?): InputTransformation? {
210 return when {
211 this == null -> next
212 next == null -> this
213 else -> this.then(next)
214 }
215 }
216
217 internal class SecureTextFieldController(private val obfuscationMaskState: State<Char>) {
218 /**
219 * A special [InputTransformation] that tracks changes to the content to identify the last typed
220 * character to reveal. `scheduleHide` lambda is delegated to a member function to be able to
221 * use [passwordInputTransformation] instance.
222 */
223 val passwordInputTransformation = PasswordInputTransformation(::scheduleHide)
224
225 /** Pass to [BasicTextField] for obscuring text input. */
codepointnull226 val codepointTransformation = CodepointTransformation { codepointIndex, codepoint ->
227 if (codepointIndex == passwordInputTransformation.revealCodepointIndex) {
228 // reveal the last typed character by not obscuring it
229 codepoint
230 } else {
231 obfuscationMaskState.value.code
232 }
233 }
234
235 val focusChangeModifier =
<lambda>null236 Modifier.onFocusChanged { if (!it.isFocused) passwordInputTransformation.hide() }
237
238 private val resetTimerSignal = Channel<Unit>(Channel.UNLIMITED)
239
observeHideEventsnull240 suspend fun observeHideEvents() {
241 resetTimerSignal.consumeAsFlow().collectLatest {
242 delay(LAST_TYPED_CHARACTER_REVEAL_DURATION_MILLIS)
243 passwordInputTransformation.hide()
244 }
245 }
246
scheduleHidenull247 private fun scheduleHide() {
248 // signal the listener that a new hide call is scheduled.
249 val result = resetTimerSignal.trySend(Unit)
250 if (result.isFailure) {
251 passwordInputTransformation.hide()
252 }
253 }
254 }
255
256 /**
257 * Special filter that tracks the changes in a TextField to identify the last typed character and
258 * mark it for reveal in password fields.
259 *
260 * @param scheduleHide A lambda that schedules a [hide] call into future after a new character is
261 * typed.
262 */
263 @OptIn(ExperimentalFoundationApi::class)
264 internal class PasswordInputTransformation(val scheduleHide: () -> Unit) : InputTransformation {
265 // TODO: Consider setting this as a tracking annotation in AnnotatedString.
266 internal var revealCodepointIndex by mutableIntStateOf(-1)
267 private set
268
transformInputnull269 override fun TextFieldBuffer.transformInput() {
270 // We only care about changes that add a single character
271 val singleCharacterChange = changes.changeCount == 1 && changes.getRange(0).length == 1
272
273 // if there is an expanded selection, don't reveal anything
274 if (!singleCharacterChange || hasSelection) {
275 revealCodepointIndex = -1
276 return
277 }
278
279 val insertionPoint = changes.getRange(0).min
280 if (revealCodepointIndex != insertionPoint) {
281 // start the timer for auto hide
282 scheduleHide()
283 revealCodepointIndex = insertionPoint
284 }
285 }
286
287 /** Removes any revealed character index. Everything goes back into hiding. */
hidenull288 fun hide() {
289 revealCodepointIndex = -1
290 }
291 }
292
293 // adopted from PasswordTransformationMethod from Android platform.
294 private const val LAST_TYPED_CHARACTER_REVEAL_DURATION_MILLIS = 1500L
295
296 private const val DefaultObfuscationCharacter: Char = '\u2022'
297
298 /**
299 * Overrides the TextToolbar and keyboard shortcuts to never allow copy or cut options by the
300 * composables inside [content].
301 */
302 @Composable
DisableCutCopynull303 private fun DisableCutCopy(content: @Composable () -> Unit) {
304 val currentToolbar = LocalTextToolbar.current
305 val copyDisabledToolbar =
306 remember(currentToolbar) {
307 object : TextToolbar by currentToolbar {
308 override fun showMenu(
309 rect: Rect,
310 onCopyRequested: (() -> Unit)?,
311 onPasteRequested: (() -> Unit)?,
312 onCutRequested: (() -> Unit)?,
313 onSelectAllRequested: (() -> Unit)?,
314 onAutofillRequested: (() -> Unit)?
315 ) {
316 currentToolbar.showMenu(
317 rect = rect,
318 onPasteRequested = onPasteRequested,
319 onSelectAllRequested = onSelectAllRequested,
320 onCopyRequested = null,
321 onCutRequested = null,
322 onAutofillRequested = onAutofillRequested
323 )
324 }
325 }
326 }
327 CompositionLocalProvider(LocalTextToolbar provides copyDisabledToolbar, content)
328 }
329
330 @Deprecated(
331 message = "Please use the overload that takes in readOnly parameter.",
332 level = DeprecationLevel.HIDDEN
333 )
334 @Suppress("ComposableLambdaParameterPosition")
335 @Composable
BasicSecureTextFieldnull336 fun BasicSecureTextField(
337 state: TextFieldState,
338 modifier: Modifier = Modifier,
339 enabled: Boolean = true,
340 inputTransformation: InputTransformation? = null,
341 textStyle: TextStyle = TextStyle.Default,
342 keyboardOptions: KeyboardOptions = KeyboardOptions.SecureTextField,
343 onKeyboardAction: KeyboardActionHandler? = null,
344 onTextLayout: (Density.(getResult: () -> TextLayoutResult?) -> Unit)? = null,
345 interactionSource: MutableInteractionSource? = null,
346 cursorBrush: Brush = SolidColor(Color.Black),
347 decorator: TextFieldDecorator? = null,
348 // Last parameter must not be a function unless it's intended to be commonly used as a trailing
349 // lambda.
350 textObfuscationMode: TextObfuscationMode = TextObfuscationMode.RevealLastTyped,
351 textObfuscationCharacter: Char = DefaultObfuscationCharacter,
352 ) {
353 BasicSecureTextField(
354 state = state,
355 modifier = modifier,
356 enabled = enabled,
357 readOnly = false,
358 inputTransformation = inputTransformation,
359 textStyle = textStyle,
360 keyboardOptions = keyboardOptions,
361 onKeyboardAction = onKeyboardAction,
362 onTextLayout = onTextLayout,
363 interactionSource = interactionSource,
364 cursorBrush = cursorBrush,
365 decorator = decorator,
366 textObfuscationMode = textObfuscationMode,
367 textObfuscationCharacter = textObfuscationCharacter
368 )
369 }
370