Add native Android password unlock flow (#1776)

This commit is contained in:
Leendert de Borst
2026-02-24 20:27:21 +01:00
parent 0eeb2a487f
commit 28e448933d
15 changed files with 832 additions and 77 deletions

View File

@@ -58,6 +58,14 @@
android:theme="@style/PasskeyRegistrationTheme"
android:screenOrientation="portrait" />
<!-- Password Unlock Activity -->
<activity
android:name=".passwordunlock.PasswordUnlockActivity"
android:exported="false"
android:theme="@style/PasskeyRegistrationTheme"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustPan" />
<!-- QR Scanner Activity -->
<activity
android:name=".qrscanner.QRScannerActivity"

View File

@@ -106,16 +106,17 @@ class MainActivity : ReactActivity() {
}
/**
* Handle activity results - specifically for PIN unlock and PIN setup.
* Handle activity results.
*/
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
// 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)
} else if (requestCode == net.aliasvault.app.nativevaultmanager.NativeVaultManager.PASSWORD_UNLOCK_REQUEST_CODE) {
handlePasswordUnlockResult(resultCode, data)
} else if (requestCode == net.aliasvault.app.nativevaultmanager.NativeVaultManager.QR_SCANNER_REQUEST_CODE) {
handleQRScannerResult(resultCode, data)
}
@@ -184,7 +185,7 @@ 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).
* @param data The intent data
*/
@Suppress("UNUSED_PARAMETER")
private fun handlePinSetupResult(resultCode: Int, data: Intent?) {
@@ -210,6 +211,35 @@ class MainActivity : ReactActivity() {
}
}
/**
* Handle password unlock result.
* @param resultCode The result code from the password unlock activity.
* @param data The intent data containing the encryption key.
*/
private fun handlePasswordUnlockResult(resultCode: Int, data: Intent?) {
val promise = net.aliasvault.app.nativevaultmanager.NativeVaultManager.passwordUnlockPromise
net.aliasvault.app.nativevaultmanager.NativeVaultManager.passwordUnlockPromise = null
if (promise == null) {
return
}
when (resultCode) {
net.aliasvault.app.passwordunlock.PasswordUnlockActivity.RESULT_SUCCESS -> {
val encryptionKeyBase64 = data?.getStringExtra(
net.aliasvault.app.passwordunlock.PasswordUnlockActivity.EXTRA_ENCRYPTION_KEY,
)
promise.resolve(encryptionKeyBase64)
}
net.aliasvault.app.passwordunlock.PasswordUnlockActivity.RESULT_CANCELLED -> {
promise.resolve(null)
}
else -> {
promise.resolve(null)
}
}
}
/**
* Handle QR scanner result.
* @param resultCode The result code from the QR scanner activity.

View File

@@ -68,6 +68,11 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
*/
const val QR_SCANNER_REQUEST_CODE = 1003
/**
* Request code for password unlock activity.
*/
const val PASSWORD_UNLOCK_REQUEST_CODE = 1004
/**
* Static holder for the pending promise from showPinUnlock.
* This allows MainActivity to resolve/reject the promise directly without
@@ -83,6 +88,14 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
*/
@Volatile
var pinSetupPromise: Promise? = null
/**
* Static holder for the pending promise from showPasswordUnlock.
* This allows MainActivity to resolve/reject the promise directly without
* depending on React context availability.
*/
@Volatile
var passwordUnlockPromise: Promise? = null
}
private val vaultStore = VaultStore.getInstance(
@@ -1410,14 +1423,35 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
}
/**
* Authenticate the user using biometric or PIN unlock.
* This method automatically detects which authentication method is enabled and uses it.
* Returns true if authentication succeeded, false otherwise.
* Show native password unlock screen.
* Returns encryption key (base64) if successful, null if cancelled.
*
* @param title The title for authentication. If null or empty, uses default.
* @param subtitle The subtitle for authentication. If null or empty, uses default.
* @param promise The promise to resolve with authentication result.
* @param title Custom title for the password unlock screen. If null or empty, uses default.
* @param subtitle Custom subtitle for the password unlock screen. If null or empty, uses default.
* @param promise The promise to resolve with encryption key or null.
*/
@ReactMethod
override fun showPasswordUnlock(title: String?, subtitle: String?, promise: Promise) {
val activity = currentActivity
if (activity == null) {
promise.reject("NO_ACTIVITY", "No activity available", null)
return
}
// Store promise in static companion object so MainActivity can resolve it directly
passwordUnlockPromise = promise
// Launch password unlock activity
val intent = Intent(activity, net.aliasvault.app.passwordunlock.PasswordUnlockActivity::class.java)
if (!title.isNullOrEmpty()) {
intent.putExtra(net.aliasvault.app.passwordunlock.PasswordUnlockActivity.EXTRA_CUSTOM_TITLE, title)
}
if (!subtitle.isNullOrEmpty()) {
intent.putExtra(net.aliasvault.app.passwordunlock.PasswordUnlockActivity.EXTRA_CUSTOM_SUBTITLE, subtitle)
}
activity.startActivityForResult(intent, PASSWORD_UNLOCK_REQUEST_CODE)
}
@ReactMethod
override fun scanQRCode(prefixes: ReadableArray?, statusText: String?, promise: Promise) {
CoroutineScope(Dispatchers.Main).launch {
@@ -1491,10 +1525,23 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
// Use biometric authentication
try {
val authenticated = vaultStore.issueBiometricAuthentication(title)
promise.resolve(authenticated)
if (!authenticated) {
// Biometric failed - reject with error instead of resolving false
promise.reject(
"AUTH_ERROR",
"No authentication method available. Please enable PIN or biometric unlock in settings.",
null,
)
} else {
promise.resolve(authenticated)
}
} catch (e: Exception) {
Log.e(TAG, "Biometric authentication failed", e)
promise.resolve(false)
promise.reject(
"AUTH_ERROR",
"Biometric authentication failed: ${e.message}",
e,
)
}
}
} catch (e: Exception) {

View File

@@ -0,0 +1,335 @@
package net.aliasvault.app.passwordunlock
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.view.WindowManager
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.EditText
import android.widget.ImageButton
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.aliasvault.app.R
import net.aliasvault.app.vaultstore.VaultStore
import net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider
import net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider
/**
* Native password unlock activity.
* This activity presents a password entry UI for vault unlocking.
*
* Result:
* - RESULT_SUCCESS: Password verified, encryption key returned in EXTRA_ENCRYPTION_KEY
* - RESULT_CANCELLED: User cancelled
*/
class PasswordUnlockActivity : AppCompatActivity() {
companion object {
/** Result code for successful password unlock. */
const val RESULT_SUCCESS = Activity.RESULT_OK
/** Result code for cancelled password unlock. */
const val RESULT_CANCELLED = Activity.RESULT_CANCELED
/** Intent extra key for the encryption key (returned on success). */
const val EXTRA_ENCRYPTION_KEY = "encryption_key"
/** Intent extra key for custom title (optional). */
const val EXTRA_CUSTOM_TITLE = "custom_title"
/** Intent extra key for custom subtitle (optional). */
const val EXTRA_CUSTOM_SUBTITLE = "custom_subtitle"
}
private lateinit var vaultStore: VaultStore
// UI components
private lateinit var titleTextView: TextView
private lateinit var subtitleTextView: TextView
private lateinit var passwordEditText: EditText
private lateinit var errorTextView: TextView
private lateinit var errorContainer: View
private lateinit var unlockButton: Button
private lateinit var backButton: ImageButton
private lateinit var loadingOverlay: View
private lateinit var progressBar: ProgressBar
// State
private var isProcessing: Boolean = false
private var isShowingError: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_password_unlock)
// Keep screen on during password entry
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Adjust for soft keyboard - pan mode to ensure button stays visible
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
// Apply window insets for safe area
applyWindowInsets()
// Initialize VaultStore
vaultStore = VaultStore.getInstance(
AndroidKeystoreProvider(this) { null },
AndroidStorageProvider(this),
)
// Get custom title/subtitle from intent
val customTitle = intent.getStringExtra(EXTRA_CUSTOM_TITLE)
val customSubtitle = intent.getStringExtra(EXTRA_CUSTOM_SUBTITLE)
// Initialize views
initializeViews(customTitle, customSubtitle)
}
private fun initializeViews(customTitle: String?, customSubtitle: String?) {
titleTextView = findViewById(R.id.titleTextView)
subtitleTextView = findViewById(R.id.subtitleTextView)
passwordEditText = findViewById(R.id.passwordEditText)
errorTextView = findViewById(R.id.errorTextView)
errorContainer = findViewById(R.id.errorContainer)
unlockButton = findViewById(R.id.unlockButton)
backButton = findViewById(R.id.backButton)
loadingOverlay = findViewById(R.id.loadingOverlay)
progressBar = findViewById(R.id.progressBar)
// Set custom title/subtitle if provided
if (!customTitle.isNullOrEmpty()) {
titleTextView.text = customTitle
} else {
titleTextView.text = getString(R.string.password_unlock_title)
}
if (!customSubtitle.isNullOrEmpty()) {
subtitleTextView.text = customSubtitle
} else {
subtitleTextView.text = getString(R.string.password_unlock_subtitle)
}
// Setup button states
unlockButton.isEnabled = false
// Animate views in on appear
animateViewsIn()
// Handle password input
passwordEditText.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// Not used
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// Not used
}
override fun afterTextChanged(s: Editable?) {
// Only hide error if user is typing (not when we programmatically clear for error display)
if (!isShowingError) {
hideError()
}
unlockButton.isEnabled = !s.isNullOrEmpty() && !isProcessing
// Animate button scale based on enabled state
val scale = if (!s.isNullOrEmpty() && !isProcessing) 1.0f else 0.98f
unlockButton.animate()
.scaleX(scale)
.scaleY(scale)
.setDuration(200)
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
}
})
passwordEditText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE && unlockButton.isEnabled) {
handleUnlock()
true
} else {
false
}
}
// Setup button click listeners
unlockButton.setOnClickListener {
handleUnlock()
}
backButton.setOnClickListener {
setResult(RESULT_CANCELLED)
finish()
}
// Focus password field and show keyboard after a short delay
passwordEditText.postDelayed({
passwordEditText.requestFocus()
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(passwordEditText, InputMethodManager.SHOW_IMPLICIT)
}, 300)
}
private fun animateViewsIn() {
// Fade in and translate logo
titleTextView.alpha = 0f
titleTextView.translationY = -20f
titleTextView.animate()
.alpha(1f)
.translationY(0f)
.setDuration(400)
.setStartDelay(100)
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
// Fade in subtitle
subtitleTextView.alpha = 0f
subtitleTextView.animate()
.alpha(1f)
.setDuration(400)
.setStartDelay(200)
.start()
// Fade in and scale password field
passwordEditText.alpha = 0f
passwordEditText.scaleX = 0.95f
passwordEditText.scaleY = 0.95f
passwordEditText.animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(400)
.setStartDelay(300)
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
// Fade in button
unlockButton.alpha = 0f
unlockButton.animate()
.alpha(1f)
.setDuration(400)
.setStartDelay(400)
.start()
}
private fun handleUnlock() {
val password = passwordEditText.text.toString()
if (password.isEmpty()) {
return
}
isProcessing = true
unlockButton.isEnabled = false
passwordEditText.isEnabled = false
loadingOverlay.visibility = View.VISIBLE
hideError()
CoroutineScope(Dispatchers.Main).launch {
try {
val encryptionKey = withContext(Dispatchers.IO) {
vaultStore.verifyPassword(password)
}
if (encryptionKey != null) {
// Success - return encryption key
val resultIntent = Intent().apply {
putExtra(EXTRA_ENCRYPTION_KEY, encryptionKey)
}
setResult(RESULT_SUCCESS, resultIntent)
finish()
} else {
// Incorrect password
showError(getString(R.string.password_unlock_incorrect))
}
} catch (e: Exception) {
// Error during verification
android.util.Log.e("PasswordUnlockActivity", "Password verification failed", e)
showError(getString(R.string.password_unlock_error))
} finally {
isProcessing = false
passwordEditText.isEnabled = true
loadingOverlay.visibility = View.GONE
// Update button state based on password field content
unlockButton.isEnabled = passwordEditText.text?.isNotEmpty() == true
}
}
}
private fun showError(message: String) {
errorTextView.text = message
isShowingError = true
// Animate error in with slide from top
errorContainer.visibility = View.VISIBLE
errorContainer.alpha = 0f
errorContainer.translationY = -20f
errorContainer.animate()
.alpha(1f)
.translationY(0f)
.setDuration(300)
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
// Shake password field to indicate error
val shake = ObjectAnimator.ofFloat(passwordEditText, "translationX", 0f, 25f, -25f, 25f, -25f, 15f, -15f, 6f, -6f, 0f)
shake.duration = 600
shake.start()
passwordEditText.text?.clear()
// Reset flag after clearing is done
passwordEditText.post {
isShowingError = false
}
passwordEditText.requestFocus()
}
private fun hideError() {
if (errorContainer.visibility == View.VISIBLE) {
isShowingError = false
errorContainer.animate()
.alpha(0f)
.translationY(-20f)
.setDuration(200)
.setInterpolator(AccelerateDecelerateInterpolator())
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
errorContainer.visibility = View.GONE
}
})
.start()
}
}
private fun applyWindowInsets() {
findViewById<View>(android.R.id.content).setOnApplyWindowInsetsListener { _, insets ->
val systemBarsInsets = insets.systemWindowInsets
val backButtonParent = backButton.parent as View
val layoutParams = backButtonParent.layoutParams as androidx.constraintlayout.widget.ConstraintLayout.LayoutParams
layoutParams.topMargin = systemBarsInsets.top + 8
backButtonParent.layoutParams = layoutParams
insets
}
}
override fun onBackPressed() {
if (!isProcessing) {
setResult(RESULT_CANCELLED)
super.onBackPressed()
}
}
}

