BasicSecureTextField.kt

/*
 * Copyright 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.foundation.text2

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text2.input.CodepointTransformation
import androidx.compose.foundation.text2.input.TextEditFilter
import androidx.compose.foundation.text2.input.TextFieldBufferWithSelection
import androidx.compose.foundation.text2.input.TextFieldCharSequence
import androidx.compose.foundation.text2.input.TextFieldLineLimits
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.foundation.text2.input.TextObfuscationMode
import androidx.compose.foundation.text2.input.mask
import androidx.compose.foundation.text2.input.then
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.semantics.password
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Density
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch

/**
 * BasicSecureTextField is a new text input component that is still in heavy development.
 * We strongly advise against using it in production as its API and implementation are currently
 * unstable. Many essential features such as selection, cursor, gestures, etc. may not work
 * correctly or may not even exist yet.
 *
 * BasicSecureTextField is specifically designed for password entry fields and is a preconfigured
 * alternative to BasicTextField2. It only supports a single line of content and comes with default
 * settings for KeyboardOptions, filter, and codepointTransformation that are appropriate for
 * entering secure content. Additionally, some context menu actions like cut, copy, and drag are
 * disabled for added security.
 *
 * @param state [TextFieldState] object that holds the internal state of a [BasicTextField2].
 * @param modifier optional [Modifier] for this text field.
 * @param enabled controls the enabled state of the [BasicTextField2]. When `false`, the text
 * field will be neither editable nor focusable, the input of the text field will not be selectable.
 * @param onSubmit Called when the user submits a form either by pressing the action button in the
 * input method editor (IME), or by pressing the enter key on a hardware keyboard. If the user
 * submits the form by pressing the action button in the IME, the provided IME action is passed to
 * the function. If the user submits the form by pressing the enter key on a hardware keyboard,
 * the defined [imeAction] parameter is passed to the function. Return true to indicate that the
 * action has been handled completely, which will skip the default behavior, such as hiding the
 * keyboard for the [ImeAction.Done] action.
 * @param imeAction The IME action. This IME action is honored by keyboard and may show specific
 * icons on the keyboard.
 * @param textObfuscationMode Determines the method used to obscure the input text.
 * @param keyboardType The keyboard type to be used in this text field. It is set to
 * [KeyboardType.Password] by default. Use [KeyboardType.NumberPassword] for numerical password
 * fields.
 * @param filter Optional [TextEditFilter] that will be used to filter changes to the
 * [TextFieldState] made by the user. The filter will be applied to changes made by hardware and
 * software keyboard events, pasting or dropping text, accessibility services, and tests. The filter
 * will _not_ be applied when changing the [state] programmatically, or when the filter is changed.
 * If the filter is changed on an existing text field, it will be applied to the next user edit.
 * the filter will not immediately affect the current [state].
 * @param textStyle Style configuration for text content that's displayed in the editor.
 * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
 * for this TextField. You can create and pass in your own remembered [MutableInteractionSource]
 * if you want to observe [Interaction]s and customize the appearance / behavior of this TextField
 * for different [Interaction]s.
 * @param cursorBrush [Brush] to paint cursor with. If [SolidColor] with [Color.Unspecified]
 * provided, there will be no cursor drawn
 * @param scrollState Used to manage the horizontal scroll when the input content exceeds the
 * bounds of the text field. It controls the state of the scroll for the text field.
 * @param onTextLayout Callback that is executed when a new text layout is calculated. A
 * [TextLayoutResult] object that callback provides contains paragraph information, size of the
 * text, baselines and other details. The callback can be used to add additional decoration or
 * functionality to the text. For example, to draw a cursor or selection around the text. [Density]
 * scope is the one that was used while creating the given text layout.
 * @param decorationBox Composable lambda that allows to add decorations around text field, such
 * as icon, placeholder, helper messages or similar, and automatically increase the hit target area
 * of the text field. To allow you to control the placement of the inner text field relative to your
 * decorations, the text field implementation will pass in a framework-controlled composable
 * parameter "innerTextField" to the decorationBox lambda you provide. You must call
 * innerTextField exactly once.
 */
