From 28e448933db29fa5489d3a76f8cf727491bba944 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 24 Feb 2026 20:27:21 +0100 Subject: [PATCH] Add native Android password unlock flow (#1776) --- .../android/app/src/main/AndroidManifest.xml | 8 + .../java/net/aliasvault/app/MainActivity.kt | 36 +- .../nativevaultmanager/NativeVaultManager.kt | 63 +++- .../passwordunlock/PasswordUnlockActivity.kt | 335 ++++++++++++++++++ .../app/pinunlock/PinUnlockActivity.kt | 16 +- .../aliasvault/app/vaultstore/VaultStore.kt | 36 ++ .../password_unlock_gradient.xml | 8 + .../src/main/res/drawable/ic_arrow_back.xml | 10 + .../main/res/drawable/ic_arrow_forward.xml | 10 + .../app/src/main/res/drawable/ic_error.xml | 10 + .../app/src/main/res/drawable/ic_lock.xml | 10 + .../res/drawable/password_unlock_gradient.xml | 8 + .../res/layout/activity_password_unlock.xml | 217 ++++++++++++ .../main/res/layout/activity_pin_unlock.xml | 133 ++++--- .../app/src/main/res/values/strings.xml | 9 + 15 files changed, 832 insertions(+), 77 deletions(-) create mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/passwordunlock/PasswordUnlockActivity.kt create mode 100644 apps/mobile-app/android/app/src/main/res/drawable-night/password_unlock_gradient.xml create mode 100644 apps/mobile-app/android/app/src/main/res/drawable/ic_arrow_back.xml create mode 100644 apps/mobile-app/android/app/src/main/res/drawable/ic_arrow_forward.xml create mode 100644 apps/mobile-app/android/app/src/main/res/drawable/ic_error.xml create mode 100644 apps/mobile-app/android/app/src/main/res/drawable/ic_lock.xml create mode 100644 apps/mobile-app/android/app/src/main/res/drawable/password_unlock_gradient.xml create mode 100644 apps/mobile-app/android/app/src/main/res/layout/activity_password_unlock.xml diff --git a/apps/mobile-app/android/app/src/main/AndroidManifest.xml b/apps/mobile-app/android/app/src/main/AndroidManifest.xml index a825572ed..b623d2fa3 100644 --- a/apps/mobile-app/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile-app/android/app/src/main/AndroidManifest.xml @@ -58,6 +58,14 @@ android:theme="@style/PasskeyRegistrationTheme" android:screenOrientation="portrait" /> + + + { + 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. diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index e28c5e8a3..a1299ed76 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -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) { diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/passwordunlock/PasswordUnlockActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/passwordunlock/PasswordUnlockActivity.kt new file mode 100644 index 000000000..0e8d0c3ad --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/passwordunlock/PasswordUnlockActivity.kt @@ -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(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() + } + } +} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt index d1255ef2f..0a839882d 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/pinunlock/PinUnlockActivity.kt @@ -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(android.R.id.content).setOnApplyWindowInsetsListener { _, insets -> - val cancelButton = findViewById