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