Refactor pin setup in Android to use native view (#1340)

This commit is contained in:
Leendert de Borst
2025-11-12 20:17:17 +01:00
committed by Leendert de Borst
parent 4b59776b86
commit 558d39ec96
9 changed files with 622 additions and 310 deletions

View File

@@ -103,7 +103,7 @@ class MainActivity : ReactActivity() {
}
/**
* Handle activity results - specifically for PIN unlock.
* Handle activity results - specifically for PIN unlock and PIN setup.
*/
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
@@ -111,6 +111,8 @@ class MainActivity : ReactActivity() {
// Handle PIN unlock results directly
if (requestCode == net.aliasvault.app.nativevaultmanager.NativeVaultManager.PIN_UNLOCK_REQUEST_CODE) {
handlePinUnlockResult(resultCode, data)
} else if (requestCode == net.aliasvault.app.nativevaultmanager.NativeVaultManager.PIN_SETUP_REQUEST_CODE) {
handlePinSetupResult(resultCode, data)
}
}
@@ -163,4 +165,33 @@ class MainActivity : ReactActivity() {
}
}
}
/**
* Handle PIN setup result.
* @param resultCode The result code from the PIN setup activity.
* @param data The intent data (not used for setup, setup happens internally).
*/
@Suppress("UNUSED_PARAMETER")
private fun handlePinSetupResult(resultCode: Int, data: Intent?) {
val promise = net.aliasvault.app.nativevaultmanager.NativeVaultManager.pinSetupPromise
net.aliasvault.app.nativevaultmanager.NativeVaultManager.pinSetupPromise = null
if (promise == null) {
return
}
when (resultCode) {
net.aliasvault.app.pinunlock.PinUnlockActivity.RESULT_SUCCESS -> {
// PIN setup successful
promise.resolve(null)
}
net.aliasvault.app.pinunlock.PinUnlockActivity.RESULT_CANCELLED -> {
// User cancelled PIN setup
promise.reject("USER_CANCELLED", "User cancelled PIN setup", null)
}
else -> {
promise.reject("SETUP_ERROR", "PIN setup failed", null)
}
}
}
}

View File

@@ -57,6 +57,11 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
*/
const val PIN_UNLOCK_REQUEST_CODE = 1001
/**
* Request code for PIN setup activity.
*/
const val PIN_SETUP_REQUEST_CODE = 1002
/**
* Static holder for the pending promise from showPinUnlockUI.
* This allows MainActivity to resolve/reject the promise directly without
@@ -64,6 +69,14 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
*/
@Volatile
var pendingActivityResultPromise: Promise? = null
/**
* Static holder for the pending promise from showNativePinSetup.
* This allows MainActivity to resolve/reject the promise directly without
* depending on React context availability.
*/
@Volatile
var pinSetupPromise: Promise? = null
}
private val vaultStore = VaultStore.getInstance(
@@ -1315,6 +1328,46 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
})
}
/**
* Show native PIN setup UI.
* Launches the native PinUnlockActivity in setup mode.
* Gets the vault encryption key from memory (vault must be unlocked).
* @param promise The promise to resolve when setup completes or rejects if cancelled/error.
*/
@ReactMethod
override fun showNativePinSetup(promise: Promise) {
// Get encryption key first
vaultStore.getEncryptionKey(object : net.aliasvault.app.vaultstore.interfaces.CryptoOperationCallback {
override fun onSuccess(encryptionKey: String) {
try {
val activity = currentActivity
if (activity == null) {
promise.reject("ERR_NO_ACTIVITY", "No activity available")
return
}
// Store the promise for later resolution
pinSetupPromise = promise
// Launch PIN setup activity
val intent = android.content.Intent(activity, net.aliasvault.app.pinunlock.PinUnlockActivity::class.java)
intent.putExtra(net.aliasvault.app.pinunlock.PinUnlockActivity.EXTRA_MODE, net.aliasvault.app.pinunlock.PinUnlockActivity.MODE_SETUP)
intent.putExtra(net.aliasvault.app.pinunlock.PinUnlockActivity.EXTRA_SETUP_ENCRYPTION_KEY, encryptionKey)
activity.startActivityForResult(intent, PIN_SETUP_REQUEST_CODE)
} catch (e: Exception) {
Log.e(TAG, "Error launching PIN setup activity", e)
promise.reject("ERR_LAUNCH_PIN_SETUP", "Failed to launch PIN setup: ${e.message}", e)
}
}
override fun onError(error: Exception) {
Log.e(TAG, "Error getting encryption key for PIN setup", error)
promise.reject("ERR_SETUP_PIN", "Failed to get encryption key: ${error.message}", error)
}
})
}
/**
* Unlock with PIN.
* @param pin The PIN to unlock with.

View File

@@ -0,0 +1,74 @@
package net.aliasvault.app.pinunlock
/**
* Represents the configuration and state for PIN entry screen.
*/
data class PinConfiguration(
/** The mode of operation: unlock or setup. */
val mode: PinMode,
/** The title to display. */
val title: String,
/** The subtitle/description to display. */
val subtitle: String,
/** The expected PIN length (null for variable length). */
val pinLength: Int?,
/** The current step in setup mode. */
val setupStep: PinSetupStep = PinSetupStep.ENTER_NEW,
/** The PIN entered in the first step (setup mode only). */
val firstStepPin: String? = null,
)
/**
* Modes of PIN operation.
*/
enum class PinMode {
/** Unlock vault with existing PIN. */
UNLOCK,
/** Setup a new PIN (two-step process). */
SETUP,
}
/**
* Steps in PIN setup process.
*/
enum class PinSetupStep {
/** First step: enter new PIN. */
ENTER_NEW,
/** Second step: confirm the PIN. */
CONFIRM,
}
/**
* Result of PIN processing.
*/
sealed class PinResult {
/**
* PIN processing succeeded.
* @property encryptionKey The encryption key (returned for unlock mode, null for setup mode).
*/
data class Success(val encryptionKey: String?) : PinResult()
/**
* PIN processing failed with an error.
* @property message The error message to display.
* @property shouldClear Whether to clear the entered PIN.
*/
data class Error(val message: String, val shouldClear: Boolean = true) : PinResult()
/** PIN was disabled due to max attempts. */
object PinDisabled : PinResult()
/**
* Move to next step in setup flow.
* @property newConfiguration The configuration for the next step.
*/
data class NextStep(val newConfiguration: PinConfiguration) : PinResult()
/**
* PINs don't match in setup confirmation.
* @property errorMessage The error message to display.
*/
data class Mismatch(val errorMessage: String) : PinResult()
}

