Compare commits

...

10 Commits

Author SHA1 Message Date
2424068d59 Fix(encryption): Resolve startup password unlock issues
- Fixed `Unresolved reference` error for startup password dialog variables by correctly declaring them in `AppShell.kt`.
- Added missing German translations for encryption-related strings in `strings.xml`.
- Corrected `onUnlockEncryption` logic in `AppShell.kt` to properly handle password-based decryption, addressing the "Unlock failed" error.
- Ensured the app closes when the `StartupPasswordDialog` is cancelled in `AppDialogs.kt`.
- Prevented automatic biometric binding when setting an encryption password in `EncryptionPasswordDialog.kt`, allowing users to choose password-only encryption.
- Adjusted biometric prompt behavior in `AppShell.kt` to allow multiple fingerprint attempts before falling back to the password dialog.
2025-11-04 21:07:24 +01:00
9abec4e66a 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.
2025-11-04 13:18:45 +01:00
e9d5ff1cec Feat: Implementiert Navigation zum übergeordneten Verzeichnis nach dem Sperren und behebt die Anzeige des Schlosssymbols. 2025-11-04 11:23:06 +01:00
fdfbe339d8 Fix: Behebt den Absturz beim BiometricPrompt durch Entfernen des negativen Button-Textes. 2025-11-04 10:55:09 +01:00
6029da0ee5 refactor: Remove PIN lock and consolidate with pattern lock
- Removed the separate PIN lock functionality.
- Consolidated PIN lock related UI and logic into the pattern lock implementation.
- Deleted  and introduced .
- Updated , , and  to reflect these changes.
- Added  string resource.
2025-11-04 10:04:47 +01:00
1dbbab7b47 feat: Implement pattern lock and fix lock icon visibility
- Implement pattern lock functionality with a custom pattern input view.
- Fix an issue where the lock icon was only visible for password-locked items.
- Add missing German translations.
2025-11-03 16:00:14 +01:00
06d2b21a11 Feat: Implement new lock methods (Pattern, PIN)
This commit introduces new lock methods for notes, recipes, and shopping lists, allowing users to choose between Password, Biometric, Pattern, and PIN for item protection.

Changes include:
- Expanded `LockMethod` enum with `PATTERN` and `PIN`.
- Updated `ChooseLockMethodDialog` to display new lock options.
- Added `lockMethod` field to `Note`, `Recipe`, and `ShoppingList` data classes.
- Implemented database migration (version 9 to 10) to support the new `lockMethod` field.
- Modified `AppShell.kt` to correctly handle the selection of new lock methods.
- Created placeholder dialogs (`SetPatternDialog`, `SetPinDialog`) and ViewModel functions for future implementation of pattern and PIN entry UIs.
- Updated `AppTopBar.kt` to show the "Lock" option in the dropdown menu for all item types, triggering the `ChooseLockMethodDialog`.
2025-11-03 14:29:00 +01:00
de1d02f31b Fix: Resolve race condition when saving items
This commit fixes a bug that prevented new items (notes, recipes, and shopping lists) from being saved and existing items from being renamed.

The issue was caused by a race condition where the item details were being reset before the save operation could complete. This was due to a nested coroutine launch that caused the UI to update prematurely.

The fix involves:
- Removing the nested coroutine launch to ensure sequential execution of the save and UI reset operations.
- Moving the `reset...Details()` calls to the ViewModels to ensure they are only called after the save operation is complete.
2025-11-03 14:00:27 +01:00
f613e27c6d Fix: ShoppingList protection not applying
Updated the  function in  to directly accept  as a parameter. This ensures that the correct  is fetched from the repository and its protection status is updated, addressing the issue where protection was not being applied due to an incorrect or uninitialized  from the UI state.

Note: This commit also includes other uncommitted changes as requested by the user.
2025-11-02 21:20:18 +01:00
416f1e37aa fix(security): Implement persistent secret key for reliable re-locking
- Introduced a `SecretKeySaver` to persistently store the encryption `secretKey` across configuration changes using `rememberSaveable`.
- Ensured that the `secretKey` is updated in the `AppShell`'s state upon successful biometric authentication for unlocking notes, lists, and recipes.
- This addresses the bug where locked items could be bypassed due to the `secretKey` being lost on screen rotation or other configuration changes, leading to failed re-locking.
- This also resolves the issue where the unlock button for locked items would only work after an app restart.
2025-11-02 09:45:06 +01:00
34 changed files with 3657 additions and 2214 deletions

1
.idea/vcs.xml generated
View File

@@ -2,6 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/sharelist" vcs="Git" />
</component>
</project>

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [Note::class, ShoppingList::class, ShoppingListItem::class, Recipe::class],
version = 8, // Incremented version
version = 10, // Incremented version
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
@@ -32,7 +32,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"noteshop_database"
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
.build()
INSTANCE = instance
// return instance
@@ -82,5 +82,24 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE shopping_lists ADD COLUMN encryptedItems TEXT")
}
}
private val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE notes ADD COLUMN protectionHash TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE notes ADD COLUMN protectionType INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE recipes ADD COLUMN protectionHash TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE recipes ADD COLUMN protectionType INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE shopping_lists ADD COLUMN protectionHash TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE shopping_lists ADD COLUMN protectionType INTEGER NOT NULL DEFAULT 0")
}
}
private val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE notes ADD COLUMN lockMethod INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE recipes ADD COLUMN lockMethod INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE shopping_lists ADD COLUMN lockMethod INTEGER NOT NULL DEFAULT 0")
}
}
}
}

View File

@@ -12,5 +12,7 @@ data class Note(
val title: String,
val content: String,
val displayOrder: Int = 0,
val isLocked: Boolean = false
val protectionHash: String = "",
val protectionType: Int = 0,
val lockMethod: Int = 0
)

View File

@@ -12,5 +12,7 @@ data class Recipe(
val title: String,
val content: String,
val displayOrder: Int = 0,
val isLocked: Boolean = false
val protectionHash: String = "",
val protectionType: Int = 0,
val lockMethod: Int = 0
)

View File

@@ -11,6 +11,8 @@ data class ShoppingList(
val id: Int = 0,
val name: String,
val displayOrder: Int = 0,
val isLocked: Boolean = false,
val protectionHash: String = "",
val protectionType: Int = 0,
val lockMethod: Int = 0,
val encryptedItems: String? = null
)

View File

@@ -0,0 +1,12 @@
package de.lxtools.noteshop.security
import java.security.MessageDigest
object PasswordHasher {
fun hashPassword(password: String): String {
val bytes = password.toByteArray()
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(bytes)
return digest.fold("") { str, it -> str + "%02x".format(it) }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
package de.lxtools.noteshop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.lxtools.noteshop.R
enum class LockMethod {
BIOMETRIC,
PASSWORD
}
@Composable
fun ChooseLockMethodDialog(
onDismiss: () -> Unit,
onConfirm: (LockMethod) -> Unit,
canUseBiometrics: Boolean
) {
var selectedMethod by remember { mutableStateOf(LockMethod.PASSWORD) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.choose_lock_method)) },
text = {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = canUseBiometrics) { if(canUseBiometrics) selectedMethod = LockMethod.BIOMETRIC }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedMethod == LockMethod.BIOMETRIC,
onClick = { if(canUseBiometrics) selectedMethod = LockMethod.BIOMETRIC },
enabled = canUseBiometrics
)
Text(text = stringResource(R.string.biometric_lock), modifier = Modifier.padding(start = 8.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedMethod = LockMethod.PASSWORD }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedMethod == LockMethod.PASSWORD,
onClick = { selectedMethod = LockMethod.PASSWORD }
)
Text(text = stringResource(R.string.password), modifier = Modifier.padding(start = 8.dp))
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(selectedMethod) }) {
Text(text = stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.cancel))
}
}
)
}

View File