View File

@@ -9,6 +9,7 @@ import android.os.Vibrator
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
@@ -133,15 +134,14 @@ class PinUnlockActivity : AppCompatActivity() {
private fun applyWindowInsets() {
findViewById<View>(android.R.id.content).setOnApplyWindowInsetsListener { _, insets ->
val cancelButton = findViewById<Button>(R.id.cancelButton)
val cancelButton = findViewById<ImageButton>(R.id.cancelButton)
val systemBarsInsets = insets.systemWindowInsets
cancelButton.setPadding(
cancelButton.paddingLeft,
systemBarsInsets.top + cancelButton.paddingTop,
cancelButton.paddingRight,
cancelButton.paddingBottom,
)
// Apply top inset to cancel button's parent FrameLayout margin
val cancelButtonParent = cancelButton.parent as View
val layoutParams = cancelButtonParent.layoutParams as androidx.constraintlayout.widget.ConstraintLayout.LayoutParams
layoutParams.topMargin = systemBarsInsets.top + 8 // 8dp base margin
cancelButtonParent.layoutParams = layoutParams
insets
}
@@ -158,7 +158,7 @@ class PinUnlockActivity : AppCompatActivity() {
progressBar = findViewById(R.id.progressBar)
// Cancel button
findViewById<Button>(R.id.cancelButton).setOnClickListener {
findViewById<ImageButton>(R.id.cancelButton).setOnClickListener {
setResult(RESULT_CANCELLED)
finish()
}

View File

@@ -224,6 +224,42 @@ class VaultStore(
return crypto.encryptDecryptionKeyForMobileLogin(publicKeyJWK, auth.getAuthMethods())
}
/**
* Verify password and return encryption key if correct.
* Returns null if password is incorrect.
*
* @param password The password to verify
* @return The base64-encoded encryption key if password is correct, null otherwise
*/
@Suppress("SwallowedException")
fun verifyPassword(password: String): String? {
return try {
// Get encryption key derivation parameters
val params = crypto.getEncryptionKeyDerivationParams()
val paramsJson = org.json.JSONObject(params)
val salt = paramsJson.getString("salt")
val encryptionType = paramsJson.getString("encryptionType")
val encryptionSettings = paramsJson.getString("encryptionSettings")
// Derive key from password
val derivedKey = crypto.deriveKeyFromPassword(password, salt, encryptionType, encryptionSettings)
// Try to decrypt the vault to verify the password is correct
val encryptedDb = databaseComponent.getEncryptedDatabase()
val encryptedDbBytes = android.util.Base64.decode(encryptedDb, android.util.Base64.NO_WRAP)
// Attempt decryption to verify password is correct
VaultCrypto.decrypt(encryptedDbBytes, derivedKey)
// If decryption succeeded, return the key as base64
android.util.Base64.encodeToString(derivedKey, android.util.Base64.NO_WRAP)
} catch (e: Exception) {
// Password incorrect or decryption failed - intentionally return null
// We don't log the error as this is expected when password is incorrect
null
}
}
// endregion
// region Database Methods

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:startColor="#5c4331"
android:endColor="?android:attr/colorBackground"
android:type="linear" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/primary"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/holo_red_dark"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?android:attr/textColorSecondary"
android:pathData="M18,8h-1V6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2H6c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10c0,-1.1 -0.9,-2 -2,-2zM12,17c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM15.1,8H8.9V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z"/>
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:startColor="#f6dfc4"
android:endColor="?android:attr/colorBackground"
android:type="linear" />
</shape>

View File

@@ -0,0 +1,217 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground">
<!-- Header Section -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/headerSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/password_unlock_gradient"
android:paddingBottom="24dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<!-- Logo -->
<ImageView
android:id="@+id/logoImageView"
android:layout_width="80dp"
android:layout_height="80dp"
android:src="@mipmap/ic_launcher"
android:contentDescription="@string/aliasvault_icon"
android:elevation="4dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="80dp" />
<!-- Title -->
<TextView
android:id="@+id/titleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/password_unlock_title"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintTop_toBottomOf="@id/logoImageView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp"
tools:text="Unlock Vault" />
<!-- Subtitle -->
<TextView
android:id="@+id/subtitleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/password_unlock_subtitle"
android:textSize="16sp"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
app:layout_constraintTop_toBottomOf="@id/titleTextView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="8dp"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:paddingBottom="24dp"
tools:text="Enter your master password" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Main Content Section -->
<ScrollView
android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true"
android:clipToPadding="false"
app:layout_constraintTop_toBottomOf="@id/headerSection"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="32dp"
android:paddingBottom="32dp">
<!-- Password Input -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordInputLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/password_unlock_password_hint"
app:boxStrokeColor="@color/primary"
app:boxStrokeWidth="2dp"
app:hintTextColor="?android:attr/textColorSecondary"
app:startIconDrawable="@drawable/ic_lock"
app:startIconTint="?android:attr/textColorSecondary"
app:boxCornerRadiusTopStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusBottomEnd="12dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:textColor="?android:attr/textColorPrimary"
android:textSize="16sp"
android:padding="16dp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Error Message with icon -->
<LinearLayout
android:id="@+id/errorContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/passwordInputLayout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="12dp"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
tools:visibility="visible">
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/ic_error"
android:contentDescription="Error icon"
android:tint="@android:color/holo_red_dark"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/errorTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@android:color/holo_red_dark"
tools:text="Incorrect password" />
</LinearLayout>
<!-- Unlock Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/unlockButton"
android:layout_width="0dp"
android:layout_height="54dp"
android:text="@string/password_unlock_button"
android:textColor="@android:color/white"
android:textSize="17sp"
android:textStyle="bold"
android:backgroundTint="@color/primary"
app:icon="@drawable/ic_arrow_forward"
app:iconGravity="end"
app:iconTint="@android:color/white"
app:cornerRadius="12dp"
app:elevation="4dp"
app:layout_constraintTop_toBottomOf="@id/errorContainer"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="32dp"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<!-- Back Button -->
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent">
<ImageButton
android:id="@+id/backButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_arrow_back"
android:contentDescription="@string/common_back"
android:background="?attr/selectableItemBackgroundBorderless"
android:tint="?android:attr/textColorPrimary"
android:padding="12dp"
android:elevation="8dp" />
</FrameLayout>
<!-- Loading Overlay -->
<FrameLayout
android:id="@+id/loadingOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#80000000"
android:clickable="true"
android:focusable="true"
android:visibility="gone"
tools:visibility="visible">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:indeterminateTint="@color/primary" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -7,65 +7,61 @@
android:background="?android:attr/colorBackground"
tools:context=".pinunlock.PinUnlockActivity">
<!-- Header with Cancel Button -->
<Button
android:id="@+id/cancelButton"
android:layout_width="wrap_content"
<!-- Header Section with Gradient Background -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/headerSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/common_cancel"
android:textColor="@color/primary"
style="?android:attr/borderlessButtonStyle"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:minWidth="64dp"
android:background="@drawable/password_unlock_gradient"
android:paddingBottom="24dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Logo -->
<ImageView
android:id="@+id/logoImageView"
android:layout_width="70dp"
android:layout_height="70dp"
android:src="@mipmap/ic_launcher"
android:contentDescription="@string/aliasvault_icon"
app:layout_constraintTop_toBottomOf="@id/cancelButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp" />
app:layout_constraintEnd_toEndOf="parent">
<!-- Title -->
<TextView
android:id="@+id/titleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pin_unlock_vault"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintTop_toBottomOf="@id/logoImageView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="12dp" />
<!-- Logo -->
<ImageView
android:id="@+id/logoImageView"
android:layout_width="70dp"
android:layout_height="70dp"
android:src="@mipmap/ic_launcher"
android:contentDescription="@string/aliasvault_icon"
android:elevation="4dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="80dp" />
<!-- Subtitle -->
<TextView
android:id="@+id/subtitleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/pin_enter_to_unlock"
android:textSize="15sp"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
app:layout_constraintTop_toBottomOf="@id/titleTextView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="6dp"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp" />
<!-- Title -->
<TextView
android:id="@+id/titleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pin_unlock_vault"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintTop_toBottomOf="@id/logoImageView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="12dp" />
<!-- Subtitle -->
<TextView
android:id="@+id/subtitleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/pin_enter_to_unlock"
android:textSize="15sp"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
app:layout_constraintTop_toBottomOf="@id/titleTextView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="6dp"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:paddingBottom="24dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- PIN Dots Container (for fixed-length PINs) -->
<LinearLayout
@@ -74,10 +70,10 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
app:layout_constraintTop_toBottomOf="@id/subtitleTextView"
app:layout_constraintTop_toBottomOf="@id/headerSection"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="20dp"
android:layout_marginTop="32dp"
android:visibility="visible" />
<!-- PIN Text View (for variable-length PINs) -->
@@ -91,10 +87,10 @@
android:textColor="?android:attr/textColorPrimary"
android:letterSpacing="0.2"
android:gravity="center"
app:layout_constraintTop_toBottomOf="@id/subtitleTextView"
app:layout_constraintTop_toBottomOf="@id/headerSection"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="20dp"
android:layout_marginTop="32dp"
android:visibility="gone" />
<!-- Error Message -->
@@ -266,4 +262,25 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Back Button - Floating over content -->
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginTop="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent">
<ImageButton
android:id="@+id/cancelButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_arrow_back"
android:contentDescription="@string/common_cancel"
android:background="?attr/selectableItemBackgroundBorderless"
android:tint="?android:attr/textColorPrimary"
android:padding="12dp"
android:elevation="8dp" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -10,6 +10,7 @@
<string name="common_close">Close</string>
<string name="common_next">Next</string>
<string name="common_cancel">Cancel</string>
<string name="common_back">Back</string>
<string name="unknown_error">An unknown error occurred</string>
<!-- AutofillService strings -->
@@ -91,4 +92,12 @@
<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>
<!-- Password unlock -->
<string name="password_unlock_title">Unlock Vault</string>
<string name="password_unlock_subtitle">Enter your master password</string>
<string name="password_unlock_password_hint">Password</string>
<string name="password_unlock_button">Unlock</string>
<string name="password_unlock_incorrect">Incorrect password. Please try again.</string>
<string name="password_unlock_error">Failed to verify password</string>
</resources>