Add password fingerprint identicon next to master password field
This commit is contained in:
parent
006f254b53
commit
0ae8ef49d7
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user