From 954c2698a6ce66702b6039a4de8822842ae222cc Mon Sep 17 00:00:00 2001 From: pavelb Date: Fri, 6 Mar 2026 17:27:22 +0100 Subject: [PATCH] Add password fingerprint identicon next to master password field --- .../ui/components/PasswordFingerprint.kt | 93 +++++++++++++++++++ .../ui/screens/DeterministicScreen.kt | 71 ++++++++------ 2 files changed, 134 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/ui/components/PasswordFingerprint.kt diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/components/PasswordFingerprint.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/components/PasswordFingerprint.kt new file mode 100644 index 0000000..e271cd8 --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/components/PasswordFingerprint.kt @@ -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), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt index c087729..a1b5e1d 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cz.bugsy.passwordzebra.R import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel +import cz.bugsy.passwordzebra.ui.components.PasswordFingerprint @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -278,37 +279,47 @@ fun DeterministicScreen( Spacer(Modifier.height(8.dp)) - // Master password - OutlinedTextField( - value = String(state.masterPassword), - onValueChange = { viewModel.updateMasterPassword(it.toCharArray()) }, - label = { Text(stringResource(R.string.det_master_password)) }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(masterPasswordFocus), - singleLine = true, - visualTransformation = if (state.masterPasswordVisible) VisualTransformation.None - else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { - keyboardController?.hide() - viewModel.generate() + // Master password + visual fingerprint + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = String(state.masterPassword), + onValueChange = { viewModel.updateMasterPassword(it.toCharArray()) }, + label = { Text(stringResource(R.string.det_master_password)) }, + modifier = Modifier + .weight(1f) + .focusRequester(masterPasswordFocus), + singleLine = true, + visualTransformation = if (state.masterPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + 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 = { - IconButton(onClick = { viewModel.toggleMasterPasswordVisible() }) { - Icon( - imageVector = if (state.masterPasswordVisible) Icons.Default.VisibilityOff - else Icons.Default.Visibility, - contentDescription = stringResource(R.string.det_toggle_pw_visibility), - ) - } - }, - ) + ) + Spacer(Modifier.width(8.dp)) + PasswordFingerprint( + password = state.masterPassword, + size = 56.dp, + ) + } Spacer(modifier = Modifier.height(4.dp)) } // end Column