View File

@@ -18,40 +18,77 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.aliasvault.app.R
import net.aliasvault.app.vaultstore.VaultStore
/**
* Native PIN unlock activity matching iOS PinUnlockView.
* This activity presents a numpad UI for PIN entry and handles vault unlocking.
* Native PIN unlock and setup activity.
* This activity presents a numpad UI for PIN entry and handles both vault unlocking and PIN setup.
*
* Modes:
* - UNLOCK: Unlock vault with existing PIN
* - SETUP: Setup new PIN (two-step: enter → confirm)
*
* Usage:
* ```
* // Unlock mode
* val intent = Intent(context, PinUnlockActivity::class.java)
* intent.putExtra(PinUnlockActivity.EXTRA_MODE, PinUnlockActivity.MODE_UNLOCK)
* startActivityForResult(intent, REQUEST_CODE)
*
* // Setup mode
* val intent = Intent(context, PinUnlockActivity::class.java)
* intent.putExtra(PinUnlockActivity.EXTRA_MODE, PinUnlockActivity.MODE_SETUP)
* intent.putExtra(PinUnlockActivity.EXTRA_SETUP_ENCRYPTION_KEY, encryptionKey)
* startActivityForResult(intent, REQUEST_CODE)
* ```
*/
class PinUnlockActivity : AppCompatActivity() {
companion object {
/** Result code for successful PIN unlock. */
/** Result code for successful PIN unlock or setup. */
const val RESULT_SUCCESS = Activity.RESULT_OK
/** Result code for cancelled PIN unlock. */
/** Result code for cancelled PIN unlock or setup. */
const val RESULT_CANCELLED = Activity.RESULT_CANCELED
/** Result code when PIN was disabled due to max attempts. */
const val RESULT_PIN_DISABLED = 100
/** Intent extra key for the encryption key. */
/** Intent extra key for the encryption key (returned in unlock mode). */
const val EXTRA_ENCRYPTION_KEY = "encryption_key"
/** Intent extra key for the mode (unlock or setup). */
const val EXTRA_MODE = "mode"
/** Intent extra key for the encryption key to use during setup. */
const val EXTRA_SETUP_ENCRYPTION_KEY = "setup_encryption_key"
/** Mode: Unlock vault with existing PIN. */
const val MODE_UNLOCK = "unlock"
/** Mode: Setup new PIN. */
const val MODE_SETUP = "setup"
}
private lateinit var viewModel: PinViewModel
private lateinit var vaultStore: VaultStore
// UI components
private lateinit var titleTextView: TextView
private lateinit var subtitleTextView: TextView
private lateinit var pinDotsContainer: LinearLayout
private lateinit var pinTextView: TextView
private lateinit var errorTextView: TextView
private lateinit var continueButton: Button
private lateinit var loadingOverlay: View
private lateinit var progressBar: ProgressBar
private var pinLength: Int? = null
// State
private var configuration: PinConfiguration? = null
private var setupEncryptionKey: String? = null
private var currentPin: String = ""
private var isUnlocking: Boolean = false
private var isProcessing: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -60,12 +97,37 @@ class PinUnlockActivity : AppCompatActivity() {
// Keep screen on during PIN entry
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Apply window insets to the root layout for safe area
findViewById<android.view.View>(android.R.id.content).setOnApplyWindowInsetsListener { view, insets ->
// Apply window insets for safe area
applyWindowInsets()
// Initialize VaultStore and ViewModel
vaultStore = VaultStore.getInstance(
net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider(this) { null },
net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider(this),
)
viewModel = PinViewModel(this, vaultStore)
// Get mode and encryption key from intent
val mode = when (intent.getStringExtra(EXTRA_MODE)) {
MODE_SETUP -> PinMode.SETUP
else -> PinMode.UNLOCK
}
setupEncryptionKey = intent.getStringExtra(EXTRA_SETUP_ENCRYPTION_KEY)
// Initialize configuration
configuration = viewModel.initializeConfiguration(mode)
// Initialize views
initializeViews()
setupNumpad()
updateUI()
}
private fun applyWindowInsets() {
findViewById<View>(android.R.id.content).setOnApplyWindowInsetsListener { _, insets ->
val cancelButton = findViewById<Button>(R.id.cancelButton)
val systemBarsInsets = insets.systemWindowInsets
// Apply top inset to cancel button
cancelButton.setPadding(
cancelButton.paddingLeft,
systemBarsInsets.top + cancelButton.paddingTop,
@@ -75,25 +137,15 @@ class PinUnlockActivity : AppCompatActivity() {
insets
}
// Initialize VaultStore
vaultStore = VaultStore.getInstance(
net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider(this) { null },
net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider(this),
)
// Get PIN length
pinLength = vaultStore.getPinLength()
// Initialize views
setupViews()
setupNumpad()
}
private fun setupViews() {
private fun initializeViews() {
titleTextView = findViewById(R.id.titleTextView)
subtitleTextView = findViewById(R.id.subtitleTextView)
pinDotsContainer = findViewById(R.id.pinDotsContainer)
pinTextView = findViewById(R.id.pinTextView)
errorTextView = findViewById(R.id.errorTextView)
continueButton = findViewById(R.id.continueButton)
loadingOverlay = findViewById(R.id.loadingOverlay)
progressBar = findViewById(R.id.progressBar)
@@ -103,18 +155,62 @@ class PinUnlockActivity : AppCompatActivity() {
finish()
}
// Continue button (for PIN setup)
continueButton.setOnClickListener {
submitPin()
}
}
private fun setupNumpad() {
// Number buttons
findViewById<Button>(R.id.btn1).setOnClickListener { addDigit("1") }
findViewById<Button>(R.id.btn2).setOnClickListener { addDigit("2") }
findViewById<Button>(R.id.btn3).setOnClickListener { addDigit("3") }
findViewById<Button>(R.id.btn4).setOnClickListener { addDigit("4") }
findViewById<Button>(R.id.btn5).setOnClickListener { addDigit("5") }
findViewById<Button>(R.id.btn6).setOnClickListener { addDigit("6") }
findViewById<Button>(R.id.btn7).setOnClickListener { addDigit("7") }
findViewById<Button>(R.id.btn8).setOnClickListener { addDigit("8") }
findViewById<Button>(R.id.btn9).setOnClickListener { addDigit("9") }
findViewById<Button>(R.id.btn0).setOnClickListener { addDigit("0") }
// Backspace button
findViewById<Button>(R.id.btnBackspace).setOnClickListener { removeDigit() }
}
private fun updateUI() {
val config = configuration ?: return
// Update title and subtitle
titleTextView.text = config.title
subtitleTextView.text = config.subtitle
// Show Continue button only for setup mode on first step (variable length)
val showContinueButton = config.mode == PinMode.SETUP &&
config.setupStep == PinSetupStep.ENTER_NEW &&
config.pinLength == null
continueButton.visibility = if (showContinueButton) View.VISIBLE else View.GONE
// Update button text based on current step
if (showContinueButton) {
continueButton.text = getString(R.string.pin_next)
}
// Setup PIN display based on whether we have a fixed length
if (pinLength != null) {
if (config.pinLength != null) {
// Show dots for fixed length PIN
pinDotsContainer.visibility = View.VISIBLE
pinTextView.visibility = View.GONE
createPinDots(pinLength!!)
createPinDots(config.pinLength)
} else {
// Show text for variable length PIN
pinDotsContainer.visibility = View.GONE
pinTextView.visibility = View.VISIBLE
updatePinText()
}
// Update continue button enabled state
updateContinueButtonState()
}
private fun createPinDots(count: Int) {
@@ -154,58 +250,51 @@ class PinUnlockActivity : AppCompatActivity() {
}
}
private fun setupNumpad() {
// Number buttons
findViewById<Button>(R.id.btn1).setOnClickListener { addDigit("1") }
findViewById<Button>(R.id.btn2).setOnClickListener { addDigit("2") }
findViewById<Button>(R.id.btn3).setOnClickListener { addDigit("3") }
findViewById<Button>(R.id.btn4).setOnClickListener { addDigit("4") }
findViewById<Button>(R.id.btn5).setOnClickListener { addDigit("5") }
findViewById<Button>(R.id.btn6).setOnClickListener { addDigit("6") }
findViewById<Button>(R.id.btn7).setOnClickListener { addDigit("7") }
findViewById<Button>(R.id.btn8).setOnClickListener { addDigit("8") }
findViewById<Button>(R.id.btn9).setOnClickListener { addDigit("9") }
findViewById<Button>(R.id.btn0).setOnClickListener { addDigit("0") }
// Backspace button
findViewById<Button>(R.id.btnBackspace).setOnClickListener { removeDigit() }
private fun updateContinueButtonState() {
val config = configuration ?: return
if (continueButton.visibility == View.VISIBLE) {
// Enable button only if PIN is at least 4 digits
continueButton.isEnabled = currentPin.length >= 4
continueButton.alpha = if (currentPin.length >= 4) 1.0f else 0.5f
}
}
private fun addDigit(digit: String) {
if (isUnlocking) return
if (isProcessing) return
val config = configuration ?: return
// Clear error when user starts typing
errorTextView.visibility = View.GONE
// Check if we've reached max length
pinLength?.let { maxLength ->
if (currentPin.length >= maxLength) return
}
val maxLength = config.pinLength ?: 8 // Max 8 digits in setup mode
if (currentPin.length >= maxLength) return
// Add digit
currentPin += digit
// Update UI
if (pinLength != null) {
if (config.pinLength != null) {
updatePinDots()
} else {
updatePinText()
}
// Auto-submit when PIN reaches expected length
pinLength?.let { expectedLength ->
if (currentPin.length == expectedLength) {
// Small delay to show the last dot filled before attempting unlock
CoroutineScope(Dispatchers.Main).launch {
delay(100)
attemptUnlock()
}
// Update continue button state
updateContinueButtonState()
// Auto-submit when PIN reaches expected length (only for fixed-length PINs)
if (viewModel.shouldAutoSubmit(currentPin.length, config)) {
CoroutineScope(Dispatchers.Main).launch {
delay(100) // Small delay to show the last dot filled
submitPin()
}
}
}
private fun removeDigit() {
if (isUnlocking || currentPin.isEmpty()) return
if (isProcessing || currentPin.isEmpty()) return
// Remove last digit
currentPin = currentPin.dropLast(1)
@@ -214,77 +303,111 @@ class PinUnlockActivity : AppCompatActivity() {
errorTextView.visibility = View.GONE
// Update UI
if (pinLength != null) {
val config = configuration ?: return
if (config.pinLength != null) {
updatePinDots()
} else {
updatePinText()
}
// Update continue button state
updateContinueButtonState()
}
private fun attemptUnlock() {
if (isUnlocking) return
private fun submitPin() {
if (isProcessing || currentPin.isEmpty()) return
val config = configuration ?: return
// Validate minimum length for setup mode
if (config.mode == PinMode.SETUP &&
config.setupStep == PinSetupStep.ENTER_NEW &&
currentPin.length < 4
) {
return // Don't submit until at least 4 digits
}
CoroutineScope(Dispatchers.Main).launch {
try {
// Show loading state
isUnlocking = true
isProcessing = true
loadingOverlay.visibility = View.VISIBLE
progressBar.visibility = View.VISIBLE
// Give UI time to update
delay(50)
// Perform unlock in background (Argon2 is CPU intensive)
val encryptionKeyBase64 = withContext(Dispatchers.IO) {
vaultStore.unlockWithPin(currentPin)
}
// Process the PIN
val result = viewModel.processPin(currentPin, config, setupEncryptionKey)
// Success - return encryption key
val resultIntent = Intent().apply {
putExtra(EXTRA_ENCRYPTION_KEY, encryptionKeyBase64)
// Handle result
handlePinResult(result)
} catch (e: Exception) {
isProcessing = false
loadingOverlay.visibility = View.GONE
progressBar.visibility = View.GONE
showError(e.message ?: "An error occurred")
triggerErrorFeedback()
shakeAndClear()
}
}
}
private suspend fun handlePinResult(result: PinResult) {
when (result) {
is PinResult.Success -> {
// Success - return result
val resultIntent = Intent()
result.encryptionKey?.let {
resultIntent.putExtra(EXTRA_ENCRYPTION_KEY, it)
}
setResult(RESULT_SUCCESS, resultIntent)
finish()
} catch (e: net.aliasvault.app.vaultstore.PinUnlockException) {
// Handle PinUnlockException with localized strings
isUnlocking = false
}
is PinResult.Error -> {
isProcessing = false
loadingOverlay.visibility = View.GONE
progressBar.visibility = View.GONE
when (e) {
is net.aliasvault.app.vaultstore.PinUnlockException.NotConfigured -> {
// PIN not configured - show error briefly then dismiss
showError(getString(R.string.pin_not_configured))
triggerErrorFeedback()
delay(1000)
setResult(RESULT_PIN_DISABLED)
finish()
}
is net.aliasvault.app.vaultstore.PinUnlockException.Locked -> {
// PIN locked after max attempts - show error briefly then dismiss
showError(getString(R.string.pin_locked_max_attempts))
triggerErrorFeedback()
delay(1000)
setResult(RESULT_PIN_DISABLED)
finish()
}
is net.aliasvault.app.vaultstore.PinUnlockException.IncorrectPin -> {
// Incorrect PIN - show error with attempts remaining and clear
val errorMessage = getString(R.string.pin_incorrect_attempts_remaining, e.attemptsRemaining)
showError(errorMessage)
triggerErrorFeedback()
shakeAndClear()
}
}
} catch (e: Exception) {
// Fallback for any other errors
isUnlocking = false
loadingOverlay.visibility = View.GONE
progressBar.visibility = View.GONE
showError(e.message ?: getString(R.string.pin_unlock_failed))
showError(result.message)
triggerErrorFeedback()
shakeAndClear()
if (result.shouldClear) {
shakeAndClear()
}
}
is PinResult.PinDisabled -> {
showError(getString(R.string.pin_locked_max_attempts))
triggerErrorFeedback()
delay(1000)
setResult(RESULT_PIN_DISABLED)
finish()
}
is PinResult.NextStep -> {
// Move to next step (setup confirmation)
isProcessing = false
loadingOverlay.visibility = View.GONE
progressBar.visibility = View.GONE
configuration = result.newConfiguration
currentPin = ""
updateUI()
}
is PinResult.Mismatch -> {
// PINs don't match - restart setup
isProcessing = false
loadingOverlay.visibility = View.GONE
progressBar.visibility = View.GONE
showError(result.errorMessage)
triggerErrorFeedback()
// Restart from beginning after showing error
delay(1000)
configuration = viewModel.initializeConfiguration(PinMode.SETUP)
currentPin = ""
errorTextView.visibility = View.GONE
updateUI()
}
}
}
@@ -310,7 +433,8 @@ class PinUnlockActivity : AppCompatActivity() {
// Clear the PIN after a short delay to show error
delay(500)
currentPin = ""
if (pinLength != null) {
val config = configuration ?: return@launch
if (config.pinLength != null) {
updatePinDots()
} else {
updatePinText()

View File

@@ -0,0 +1,170 @@
package net.aliasvault.app.pinunlock
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.aliasvault.app.R
import net.aliasvault.app.vaultstore.PinUnlockException
import net.aliasvault.app.vaultstore.VaultStore
/**
* ViewModel for PIN unlock and setup operations.
* Handles business logic and state management for PIN flows.
*/
class PinViewModel(
private val context: Context,
private val vaultStore: VaultStore,
) {
/**
* Initialize PIN configuration based on mode.
*/
fun initializeConfiguration(mode: PinMode): PinConfiguration {
return when (mode) {
PinMode.UNLOCK -> {
val pinLength = vaultStore.getPinLength()
PinConfiguration(
mode = PinMode.UNLOCK,
title = context.getString(R.string.pin_unlock_vault),
subtitle = context.getString(R.string.pin_enter_to_unlock),
pinLength = pinLength,
)
}
PinMode.SETUP -> {
PinConfiguration(
mode = PinMode.SETUP,
title = context.getString(R.string.pin_setup_title),
subtitle = context.getString(R.string.pin_setup_description),
pinLength = null, // Allow any length from 4-8
setupStep = PinSetupStep.ENTER_NEW,
)
}
}
}
/**
* Process the entered PIN based on current configuration.
*/
suspend fun processPin(
pin: String,
configuration: PinConfiguration,
setupEncryptionKey: String? = null,
): PinResult = withContext(Dispatchers.IO) {
return@withContext when (configuration.mode) {
PinMode.UNLOCK -> processUnlock(pin)
PinMode.SETUP -> processSetup(pin, configuration, setupEncryptionKey)
}
}
/**
* Process unlock attempt.
*/
private fun processUnlock(pin: String): PinResult {
return try {
val encryptionKey = vaultStore.unlockWithPin(pin)
PinResult.Success(encryptionKey)
} catch (e: PinUnlockException) {
when (e) {
is PinUnlockException.NotConfigured -> {
PinResult.Error(
context.getString(R.string.pin_not_configured),
shouldClear = false,
)
}
is PinUnlockException.Locked -> {
PinResult.PinDisabled
}
is PinUnlockException.IncorrectPin -> {
PinResult.Error(
context.getString(R.string.pin_incorrect_attempts_remaining, e.attemptsRemaining),
shouldClear = true,
)
}
}
} catch (e: Exception) {
PinResult.Error(
e.message ?: context.getString(R.string.pin_unlock_failed),
shouldClear = true,
)
}
}
/**
* Process setup flow.
*/
private fun processSetup(
pin: String,
configuration: PinConfiguration,
setupEncryptionKey: String?,
): PinResult {
return when (configuration.setupStep) {
PinSetupStep.ENTER_NEW -> {
// Validate PIN length
if (pin.length < 4) {
return PinResult.Error(
"PIN must be at least 4 digits",
shouldClear = false,
)
}
if (pin.length > 8) {
return PinResult.Error(
"PIN must be at most 8 digits",
shouldClear = false,
)
}
// Move to confirm step
val newConfig = configuration.copy(
title = context.getString(R.string.pin_confirm_title),
subtitle = context.getString(R.string.pin_confirm_description),
setupStep = PinSetupStep.CONFIRM,
firstStepPin = pin,
pinLength = pin.length, // Fix length for confirmation
)
PinResult.NextStep(newConfig)
}
PinSetupStep.CONFIRM -> {
// Check if PINs match
if (pin != configuration.firstStepPin) {
return PinResult.Mismatch(context.getString(R.string.pin_mismatch))
}
// Setup the PIN
try {
if (setupEncryptionKey == null) {
return PinResult.Error(
"Encryption key required for PIN setup",
shouldClear = false,
)
}
vaultStore.setupPin(pin, setupEncryptionKey)
PinResult.Success(null)
} catch (e: Exception) {
PinResult.Error(
e.message ?: "Failed to setup PIN",
shouldClear = false,
)
}
}
}
}
/**
* Check if PIN entry should auto-submit based on configuration.
*/
fun shouldAutoSubmit(pinLength: Int, configuration: PinConfiguration): Boolean {
// Auto-submit when:
// 1. In unlock mode with fixed length PIN
// 2. In confirm step with matching length
return when {
configuration.mode == PinMode.UNLOCK && configuration.pinLength != null -> {
pinLength == configuration.pinLength
}
configuration.mode == PinMode.SETUP &&
configuration.setupStep == PinSetupStep.CONFIRM &&
configuration.pinLength != null -> {
pinLength == configuration.pinLength
}
else -> false
}
}
}

View File

@@ -113,6 +113,24 @@
android:layout_marginStart="40dp"
android:layout_marginEnd="40dp" />
<!-- Continue Button (for PIN setup) -->
<Button
android:id="@+id/continueButton"
android:layout_width="0dp"
android:layout_height="56dp"
android:text="@string/pin_next"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold"
android:background="@color/primary"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/numpadContainer"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginBottom="24dp"
android:layout_marginStart="40dp"
android:layout_marginEnd="40dp" />
<!-- Numpad -->
<LinearLayout
android:id="@+id/numpadContainer"

View File

@@ -80,4 +80,13 @@
<string name="pin_not_configured">PIN unlock is not configured</string>
<string name="pin_locked_max_attempts">PIN locked after too many failed attempts</string>
<string name="pin_incorrect_attempts_remaining">Incorrect PIN. %d attempts remaining</string>
<!-- PIN setup -->
<string name="pin_setup_title">Setup PIN</string>
<string name="pin_setup_description">Choose a PIN to unlock your vault</string>
<string name="pin_confirm_title">Confirm PIN</string>
<string name="pin_confirm_description">Re-enter your PIN to confirm</string>
<string name="pin_mismatch">PINs do not match. Please try again.</string>
<string name="pin_next">Next</string>
<string name="pin_confirm">Confirm</string>
</resources>

View File

@@ -1,20 +1,18 @@
import { MaterialIcons } from '@expo/vector-icons';
import * as LocalAuthentication from 'expo-local-authentication';
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View, Alert, Platform, Linking, Switch, TouchableOpacity, Modal } from 'react-native';
import { StyleSheet, View, Alert, Platform, Linking, Switch, TouchableOpacity } from 'react-native';
import Toast from 'react-native-toast-message';
import { isPinEnabled, setupPin, removeAndDisablePin } from '@/utils/PinUnlockService';
import { isPinEnabled, removeAndDisablePin } from '@/utils/PinUnlockService';
import { useColors } from '@/hooks/useColorScheme';
import { PinNumpad } from '@/components/pin/PinNumpad';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { AuthMethod, useAuth } from '@/context/AuthContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
/**
* Vault unlock settings screen.
@@ -31,11 +29,6 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
// PIN state
const [pinEnabled, setPinEnabled] = useState(false);
const [showPinSetup, setShowPinSetup] = useState(false);
const [pinSetupStep, setPinSetupStep] = useState(1);
const [newPin, setNewPin] = useState('');
const [confirmPin, setConfirmPin] = useState('');
const [pinError, setPinError] = useState<string | null>(null);
useEffect(() => {
/**
@@ -166,15 +159,41 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
}, [hasBiometrics, pinEnabled, setAuthMethods, biometricDisplayName, t]);
/**
* Handle enable PIN.
* Handle enable PIN - launches native PIN setup UI.
*/
const handleEnablePin = useCallback(() : void => {
// Don't disable biometrics yet - only after successful PIN setup
setPinSetupStep(1);
setNewPin('');
setConfirmPin('');
setShowPinSetup(true);
}, []);
const handleEnablePin = useCallback(async () : Promise<void> => {
try {
// Launch native PIN setup UI
await NativeVaultManager.showNativePinSetup();
// PIN setup successful - now disable biometrics if it was enabled
if (isBiometricsEnabled) {
setIsBiometricsEnabled(false);
await setAuthMethods(['password']);
}
setPinEnabled(true);
Toast.show({
type: 'success',
text1: t('settings.vaultUnlockSettings.pinEnabled'),
position: 'bottom',
visibilityTime: 1200,
});
} catch (error) {
// Handle cancellation or errors
if ((error as { code?: string })?.code === 'USER_CANCELLED') {
// User cancelled - do nothing
return;
}
console.error('Failed to enable PIN:', error);
Alert.alert(
t('common.error'),
t('common.errors.unknownErrorTryAgain'),
[{ text: t('common.ok'), style: 'default' }]
);
}
}, [isBiometricsEnabled, setAuthMethods, t]);
/**
* Handle disable PIN.
@@ -199,84 +218,6 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
}
}, [t]);
/**
* Handle PIN change for step 1 (enter new PIN).
*/
const handleNewPinChange = useCallback((pin: string) : void => {
setNewPin(pin);
setPinError(null);
}, []);
/**
* Handle step 1 submit (advance to confirmation).
*/
const handleNewPinSubmit = useCallback(() : void => {
setPinSetupStep(2);
}, []);
/**
* Handle PIN change for step 2 (confirm PIN).
*/
const handleConfirmPinChange = useCallback((pin: string) : void => {
setConfirmPin(pin);
setPinError(null);
}, []);
/**
* Handle PIN setup submit.
*/
const handlePinSetupSubmit = useCallback(async (pin: string) : Promise<void> => {
try {
// Setup PIN - encryption key is retrieved internally by native code
await setupPin(pin);
// PIN setup successful - now disable biometrics if it was enabled
if (isBiometricsEnabled) {
setIsBiometricsEnabled(false);
await setAuthMethods(['password']);
}
setPinEnabled(true);
setShowPinSetup(false);
setPinSetupStep(1);
setNewPin('');
setConfirmPin('');
setPinError(null);
Toast.show({
type: 'success',
text1: t('settings.vaultUnlockSettings.pinEnabled'),
position: 'bottom',
visibilityTime: 1200,
});
} catch (error) {
console.error('Failed to enable PIN:', error);
let errorMessage = t('common.errors.unknownErrorTryAgain');
setPinError(errorMessage);
setConfirmPin('');
}
}, [isBiometricsEnabled, setAuthMethods, t]);
/**
* Handle step 2 submit (confirm and save PIN).
*/
const handleConfirmPinSubmit = useCallback(() : void => {
if (confirmPin !== newPin) {
setPinError(t('settings.vaultUnlockSettings.pinMismatch'));
// Restart from step 1 on mismatch
setTimeout(() => {
setPinSetupStep(1);
setNewPin('');
setConfirmPin('');
setPinError(null);
}, 1000); // Show error for 1s before restarting
return;
}
// PINs match, submit
handlePinSetupSubmit(confirmPin);
}, [confirmPin, newPin, t, handlePinSetupSubmit]);
const styles = StyleSheet.create({
button: {
alignItems: 'center',
@@ -308,61 +249,6 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
fontSize: 13,
marginTop: 4,
},
input: {
backgroundColor: colors.background,
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
color: colors.text,
fontSize: 24,
height: 60,
letterSpacing: 8,
paddingHorizontal: 16,
textAlign: 'center',
},
modalButton: {
alignItems: 'center',
backgroundColor: colors.primary,
borderRadius: 8,
height: 50,
justifyContent: 'center',
marginTop: 16,
width: '100%',
},
modalButtonText: {
color: colors.primarySurfaceText,
fontSize: 16,
fontWeight: '600',
},
modalCloseButton: {
padding: 8,
position: 'absolute',
right: 8,
top: 8,
},
modalContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
flex: 1,
justifyContent: 'center',
padding: 20,
},
modalContent: {
backgroundColor: colors.accentBackground,
borderRadius: 16,
padding: 24,
},
modalText: {
color: colors.textMuted,
fontSize: 14,
marginBottom: 16,
},
modalTitle: {
color: colors.text,
fontSize: 18,
fontWeight: '600',
marginBottom: 16,
paddingRight: 32,
},
option: {
borderBottomColor: colors.accentBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
@@ -471,60 +357,6 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
</View>
</View>
{/* PIN Setup Modal */}
<Modal
visible={showPinSetup}
transparent={true}
animationType="fade"
onRequestClose={() => {
setShowPinSetup(false);
setPinSetupStep(1);
setNewPin('');
setConfirmPin('');
}}
>
<View style={styles.modalContainer}>
<ThemedView style={styles.modalContent}>
<TouchableOpacity
style={styles.modalCloseButton}
onPress={() => {
setShowPinSetup(false);
setPinSetupStep(1);
setNewPin('');
setConfirmPin('');
}}
>
<MaterialIcons name="close" size={24} color={colors.textMuted} />
</TouchableOpacity>
{pinSetupStep === 1 ? (
<PinNumpad
pin={newPin}
onPinChange={handleNewPinChange}
onSubmit={handleNewPinSubmit}
error={pinError}
title={t('settings.vaultUnlockSettings.setupPin')}
subtitle={t('settings.vaultUnlockSettings.enterNewPinDescription')}
submitButtonText={t('common.next')}
minLength={4}
maxLength={8}
/>
) : (
<PinNumpad
pin={confirmPin}
onPinChange={handleConfirmPinChange}
onSubmit={handleConfirmPinSubmit}
error={pinError}
title={t('settings.vaultUnlockSettings.confirmPin')}
subtitle={t('settings.vaultUnlockSettings.confirmPinDescription')}
submitButtonText={t('common.confirm')}
minLength={4}
maxLength={8}
/>
)}
</ThemedView>
</View>
</Modal>
</ThemedScrollView>
</ThemedContainer>
);

View File

@@ -97,6 +97,7 @@ export interface Spec extends TurboModule {
setupPin(pin: string): Promise<void>;
removeAndDisablePin(): Promise<void>;
showPinUnlockUI(): Promise<void>;
showNativePinSetup(): Promise<void>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeVaultManager');