mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-10 10:52:42 -04:00
Add passkey replace flow (#520)
This commit is contained in:
@@ -4,7 +4,9 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.credentials.CreatePublicKeyCredentialRequest
|
||||
import androidx.credentials.CreatePublicKeyCredentialResponse
|
||||
@@ -19,11 +21,14 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.aliasvault.app.R
|
||||
import net.aliasvault.app.components.LoadingIndicator
|
||||
import net.aliasvault.app.vaultstore.PasskeyWithCredentialInfo
|
||||
import net.aliasvault.app.vaultstore.VaultStore
|
||||
import net.aliasvault.app.vaultstore.createCredentialWithPasskey
|
||||
import net.aliasvault.app.vaultstore.getPasskeysWithCredentialInfo
|
||||
import net.aliasvault.app.vaultstore.models.Passkey
|
||||
import net.aliasvault.app.vaultstore.passkey.PasskeyAuthenticator
|
||||
import net.aliasvault.app.vaultstore.passkey.PasskeyHelper
|
||||
import net.aliasvault.app.vaultstore.replacePasskey
|
||||
import net.aliasvault.app.webapi.WebApiService
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
@@ -36,6 +41,10 @@ import java.util.UUID
|
||||
* Handles passkey registration (credential creation) with a full UI.
|
||||
* Shows a form where the user can edit the display name, then creates and saves the passkey.
|
||||
* Displays loading states and error messages similar to iOS PasskeyRegistrationView.
|
||||
*
|
||||
* Supports two modes:
|
||||
* 1. Selection mode: When existing passkeys are found, shows options to create new or replace existing
|
||||
* 2. Form mode: Direct passkey creation form (either new or replacing a selected passkey)
|
||||
*/
|
||||
class PasskeyRegistrationActivity : Activity() {
|
||||
|
||||
@@ -46,18 +55,22 @@ class PasskeyRegistrationActivity : Activity() {
|
||||
private lateinit var vaultStore: VaultStore
|
||||
private lateinit var webApiService: WebApiService
|
||||
|
||||
// UI elements
|
||||
private lateinit var headerSubtitle: TextView
|
||||
private lateinit var displayNameInput: TextInputEditText
|
||||
private lateinit var websiteText: TextView
|
||||
private lateinit var usernameContainer: View
|
||||
private lateinit var usernameText: TextView
|
||||
private lateinit var errorText: TextView
|
||||
private lateinit var saveButton: MaterialButton
|
||||
private lateinit var cancelButton: MaterialButton
|
||||
private lateinit var scrollView: View
|
||||
private lateinit var loadingOverlay: View
|
||||
private lateinit var loadingIndicator: LoadingIndicator
|
||||
// UI elements - Form mode
|
||||
private var headerSubtitle: TextView? = null
|
||||
private var displayNameInput: TextInputEditText? = null
|
||||
private var websiteText: TextView? = null
|
||||
private var usernameContainer: View? = null
|
||||
private var usernameText: TextView? = null
|
||||
private var errorText: TextView? = null
|
||||
private var saveButton: MaterialButton? = null
|
||||
private var cancelButton: MaterialButton? = null
|
||||
private var scrollView: View? = null
|
||||
private var loadingOverlay: View? = null
|
||||
private var loadingIndicator: LoadingIndicator? = null
|
||||
|
||||
// UI elements - Selection mode
|
||||
private var createNewButton: MaterialButton? = null
|
||||
private var existingPasskeysContainer: LinearLayout? = null
|
||||
|
||||
// Request data
|
||||
private var providerRequest: ProviderCreateCredentialRequest? = null
|
||||
@@ -69,15 +82,16 @@ class PasskeyRegistrationActivity : Activity() {
|
||||
private var userDisplayName: String? = null
|
||||
private var userId: ByteArray? = null
|
||||
|
||||
// State
|
||||
private var existingPasskeys: List<PasskeyWithCredentialInfo> = emptyList()
|
||||
private var selectedPasskeyToReplace: PasskeyWithCredentialInfo? = null
|
||||
private var isReplaceMode: Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_passkey_registration)
|
||||
|
||||
Log.d(TAG, "PasskeyRegistrationActivity onCreate called")
|
||||
|
||||
// Initialize UI elements
|
||||
initializeViews()
|
||||
|
||||
try {
|
||||
// Initialize VaultStore and WebApiService
|
||||
vaultStore = VaultStore.getExistingInstance()
|
||||
@@ -90,7 +104,7 @@ class PasskeyRegistrationActivity : Activity() {
|
||||
providerRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
|
||||
if (providerRequest == null) {
|
||||
Log.e(TAG, "No provider request found in intent")
|
||||
showError(getString(R.string.passkey_creation_failed))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,7 +114,7 @@ class PasskeyRegistrationActivity : Activity() {
|
||||
val createRequest = providerRequest!!.callingRequest
|
||||
if (createRequest !is CreatePublicKeyCredentialRequest) {
|
||||
Log.e(TAG, "Request is not a CreatePublicKeyCredentialRequest")
|
||||
showError(getString(R.string.passkey_creation_failed))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,7 +144,7 @@ class PasskeyRegistrationActivity : Activity() {
|
||||
|
||||
if (rpId.isEmpty() || requestJson.isEmpty()) {
|
||||
Log.e(TAG, "Missing required parameters")
|
||||
showError(getString(R.string.passkey_creation_failed))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -146,24 +160,97 @@ class PasskeyRegistrationActivity : Activity() {
|
||||
null
|
||||
}
|
||||
|
||||
// Populate UI
|
||||
populateUI()
|
||||
|
||||
// Set up button listeners
|
||||
saveButton.setOnClickListener {
|
||||
onSaveClicked()
|
||||
// Check for existing passkeys
|
||||
val db = vaultStore.database
|
||||
if (db != null) {
|
||||
existingPasskeys = vaultStore.getPasskeysWithCredentialInfo(
|
||||
rpId = rpId,
|
||||
userName = userName,
|
||||
userId = userId,
|
||||
db = db,
|
||||
)
|
||||
Log.d(TAG, "Found ${existingPasskeys.size} existing passkeys for rpId=$rpId")
|
||||
}
|
||||
|
||||
cancelButton.setOnClickListener {
|
||||
onCancelClicked()
|
||||
// Decide which layout to show
|
||||
if (existingPasskeys.isEmpty()) {
|
||||
// No existing passkeys - show form directly
|
||||
showFormView(isReplace = false, passkeyToReplace = null)
|
||||
} else {
|
||||
// Existing passkeys found - show selection view
|
||||
showSelectionView()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in onCreate", e)
|
||||
showError(getString(R.string.passkey_creation_failed))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeViews() {
|
||||
/**
|
||||
* Show selection view when there are existing passkeys
|
||||
*/
|
||||
private fun showSelectionView() {
|
||||
setContentView(R.layout.activity_passkey_selection)
|
||||
|
||||
// Initialize selection view elements
|
||||
val headerSubtitle: TextView = findViewById(R.id.headerSubtitle)
|
||||
createNewButton = findViewById(R.id.createNewButton)
|
||||
existingPasskeysContainer = findViewById(R.id.existingPasskeysContainer)
|
||||
cancelButton = findViewById(R.id.cancelButton)
|
||||
scrollView = findViewById(R.id.scrollView)
|
||||
loadingOverlay = findViewById(R.id.loadingOverlay)
|
||||
loadingIndicator = findViewById(R.id.loadingIndicator)
|
||||
|
||||
// Set subtitle
|
||||
headerSubtitle.text = "Create a new passkey for $rpId"
|
||||
|
||||
// Set up create new button
|
||||
createNewButton?.setOnClickListener {
|
||||
showFormView(isReplace = false, passkeyToReplace = null)
|
||||
}
|
||||
|
||||
// Populate existing passkeys list
|
||||
val inflater = LayoutInflater.from(this)
|
||||
existingPasskeys.forEach { passkeyInfo ->
|
||||
val itemView = inflater.inflate(R.layout.item_existing_passkey, existingPasskeysContainer, false)
|
||||
|
||||
val displayNameView = itemView.findViewById<TextView>(R.id.passkeyDisplayName)
|
||||
val subtitleView = itemView.findViewById<TextView>(R.id.passkeySubtitle)
|
||||
|
||||
displayNameView.text = passkeyInfo.passkey.displayName
|
||||
val subtitle = buildString {
|
||||
passkeyInfo.username?.let { append(it) }
|
||||
if (passkeyInfo.username != null && passkeyInfo.serviceName != null) {
|
||||
append(" • ")
|
||||
}
|
||||
passkeyInfo.serviceName?.let { append(it) }
|
||||
}
|
||||
subtitleView.text = subtitle.ifEmpty { rpId }
|
||||
|
||||
itemView.setOnClickListener {
|
||||
showFormView(isReplace = true, passkeyToReplace = passkeyInfo)
|
||||
}
|
||||
|
||||
existingPasskeysContainer?.addView(itemView)
|
||||
}
|
||||
|
||||
// Set up cancel button
|
||||
cancelButton?.setOnClickListener {
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form view for creating or replacing a passkey
|
||||
*/
|
||||
private fun showFormView(isReplace: Boolean, passkeyToReplace: PasskeyWithCredentialInfo?) {
|
||||
setContentView(R.layout.activity_passkey_registration)
|
||||
|
||||
this.isReplaceMode = isReplace
|
||||
this.selectedPasskeyToReplace = passkeyToReplace
|
||||
|
||||
// Initialize form view elements
|
||||
headerSubtitle = findViewById(R.id.headerSubtitle)
|
||||
displayNameInput = findViewById(R.id.displayNameInput)
|
||||
websiteText = findViewById(R.id.websiteText)
|
||||
@@ -175,75 +262,92 @@ class PasskeyRegistrationActivity : Activity() {
|
||||
scrollView = findViewById(R.id.scrollView)
|
||||
loadingOverlay = findViewById(R.id.loadingOverlay)
|
||||
loadingIndicator = findViewById(R.id.loadingIndicator)
|
||||
}
|
||||
|
||||
private fun populateUI() {
|
||||
// Set subtitle
|
||||
headerSubtitle.text = "Create a new passkey for $rpId"
|
||||
|
||||
// Set display name (default to rpId)
|
||||
displayNameInput.setText(rpId)
|
||||
// Update UI based on mode
|
||||
if (isReplace && passkeyToReplace != null) {
|
||||
headerSubtitle?.text = "Replace passkey for $rpId"
|
||||
displayNameInput?.setText(passkeyToReplace.passkey.displayName)
|
||||
saveButton?.text = getString(R.string.passkey_replace_button)
|
||||
} else {
|
||||
headerSubtitle?.text = "Create a new passkey for $rpId"
|
||||
displayNameInput?.setText(rpId)
|
||||
saveButton?.text = getString(R.string.passkey_create_button)
|
||||
}
|
||||
|
||||
// Set website
|
||||
websiteText.text = rpId
|
||||
websiteText?.text = rpId
|
||||
|
||||
// Set username if available
|
||||
if (!userName.isNullOrEmpty()) {
|
||||
usernameContainer.visibility = View.VISIBLE
|
||||
usernameText.text = userName
|
||||
usernameContainer?.visibility = View.VISIBLE
|
||||
usernameText?.text = userName
|
||||
} else {
|
||||
usernameContainer.visibility = View.GONE
|
||||
usernameContainer?.visibility = View.GONE
|
||||
}
|
||||
|
||||
// Set up button listeners
|
||||
saveButton?.setOnClickListener {
|
||||
onSaveClicked()
|
||||
}
|
||||
|
||||
cancelButton?.setOnClickListener {
|
||||
if (existingPasskeys.isNotEmpty()) {
|
||||
// Go back to selection view
|
||||
showSelectionView()
|
||||
} else {
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSaveClicked() {
|
||||
// Validate display name
|
||||
val displayName = displayNameInput.text.toString().trim()
|
||||
val displayName = displayNameInput?.text.toString().trim()
|
||||
if (displayName.isEmpty()) {
|
||||
errorText.text = getString(R.string.passkey_error_empty_name)
|
||||
errorText.visibility = View.VISIBLE
|
||||
errorText?.text = getString(R.string.passkey_error_empty_name)
|
||||
errorText?.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
|
||||
// Hide error and start creation
|
||||
errorText.visibility = View.GONE
|
||||
errorText?.visibility = View.GONE
|
||||
|
||||
// Start passkey creation in coroutine
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
createPasskey(displayName)
|
||||
if (isReplaceMode && selectedPasskeyToReplace != null) {
|
||||
replacePasskeyFlow(displayName, selectedPasskeyToReplace!!)
|
||||
} else {
|
||||
createPasskeyFlow(displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCancelClicked() {
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun showLoading(message: String) {
|
||||
loadingIndicator.setMessage(message)
|
||||
loadingIndicator.startAnimation()
|
||||
loadingOverlay.visibility = View.VISIBLE
|
||||
scrollView.alpha = 0.3f
|
||||
scrollView.isEnabled = false
|
||||
loadingIndicator?.setMessage(message)
|
||||
loadingIndicator?.startAnimation()
|
||||
loadingOverlay?.visibility = View.VISIBLE
|
||||
scrollView?.alpha = 0.3f
|
||||
scrollView?.isEnabled = false
|
||||
}
|
||||
|
||||
private fun hideLoading() {
|
||||
loadingIndicator.stopAnimation()
|
||||
loadingOverlay.visibility = View.GONE
|
||||
scrollView.alpha = 1.0f
|
||||
scrollView.isEnabled = true
|
||||
loadingIndicator?.stopAnimation()
|
||||
loadingOverlay?.visibility = View.GONE
|
||||
scrollView?.alpha = 1.0f
|
||||
scrollView?.isEnabled = true
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
hideLoading()
|
||||
errorText.text = message
|
||||
errorText.visibility = View.VISIBLE
|
||||
errorText?.text = message
|
||||
errorText?.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the passkey
|
||||
* Create a new passkey flow
|
||||
*/
|
||||
private suspend fun createPasskey(displayName: String) = withContext(Dispatchers.IO) {
|
||||
private suspend fun createPasskeyFlow(displayName: String) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
showLoading(getString(R.string.passkey_creating))
|
||||
@@ -433,6 +537,206 @@ class PasskeyRegistrationActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an existing passkey flow
|
||||
*/
|
||||
private suspend fun replacePasskeyFlow(displayName: String, passkeyToReplace: PasskeyWithCredentialInfo) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
showLoading(getString(R.string.passkey_replacing))
|
||||
}
|
||||
|
||||
Log.d(TAG, "Replacing passkey ${passkeyToReplace.passkey.id} for RP: $rpId")
|
||||
|
||||
// Extract favicon (optional)
|
||||
var logo: ByteArray? = null
|
||||
try {
|
||||
logo = webApiService.extractFavicon("https://$rpId")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Favicon extraction failed", e)
|
||||
// Continue without logo
|
||||
}
|
||||
|
||||
// Generate new passkey credentials
|
||||
val newPasskeyId = UUID.randomUUID()
|
||||
val credentialId = PasskeyHelper.guidToBytes(newPasskeyId.toString())
|
||||
|
||||
// Use clientDataHash from the request
|
||||
val requestClientDataHash = this@PasskeyRegistrationActivity.clientDataHash
|
||||
if (requestClientDataHash == null) {
|
||||
throw Exception("Client data hash not available")
|
||||
}
|
||||
|
||||
// Parse request to get challenge
|
||||
val requestObj = JSONObject(requestJson)
|
||||
val challenge = requestObj.optString("challenge", "")
|
||||
|
||||
// Use origin from the request
|
||||
val requestOrigin = this@PasskeyRegistrationActivity.origin
|
||||
if (requestOrigin == null) {
|
||||
throw Exception("Origin not available")
|
||||
}
|
||||
|
||||
// Extract PRF inputs if present
|
||||
val prfInputs = extractPrfInputs(requestObj)
|
||||
val enablePrf = prfInputs != null
|
||||
|
||||
// Create the new passkey using PasskeyAuthenticator
|
||||
val passkeyResult = PasskeyAuthenticator.createPasskey(
|
||||
credentialId = credentialId,
|
||||
clientDataHash = requestClientDataHash,
|
||||
rpId = rpId,
|
||||
userId = userId,
|
||||
userName = userName,
|
||||
userDisplayName = userDisplayName,
|
||||
uvPerformed = true,
|
||||
enablePrf = enablePrf,
|
||||
prfInputs = prfInputs,
|
||||
)
|
||||
|
||||
// Create new Passkey model object
|
||||
val now = Date()
|
||||
val newPasskey = Passkey(
|
||||
id = newPasskeyId,
|
||||
parentCredentialId = passkeyToReplace.passkey.parentCredentialId,
|
||||
rpId = rpId,
|
||||
userHandle = userId,
|
||||
userName = userName,
|
||||
publicKey = passkeyResult.publicKey,
|
||||
privateKey = passkeyResult.privateKey,
|
||||
prfKey = passkeyResult.prfSecret,
|
||||
displayName = displayName,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
isDeleted = false,
|
||||
)
|
||||
|
||||
// Replace in database
|
||||
withContext(Dispatchers.Main) {
|
||||
showLoading(getString(R.string.passkey_saving))
|
||||
}
|
||||
|
||||
val db = vaultStore.database ?: throw Exception("Vault not unlocked")
|
||||
db.beginTransaction()
|
||||
try {
|
||||
vaultStore.replacePasskey(
|
||||
oldPasskeyId = passkeyToReplace.passkey.id,
|
||||
newPasskey = newPasskey,
|
||||
displayName = displayName,
|
||||
logo = logo,
|
||||
db = db,
|
||||
)
|
||||
|
||||
// Commit transaction and persist to encrypted vault file
|
||||
vaultStore.commitTransaction()
|
||||
|
||||
Log.d(TAG, "Passkey replaced successfully")
|
||||
} catch (e: Exception) {
|
||||
db.endTransaction()
|
||||
throw e
|
||||
}
|
||||
|
||||
// Upload vault changes to server
|
||||
withContext(Dispatchers.Main) {
|
||||
showLoading(getString(R.string.passkey_syncing))
|
||||
}
|
||||
|
||||
try {
|
||||
vaultStore.mutateVault(webApiService)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Vault mutation failed, but passkey was replaced locally", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
showError("Saved locally, but sync failed: ${e.message}")
|
||||
delay(2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Build response (same as create flow)
|
||||
val credentialIdB64 = base64urlEncode(credentialId)
|
||||
val attestationObjectB64 = base64urlEncode(passkeyResult.attestationObject)
|
||||
|
||||
val clientDataJson =
|
||||
"""{"type":"webauthn.create","challenge":"$challenge","origin":"$requestOrigin","crossOrigin":false}"""
|
||||
val clientDataJsonB64 = base64urlEncode(clientDataJson.toByteArray(Charsets.UTF_8))
|
||||
|
||||
val responseJson = JSONObject().apply {
|
||||
put("id", credentialIdB64)
|
||||
put("rawId", credentialIdB64)
|
||||
put("type", "public-key")
|
||||
put("authenticatorAttachment", "platform")
|
||||
put(
|
||||
"response",
|
||||
JSONObject().apply {
|
||||
put("clientDataJSON", clientDataJsonB64)
|
||||
put("attestationObject", attestationObjectB64)
|
||||
put("authenticatorData", base64urlEncode(passkeyResult.authenticatorData))
|
||||
put(
|
||||
"transports",
|
||||
org.json.JSONArray().apply {
|
||||
put("internal")
|
||||
},
|
||||
)
|
||||
put("publicKey", base64urlEncode(passkeyResult.publicKeyDER))
|
||||
put("publicKeyAlgorithm", -7)
|
||||
},
|
||||
)
|
||||
|
||||
// Add PRF extension results if present
|
||||
val prfResults = if (enablePrf) passkeyResult.prfResults else null
|
||||
if (prfResults != null) {
|
||||
put(
|
||||
"clientExtensionResults",
|
||||
JSONObject().apply {
|
||||
put(
|
||||
"prf",
|
||||
JSONObject().apply {
|
||||
put("enabled", true)
|
||||
put(
|
||||
"results",
|
||||
JSONObject().apply {
|
||||
put("first", base64urlEncode(prfResults.first))
|
||||
prfResults.second?.let {
|
||||
put("second", base64urlEncode(it))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
put("clientExtensionResults", JSONObject())
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Response JSON: ${responseJson.toString(2)}")
|
||||
|
||||
val response = CreatePublicKeyCredentialResponse(responseJson.toString())
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
hideLoading()
|
||||
val resultIntent = Intent()
|
||||
try {
|
||||
Log.d(TAG, "Setting credential response...")
|
||||
PendingIntentHandler.setCreateCredentialResponse(resultIntent, response)
|
||||
Log.d(TAG, "Credential response set successfully")
|
||||
|
||||
setResult(RESULT_OK, resultIntent)
|
||||
Log.d(TAG, "Result set to RESULT_OK, finishing activity")
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error setting credential response", e)
|
||||
showError(getString(R.string.passkey_creation_failed) + ": ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error replacing passkey", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
showError(getString(R.string.passkey_creation_failed) + ": ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract PRF extension inputs from request
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@color/av_primary_ripple">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/av_accent_background" />
|
||||
<corners android:radius="8dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/av_accent_border" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/av_background"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<!-- Main content -->
|
||||
<ScrollView
|
||||
android:id="@+id/scrollView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp"
|
||||
android:paddingBottom="80dp">
|
||||
|
||||
<!-- Logo -->
|
||||
<ImageView
|
||||
android:id="@+id/logoImage"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:src="@drawable/av_logo"
|
||||
android:contentDescription="@string/app_name" />
|
||||
|
||||
<!-- Header -->
|
||||
<TextView
|
||||
android:id="@+id/headerTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/passkey_registration_title"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/av_text"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/headerSubtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/av_text_muted"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<!-- Create new button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/createNewButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/passkey_create_new_button"
|
||||
android:textColor="#FFFFFF"
|
||||
android:backgroundTint="@color/av_primary"
|
||||
android:layout_marginBottom="36dp"
|
||||
app:cornerRadius="8dp"
|
||||
app:icon="@android:drawable/ic_lock_lock"
|
||||
app:iconTint="#FFFFFF"
|
||||
app:iconGravity="textStart"
|
||||
android:paddingVertical="14dp"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<!-- Separator with text -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/passkey_select_to_replace"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/av_text"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp" />
|
||||
|
||||
<!-- Existing passkeys list container -->
|
||||
<LinearLayout
|
||||
android:id="@+id/existingPasskeysContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
<!-- Passkey items will be added programmatically -->
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<!-- Cancel button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/cancelButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/passkey_cancel_button"
|
||||
android:textColor="@color/av_primary"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:paddingVertical="14dp"
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<FrameLayout
|
||||
android:id="@+id/loadingOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#B3000000"
|
||||
android:visibility="gone"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<net.aliasvault.app.components.LoadingIndicator
|
||||
android:id="@+id/loadingIndicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/passkey_item_background"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<!-- Icon -->
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:src="@android:drawable/ic_lock_lock"
|
||||
android:tint="@color/av_primary"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:contentDescription="@string/passkey_info_icon" />
|
||||
|
||||
<!-- Content -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/passkeyDisplayName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/av_text"
|
||||
android:textStyle="bold"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/passkeySubtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/av_text_muted"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:layout_marginTop="2dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Arrow icon -->
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:src="@android:drawable/ic_menu_more"
|
||||
android:tint="@color/av_text_muted"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@string/passkey_info_icon" />
|
||||
</LinearLayout>
|
||||
@@ -6,6 +6,7 @@
|
||||
<color name="av_accent_background">#202020</color>
|
||||
<color name="av_accent_border">#444444</color>
|
||||
<color name="av_primary">#f49541</color>
|
||||
<color name="av_primary_ripple">#33f49541</color>
|
||||
<color name="av_secondary">#6b7280</color>
|
||||
<color name="av_tertiary">#eabf69</color>
|
||||
<color name="av_icon">#9BA1A6</color>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<color name="av_accent_background">#ffffff</color>
|
||||
<color name="av_accent_border">#d1d5db</color>
|
||||
<color name="av_primary">#f49541</color>
|
||||
<color name="av_primary_ripple">#33f49541</color>
|
||||
<color name="av_secondary">#6b7280</color>
|
||||
<color name="av_tertiary">#eabf69</color>
|
||||
<color name="av_icon">#687076</color>
|
||||
|
||||
@@ -35,4 +35,10 @@
|
||||
<string name="passkey_retry_button">Retry</string>
|
||||
<string name="passkey_info_icon">Info icon</string>
|
||||
<string name="passkey_create_explanation">Create a passkey to securely sign in without passwords</string>
|
||||
<string name="passkey_create_new_button">Create New Passkey</string>
|
||||
<string name="passkey_select_to_replace">Select passkey to replace</string>
|
||||
<string name="passkey_replace_title">Replace Passkey</string>
|
||||
<string name="passkey_replace_button">Replace Passkey</string>
|
||||
<string name="passkey_replace_explanation">Replacing this passkey will delete the old one and create a new one</string>
|
||||
<string name="passkey_replacing">Replacing passkey…</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user