Add password fingerprint identicon next to master password field

This commit is contained in:
Pavel Baksy 2026-03-06 17:27:22 +01:00
parent 006f254b53
commit 0ae8ef49d7
2 changed files with 134 additions and 30 deletions

View File

@ -0,0 +1,93 @@
package cz.bugsy.passwordzebra.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import java.security.MessageDigest
/**
* Renders a small symmetric 5×5 identicon derived from the SHA-256 hash of [password].
* Color hue, saturation, and lightness are taken from hash bytes so that every unique
* password produces a visually distinct pattern that is easy to memorise.
*
* Shown as a plain surface-variant box when [password] is empty.
*/
@Composable
fun PasswordFingerprint(
password: CharArray,
modifier: Modifier = Modifier,
size: Dp = 52.dp,
) {
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
val outline = MaterialTheme.colorScheme.outline
// Recompute hash only when the actual password content changes.
val hash: ByteArray? = if (password.isEmpty()) null else remember(String(password)) {
MessageDigest.getInstance("SHA-256")
.digest(String(password).toByteArray(Charsets.UTF_8))
}
val fillColor: Color? = hash?.let {
val hue = (it[0].toInt() and 0xFF) * 360f / 255f
val sat = 0.50f + (it[1].toInt() and 0xFF) / 255f * 0.35f
val lit = 0.38f + (it[2].toInt() and 0xFF) / 255f * 0.18f
Color.hsl(hue, sat, lit)
}
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(8.dp))
.background(surfaceVariant)
.border(1.dp, outline.copy(alpha = 0.35f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
if (hash != null && fillColor != null) {
val canvasSize = size * 0.80f // leave a small margin inside the box
Canvas(modifier = Modifier.size(canvasSize)) {
val gridSize = 5
val cellW = this.size.width / gridSize
val cellH = this.size.height / gridSize
val gap = 1.5f
// Only decide cols 0..2; mirror cols 0..1 to cols 4..3.
// Col 2 is the centre column (not mirrored).
for (row in 0 until gridSize) {
for (col in 0..2) {
val bitIndex = row * 3 + col
val filled = (hash[bitIndex / 8].toInt() ushr (bitIndex % 8)) and 1 == 1
if (filled) {
drawRect(
color = fillColor,
topLeft = Offset(col * cellW + gap, row * cellH + gap),
size = Size(cellW - gap * 2, cellH - gap * 2),
)
if (col < 2) {
drawRect(
color = fillColor,
topLeft = Offset((4 - col) * cellW + gap, row * cellH + gap),
size = Size(cellW - gap * 2, cellH - gap * 2),
)
}
}
}
}
}
}
}
}

View File

@ -66,6 +66,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import cz.bugsy.passwordzebra.R import cz.bugsy.passwordzebra.R
import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel
import cz.bugsy.passwordzebra.ui.components.PasswordFingerprint
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -278,37 +279,47 @@ fun DeterministicScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Master password // Master password + visual fingerprint
OutlinedTextField( Row(
value = String(state.masterPassword), verticalAlignment = Alignment.CenterVertically,
onValueChange = { viewModel.updateMasterPassword(it.toCharArray()) }, modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.det_master_password)) }, ) {
modifier = Modifier OutlinedTextField(
.fillMaxWidth() value = String(state.masterPassword),
.focusRequester(masterPasswordFocus), onValueChange = { viewModel.updateMasterPassword(it.toCharArray()) },
singleLine = true, label = { Text(stringResource(R.string.det_master_password)) },
visualTransformation = if (state.masterPasswordVisible) VisualTransformation.None modifier = Modifier
else PasswordVisualTransformation(), .weight(1f)
keyboardOptions = KeyboardOptions( .focusRequester(masterPasswordFocus),
keyboardType = KeyboardType.Password, singleLine = true,
imeAction = ImeAction.Done, visualTransformation = if (state.masterPasswordVisible) VisualTransformation.None
), else PasswordVisualTransformation(),
keyboardActions = KeyboardActions( keyboardOptions = KeyboardOptions(
onDone = { keyboardType = KeyboardType.Password,
keyboardController?.hide() imeAction = ImeAction.Done,
viewModel.generate() ),
keyboardActions = KeyboardActions(
onDone = {
keyboardController?.hide()
viewModel.generate()
},
),
trailingIcon = {
IconButton(onClick = { viewModel.toggleMasterPasswordVisible() }) {
Icon(
imageVector = if (state.masterPasswordVisible) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
contentDescription = stringResource(R.string.det_toggle_pw_visibility),
)
}
}, },
), )
trailingIcon = { Spacer(Modifier.width(8.dp))
IconButton(onClick = { viewModel.toggleMasterPasswordVisible() }) { PasswordFingerprint(
Icon( password = state.masterPassword,
imageVector = if (state.masterPasswordVisible) Icons.Default.VisibilityOff size = 56.dp,
else Icons.Default.Visibility, )
contentDescription = stringResource(R.string.det_toggle_pw_visibility), }
)
}
},
)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
} // end Column } // end Column