@@ -97,7 +97,7 @@ fun EncryptionPasswordDialog(
onClick = {
scope.launch {
try {
val useBiometrics = biometricAuthenticator.isBiometricAuthAvailable()
val useBiometrics = false
val pbeKey = keyManager.derivePbeKey(password.toCharArray())
val finalTask = { authorizedCipher: javax.crypto.Cipher ->
@@ -115,7 +115,6 @@ fun EncryptionPasswordDialog(
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.set_encryption_password),
subtitle = "",
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = context as FragmentActivity,
crypto = cryptoObject,
onSuccess = { result ->

View File

@@ -61,7 +61,6 @@ fun JsonImportExportDialog(
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.authenticate_for_import_export),
subtitle = "",
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
crypto = crypto,
onSuccess = { result ->

View File

@@ -0,0 +1,7 @@
package de.lxtools.noteshop.ui
enum class LockableItemType {
SHOPPING_LIST,
NOTE,
RECIPE
}

View File

@@ -0,0 +1,107 @@
package de.lxtools.noteshop.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
@Composable
fun PatternInput(
onPatternComplete: (List<Int>) -> Unit,
modifier: Modifier = Modifier
) {
var pattern by remember { mutableStateOf(emptyList<Int>()) }
var currentPosition by remember { mutableStateOf<Offset?>(null) }
val dotPositions = remember { mutableMapOf<Int, Offset>() }
val density = LocalDensity.current
Box(modifier = modifier) {
Canvas(modifier = Modifier
.size(300.dp)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
currentPosition = offset
val dot = getDotAt(offset, dotPositions, density)
if (dot != null && !pattern.contains(dot)) {
pattern = pattern + dot
}
},
onDrag = { change, _ ->
currentPosition = change.position
val dot = getDotAt(change.position, dotPositions, density)
if (dot != null && !pattern.contains(dot)) {
pattern = pattern + dot
}
},
onDragEnd = {
onPatternComplete(pattern)
pattern = emptyList()
currentPosition = null
}
)
}) {
val dotRadius = 16.dp.toPx()
val dotColor = Color.Gray
val selectedDotColor = Color.Black
val lineColor = Color.Black
for (i in 1..9) {
val row = (i - 1) / 3
val col = (i - 1) % 3
val x = col * (size.width / 3) + (size.width / 6)
val y = row * (size.height / 3) + (size.height / 6)
val center = Offset(x, y)
dotPositions[i] = center
drawCircle(
color = if (pattern.contains(i)) selectedDotColor else dotColor,
radius = dotRadius,
center = center
)
}
if (pattern.isNotEmpty()) {
for (i in 0 until pattern.size - 1) {
val start = dotPositions[pattern[i]]
val end = dotPositions[pattern[i + 1]]
if (start != null && end != null) {
drawLine(
color = lineColor,
start = start,
end = end,
strokeWidth = 8.dp.toPx()
)
}
}
val lastDot = pattern.last()
val lastDotCenter = dotPositions[lastDot]
if (lastDotCenter != null && currentPosition != null) {
drawLine(
color = lineColor,
start = lastDotCenter,
end = currentPosition!!,
strokeWidth = 8.dp.toPx()
)
}
}
}
}
}
private fun getDotAt(offset: Offset, dotPositions: Map<Int, Offset>, density: Density): Int? {
for ((dot, position) in dotPositions) {
val distance = (offset - position).getDistance()
if (distance < with(density) { 50.dp.toPx() }) {
return dot
}
}
return null
}

View File

@@ -0,0 +1,39 @@
package de.lxtools.noteshop.ui
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import de.lxtools.noteshop.R
@Composable
fun RenameDialog(currentTitle: String, onDismiss: () -> Unit, onSave: (String) -> Unit) {
var text by remember { mutableStateOf(currentTitle) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.rename_title)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text(stringResource(R.string.new_name)) }
)
},
confirmButton = {
Button(onClick = { onSave(text) }) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}

View File

@@ -96,7 +96,6 @@ fun SettingsScreen(
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.confirm_to_proceed),
subtitle = context.getString(R.string.authenticate_to_perform_action),
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = context as FragmentActivity,
onSuccess = { successAction() },
onFailed = {

View File

@@ -0,0 +1,83 @@
package de.lxtools.noteshop.ui
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StartupPasswordDialog(
onDismiss: () -> Unit,
onUnlock: (String) -> Unit,
errorMessage: String? = null
) {
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.unlock_encryption)) },
text = {
Column {
Text(text = stringResource(R.string.enter_password_to_unlock_encryption))
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
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 {
Text(text = it, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
}
}
},
confirmButton = {
Button(
onClick = { onUnlock(password) },
enabled = password.isNotBlank()
) {
Text(stringResource(R.string.unlock))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}

View File

@@ -0,0 +1,83 @@
package de.lxtools.noteshop.ui
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UnlockPasswordDialog(
onDismiss: () -> Unit,
onUnlock: (String) -> Unit,
errorMessage: String? = null
) {
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.unlock_item)) },
text = {
Column {
Text(text = stringResource(R.string.enter_password_to_unlock))
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
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 {
Text(text = it, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
}
}
},
confirmButton = {
Button(
onClick = { onUnlock(password) },
enabled = password.isNotBlank()
) {
Text(stringResource(R.string.unlock))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}

View File

@@ -0,0 +1,216 @@
package de.lxtools.noteshop.ui.appshell
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import de.lxtools.noteshop.BiometricAuthenticator
import de.lxtools.noteshop.Screen
import de.lxtools.noteshop.getDynamicStrings
import de.lxtools.noteshop.ui.AboutScreen
import de.lxtools.noteshop.ui.GuidedTourScreen
import de.lxtools.noteshop.ui.SettingsScreen
import de.lxtools.noteshop.ui.notes.NoteDetailScreen
import de.lxtools.noteshop.ui.notes.NotesScreen
import de.lxtools.noteshop.ui.notes.NotesViewModel
import de.lxtools.noteshop.ui.recipes.RecipesViewModel
import de.lxtools.noteshop.ui.shopping.ShoppingListDetailScreen
import de.lxtools.noteshop.ui.shopping.ShoppingListsScreen
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
import de.lxtools.noteshop.ui.theme.ColorTheme
import de.lxtools.noteshop.ui.webapp.WebAppIntegrationScreen
import de.lxtools.noteshop.ui.webapp.WebAppIntegrationViewModel
@Composable
fun AppContent(
innerPadding: PaddingValues,
currentScreen: Screen,
onScreenChange: (Screen) -> Unit,
notesViewModel: NotesViewModel,
shoppingListsViewModel: ShoppingListsViewModel,
recipesViewModel: RecipesViewModel,
selectedListId: Int?,
onSetSelectedListId: (Int?) -> Unit,
onShowListDialog: () -> Unit,
selectedNoteId: Int?,
onSetSelectedNoteId: (Int?) -> Unit,
onShowNoteDialog: () -> Unit,
selectedRecipeId: Int?,
onSetSelectedRecipeId: (Int?) -> Unit,
onShowRecipeDialog: () -> Unit,
itemWasInitiallyLocked: Boolean,
onUnlockItem: (Int, Screen, Int) -> Unit,
shoppingListsTitle: String,
onThemeChange: (ColorTheme) -> Unit,
colorTheme: ColorTheme,
isAppLockEnabled: Boolean,
onAppLockChange: (Boolean) -> Unit,
isEncryptionEnabled: Boolean,
onEncryptionToggle: (Boolean) -> Unit,
onSetEncryptionPassword: () -> Unit,
onRemoveEncryption: () -> Unit,
hasEncryptionPassword: Boolean,
biometricAuthenticator: BiometricAuthenticator,
sharedPrefs: android.content.SharedPreferences,
onResetShoppingListsTitle: () -> Unit,
onResetRecipesTitle: () -> Unit,
startScreen: String,
onStartScreenChange: (String) -> Unit,
canUseBiometrics: Boolean,
webAppIntegrationViewModel: WebAppIntegrationViewModel,
onAuthenticateAndTogglePasswordVisibility: (Boolean) -> Unit
) {
val dynamicStrings = getDynamicStrings(shoppingListsTitle)
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(MaterialTheme.colorScheme.background),
horizontalAlignment = Alignment.CenterHorizontally
) {
when (currentScreen) {
is Screen.ShoppingLists -> {
ShoppingListsScreen(
viewModel = shoppingListsViewModel,
onListClick = { listId, protectionType ->
if (protectionType != 0) {
onUnlockItem(listId, Screen.ShoppingListDetail, protectionType)
} else {
onSetSelectedListId(listId)
onScreenChange(Screen.ShoppingListDetail)
}
},
onEditList = { listWithItems ->
shoppingListsViewModel.updateListDetails(listWithItems.shoppingList)
onShowListDialog()
}
)
}
is Screen.Notes -> {
NotesScreen(
viewModel = notesViewModel,
onNoteClick = { noteId, protectionType ->
if (protectionType != 0) {
onUnlockItem(noteId, Screen.NoteDetail, protectionType)
} else {
onSetSelectedNoteId(noteId)
onScreenChange(Screen.NoteDetail)
}
},
onEditNote = { note ->
notesViewModel.updateNoteDetails(note)
onShowNoteDialog()
}
)
}
is Screen.ShoppingListDetail -> {
ShoppingListDetailScreen(
listId = selectedListId,
viewModel = shoppingListsViewModel,
dynamicStrings = dynamicStrings,
wasInitiallyLocked = itemWasInitiallyLocked,
isContentDecrypted = itemWasInitiallyLocked,
onUnlockClick = {
val list = shoppingListsViewModel.uiState.value.shoppingLists.find { it.shoppingList.id == selectedListId }?.shoppingList
onUnlockItem(selectedListId!!, Screen.ShoppingListDetail, list?.protectionType ?: 0)
},
onNavigateBack = {
onScreenChange(Screen.ShoppingLists)
onSetSelectedListId(null)
}
)
}
is Screen.NoteDetail -> {
NoteDetailScreen(
noteId = selectedNoteId,
viewModel = notesViewModel,
wasInitiallyLocked = itemWasInitiallyLocked,
isContentDecrypted = itemWasInitiallyLocked,
onUnlockClick = {
val note = notesViewModel.uiState.value.noteList.find { it.id == selectedNoteId }
onUnlockItem(selectedNoteId!!, Screen.NoteDetail, note?.protectionType ?: 0)
}
)
}
is Screen.Recipes -> {
de.lxtools.noteshop.ui.recipes.RecipesScreen(
viewModel = recipesViewModel,
onRecipeClick = { recipeId, protectionType ->
if (protectionType != 0) {
onUnlockItem(recipeId, Screen.RecipeDetail, protectionType)
} else {
onSetSelectedRecipeId(recipeId)
onScreenChange(Screen.RecipeDetail)
}
},
onEditRecipe = { recipe ->
recipesViewModel.updateRecipeDetails(recipe)
onShowRecipeDialog()
}
)
}
is Screen.RecipeDetail -> {
de.lxtools.noteshop.ui.recipes.RecipeDetailScreen(
recipeId = selectedRecipeId,
viewModel = recipesViewModel,
wasInitiallyLocked = itemWasInitiallyLocked,
isContentDecrypted = itemWasInitiallyLocked,
onUnlockClick = {
val recipe = recipesViewModel.uiState.value.recipeList.find { it.id == selectedRecipeId }
onUnlockItem(selectedRecipeId!!, Screen.RecipeDetail, recipe?.protectionType ?: 0)
}
)
}
is Screen.About -> {
AboutScreen()
}
is Screen.Settings -> {
SettingsScreen(
onThemeChange = onThemeChange,
currentTheme = colorTheme,
isAppLockEnabled = isAppLockEnabled,
onAppLockChange = onAppLockChange,
isEncryptionEnabled = isEncryptionEnabled,
onEncryptionToggle = onEncryptionToggle,
onSetEncryptionPassword = onSetEncryptionPassword,
onRemoveEncryption = onRemoveEncryption,
hasEncryptionPassword = hasEncryptionPassword,
biometricAuthenticator = biometricAuthenticator,
sharedPrefs = sharedPrefs,
onResetShoppingListsTitle = onResetShoppingListsTitle,
onResetRecipesTitle = onResetRecipesTitle,
currentStartScreen = startScreen,
onStartScreenChange = onStartScreenChange,
canUseBiometrics = canUseBiometrics,
onNavigate = { screen -> onScreenChange(screen) }
)
}
is Screen.WebAppIntegration -> {
WebAppIntegrationScreen(
viewModel = webAppIntegrationViewModel,
onNavigateUp = { onScreenChange(Screen.Settings) },
padding = innerPadding,
onAuthenticateAndTogglePasswordVisibility = onAuthenticateAndTogglePasswordVisibility
)
}
is Screen.GuidedTour -> {
GuidedTourScreen(
onTourFinished = { onScreenChange(Screen.Settings) }
)
}
}
}
}

View File

@@ -0,0 +1,302 @@
package de.lxtools.noteshop.ui.appshell
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource
import de.lxtools.noteshop.BiometricAuthenticator
import de.lxtools.noteshop.R
import de.lxtools.noteshop.getDynamicRecipeStrings
import de.lxtools.noteshop.security.FileEncryptor
import de.lxtools.noteshop.security.KeyManager
import de.lxtools.noteshop.ui.EncryptionPasswordDialog
import de.lxtools.noteshop.ui.JsonImportExportDialog
import de.lxtools.noteshop.ui.RenameDialog
import de.lxtools.noteshop.ui.UnlockPasswordDialog
import de.lxtools.noteshop.ui.notes.NoteDetails
import de.lxtools.noteshop.ui.notes.NoteInputDialog
import de.lxtools.noteshop.ui.notes.NotesViewModel
import de.lxtools.noteshop.ui.notes.SetPasswordDialog
import de.lxtools.noteshop.ui.recipes.RecipeDetails
import de.lxtools.noteshop.ui.recipes.RecipeInputDialog
import de.lxtools.noteshop.ui.recipes.RecipesViewModel
import de.lxtools.noteshop.ui.shopping.ShoppingListDetails
import de.lxtools.noteshop.ui.shopping.ShoppingListInputDialog
import de.lxtools.noteshop.ui.ChooseLockMethodDialog
import de.lxtools.noteshop.ui.LockableItemType
import de.lxtools.noteshop.ui.LockMethod
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
import de.lxtools.noteshop.ui.StartupPasswordDialog
import kotlinx.coroutines.launch
import androidx.compose.ui.platform.LocalContext
import de.lxtools.noteshop.findActivity
@Composable
fun AppDialogs(
notesViewModel: NotesViewModel,
shoppingListsViewModel: ShoppingListsViewModel,
recipesViewModel: RecipesViewModel,
showListDialog: Boolean,
onShowListDialogChange: (Boolean) -> Unit,
listDetails: ShoppingListDetails,
onListDetailsChange: (ShoppingListDetails) -> Unit,
onSaveList: () -> Unit,
onResetListDetails: () -> Unit,
onSetListProtection: (String) -> Unit,
onSetListProtectionBiometric: (Int) -> Unit,
txtImportLauncher: ActivityResultLauncher<Array<String>>,
showNoteDialog: Boolean,
onShowNoteDialogChange: (Boolean) -> Unit,
noteDetails: NoteDetails,
onNoteDetailsChange: (NoteDetails) -> Unit,
onSaveNote: () -> Unit,
onResetNoteDetails: () -> Unit,
onSetNoteProtectionBiometric: (Int) -> Unit,
noteImportLauncher: ActivityResultLauncher<Array<String>>,
showRecipeDialog: Boolean,
onShowRecipeDialogChange: (Boolean) -> Unit,
recipeDetails: RecipeDetails,
onRecipeDetailsChange: (RecipeDetails) -> Unit,
onSaveRecipe: () -> Unit,
onResetRecipeDetails: () -> Unit,
onSetRecipeProtectionBiometric: (Int) -> Unit,
recipeImportLauncher: ActivityResultLauncher<Array<String>>,
recipesTitle: String,
showJsonDialog: Boolean,
onShowJsonDialogChange: (Boolean) -> Unit,
shoppingListsTitle: String,
fileEncryptor: FileEncryptor,
keyManager: KeyManager,
biometricAuthenticator: BiometricAuthenticator,
showSetPasswordDialog: Boolean,
onShowSetPasswordDialogChange: (Boolean) -> Unit,
onSetNotePassword: (String) -> Unit,
showSetRecipePasswordDialog: Boolean,
onShowSetRecipePasswordDialogChange: (Boolean) -> Unit,
onSetRecipePassword: (String) -> Unit,
showSetListPasswordDialog: Boolean,
onShowSetListPasswordDialogChange: (Boolean) -> Unit,
showUnlockPasswordDialog: Boolean,
onShowUnlockPasswordDialogChange: (Boolean) -> Unit,
onUnlock: (String) -> Unit,
unlockErrorMessage: String?,
showPasswordDialog: Boolean,
onShowPasswordDialogChange: (Boolean) -> Unit,
onHasEncryptionPasswordChange: (Boolean) -> Unit,
sharedPrefs: android.content.SharedPreferences,
showDeleteConfirmationDialog: Boolean,
onShowDeleteConfirmationDialogChange: (Boolean) -> Unit,
onDeleteSyncFolder: () -> Unit,
showRenameDialog: Boolean,
onShowRenameDialogChange: (Boolean) -> Unit,
onRenameShoppingListsTitle: (String) -> Unit,
showRecipeRenameDialog: Boolean,
onShowRecipeRenameDialogChange: (Boolean) -> Unit,
onRenameRecipesTitle: (String) -> Unit,
showChooseLockMethodDialog: Boolean,
onShowChooseLockMethodDialogChange: (Boolean) -> Unit,
onConfirmLockMethod: (LockMethod) -> Unit,
canUseBiometrics: Boolean,
itemToLockType: de.lxtools.noteshop.ui.LockableItemType?,
showStartupPasswordDialog: Boolean,
onShowStartupPasswordDialogChange: (Boolean) -> Unit,
onUnlockEncryption: (String) -> Unit,
startupUnlockErrorMessage: String?
) {
val scope = rememberCoroutineScope()
val dynamicRecipeStrings = getDynamicRecipeStrings(recipesTitle)
if (showListDialog) {
ShoppingListInputDialog(
listDetails = listDetails,
onValueChange = onListDetailsChange,
onSave = {
scope.launch {
shoppingListsViewModel.saveList()
onShowListDialogChange(false)
}
},
onDismiss = { onShowListDialogChange(false) },
onImportFromTxt = {
txtImportLauncher.launch(arrayOf("text/plain"))
},
isNewList = listDetails.id == 0
)
}
if (showNoteDialog) {
NoteInputDialog(
noteDetails = noteDetails,
onValueChange = onNoteDetailsChange,
onSave = {
scope.launch {
notesViewModel.saveNote()
onShowNoteDialogChange(false)
}
},
onDismiss = { onShowNoteDialogChange(false) },
onImport = {
noteImportLauncher.launch(arrayOf("text/plain"))
onShowNoteDialogChange(false)
},
isNewNote = noteDetails.id == 0
)
}
if (showRecipeDialog) {
RecipeInputDialog(
recipeDetails = recipeDetails,
onValueChange = onRecipeDetailsChange,
onSave = {
Log.d("AppDialogs", "RecipeInputDialog onSave called")
scope.launch {
recipesViewModel.saveRecipe()
onShowRecipeDialogChange(false)
}
},
onDismiss = { onShowRecipeDialogChange(false) },
onImport = {
recipeImportLauncher.launch(arrayOf("text/plain", "text/markdown"))
onShowRecipeDialogChange(false)
},
isNewRecipe = recipeDetails.id == 0,
dynamicRecipeStrings = dynamicRecipeStrings
)
}
if (showJsonDialog) {
JsonImportExportDialog(
onDismissRequest = { onShowJsonDialogChange(false) },
notesViewModel = notesViewModel,
shoppingListsViewModel = shoppingListsViewModel,
recipesViewModel = recipesViewModel,
shoppingListsTitle = shoppingListsTitle,
recipesTitle = recipesTitle,
fileEncryptor = fileEncryptor,
keyManager = keyManager,
biometricAuthenticator = biometricAuthenticator
)
}
if (showSetPasswordDialog) {
SetPasswordDialog(
onDismiss = { onShowSetPasswordDialogChange(false) },
onSetPassword = { password ->
onSetNotePassword(password)
onShowSetPasswordDialogChange(false)
}
)
}
if (showSetRecipePasswordDialog) {
SetPasswordDialog(
onDismiss = { onShowSetRecipePasswordDialogChange(false) },
onSetPassword = { password ->
onSetRecipePassword(password)
onShowSetRecipePasswordDialogChange(false)
}
)
}
if (showSetListPasswordDialog) {
SetPasswordDialog(
onDismiss = { onShowSetListPasswordDialogChange(false) },
onSetPassword = { password ->
onSetListProtection(password)
onShowSetListPasswordDialogChange(false)
}
)
}
if (showPasswordDialog) {
EncryptionPasswordDialog(
onDismiss = { onShowPasswordDialogChange(false) },
onPasswordSet = {
onHasEncryptionPasswordChange(true)
onShowPasswordDialogChange(false)
},
keyManager = keyManager,
sharedPrefs = sharedPrefs
)
}
if (showDeleteConfirmationDialog) {
AlertDialog(
onDismissRequest = { onShowDeleteConfirmationDialogChange(false) },
title = { Text(stringResource(R.string.delete_folder)) },
text = { Text(stringResource(R.string.delete_folder_confirmation)) },
confirmButton = {
Button(
onClick = {
onDeleteSyncFolder()
onShowDeleteConfirmationDialogChange(false)
}
) {
Text(stringResource(R.string.delete))
}
},
dismissButton = {
Button(onClick = { onShowDeleteConfirmationDialogChange(false) }) {
Text(stringResource(R.string.cancel))
}
}
)
}
if (showRenameDialog) {
RenameDialog(
currentTitle = shoppingListsTitle,
onDismiss = { onShowRenameDialogChange(false) },
onSave = { newTitle ->
onRenameShoppingListsTitle(newTitle)
onShowRenameDialogChange(false)
}
)
}
if (showRecipeRenameDialog) {
RenameDialog(
currentTitle = recipesTitle,
onDismiss = { onShowRecipeRenameDialogChange(false) },
onSave = { newTitle ->
onRenameRecipesTitle(newTitle)
onShowRecipeRenameDialogChange(false)
}
)
}
if (showUnlockPasswordDialog) {
UnlockPasswordDialog(
onDismiss = {
onShowUnlockPasswordDialogChange(false)
},
onUnlock = onUnlock,
errorMessage = unlockErrorMessage
)
}
if (showChooseLockMethodDialog) {
ChooseLockMethodDialog(
onDismiss = { onShowChooseLockMethodDialogChange(false) },
onConfirm = { lockMethod ->
onConfirmLockMethod(lockMethod)
},
canUseBiometrics = canUseBiometrics
)
}
if (showStartupPasswordDialog) {
val context = LocalContext.current
StartupPasswordDialog(
onDismiss = {
onShowStartupPasswordDialogChange(false)
context.findActivity().finish()
},
onUnlock = onUnlockEncryption,
errorMessage = startupUnlockErrorMessage
)
}
}

View File

@@ -0,0 +1,288 @@
package de.lxtools.noteshop.ui.appshell
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import de.lxtools.noteshop.R
import de.lxtools.noteshop.Screen
import de.lxtools.noteshop.ui.notes.NotesViewModel
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppDrawer(
drawerState: DrawerState,
currentScreen: Screen,
onScreenChange: (Screen) -> Unit,
shoppingListsViewModel: ShoppingListsViewModel,
notesViewModel: NotesViewModel,
shoppingListsTitle: String,
recipesTitle: String,
onShowRenameDialog: () -> Unit,
onShowRecipeRenameDialog: () -> Unit,
onShowJsonDialog: () -> Unit,
syncFolderUriString: String?,
onShowDeleteConfirmationDialog: () -> Unit,
onLaunchSyncFolderChooser: () -> Unit,
onSetSelectedListId: (Int?) -> Unit,
onSetSelectedNoteId: (Int?) -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val navigationItems = listOf(
Screen.ShoppingLists,
Screen.Recipes,
Screen.Notes
)
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars)) {
LazyColumn {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF6975BC))
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.noteshop_fg_logo6),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(id = R.string.app_name),
style = MaterialTheme.typography.titleLarge,
color = Color.White
)
}
}
items(navigationItems, key = { it.route }) { screen ->
when (screen) {
is Screen.ShoppingLists -> {
NavigationDrawerItem(
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(shoppingListsTitle, modifier = Modifier.weight(1f))
IconButton(onClick = { onShowRenameDialog() }) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(R.string.rename_title)
)
}
}
},
selected = screen == currentScreen,
onClick = {
shoppingListsViewModel.disableReorderMode()
notesViewModel.disableReorderMode()
onScreenChange(screen)
onSetSelectedListId(null)
onSetSelectedNoteId(null)
scope.launch { drawerState.close() }
}
)
}
is Screen.Recipes -> {
NavigationDrawerItem(
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(recipesTitle, modifier = Modifier.weight(1f))
IconButton(onClick = {
onShowRecipeRenameDialog()
}) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(R.string.rename_title)
)
}
}
},
selected = screen == currentScreen,
onClick = {
shoppingListsViewModel.disableReorderMode()
notesViewModel.disableReorderMode()
onScreenChange(screen)
onSetSelectedListId(null)
onSetSelectedNoteId(null)
scope.launch { drawerState.close() }
}
)
}
else -> {
NavigationDrawerItem(
label = { Text(stringResource(id = screen.titleRes)) },
selected = screen == currentScreen,
onClick = {
shoppingListsViewModel.disableReorderMode()
notesViewModel.disableReorderMode()
onScreenChange(screen)
onSetSelectedListId(null)
onSetSelectedNoteId(null)
scope.launch { drawerState.close() }
}
)
}
}
}
item {
val standardListName = stringResource(R.string.standard_list_name)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.load_standard_list)) },
selected = false,
onClick = {
shoppingListsViewModel.createStandardList(standardListName)
scope.launch { drawerState.close() }
}
)
}
item {
HorizontalDivider()
}
item {
NavigationDrawerItem(
label = { Text(stringResource(R.string.json_import_export_title)) },
selected = false,
onClick = {
onShowJsonDialog()
scope.launch { drawerState.close() }
}
)
}
item {
NavigationDrawerItem(
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(id = R.string.select_sync_folder))
val displayPath =
if (syncFolderUriString.isNullOrBlank()) {
stringResource(id = R.string.no_folder_selected)
} else {
val path = remember(syncFolderUriString) {
try {
val uri = syncFolderUriString.toUri()
val docFile =
DocumentFile.fromTreeUri(context, uri)
if (docFile?.name == "Noteshop") {
val parent = docFile.parentFile
if (parent != null) {
"${parent.name} / ${docFile.name}"
} else {
docFile.name
}
} else {
val parentName = docFile?.name
if (parentName != null) {
"$parentName / Noteshop"
} else {
null
}
}
} catch (_: Exception) {
null
}
}
if (path != null) {
stringResource(id = R.string.selected_folder) + " " + path
} else {
stringResource(id = R.string.no_folder_selected)
}
}
Text(
text = displayPath,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
}
if (!syncFolderUriString.isNullOrBlank()) {
IconButton(onClick = {
onShowDeleteConfirmationDialog()
}) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.delete_folder)
)
}
}
}
},
selected = false,
onClick = {
onLaunchSyncFolderChooser()
scope.launch { drawerState.close() }
}
)
}
item {
HorizontalDivider()
}
item {
NavigationDrawerItem(
label = { Text(stringResource(R.string.menu_settings)) },
selected = currentScreen == Screen.Settings,
onClick = {
onScreenChange(Screen.Settings)
scope.launch { drawerState.close() }
},
icon = {
Icon(
Icons.Default.Settings,
contentDescription = stringResource(R.string.menu_settings)
)
}
)
}
item {
NavigationDrawerItem(
label = { Text(stringResource(R.string.menu_about)) },
selected = currentScreen == Screen.About,
onClick = {
onScreenChange(Screen.About)
scope.launch { drawerState.close() }
},
icon = {
Icon(
Icons.Default.Info,
contentDescription = stringResource(R.string.menu_about)
)
}
)
}
}
}
}

