You've already forked FIDO2_Bridge
mirror of
https://github.com/token2/FIDO2_Bridge.git
synced 2026-03-13 11:12:26 -07:00
Extract PIN input into reusable PinInputField component
This commit is contained in:
@@ -5,13 +5,10 @@ import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
@@ -23,8 +20,6 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
|
||||
class CredentialBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
@@ -49,8 +44,7 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var btnCancel: MaterialButton
|
||||
private lateinit var btnContinue: MaterialButton
|
||||
private lateinit var pinInputLayout: TextInputLayout
|
||||
private lateinit var pinEditText: TextInputEditText
|
||||
private lateinit var pinInputField: PinInputField
|
||||
private lateinit var iconStatus: ImageView
|
||||
private lateinit var iconBackground: View
|
||||
private lateinit var accountList: RecyclerView
|
||||
@@ -66,10 +60,6 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
|
||||
var onPinEntered: ((String) -> Unit)? = null
|
||||
var onAccountSelected: ((Int) -> Unit)? = null
|
||||
|
||||
private val useNumericKeyboard: Boolean
|
||||
get() = requireContext().getSharedPreferences("authnkey_prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("use_numeric_keyboard", true)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -95,22 +85,24 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
|
||||
progressBar = view.findViewById(R.id.progressBar)
|
||||
btnCancel = view.findViewById(R.id.btnCancel)
|
||||
btnContinue = view.findViewById(R.id.btnContinue)
|
||||
pinInputLayout = view.findViewById(R.id.pinInputLayout)
|
||||
pinEditText = view.findViewById(R.id.pinEditText)
|
||||
pinInputField = view.findViewById(R.id.pinInputField)
|
||||
iconStatus = view.findViewById(R.id.iconStatus)
|
||||
iconBackground = view.findViewById(R.id.iconBackground)
|
||||
accountList = view.findViewById(R.id.accountList)
|
||||
|
||||
accountList.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
// Configure PIN input field
|
||||
pinInputField.useNumericKeyboard = getKeyboardPreference()
|
||||
pinInputField.onKeyboardModeChanged = { saveKeyboardPreference(it) }
|
||||
|
||||
pendingStatus?.let { statusText.text = it }
|
||||
pendingInstruction?.let { instructionText.text = it }
|
||||
|
||||
if (pendingShowPinInput) {
|
||||
pinInputLayout.visibility = View.VISIBLE
|
||||
pinInputField.visibility = View.VISIBLE
|
||||
btnContinue.visibility = View.VISIBLE
|
||||
applyKeyboardMode(useNumericKeyboard)
|
||||
pinEditText.requestFocus()
|
||||
pinInputField.focus()
|
||||
}
|
||||
|
||||
applyState(pendingState)
|
||||
@@ -123,17 +115,8 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
|
||||
submitPin()
|
||||
}
|
||||
|
||||
pinEditText.setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
submitPin()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pinInputLayout.setStartIconOnClickListener {
|
||||
onKeyboardModeToggled()
|
||||
pinInputField.setOnDoneAction {
|
||||
submitPin()
|
||||
}
|
||||
|
||||
(dialog as? BottomSheetDialog)?.behavior?.apply {
|
||||
@@ -153,57 +136,18 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
private fun submitPin() {
|
||||
val pin = pinEditText.text?.toString() ?: ""
|
||||
if (pin.length >= 4) {
|
||||
pinInputLayout.error = null
|
||||
pinInputField.validateAndGetPin()?.let { pin ->
|
||||
onPinEntered?.invoke(pin)
|
||||
} else {
|
||||
pinInputLayout.error = getString(R.string.pin_too_short)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyKeyboardMode(numeric: Boolean) {
|
||||
pinInputLayout.setStartIconDrawable(
|
||||
if (numeric) R.drawable.keyboard_24 else R.drawable.dialpad_24
|
||||
)
|
||||
private fun getKeyboardPreference(): Boolean =
|
||||
requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(PREF_USE_NUMERIC_KEYBOARD, true)
|
||||
|
||||
pinEditText.inputType = if (numeric) {
|
||||
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
} else {
|
||||
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
|
||||
private fun onKeyboardModeToggled() {
|
||||
val newMode = !useNumericKeyboard
|
||||
|
||||
requireContext().getSharedPreferences("authnkey_prefs", Context.MODE_PRIVATE)
|
||||
.edit {
|
||||
putBoolean("use_numeric_keyboard", newMode)
|
||||
}
|
||||
|
||||
pinEditText.inputType = if (newMode) {
|
||||
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
} else {
|
||||
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
|
||||
val iconRes = if (newMode) R.drawable.keyboard_24 else R.drawable.dialpad_24
|
||||
pinInputLayout.findViewById<View>(com.google.android.material.R.id.text_input_start_icon)?.let { iconView ->
|
||||
iconView.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(100)
|
||||
.withEndAction {
|
||||
pinInputLayout.setStartIconDrawable(iconRes)
|
||||
iconView.alpha = 1f
|
||||
}
|
||||
.start()
|
||||
} ?: pinInputLayout.setStartIconDrawable(iconRes)
|
||||
|
||||
pinEditText.post {
|
||||
val imm = requireContext().getSystemService(InputMethodManager::class.java)
|
||||
imm?.restartInput(pinEditText)
|
||||
}
|
||||
private fun saveKeyboardPreference(numeric: Boolean) {
|
||||
requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit { putBoolean(PREF_USE_NUMERIC_KEYBOARD, numeric) }
|
||||
}
|
||||
|
||||
fun setState(state: State) {
|
||||
@@ -284,23 +228,14 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
fun showPinInput(show: Boolean) {
|
||||
if (::pinInputLayout.isInitialized) {
|
||||
pinInputLayout.visibility = if (show) View.VISIBLE else View.GONE
|
||||
if (::pinInputField.isInitialized) {
|
||||
pinInputField.visibility = if (show) View.VISIBLE else View.GONE
|
||||
btnContinue.visibility = if (show) View.VISIBLE else View.GONE
|
||||
if (show) {
|
||||
hideAccounts()
|
||||
pinEditText.text?.clear()
|
||||
pinInputLayout.error = null
|
||||
applyKeyboardMode(useNumericKeyboard)
|
||||
pinEditText.requestFocus()
|
||||
pinInputField.clear()
|
||||
setState(State.PIN)
|
||||
pinEditText.post {
|
||||
val imm = requireContext().getSystemService(InputMethodManager::class.java)
|
||||
imm?.showSoftInput(pinEditText, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
} else {
|
||||
val imm = requireContext().getSystemService(InputMethodManager::class.java)
|
||||
imm?.hideSoftInputFromWindow(pinEditText.windowToken, 0)
|
||||
pinInputField.focus()
|
||||
}
|
||||
} else {
|
||||
pendingShowPinInput = show
|
||||
@@ -312,7 +247,7 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
|
||||
if (!::accountList.isInitialized) return
|
||||
|
||||
setState(State.ACCOUNT_SELECT)
|
||||
pinInputLayout.visibility = View.GONE
|
||||
pinInputField.visibility = View.GONE
|
||||
btnContinue.visibility = View.GONE
|
||||
accountList.visibility = View.VISIBLE
|
||||
accountList.adapter = AccountAdapter(accounts) { index ->
|
||||
@@ -327,15 +262,15 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
fun setPinError(error: String?) {
|
||||
if (::pinInputLayout.isInitialized) {
|
||||
pinInputLayout.error = error
|
||||
if (::pinInputField.isInitialized) {
|
||||
pinInputField.error = error
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentPinIfValid(): String? {
|
||||
if (!::pinEditText.isInitialized) return null
|
||||
val pin = pinEditText.text?.toString() ?: return null
|
||||
return if (pin.length >= 4) pin else null
|
||||
if (!::pinInputField.isInitialized) return null
|
||||
val pin = pinInputField.pin ?: return null
|
||||
return if (pin.length >= pinInputField.minPinLength) pin else null
|
||||
}
|
||||
|
||||
private class AccountAdapter(
|
||||
@@ -375,6 +310,8 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
|
||||
const val TAG = "CredentialBottomSheet"
|
||||
private const val ARG_STATUS = "status"
|
||||
private const val ARG_INSTRUCTION = "instruction"
|
||||
private const val PREFS_NAME = "authnkey_prefs"
|
||||
private const val PREF_USE_NUMERIC_KEYBOARD = "use_numeric_keyboard"
|
||||
|
||||
fun newInstance(status: String, instruction: String): CredentialBottomSheet {
|
||||
return CredentialBottomSheet().apply {
|
||||
|
||||
186
app/src/main/java/pl/lebihan/authnkey/PinInputField.kt
Normal file
186
app/src/main/java/pl/lebihan/authnkey/PinInputField.kt
Normal file
@@ -0,0 +1,186 @@
|
||||
package pl.lebihan.authnkey
|
||||
|
||||
import android.content.Context
|
||||
import android.text.InputType
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.google.android.material.theme.overlay.MaterialThemeOverlay
|
||||
import androidx.core.view.isVisible
|
||||
|
||||
/**
|
||||
* A reusable PIN input field that extends [TextInputLayout] with:
|
||||
* - Password visibility toggle
|
||||
* - Switchable numeric/alphanumeric keyboard
|
||||
* - Built-in validation for minimum PIN length
|
||||
*
|
||||
* Configure via XML attributes [R.styleable.PinInputField] or programmatically.
|
||||
*/
|
||||
class PinInputField @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = com.google.android.material.R.attr.textInputStyle
|
||||
) : TextInputLayout(
|
||||
MaterialThemeOverlay.wrap(context, attrs, defStyleAttr, 0),
|
||||
attrs,
|
||||
defStyleAttr
|
||||
) {
|
||||
|
||||
private val pinEditText = TextInputEditText(this.context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to use a numeric keyboard. Updating this property will immediately
|
||||
* update the keyboard type and toggle icon.
|
||||
*/
|
||||
var useNumericKeyboard: Boolean = true
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
applyKeyboardMode()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when the user toggles the keyboard mode via the start icon.
|
||||
* Use this to persist the preference if desired.
|
||||
*/
|
||||
var onKeyboardModeChanged: ((Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Minimum PIN length for validation. Defaults to 4.
|
||||
*/
|
||||
var minPinLength: Int = 4
|
||||
|
||||
init {
|
||||
addView(pinEditText)
|
||||
|
||||
context.withStyledAttributes(attrs, R.styleable.PinInputField) {
|
||||
hint = getString(R.styleable.PinInputField_pinHint)
|
||||
?: context.getString(R.string.pin_hint)
|
||||
minPinLength = getInt(R.styleable.PinInputField_minPinLength, 4)
|
||||
}
|
||||
|
||||
endIconMode = END_ICON_PASSWORD_TOGGLE
|
||||
setStartIconContentDescription(R.string.toggle_keyboard_type)
|
||||
setStartIconOnClickListener { onKeyboardModeToggled() }
|
||||
|
||||
applyKeyboardMode(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* The current PIN text.
|
||||
*/
|
||||
val pin: String?
|
||||
get() = pinEditText.text?.toString()
|
||||
|
||||
/**
|
||||
* Validates the PIN against [minPinLength].
|
||||
* Sets the error message if invalid, clears it if valid.
|
||||
* @return true if valid, false otherwise
|
||||
*/
|
||||
fun validate(): Boolean {
|
||||
val currentPin = pin
|
||||
return if (currentPin != null && currentPin.length >= minPinLength) {
|
||||
error = null
|
||||
true
|
||||
} else {
|
||||
error = context.getString(R.string.pin_too_short)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the PIN against [minPinLength]. Returns the PIN if valid,
|
||||
* or null if invalid (also sets the error message).
|
||||
*/
|
||||
fun validateAndGetPin(): String? {
|
||||
return if (validate()) pin else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the PIN text and any error message.
|
||||
*/
|
||||
fun clear() {
|
||||
pinEditText.text?.clear()
|
||||
error = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests focus and shows the soft keyboard.
|
||||
*/
|
||||
fun focus() {
|
||||
pinEditText.requestFocus()
|
||||
pinEditText.post {
|
||||
val imm = context.getSystemService(InputMethodManager::class.java)
|
||||
imm?.showSoftInput(pinEditText, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a callback for the IME "Done" action.
|
||||
*/
|
||||
fun setOnDoneAction(action: () -> Unit) {
|
||||
pinEditText.setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
action()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setVisibility(visibility: Int) {
|
||||
if (visibility != VISIBLE && pinEditText.hasFocus()) {
|
||||
val imm = context.getSystemService(InputMethodManager::class.java)
|
||||
imm?.hideSoftInputFromWindow(pinEditText.windowToken, 0)
|
||||
}
|
||||
super.setVisibility(visibility)
|
||||
}
|
||||
|
||||
private fun applyKeyboardMode(animate: Boolean = true) {
|
||||
val iconRes = if (useNumericKeyboard) R.drawable.keyboard_24 else R.drawable.dialpad_24
|
||||
|
||||
if (animate && isVisible) {
|
||||
findViewById<View>(com.google.android.material.R.id.text_input_start_icon)?.let { iconView ->
|
||||
iconView.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(ICON_ANIMATION_DURATION)
|
||||
.withEndAction {
|
||||
setStartIconDrawable(iconRes)
|
||||
iconView.alpha = 1f
|
||||
}
|
||||
.start()
|
||||
} ?: setStartIconDrawable(iconRes)
|
||||
} else {
|
||||
setStartIconDrawable(iconRes)
|
||||
}
|
||||
|
||||
pinEditText.inputType = if (useNumericKeyboard) {
|
||||
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
} else {
|
||||
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
|
||||
private fun onKeyboardModeToggled() {
|
||||
useNumericKeyboard = !useNumericKeyboard
|
||||
onKeyboardModeChanged?.invoke(useNumericKeyboard)
|
||||
|
||||
pinEditText.post {
|
||||
val imm = context.getSystemService(InputMethodManager::class.java)
|
||||
imm?.restartInput(pinEditText)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ICON_ANIMATION_DURATION = 100L
|
||||
}
|
||||
}
|
||||
@@ -69,25 +69,13 @@
|
||||
android:visibility="gone"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/pinInputLayout"
|
||||
<pl.lebihan.authnkey.PinInputField
|
||||
android:id="@+id/pinInputField"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:visibility="gone"
|
||||
android:hint="@string/pin_hint"
|
||||
app:startIconDrawable="@drawable/keyboard_24"
|
||||
app:startIconContentDescription="@string/toggle_keyboard_type"
|
||||
app:endIconMode="password_toggle"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/pinEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionDone" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnContinue"
|
||||
|
||||
7
app/src/main/res/values/attrs.xml
Normal file
7
app/src/main/res/values/attrs.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<declare-styleable name="PinInputField">
|
||||
<attr name="pinHint" format="string" />
|
||||
<attr name="minPinLength" format="integer" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user