feat(security): Enhance password protection and fix lock state bugs

This commit introduces several improvements to the password protection feature for notes, recipes, and shopping lists.

- Password confirmation and visibility: When setting a password, the user is now prompted to enter it twice for confirmation. A visibility toggle has been added to both the set password and unlock dialogs to improve user experience.
- Differentiated unlock methods: The app now distinguishes between password and biometric protection. If an item is locked with a password, the app will directly prompt for the password instead of attempting biometric authentication first.
- Fixed disappearing lock icon: A bug was fixed where the lock icon would disappear after an item was unlocked and then edited. The lock state is now correctly preserved.
This commit is contained in:
2025-11-04 13:18:45 +01:00
parent e9d5ff1cec
commit 9abec4e66a
6 changed files with 123 additions and 44 deletions

View File

@@ -620,12 +620,20 @@ fun AppShell(
sharedPrefs.edit { putBoolean("first_launch_completed", true) }
}
}
var selectedListId: Int? by rememberSaveable { mutableStateOf(null) }
var selectedNoteId: Int? by rememberSaveable { mutableStateOf(null) }
var selectedRecipeId: Int? by rememberSaveable { mutableStateOf(null) }
var itemWasInitiallyLocked by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(selectedNoteId, selectedListId, selectedRecipeId) {
if (selectedNoteId == null && selectedListId == null && selectedRecipeId == null) {
itemWasInitiallyLocked = false
}
}
var showListDialog by rememberSaveable { mutableStateOf(false) }
val listDetails by shoppingListsViewModel.listDetails.collectAsState()
@@ -652,34 +660,41 @@ fun AppShell(
val onUnlockItem: (Int, Screen, Int) -> Unit = { id, type, protectionType ->
if (protectionType == 1 || protectionType == 2) { // Password or Biometric protected
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.unlock_item),
subtitle = "",
fragmentActivity = context.findActivity() as FragmentActivity,
onSuccess = { _ ->
when (type) {
is Screen.ShoppingListDetail -> selectedListId = id
is Screen.NoteDetail -> selectedNoteId = id
is Screen.RecipeDetail -> selectedRecipeId = id
else -> {}
when (protectionType) {
1 -> { // Password protected
itemToUnlockId = id
itemToUnlockType = type
showUnlockPasswordDialog = true
}
2 -> { // Biometric protected
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.unlock_item),
subtitle = "",
fragmentActivity = context.findActivity() as FragmentActivity,
onSuccess = { _ ->
when (type) {
is Screen.ShoppingListDetail -> selectedListId = id
is Screen.NoteDetail -> selectedNoteId = id
is Screen.RecipeDetail -> selectedRecipeId = id
else -> {}
}
itemWasInitiallyLocked = true
currentScreen = type
},
onFailed = {
// Biometric failed, offer password as fallback if desired
itemToUnlockId = id
itemToUnlockType = type
showUnlockPasswordDialog = true
},
onError = { _, _ ->
// Biometric error, offer password as fallback if desired
itemToUnlockId = id
itemToUnlockType = type
showUnlockPasswordDialog = true
}
itemWasInitiallyLocked = true
currentScreen = type
},
onFailed = {
// Biometric failed, offer password as fallback if desired
itemToUnlockId = id
itemToUnlockType = type
showUnlockPasswordDialog = true
},
onError = { _, _ ->
// Biometric error, offer password as fallback if desired
itemToUnlockId = id
itemToUnlockType = type
showUnlockPasswordDialog = true
}
)
)
}
}
}
@@ -1180,8 +1195,8 @@ fun AppShell(
when (itemToUnlockType) {
Screen.NoteDetail -> {
val note = notesViewModel.noteDetails.value
if (note.id == itemToUnlockId && note.protectionHash == hashedPassword) {
val note = notesViewModel.uiState.value.noteList.find { it.id == itemToUnlockId }
if (note != null && note.protectionHash == hashedPassword) {
isPasswordCorrect = true
selectedNoteId = itemToUnlockId
}

View File

@@ -4,9 +4,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -17,7 +23,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import de.lxtools.noteshop.R
@@ -29,6 +37,7 @@ fun UnlockPasswordDialog(
errorMessage: String? = null
) {
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
@@ -41,11 +50,19 @@ fun UnlockPasswordDialog(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
visualTransformation = PasswordVisualTransformation(),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
val image = if (showPassword) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description = if (showPassword) "Hide password" else "Show password"
IconButton(onClick = { showPassword = !showPassword }) {
Icon(imageVector = image, description)
}
},
modifier = Modifier.fillMaxWidth()
)
errorMessage?.let { message ->
Text(text = message, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
errorMessage?.let {
Text(text = it, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
}
}
},

View File

@@ -71,7 +71,8 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
content = note.content,
displayOrder = note.displayOrder,
protectionHash = note.protectionHash,
protectionType = note.protectionType
protectionType = note.protectionType,
lockMethod = note.lockMethod
)
}
@@ -224,7 +225,8 @@ data class NoteDetails(
val content: String = "",
val displayOrder: Int = 0,
val protectionHash: String = "",
val protectionType: Int = 0
val protectionType: Int = 0,
val lockMethod: Int = 0
) {
fun toNote(): Note = Note(
id = id,
@@ -232,7 +234,8 @@ data class NoteDetails(
content = content,
displayOrder = displayOrder,
protectionHash = protectionHash,
protectionType = protectionType
protectionType = protectionType,
lockMethod = lockMethod
)
fun isValid(): Boolean {

View File

@@ -4,9 +4,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -17,7 +23,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import de.lxtools.noteshop.R
@@ -28,6 +36,11 @@ fun SetPasswordDialog(
onSetPassword: (String) -> Unit
) {
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var showConfirmPassword by remember { mutableStateOf(false) }
val isNewPasswordValid = password.isNotBlank() && password == confirmPassword
AlertDialog(
onDismissRequest = onDismiss,
@@ -40,7 +53,31 @@ fun SetPasswordDialog(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
visualTransformation = PasswordVisualTransformation(),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
val image = if (showPassword) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description = if (showPassword) "Hide password" else "Show password"
IconButton(onClick = { showPassword = !showPassword }) {
Icon(imageVector = image, description)
}
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text(stringResource(R.string.confirm_password)) },
visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
val image = if (showConfirmPassword) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description = if (showConfirmPassword) "Hide password" else "Show password"
IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) {
Icon(imageVector = image, description)
}
},
modifier = Modifier.fillMaxWidth()
)
}
@@ -48,7 +85,7 @@ fun SetPasswordDialog(
confirmButton = {
Button(
onClick = { onSetPassword(password) },
enabled = password.isNotBlank()
enabled = isNewPasswordValid
) {
Text(stringResource(R.string.save))
}

View File

@@ -68,7 +68,8 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
content = recipe.content,
displayOrder = recipe.displayOrder,
protectionHash = recipe.protectionHash,
protectionType = recipe.protectionType
protectionType = recipe.protectionType,
lockMethod = recipe.lockMethod
)
}
@@ -222,7 +223,8 @@ data class RecipeDetails(
val content: String = "",
val displayOrder: Int = 0,
val protectionHash: String = "",
val protectionType: Int = 0
val protectionType: Int = 0,
val lockMethod: Int = 0
) {
fun toRecipe(): Recipe = Recipe(
id = id,
@@ -230,7 +232,8 @@ data class RecipeDetails(
content = content,
displayOrder = displayOrder,
protectionHash = protectionHash,
protectionType = protectionType
protectionType = protectionType,
lockMethod = lockMethod
)
fun isValid(): Boolean {

View File

@@ -114,7 +114,8 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
name = list.name,
displayOrder = list.displayOrder,
protectionHash = list.protectionHash,
protectionType = list.protectionType
protectionType = list.protectionType,
lockMethod = list.lockMethod
)
}
@@ -139,7 +140,8 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
name = currentList.name,
displayOrder = currentList.displayOrder,
protectionHash = currentList.protectionHash,
protectionType = currentList.protectionType
protectionType = currentList.protectionType,
lockMethod = currentList.lockMethod
) ?: currentList
noteshopRepository.updateShoppingList(listToUpdate)
}
@@ -609,14 +611,16 @@ data class ShoppingListDetails(
val name: String = "",
val displayOrder: Int = 0,
val protectionHash: String = "",
val protectionType: Int = 0
val protectionType: Int = 0,
val lockMethod: Int = 0
) {
fun toShoppingList(): ShoppingList = ShoppingList(
id = id,
name = name,
displayOrder = displayOrder,
protectionHash = protectionHash,
protectionType = protectionType
protectionType = protectionType,
lockMethod = lockMethod
)
fun isValid(): Boolean {