View File

@@ -0,0 +1,556 @@
package de.lxtools.noteshop.ui.appshell
import android.content.Intent
import android.net.Uri
import androidx.activity.result.ActivityResultLauncher
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.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DrawerState
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.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import de.lxtools.noteshop.R
import de.lxtools.noteshop.Screen
import de.lxtools.noteshop.getDynamicRecipeStrings
import de.lxtools.noteshop.ui.LockableItemType
import de.lxtools.noteshop.ui.notes.NotesViewModel
import de.lxtools.noteshop.ui.recipes.RecipesViewModel
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppTopBar(
currentScreen: Screen,
onScreenChange: (Screen) -> Unit,
drawerState: DrawerState,
shoppingListsViewModel: ShoppingListsViewModel,
notesViewModel: NotesViewModel,
recipesViewModel: RecipesViewModel,
shoppingListsTitle: String,
recipesTitle: String,
onShowListDialog: () -> Unit,
onShowNoteDialog: () -> Unit,
onShowRecipeDialog: () -> Unit,
selectedListId: Int?,
onSetSelectedListId: (Int?) -> Unit,
onSetSelectedNoteId: (Int?) -> Unit,
onSetSelectedRecipeId: (Int?) -> Unit,
exportLauncher: ActivityResultLauncher<String>,
noteExportLauncher: ActivityResultLauncher<String>,
recipeExportLauncher: ActivityResultLauncher<String>,
hasEncryptionPassword: Boolean,
onShowSetPasswordDialog: () -> Unit,
onShowSetRecipePasswordDialog: () -> Unit,
onShowSetListPasswordDialog: () -> Unit,
onShowChooseLockMethodDialog: (LockableItemType, Int) -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val isReorderMode by shoppingListsViewModel.isReorderMode.collectAsState()
val isNotesReorderMode by notesViewModel.isReorderMode.collectAsState()
val isRecipesReorderMode by recipesViewModel.isReorderMode.collectAsState()
val isDetailSearchActive by shoppingListsViewModel.isDetailSearchActive.collectAsState()
val shoppingListWithItems by shoppingListsViewModel.getShoppingListWithItemsStream(
selectedListId ?: 0
).collectAsState(initial = null)
val noteDetails by notesViewModel.noteDetails.collectAsState()
val recipeDetails by recipesViewModel.recipeDetails.collectAsState()
val topBarTitle = when (currentScreen) {
is Screen.ShoppingListDetail -> shoppingListWithItems?.shoppingList?.name ?: ""
is Screen.NoteDetail -> notesViewModel.noteDetails.collectAsState().value.title
is Screen.RecipeDetail -> recipesViewModel.recipeDetails.collectAsState().value.title
is Screen.ShoppingLists -> shoppingListsTitle
is Screen.Recipes -> recipesTitle
else -> stringResource(id = currentScreen.titleRes)
}
Column {
TopAppBar(
title = {
Text(
if (currentScreen == Screen.ShoppingListDetail && isDetailSearchActive) stringResource(
R.string.search
) else topBarTitle
)
},
navigationIcon = {
if (isReorderMode || isNotesReorderMode || isRecipesReorderMode) {
IconButton(onClick = {
if (isReorderMode) shoppingListsViewModel.disableReorderMode()
if (isNotesReorderMode) notesViewModel.disableReorderMode()
if (isRecipesReorderMode) recipesViewModel.disableReorderMode()
}) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
} else {
when (currentScreen) {
is Screen.ShoppingListDetail -> {
IconButton(onClick = {
if (isDetailSearchActive) {
shoppingListsViewModel.toggleDetailSearch()
} else {
onScreenChange(Screen.ShoppingLists)
onSetSelectedListId(null)
}
}) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
}
is Screen.NoteDetail -> {
IconButton(onClick = {
scope.launch {
notesViewModel.saveNote()
onScreenChange(Screen.Notes)
onSetSelectedNoteId(null)
}
}) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
}
is Screen.RecipeDetail -> {
IconButton(onClick = {
scope.launch {
recipesViewModel.saveRecipe()
onScreenChange(Screen.Recipes)
onSetSelectedRecipeId(null)
}
}) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
}
is Screen.WebAppIntegration -> {
IconButton(onClick = { onScreenChange(Screen.Settings) }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
}
else -> {
IconButton(onClick = { scope.launch { drawerState.apply { if (isClosed) open() else close() } } }) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(id = R.string.menu_open)
)
}
}
}
}
},
actions = {
if (currentScreen == Screen.ShoppingLists && !isReorderMode) {
IconButton(onClick = {
shoppingListsViewModel.resetListDetails()
onShowListDialog()
}) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(R.string.add_shopping_list)
)
}
}
if (currentScreen == Screen.Notes && !isNotesReorderMode) {
IconButton(onClick = {
notesViewModel.resetNoteDetails()
onShowNoteDialog()
}) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(R.string.add_note)
)
}
}
if (currentScreen == Screen.Recipes && !isRecipesReorderMode) {
IconButton(onClick = {
recipesViewModel.resetRecipeDetails()
onShowRecipeDialog()
}) {
Icon(
Icons.Default.Add,
contentDescription = getDynamicRecipeStrings(recipesTitle).addItem
)
}
}
when (currentScreen) {
is Screen.ShoppingListDetail -> {
IconButton(onClick = {
selectedListId?.let {
shoppingListsViewModel.importItemsFromWebApp(
it
)
}
}) {
Icon(
Icons.Default.CloudDownload,
contentDescription = stringResource(R.string.import_from_web_app)
)
}
IconButton(onClick = { shoppingListsViewModel.toggleDetailSearch() }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
var showMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMenu = !showMenu }) {
Icon(Icons.Default.MoreVert, contentDescription = "More")
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.export)) },
onClick = {
showMenu = false
shoppingListWithItems?.let {
val fileName = "${it.shoppingList.name}.txt"
exportLauncher.launch(fileName)
}
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.share)) },
onClick = {
showMenu = false
shoppingListWithItems?.let { list ->
val content =
shoppingListsViewModel.formatShoppingListForExport(
list
)
val fileName = "${list.shoppingList.name}.txt"
val cachePath = java.io.File(
context.cacheDir,
"shared_files"
)
cachePath.mkdirs()
val newFile = java.io.File(cachePath, fileName)
newFile.writeText(content)
val contentUri: Uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
newFile
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, contentUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
context.getString(R.string.share)
)
)
}
}
)
shoppingListWithItems?.let { listWithItems ->
DropdownMenuItem(
text = { Text(stringResource(R.string.lock)) },
enabled = hasEncryptionPassword,
onClick = {
showMenu = false
onShowChooseLockMethodDialog(LockableItemType.SHOPPING_LIST, listWithItems.shoppingList.id)
}
)
if (listWithItems.shoppingList.protectionType != 0) {
DropdownMenuItem(
text = { Text(stringResource(R.string.remove_lock)) },
onClick = {
showMenu = false
shoppingListsViewModel.setProtection(listWithItems.shoppingList.id, "")
}
)
}
}
}
}
is Screen.NoteDetail -> {
var showMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMenu = !showMenu }) {
Icon(Icons.Default.MoreVert, contentDescription = "More")
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.export)) },
onClick = {
showMenu = false
notesViewModel.noteDetails.value.toNote()
.let { note ->
val fileName = "${note.title}.txt"
noteExportLauncher.launch(fileName)
}
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.share)) },
onClick = {
showMenu = false
notesViewModel.noteDetails.value.toNote()
.let { note ->
val content =
notesViewModel.formatNoteForExport(note)
val fileName = "${note.title}.txt"
val cachePath = java.io.File(
context.cacheDir,
"shared_files"
)
cachePath.mkdirs()
val newFile =
java.io.File(cachePath, fileName)
newFile.writeText(content)
val contentUri: Uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
newFile
)
val shareIntent =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(
Intent.EXTRA_STREAM,
contentUri
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
context.getString(R.string.share)
)
)
}
}
)
noteDetails.let {
DropdownMenuItem(
text = { Text(stringResource(R.string.lock)) },
enabled = hasEncryptionPassword,
onClick = {
showMenu = false
onShowChooseLockMethodDialog(LockableItemType.NOTE, it.id)
}
)
if (it.protectionType != 0) {
DropdownMenuItem(
text = { Text(stringResource(R.string.remove_lock)) },
onClick = {
showMenu = false
notesViewModel.setProtectionPassword("")
}
)
}
}
}
}
is Screen.RecipeDetail -> {
var showMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMenu = !showMenu }) {
Icon(Icons.Default.MoreVert, contentDescription = "More")
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.export)) },
onClick = {
showMenu = false
recipesViewModel.recipeDetails.value.toRecipe()
.let { recipe ->
val fileName = "${recipe.title}.txt"
recipeExportLauncher.launch(fileName)
}
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.share)) },
onClick = {
showMenu = false
recipesViewModel.recipeDetails.value.toRecipe()
.let { recipe ->
val content =
recipesViewModel.formatRecipeForExport(
recipe
)
val fileName = "${recipe.title}.txt"
val cachePath = java.io.File(
context.cacheDir,
"shared_files"
)
cachePath.mkdirs()
val newFile =
java.io.File(cachePath, fileName)
newFile.writeText(content)
val contentUri: Uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
newFile
)
val shareIntent =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(
Intent.EXTRA_STREAM,
contentUri
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
context.getString(R.string.share)
)
)
}
}
)
recipeDetails.let {
DropdownMenuItem(
text = { Text(stringResource(R.string.lock)) },
enabled = hasEncryptionPassword,
onClick = {
showMenu = false
onShowChooseLockMethodDialog(LockableItemType.RECIPE, it.id)
}
)
if (it.protectionType != 0) {
DropdownMenuItem(
text = { Text(stringResource(R.string.remove_lock)) },
onClick = {
showMenu = false
recipesViewModel.setProtectionPassword("")
}
)
}
}
}
}
else -> {}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
when (currentScreen) {
Screen.ShoppingLists -> {
OutlinedTextField(
value = shoppingListsViewModel.searchQuery.collectAsState().value,
onValueChange = shoppingListsViewModel::updateSearchQuery,
label = { Text(stringResource(R.string.search_list_hint)) },
leadingIcon = {
Icon(
Icons.Default.Search,
contentDescription = null
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
Screen.Notes -> {
OutlinedTextField(
value = notesViewModel.searchQuery.collectAsState().value,
onValueChange = notesViewModel::updateSearchQuery,
label = { Text(stringResource(R.string.search_notes_hint)) },
leadingIcon = {
Icon(
Icons.Default.Search,
contentDescription = null
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
Screen.Recipes -> {
OutlinedTextField(
value = recipesViewModel.searchQuery.collectAsState().value,
onValueChange = recipesViewModel::updateSearchQuery,
label = { Text(getDynamicRecipeStrings(recipesTitle).searchHint) },
leadingIcon = {
Icon(
Icons.Default.Search,
contentDescription = null
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
else -> {}
}
}
}

View File

@@ -44,31 +44,18 @@ fun NoteDetailScreen(
noteId: Int?,
viewModel: NotesViewModel,
modifier: Modifier = Modifier,
onUnlockClick: () -> Unit,
secretKey: SecretKey?,
fileEncryptor: FileEncryptor
wasInitiallyLocked: Boolean,
isContentDecrypted: Boolean,
onUnlockClick: (Int) -> Unit
) {
val noteDetails by viewModel.noteDetails.collectAsState()
val scope = rememberCoroutineScope()
var wasLockedInitially by rememberSaveable { mutableStateOf<Boolean?>(null) }
LaunchedEffect(noteDetails) {
if (wasLockedInitially == null) { // This ensures we only set it once
wasLockedInitially = noteDetails.isLocked
}
}
val context = LocalContext.current
DisposableEffect(noteId) {
onDispose {
val activity = context.findActivity()
if (!activity.isChangingConfigurations) {
if (wasLockedInitially == true) {
viewModel.toggleNoteLock(noteId ?: 0, secretKey, fileEncryptor)
}
}
// The automatic re-locking logic has been removed in favor of a stateless protection model.
}
}
@@ -81,8 +68,8 @@ fun NoteDetailScreen(
}
}
if (noteDetails.isLocked) {
LockedScreenPlaceholder(onUnlockClick = onUnlockClick)
if (noteDetails.protectionHash.isNotEmpty() && !isContentDecrypted) {
LockedScreenPlaceholder(onUnlockClick = { onUnlockClick(noteDetails.protectionType) })
} else {
Column(
modifier = modifier

View File

@@ -47,7 +47,7 @@ fun NotesScreen(
modifier: Modifier = Modifier,
viewModel: NotesViewModel = viewModel(factory = AppViewModelProvider.Factory),
onNoteClick: (Int) -> Unit,
onNoteClick: (Int, Int) -> Unit,
onEditNote: (Note) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
@@ -81,7 +81,7 @@ fun NotesScreen(
if (!isReorderMode) {
detectTapGestures(
onLongPress = { viewModel.enableReorderMode() },
onTap = { onNoteClick(note.id) }
onTap = { onNoteClick(note.id, note.protectionType) }
)
}
},
@@ -128,7 +128,7 @@ fun NoteCard(
Icon(Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.reorder))
}
}
if (note.isLocked) {
if (note.lockMethod != 0) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
}
Text(

View File

@@ -17,6 +17,7 @@ import android.util.Log
import de.lxtools.noteshop.security.FileEncryptor
import java.util.Base64
import javax.crypto.SecretKey
import de.lxtools.noteshop.security.PasswordHasher
class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewModel() {
@@ -38,48 +39,7 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
_searchQuery.value = query
}
fun toggleNoteLock(noteId: Int, secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val note = noteshopRepository.getNoteStream(noteId).firstOrNull()
note?.let {
val newLockedState = !it.isLocked
var noteToUpdate = it.copy(isLocked = newLockedState)
if (newLockedState) {
// Encrypt
try {
val encryptedContent = Base64.getEncoder().encodeToString(
fileEncryptor.encrypt(it.content.toByteArray(Charsets.UTF_8), secretKey!!)
)
noteToUpdate = noteToUpdate.copy(
content = encryptedContent
)
} catch (e: Exception) {
Log.e("NotesViewModel", "Failed to encrypt note content on lock.", e)
return@launch // Don't update if encryption fails
}
} else {
// Decrypt
var decryptedContent: String
try {
decryptedContent = String(fileEncryptor.decrypt(Base64.getDecoder().decode(it.content), secretKey!!), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("NotesViewModel", "Failed to decrypt note on permanent unlock, assuming plaintext.", e)
decryptedContent = it.content
}
noteToUpdate = noteToUpdate.copy(
content = decryptedContent
)
}
noteshopRepository.updateNote(noteToUpdate)
// Also update the details state
_noteDetails.value = _noteDetails.value.copy(
isLocked = newLockedState,
title = noteToUpdate.title,
content = noteToUpdate.content
)
}
}
}
val uiState: StateFlow<NotesUiState> = combine(
noteshopRepository.getAllNotesStream(),
@@ -110,91 +70,105 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
title = note.title,
content = note.content,
displayOrder = note.displayOrder,
isLocked = note.isLocked
protectionHash = note.protectionHash,
protectionType = note.protectionType,
lockMethod = note.lockMethod
)
}
fun updateNoteDetails(noteDetails: NoteDetails) {
_noteDetails.value = noteDetails
}
suspend fun saveNote(secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
suspend fun saveNote() {
if (noteDetails.value.isValid()) {
var currentNote = noteDetails.value.toNote()
if (noteDetails.value.isValid()) {
var currentNote = noteDetails.value.toNote()
// If it's a new note that should be locked, encrypt its content before saving.
if (currentNote.id == 0 && currentNote.isLocked && secretKey != null) {
try {
val encryptedContent = Base64.getEncoder().encodeToString(
fileEncryptor.encrypt(currentNote.content.toByteArray(Charsets.UTF_8), secretKey)
)
currentNote = currentNote.copy(content = encryptedContent)
} catch (e: Exception) {
Log.e("NotesViewModel", "Failed to encrypt new note.", e)
return // Prevent saving if encryption fails
}
}
if (currentNote.id == 0) {
val notesCount = noteshopRepository.getNotesCount()
currentNote = currentNote.copy(displayOrder = notesCount)
}
noteshopRepository.insertNote(currentNote)
// Encryption on save is now handled by the new protection flow.
if (currentNote.id == 0) {
val notesCount = noteshopRepository.getNotesCount()
val newNote = currentNote.copy(displayOrder = notesCount)
noteshopRepository.insertNote(newNote)
} else {
noteshopRepository.updateNote(currentNote)
}
}
}
suspend fun deleteNote(note: Note) {
noteshopRepository.deleteNote(note)
}
fun setProtectionPassword(password: String) {
viewModelScope.launch {
val currentNoteDetails = _noteDetails.value
if (password.isNotBlank()) {
val hash = PasswordHasher.hashPassword(password)
val updatedNote = currentNoteDetails.toNote().copy(
protectionHash = hash,
protectionType = 1, // 1 for password protection
lockMethod = 1
)
noteshopRepository.updateNote(updatedNote)
updateNoteDetails(updatedNote) // Update the UI state
} else {
// Password is blank, so we remove protection
val updatedNote = currentNoteDetails.toNote().copy(
protectionHash = "",
protectionType = 0,
lockMethod = 0
)
noteshopRepository.updateNote(updatedNote)
updateNoteDetails(updatedNote) // Update the UI state
}
}
}
fun setProtectionBiometric(noteId: Int) {
viewModelScope.launch {
val note = noteshopRepository.getNoteStream(noteId).firstOrNull()
note?.let {
val updatedNote = it.copy(
protectionHash = "", // Clear password hash
protectionType = 2, // 2 for biometric protection
lockMethod = 2
)
noteshopRepository.updateNote(updatedNote)
// No need to update _noteDetails.value here as it's for editing, not for the note itself
}
}
}
fun setProtectionPattern(pattern: String) {
Log.d("NotesViewModel", "setProtectionPattern called with pattern: $pattern")
}
fun setProtectionPin(pin: String) {
viewModelScope.launch {
val currentNoteDetails = _noteDetails.value
if (pin.isNotBlank()) {
val hash = PasswordHasher.hashPassword(pin)
val updatedNote = currentNoteDetails.toNote().copy(
protectionHash = hash,
protectionType = 3, // 3 for PIN protection
lockMethod = 3
)
noteshopRepository.updateNote(updatedNote)
updateNoteDetails(updatedNote) // Update the UI state
} else {
// PIN is blank, so we remove protection
val updatedNote = currentNoteDetails.toNote().copy(
protectionHash = "",
protectionType = 0,
lockMethod = 0
)
noteshopRepository.updateNote(updatedNote)
updateNoteDetails(updatedNote) // Update the UI state
}
}
}
fun resetNoteDetails() {
_noteDetails.value = NoteDetails()
}
@@ -234,14 +208,7 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
}
}
suspend fun decryptAllLockedItems(secretKey: SecretKey, fileEncryptor: FileEncryptor) {
val allNotes = noteshopRepository.getAllNotesStream().firstOrNull()
allNotes?.forEach { note ->
if (note.isLocked) {
toggleNoteLock(note.id, secretKey, fileEncryptor)
}
}
}
companion object {
private const val TIMEOUT_MILLIS = 5_000L
@@ -257,14 +224,18 @@ data class NoteDetails(
val title: String = "",
val content: String = "",
val displayOrder: Int = 0,
val isLocked: Boolean = false
val protectionHash: String = "",
val protectionType: Int = 0,
val lockMethod: Int = 0
) {
fun toNote(): Note = Note(
id = id,
title = title,
content = content,
displayOrder = displayOrder,
isLocked = isLocked
protectionHash = protectionHash,
protectionType = protectionType,
lockMethod = lockMethod
)
fun isValid(): Boolean {

View File

@@ -0,0 +1,99 @@
package de.lxtools.noteshop.ui.notes
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SetPasswordDialog(
onDismiss: () -> Unit,
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,
title = { Text(text = stringResource(R.string.set_password)) },
text = {
Column {
Text(text = stringResource(R.string.set_password_description))
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
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()
)
}
},
confirmButton = {
Button(
onClick = { onSetPassword(password) },
enabled = isNewPasswordValid
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}

View File

@@ -39,11 +39,7 @@ import dev.jeziellago.compose.markdowntext.MarkdownText
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import de.lxtools.noteshop.security.FileEncryptor
import javax.crypto.SecretKey
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.saveable.rememberSaveable
import de.lxtools.noteshop.findActivity
@OptIn(ExperimentalMaterial3Api::class)
@@ -52,31 +48,21 @@ fun RecipeDetailScreen(
recipeId: Int?,
viewModel: RecipesViewModel,
modifier: Modifier = Modifier,
onUnlockClick: () -> Unit,
secretKey: SecretKey?,
fileEncryptor: FileEncryptor
wasInitiallyLocked: Boolean,
isContentDecrypted: Boolean,
onUnlockClick: (Int) -> Unit
) {
val recipeDetails by viewModel.recipeDetails.collectAsState()
var isEditMode by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var wasLockedInitially by rememberSaveable { mutableStateOf<Boolean?>(null) }
LaunchedEffect(recipeDetails) {
if (wasLockedInitially == null) { // This ensures we only set it once
wasLockedInitially = recipeDetails.isLocked
}
}
val context = LocalContext.current
DisposableEffect(recipeId) {
onDispose {
val activity = context.findActivity()
if (!activity.isChangingConfigurations) {
if (wasLockedInitially == true) {
viewModel.toggleRecipeLock(recipeId ?: 0, secretKey, fileEncryptor)
}
// TODO: Handle lock state correctly
}
}
}
@@ -90,8 +76,8 @@ fun RecipeDetailScreen(
}
}
if (recipeDetails.isLocked) {
LockedScreenPlaceholder(onUnlockClick = onUnlockClick)
if (recipeDetails.protectionHash.isNotEmpty() && !isContentDecrypted) {
LockedScreenPlaceholder(onUnlockClick = { onUnlockClick(recipeDetails.protectionType) })
} else {
Column(
modifier = modifier

View File

@@ -1,5 +1,6 @@
package de.lxtools.noteshop.ui.recipes
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth

View File

@@ -46,7 +46,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
fun RecipesScreen(
modifier: Modifier = Modifier,
viewModel: RecipesViewModel = viewModel(factory = AppViewModelProvider.Factory),
onRecipeClick: (Int) -> Unit,
onRecipeClick: (Int, Int) -> Unit,
onEditRecipe: (Recipe) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
@@ -57,7 +57,7 @@ fun RecipesScreen(
modifier = modifier.fillMaxSize()
) {
if (uiState.recipeList.isEmpty()) {
Text(text = "No recipes yet. Click + to add one!")
Text(text = stringResource(R.string.no_recipes_yet))
} else {
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
@@ -80,7 +80,7 @@ fun RecipesScreen(
if (!isReorderMode) {
detectTapGestures(
onLongPress = { viewModel.enableReorderMode() },
onTap = { onRecipeClick(recipe.id) }
onTap = { onRecipeClick(recipe.id, recipe.protectionType) }
)
}
},
@@ -127,7 +127,7 @@ fun RecipeCard(
Icon(Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.reorder))
}
}
if (recipe.isLocked) {
if (recipe.lockMethod != 0) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
}
Text(

View File

@@ -14,9 +14,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import android.util.Log
import de.lxtools.noteshop.security.FileEncryptor
import java.util.Base64
import javax.crypto.SecretKey
import de.lxtools.noteshop.security.PasswordHasher
class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : ViewModel() {
@@ -38,48 +36,7 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
_searchQuery.value = query
}
fun toggleRecipeLock(recipeId: Int, secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val recipe = noteshopRepository.getRecipeStream(recipeId).firstOrNull()
recipe?.let {
val newLockedState = !it.isLocked
var recipeToUpdate = it.copy(isLocked = newLockedState)
if (newLockedState) {
// Encrypt
try {
val encryptedContent = Base64.getEncoder().encodeToString(
fileEncryptor.encrypt(it.content.toByteArray(Charsets.UTF_8), secretKey!!)
)
recipeToUpdate = recipeToUpdate.copy(
content = encryptedContent
)
} catch (e: Exception) {
Log.e("RecipesViewModel", "Failed to encrypt recipe content on lock.", e)
return@launch // Don't update if encryption fails
}
} else {
// Decrypt
var decryptedContent: String
try {
decryptedContent = String(fileEncryptor.decrypt(Base64.getDecoder().decode(it.content), secretKey!!), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("RecipesViewModel", "Failed to decrypt recipe on permanent unlock, assuming plaintext.", e)
decryptedContent = it.content
}
recipeToUpdate = recipeToUpdate.copy(
content = decryptedContent
)
}
noteshopRepository.updateRecipe(recipeToUpdate)
// Also update the details state
_recipeDetails.value = _recipeDetails.value.copy(
isLocked = newLockedState,
title = recipeToUpdate.title,
content = recipeToUpdate.content
)
}
}
}
val uiState: StateFlow<RecipesUiState> = combine(
noteshopRepository.getAllRecipesStream(),
@@ -110,95 +67,106 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
title = recipe.title,
content = recipe.content,
displayOrder = recipe.displayOrder,
isLocked = recipe.isLocked
protectionHash = recipe.protectionHash,
protectionType = recipe.protectionType,
lockMethod = recipe.lockMethod
)
}
fun updateRecipeDetails(recipeDetails: RecipeDetails) {
Log.d("RecipesViewModel", "updateRecipeDetails called with details: $recipeDetails")
_recipeDetails.value = recipeDetails
}
suspend fun saveRecipe(secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
if (recipeDetails.value.isValid()) {
var currentRecipe = recipeDetails.value.toRecipe()
// If it's a new recipe that should be locked, encrypt its content before saving.
if (currentRecipe.id == 0 && currentRecipe.isLocked && secretKey != null) {
try {
val encryptedContent = Base64.getEncoder().encodeToString(
fileEncryptor.encrypt(currentRecipe.content.toByteArray(Charsets.UTF_8), secretKey)
)
currentRecipe = currentRecipe.copy(content = encryptedContent)
} catch (e: Exception) {
Log.e("RecipesViewModel", "Failed to encrypt new recipe.", e)
return // Prevent saving if encryption fails
}
}
if (currentRecipe.id == 0) {
val recipesCount = noteshopRepository.getRecipesCount()
val newRecipe = currentRecipe.copy(displayOrder = recipesCount)
noteshopRepository.insertRecipe(newRecipe)
} else {
noteshopRepository.updateRecipe(currentRecipe)
}
suspend fun saveRecipe() {
if (recipeDetails.value.isValid()) {
var currentRecipe = recipeDetails.value.toRecipe()
// Encryption on save is now handled by the new protection flow.
if (currentRecipe.id == 0) {
val recipesCount = noteshopRepository.getRecipesCount()
val newRecipe = currentRecipe.copy(displayOrder = recipesCount)
noteshopRepository.insertRecipe(newRecipe)
} else {
noteshopRepository.updateRecipe(currentRecipe)
}
}
}
suspend fun deleteRecipe(recipe: Recipe) {
noteshopRepository.deleteRecipe(recipe)
}
fun setProtectionPassword(password: String) {
viewModelScope.launch {
val currentRecipeDetails = _recipeDetails.value
if (password.isNotBlank()) {
val hash = PasswordHasher.hashPassword(password)
val updatedRecipe = currentRecipeDetails.toRecipe().copy(
protectionHash = hash,
protectionType = 1, // 1 for password protection
lockMethod = 1
)
noteshopRepository.updateRecipe(updatedRecipe)
updateRecipeDetails(updatedRecipe) // Update the UI state
} else {
// Password is blank, so we remove protection
val updatedRecipe = currentRecipeDetails.toRecipe().copy(
protectionHash = "",
protectionType = 0,
lockMethod = 0
)
noteshopRepository.updateRecipe(updatedRecipe)
updateRecipeDetails(updatedRecipe) // Update the UI state
}
}
}
fun setProtectionBiometric(recipeId: Int) {
viewModelScope.launch {
val recipe = noteshopRepository.getRecipeStream(recipeId).firstOrNull()
recipe?.let {
val updatedRecipe = it.copy(
protectionHash = "", // Clear password hash
protectionType = 2, // 2 for biometric protection
lockMethod = 2
)
noteshopRepository.updateRecipe(updatedRecipe)
// No need to update _recipeDetails.value here as it's for editing, not for the recipe itself
}
}
}
fun setProtectionPattern(pattern: String) {
Log.d("RecipesViewModel", "setProtectionPattern called with pattern: $pattern")
}
fun setProtectionPin(pin: String) {
viewModelScope.launch {
val currentRecipeDetails = _recipeDetails.value
if (pin.isNotBlank()) {
val hash = PasswordHasher.hashPassword(pin)
val updatedRecipe = currentRecipeDetails.toRecipe().copy(
protectionHash = hash,
protectionType = 3, // 3 for PIN protection
lockMethod = 3
)
noteshopRepository.updateRecipe(updatedRecipe)
updateRecipeDetails(updatedRecipe) // Update the UI state
} else {
// PIN is blank, so we remove protection
val updatedRecipe = currentRecipeDetails.toRecipe().copy(
protectionHash = "",
protectionType = 0,
lockMethod = 0
)
noteshopRepository.updateRecipe(updatedRecipe)
updateRecipeDetails(updatedRecipe) // Update the UI state
}
}
}
fun resetRecipeDetails() {
_recipeDetails.value = RecipeDetails()
}
@@ -238,14 +206,7 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
}
}
suspend fun decryptAllLockedItems(secretKey: SecretKey, fileEncryptor: FileEncryptor) {
val allRecipes = noteshopRepository.getAllRecipesStream().firstOrNull()
allRecipes?.forEach { recipe ->
if (recipe.isLocked) {
toggleRecipeLock(recipe.id, secretKey, fileEncryptor)
}
}
}
companion object {
private const val TIMEOUT_MILLIS = 5_000L
@@ -261,14 +222,18 @@ data class RecipeDetails(
val title: String = "",
val content: String = "",
val displayOrder: Int = 0,
val isLocked: Boolean = false
val protectionHash: String = "",
val protectionType: Int = 0,
val lockMethod: Int = 0
) {
fun toRecipe(): Recipe = Recipe(
id = id,
title = title,
content = content,
displayOrder = displayOrder,
isLocked = isLocked
protectionHash = protectionHash,
protectionType = protectionType,
lockMethod = lockMethod
)
fun isValid(): Boolean {

View File

@@ -60,6 +60,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.text.withStyle
import de.lxtools.noteshop.findActivity
import androidx.activity.compose.BackHandler
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShoppingListDetailScreen(
@@ -67,11 +70,18 @@ fun ShoppingListDetailScreen(
viewModel: ShoppingListsViewModel,
dynamicStrings: de.lxtools.noteshop.DynamicListStrings,
modifier: Modifier = Modifier,
secretKey: SecretKey?,
fileEncryptor: FileEncryptor,
onUnlockClick: () -> Unit
wasInitiallyLocked: Boolean,
isContentDecrypted: Boolean,
onUnlockClick: (Int) -> Unit,
onNavigateBack: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
BackHandler(enabled = true) {
keyboardController?.hide()
onNavigateBack()
}
// Collect state from ViewModel
val newItemName by viewModel.newItemName.collectAsState()
@@ -85,25 +95,12 @@ fun ShoppingListDetailScreen(
val isDetailSearchActive by viewModel.isDetailSearchActive.collectAsState()
var wasLockedInitially by rememberSaveable { mutableStateOf<Boolean?>(null) }
// This effect runs when the screen is first displayed. It records the initial lock state.
LaunchedEffect(shoppingListWithItems) {
if (wasLockedInitially == null) { // This ensures we only set it once
wasLockedInitially = shoppingListWithItems?.shoppingList?.isLocked
}
}
val context = LocalContext.current
DisposableEffect(listId) {
onDispose {
val activity = context.findActivity()
if (!activity.isChangingConfigurations) {
// We only re-lock if the list was locked when we entered.
if (wasLockedInitially == true) {
// Calling toggleListLock will re-lock the list if it's currently unlocked.
viewModel.toggleListLock(listId ?: 0, secretKey, fileEncryptor)
}
// TODO: Handle lock state correctly
}
}
}
@@ -122,9 +119,9 @@ fun ShoppingListDetailScreen(
Spacer(modifier = Modifier.height(16.dp))
} else {
val listWithItems = shoppingListWithItems!!
if (listWithItems.shoppingList.isLocked) {
LockedScreenPlaceholder(onUnlockClick = onUnlockClick)
} else {
if (listWithItems.shoppingList.protectionHash.isNotEmpty() && !isContentDecrypted) {
LockedScreenPlaceholder(onUnlockClick = { onUnlockClick(listWithItems.shoppingList.protectionType) })
} else {
val itemsForUi = listWithItems.items
val checkedCount = itemsForUi.count { it.isChecked }
@@ -189,7 +186,7 @@ fun ShoppingListDetailScreen(
.padding(vertical = 8.dp)
.clickable {
coroutineScope.launch {
viewModel.saveShoppingListItem(item.copy(isChecked = false), secretKey, fileEncryptor)
viewModel.saveShoppingListItem(item.copy(isChecked = false))
val currentInput = viewModel.newItemName.value
val lastCommaIndex = currentInput.lastIndexOf(',')
val lastSpaceIndex = currentInput.lastIndexOf(' ')
@@ -273,7 +270,7 @@ fun ShoppingListDetailScreen(
onTap = {
coroutineScope.launch {
val updatedItem = item.copy(isChecked = !item.isChecked)
viewModel.saveShoppingListItem(updatedItem, secretKey, fileEncryptor)
viewModel.saveShoppingListItem(updatedItem)
viewModel.markItemInWebApp(item.name)
}
}
@@ -343,7 +340,7 @@ fun ShoppingListDetailScreen(
onDismiss = { viewModel.onShowRenameDialog(false) },
onRename = { newName ->
coroutineScope.launch {
viewModel.saveShoppingListItem(selectedItem!!.copy(name = newName), secretKey, fileEncryptor)
viewModel.saveShoppingListItem(selectedItem!!.copy(name = newName))
}
viewModel.onShowRenameDialog(false)
viewModel.onSelectItem(null)
@@ -358,7 +355,7 @@ fun ShoppingListDetailScreen(
onDismiss = { viewModel.onShowQuantityDialog(false) },
onSetQuantity = { quantity ->
coroutineScope.launch {
viewModel.saveShoppingListItem(selectedItem!!.copy(quantity = quantity), secretKey, fileEncryptor)
viewModel.saveShoppingListItem(selectedItem!!.copy(quantity = quantity))
}
viewModel.onShowQuantityDialog(false)
viewModel.onSelectItem(null)
@@ -396,6 +393,8 @@ fun LockedScreenPlaceholder(onUnlockClick: () -> Unit) {
}
}
@Composable
fun RenameItemDialog(item: ShoppingListItem, onDismiss: () -> Unit, onRename: (String) -> Unit, dynamicStrings: de.lxtools.noteshop.DynamicListStrings) {
var text by remember { mutableStateOf(item.name) }

View File

@@ -51,7 +51,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
fun ShoppingListsScreen(
viewModel: ShoppingListsViewModel = viewModel(factory = AppViewModelProvider.Factory),
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier,
onListClick: (Int) -> Unit,
onListClick: (Int, Int) -> Unit,
onEditList: (ShoppingListWithItems) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
@@ -86,7 +86,7 @@ fun ShoppingListsScreen(
if (!isReorderMode) {
detectTapGestures(
onLongPress = { viewModel.enableReorderMode() },
onTap = { onListClick(listWithItems.shoppingList.id) }
onTap = { onListClick(listWithItems.shoppingList.id, listWithItems.shoppingList.protectionType) }
)
}
},
@@ -127,7 +127,7 @@ fun ShoppingListCard(
Icon(Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.reorder))
}
}
if (listWithItems.shoppingList.isLocked) {
if (listWithItems.shoppingList.lockMethod != 0) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
}
Text(

View File

@@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json
import de.lxtools.noteshop.security.FileEncryptor
import java.util.Base64
import javax.crypto.SecretKey
import de.lxtools.noteshop.security.PasswordHasher
class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository, application: Application) : AndroidViewModel(application) {
@@ -55,61 +56,7 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
fun toggleListLock(listId: Int, secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val listWithItems = noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()
listWithItems?.let {
val newLockedState = !it.shoppingList.isLocked
var listToUpdate = it.shoppingList.copy(isLocked = newLockedState)
if (newLockedState) {
// Encrypt items
if (secretKey != null) {
try {
val itemsJson = Json.encodeToString(it.items)
val encryptedItems = Base64.getEncoder().encodeToString(fileEncryptor.encrypt(itemsJson.toByteArray(Charsets.UTF_8), secretKey))
listToUpdate = listToUpdate.copy(encryptedItems = encryptedItems)
noteshopRepository.updateShoppingList(listToUpdate)
if (it.items.isNotEmpty()) {
noteshopRepository.deleteShoppingListItems(it.items)
}
} catch (e: Exception) {
Log.e("ShoppingListsViewModel", "Failed to encrypt list on lock.", e)
return@launch
}
} else {
// No key provided, cannot lock
return@launch
}
} else {
// Decrypt items
if (secretKey != null && it.shoppingList.encryptedItems != null) {
try {
val decryptedItemsJson = String(fileEncryptor.decrypt(Base64.getDecoder().decode(it.shoppingList.encryptedItems), secretKey), Charsets.UTF_8)
val decryptedItems = Json.decodeFromString<List<ShoppingListItem>>(decryptedItemsJson)
listToUpdate = listToUpdate.copy(encryptedItems = null)
noteshopRepository.updateShoppingList(listToUpdate)
if (decryptedItems.isNotEmpty()) {
val itemsToInsert = decryptedItems.map { it.copy(id = 0) }
noteshopRepository.insertShoppingListItems(itemsToInsert)
}
} catch (e: Exception) {
Log.e("ShoppingListsViewModel", "Failed to decrypt list on unlock.", e)
return@launch // Abort on decryption failure
}
} else {
// No key or no encrypted data, just unlock
listToUpdate = listToUpdate.copy(encryptedItems = null)
noteshopRepository.updateShoppingList(listToUpdate)
}
}
_listDetails.value = _listDetails.value.copy(
isLocked = newLockedState,
name = listToUpdate.name
)
}
}
}
fun onNewItemNameChange(name: String) {
_newItemName.value = name
@@ -166,7 +113,9 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
id = list.id,
name = list.name,
displayOrder = list.displayOrder,
isLocked = list.isLocked
protectionHash = list.protectionHash,
protectionType = list.protectionType,
lockMethod = list.lockMethod
)
}
@@ -175,23 +124,11 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
}
suspend fun saveList(secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
suspend fun saveList() {
if (listDetails.value.isValid()) {
var currentList = listDetails.value.toShoppingList()
// If it's a new list that should be locked, encrypt an empty item list.
if (currentList.id == 0 && currentList.isLocked && secretKey != null) {
try {
val emptyItemsJson = "[]" // Empty JSON array for an empty list
val encryptedItems = Base64.getEncoder().encodeToString(
fileEncryptor.encrypt(emptyItemsJson.toByteArray(Charsets.UTF_8), secretKey)
)
currentList = currentList.copy(encryptedItems = encryptedItems)
} catch (e: Exception) {
Log.e("ShoppingListsViewModel", "Failed to encrypt new empty list.", e)
return // Prevent saving if encryption fails
}
}
// Encryption on save is now handled by the new protection flow.
if (currentList.id == 0) {
val listsCount = noteshopRepository.getListsCount()
@@ -202,13 +139,89 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
val listToUpdate = existingList?.copy(
name = currentList.name,
displayOrder = currentList.displayOrder,
isLocked = currentList.isLocked
protectionHash = currentList.protectionHash,
protectionType = currentList.protectionType,
lockMethod = currentList.lockMethod
) ?: currentList
noteshopRepository.updateShoppingList(listToUpdate)
}
}
}
fun setProtection(listId: Int, password: String) {
viewModelScope.launch {
val listToUpdate = noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()?.shoppingList
listToUpdate?.let { existingList ->
if (password.isNotBlank()) {
val hash = PasswordHasher.hashPassword(password)
val updatedList = existingList.copy(
protectionHash = hash,
protectionType = 1, // 1 for password protection
lockMethod = 1
)
noteshopRepository.updateShoppingList(updatedList)
updateListDetails(updatedList) // Update the UI state
} else {
// Password is blank, so we remove protection
val updatedList = existingList.copy(
protectionHash = "",
protectionType = 0,
lockMethod = 0
)
noteshopRepository.updateShoppingList(updatedList)
updateListDetails(updatedList) // Update the UI state
}
} ?: run {
Log.e("ShoppingListsViewModel", "Shopping list with ID $listId not found for protection update or is unsaved.")
}
}
}
fun setProtectionBiometric(listId: Int) {
viewModelScope.launch {
val list = noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()?.shoppingList
list?.let {
val updatedList = it.copy(
protectionHash = "", // Clear password hash
protectionType = 2, // 2 for biometric protection
lockMethod = 2
)
noteshopRepository.updateShoppingList(updatedList)
// No need to update _listDetails.value here as it's for editing, not for the list itself
}
}
}
fun setProtectionPattern(pattern: String) {
Log.d("ShoppingListsViewModel", "setProtectionPattern called with pattern: $pattern")
}
fun setProtectionPin(pin: String) {
viewModelScope.launch {
val currentListDetails = _listDetails.value
if (pin.isNotBlank()) {
val hash = PasswordHasher.hashPassword(pin)
val updatedList = currentListDetails.toShoppingList().copy(
protectionHash = hash,
protectionType = 3, // 3 for PIN protection
lockMethod = 3
)
noteshopRepository.updateShoppingList(updatedList)
updateListDetails(updatedList) // Update the UI state
} else {
// PIN is blank, so we remove protection
val updatedList = currentListDetails.toShoppingList().copy(
protectionHash = "",
protectionType = 0,
lockMethod = 0
)
noteshopRepository.updateShoppingList(updatedList)
updateListDetails(updatedList) // Update the UI state
}
}
}
suspend fun deleteList(list: ShoppingList) {
noteshopRepository.deleteShoppingList(list)
}
@@ -221,28 +234,29 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
suspend fun saveShoppingListItem(item: ShoppingListItem, secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
suspend fun saveShoppingListItem(item: ShoppingListItem) {
if (item.name.isNotBlank()) {
val parentList = noteshopRepository.getShoppingListWithItemsStream(item.listId).firstOrNull()?.shoppingList
if (parentList?.isLocked == true) {
if (parentList?.protectionHash?.isNotEmpty() == true) {
Log.w("ShoppingListsViewModel", "Attempted to save item to a locked list.")
return
}
var currentItem = item
if (currentItem.id == 0) {
val itemsCount = noteshopRepository.getItemsCount(currentItem.listId)
currentItem = currentItem.copy(displayOrder = itemsCount)
if (item.id == 0) {
val itemsCount = noteshopRepository.getItemsCount(item.listId)
val newItem = item.copy(displayOrder = itemsCount)
noteshopRepository.insertShoppingListItem(newItem)
} else {
noteshopRepository.updateShoppingListItem(item)
}
noteshopRepository.insertShoppingListItem(currentItem)
}
}
suspend fun saveShoppingListItem(listId: Int, itemName: String, secretKey: SecretKey?, fileEncryptor: FileEncryptor, shouldSplit: Boolean = true) {
suspend fun saveShoppingListItem(listId: Int, itemName: String, shouldSplit: Boolean = true) {
if (itemName.isNotBlank()) {
val listWithItems = noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()
listWithItems?.let {
if (it.shoppingList.isLocked) {
if (it.shoppingList.protectionHash.isNotEmpty()) {
Log.w("ShoppingListsViewModel", "Attempted to save items to a locked list.")
return
}
@@ -425,14 +439,7 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
}
}
suspend fun decryptAllLockedItems(secretKey: SecretKey, fileEncryptor: FileEncryptor) {
val allLists = noteshopRepository.getAllShoppingListsWithItemsStream().firstOrNull()
allLists?.forEach { listWithItems ->
if (listWithItems.shoppingList.isLocked) {
toggleListLock(listWithItems.shoppingList.id, secretKey, fileEncryptor)
}
}
}
fun importItemsFromWebApp(listId: Int) {
viewModelScope.launch {
@@ -603,13 +610,17 @@ data class ShoppingListDetails(
val id: Int = 0,
val name: String = "",
val displayOrder: Int = 0,
val isLocked: Boolean = false
val protectionHash: String = "",
val protectionType: Int = 0,
val lockMethod: Int = 0
) {
fun toShoppingList(): ShoppingList = ShoppingList(
id = id,
name = name,
displayOrder = displayOrder,
isLocked = isLocked
protectionHash = protectionHash,
protectionType = protectionType,
lockMethod = lockMethod
)
fun isValid(): Boolean {

View File

@@ -232,6 +232,13 @@
<string name="unlock_list">Sperre entfernen</string>
<string name="lock_note">Notiz sperren</string>
<string name="unlock_note">Sperre entfernen</string>
<string name="set_password">Passwort festlegen</string>
<string name="set_password_description">Geben Sie ein Passwort ein, um dieses Element zu schützen. Dieses Passwort wird benötigt, um es anzuzeigen oder zu bearbeiten.</string>
<string name="unlock_item">Element entsperren</string>
<string name="enter_password_to_unlock">Geben Sie das Passwort ein, um dieses Element zu entsperren.</string>
<string name="incorrect_password">Falsches Passwort.</string>
<string name="unlock_encryption">Verschlüsselung entsperren</string>
<string name="enter_password_to_unlock_encryption">Geben Sie das Passwort ein, um die Datenverschlüsselung zu entsperren.</string>
<string name="data_encryption">Datenverschlüsselung</string>
<string name="set_encryption_password">Verschlüsselungspasswort festlegen</string>
@@ -319,4 +326,14 @@
<string name="json_import_failed_invalid_format">JSON-Import fehlgeschlagen: Ungültiges Format. %1$s</string>
<string name="json_import_failed_generic">JSON-Import fehlgeschlagen: %1$s</string>
<string name="json_import_failed_no_data">JSON-Import fehlgeschlagen: Keine Daten zum Importieren.</string>
<string name="lock">Sperren</string>
<string name="remove_lock">Sperre entfernen</string>
<string name="choose_lock_method">Sperrmethode auswählen</string>
<string name="ok">OK</string>
<string name="biometric_lock">Biometrische Sperre</string>
<string name="pattern">Muster</string>
<string name="pin">PIN</string>
<string name="set_pin">PIN festlegen</string>
<string name="enter_pin">PIN eingeben</string>
<string name="set_pattern">Muster festlegen</string>
</resources>

View File

@@ -1,5 +1,6 @@
<resources>
<string name="biometric_required_to_unlock">A fingerprint sensor is required to unlock this item.</string>
<string name="biometric_lock">Biometric Lock</string>
<string name="app_name">Noteshop</string>
<string name="app_title">Noteshop</string>
<string name="menu_notes">Notes</string>
@@ -232,7 +233,14 @@
<string name="unlock_list">Remove lock</string>
<string name="lock_note">Lock note</string>
<string name="unlock_note">Remove lock</string>
<string name="set_password">Set Password</string>
<string name="set_password_description">Enter a password to protect this item. This password will be required to view or edit it.</string>
<string name="unlock_item">Unlock Item</string>
<string name="enter_password_to_unlock">Enter password to unlock this item.</string>
<string name="incorrect_password">Incorrect password.</string>
<string name="unlock_encryption">Unlock Encryption</string>
<string name="enter_password_to_unlock_encryption">Enter password to unlock data encryption.</string>
<string name="data_encryption">Data Encryption</string>
<string name="set_encryption_password">Set Encryption Password</string>
<string name="change_encryption_password">Change Encryption Password</string>
@@ -319,4 +327,13 @@
<string name="json_import_failed_invalid_format">JSON import failed: Invalid format. %1$s</string>
<string name="json_import_failed_generic">JSON import failed: %1$s</string>
<string name="json_import_failed_no_data">JSON import failed: No data to import.</string>
<string name="lock">Lock</string>
<string name="remove_lock">Remove lock</string>
<string name="choose_lock_method">Choose lock method</string>
<string name="ok">OK</string>
<string name="pattern">Pattern</string>
<string name="pin">PIN</string>
<string name="set_pin">Set PIN</string>
<string name="enter_pin">Enter PIN</string>
<string name="set_pattern">Set Pattern</string>
</resources>