@ExperimentalFoundationApi
@Composable
fun BasicSecureTextField(
    state: TextFieldState,
    modifier: Modifier = Modifier,
    onSubmit: ((ImeAction) -> Boolean)? = null,
    imeAction: ImeAction = ImeAction.Default,
    textObfuscationMode: TextObfuscationMode = TextObfuscationMode.RevealLastTyped,
    keyboardType: KeyboardType = KeyboardType.Password,
    enabled: Boolean = true,
    filter: TextEditFilter? = null,
    textStyle: TextStyle = TextStyle.Default,
    interactionSource: MutableInteractionSource? = null,
    cursorBrush: Brush = SolidColor(Color.Black),
    scrollState: ScrollState = rememberScrollState(),
    onTextLayout: Density.(TextLayoutResult) -> Unit = {},
    decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit =
        @Composable { innerTextField -> innerTextField() }
) {
    val coroutineScope = rememberCoroutineScope()
    val secureTextFieldController = remember(coroutineScope) {
        SecureTextFieldController(coroutineScope)
    }

    // revealing last typed character depends on two conditions;
    // 1 - Requested Obfuscation method
    // 2 - if the system allows it
    val revealLastTypedEnabled = textObfuscationMode == TextObfuscationMode.RevealLastTyped

    // while toggling between obfuscation methods if the revealing gets disabled, reset the reveal.
    if (!revealLastTypedEnabled) {
        secureTextFieldController.passwordRevealFilter.hide()
    }

    val codepointTransformation = when {
        revealLastTypedEnabled -> {
            secureTextFieldController.codepointTransformation
        }

        textObfuscationMode == TextObfuscationMode.Hidden -> {
            CodepointTransformation.mask('\u2022')
        }

        else -> {
            CodepointTransformation.None
        }
    }

    val secureTextFieldModifier = modifier
        .semantics(mergeDescendants = true) { password() }
        .then(
            if (revealLastTypedEnabled) {
                secureTextFieldController.focusChangeModifier
            } else {
                Modifier
            }
        )

    BasicTextField2(
        state = state,
        modifier = secureTextFieldModifier,
        enabled = enabled,
        readOnly = false,
        filter = if (revealLastTypedEnabled) {
            filter?.then(secureTextFieldController.passwordRevealFilter)
                ?: secureTextFieldController.passwordRevealFilter
        } else filter,
        textStyle = textStyle,
        interactionSource = interactionSource,
        cursorBrush = cursorBrush,
        lineLimits = TextFieldLineLimits.SingleLine,
        scrollState = scrollState,
        keyboardOptions = KeyboardOptions(
            autoCorrect = false,
            keyboardType = keyboardType,
            imeAction = imeAction
        ),
        keyboardActions = onSubmit?.let { KeyboardActions(onSubmit = it) }
            ?: KeyboardActions.Default,
        onTextLayout = onTextLayout,
        codepointTransformation = codepointTransformation,
        decorationBox = decorationBox,
    )
}

@OptIn(ExperimentalFoundationApi::class)
internal class SecureTextFieldController(
    coroutineScope: CoroutineScope
) {
    /**
     * A special [TextEditFilter] that tracks changes to the content to identify the last typed
     * character to reveal. `scheduleHide` lambda is delegated to a member function to be able to
     * use [passwordRevealFilter] instance.
     */
    val passwordRevealFilter = PasswordRevealFilter(::scheduleHide)

    /**
     * Pass to [BasicTextField2] for obscuring text input.
     */
    val codepointTransformation = CodepointTransformation { codepointIndex, codepoint ->
        if (codepointIndex == passwordRevealFilter.revealCodepointIndex) {
            // reveal the last typed character by not obscuring it
            codepoint
        } else {
            0x2022
        }
    }

    val focusChangeModifier = Modifier.onFocusChanged {
        if (!it.isFocused) passwordRevealFilter.hide()
    }

    private val resetTimerSignal = Channel<Unit>(Channel.UNLIMITED)

    init {
        // start a coroutine that listens for scheduled hide events.
        coroutineScope.launch {
            resetTimerSignal.consumeAsFlow()
                .collectLatest {
                    delay(LAST_TYPED_CHARACTER_REVEAL_DURATION_MILLIS)
                    passwordRevealFilter.hide()
                }
        }
    }

    private fun scheduleHide() {
        // signal the listener that a new hide call is scheduled.
        val result = resetTimerSignal.trySend(Unit)
        if (!result.isSuccess) {
            passwordRevealFilter.hide()
        }
    }
}

/**
 * Special filter that tracks the changes in a TextField to identify the last typed character and
 * mark it for reveal in password fields.
 *
 * @param scheduleHide A lambda that schedules a [hide] call into future after a new character is
 * typed.
 */
@OptIn(ExperimentalFoundationApi::class)
internal class PasswordRevealFilter(
    val scheduleHide: () -> Unit
) : TextEditFilter {
    // TODO: Consider setting this as a tracking annotation in AnnotatedString.
    internal var revealCodepointIndex by mutableStateOf(-1)
        private set

    override fun filter(
        originalValue: TextFieldCharSequence,
        valueWithChanges: TextFieldBufferWithSelection
    ) {
        // We only care about a single character insertion changes
        val singleCharacterInsertion = valueWithChanges.changes.changeCount == 1 &&
            valueWithChanges.changes.getRange(0).length == 1 &&
            valueWithChanges.changes.getOriginalRange(0).length == 0

        // if there is an expanded selection, don't reveal anything
        if (!singleCharacterInsertion || valueWithChanges.hasSelection) {
            revealCodepointIndex = -1
            return
        }

        val insertionPoint = valueWithChanges.changes.getRange(0).min
        if (revealCodepointIndex != insertionPoint) {
            // start the timer for auto hide
            scheduleHide()
            revealCodepointIndex = insertionPoint
        }
    }

    /**
     * Removes any revealed character index. Everything goes back into hiding.
     */
    fun hide() {
        revealCodepointIndex = -1
    }
}

// adopted from PasswordTransformationMethod from Android platform.
private const val LAST_TYPED_CHARACTER_REVEAL_DURATION_MILLIS = 1500L

private fun KeyboardActions(onSubmit: (ImeAction) -> Boolean) = KeyboardActions(
    onDone = { if (!onSubmit(ImeAction.Done)) defaultKeyboardAction(ImeAction.Done) },
    onGo = { if (!onSubmit(ImeAction.Go)) defaultKeyboardAction(ImeAction.Go) },
    onNext = { if (!onSubmit(ImeAction.Next)) defaultKeyboardAction(ImeAction.Next) },
    onPrevious = { if (!onSubmit(ImeAction.Previous)) defaultKeyboardAction(ImeAction.Previous) },
    onSearch = { if (!onSubmit(ImeAction.Search)) defaultKeyboardAction(ImeAction.Search) },
    onSend = { if (!onSubmit(ImeAction.Send)) defaultKeyboardAction(ImeAction.Send) },
)