Compare commits

...

13 Commits

Author SHA1 Message Date
44e4bd3218 refactor: Clean up UI components and improve JSON import/export
- Removed unused parameters and functions related to biometric protection and save/reset operations in AppShell.kt and AppDialogs.kt.
- Simplified logging and toast messages in JsonImportExportDialog.kt.
- Improved null safety and file output stream handling in JsonImportExportDialog.kt.
- Updated AndroidManifest.xml with target API level and tools namespace.
2025-11-06 22:18:49 +01:00
c2ea7c98ef refactor: Remove unused parameters and code in UI components - Cleaned up AppShell.kt, AppTopBar.kt, AppContent.kt, ShoppingListDetailScreen.kt, WebAppIntegrationScreen.kt, NoteDetailScreen.kt, ShoppingListsViewModel.kt, NotesViewModel.kt, RecipesViewModel.kt, and EncryptionPasswordDialog.kt by removing unused parameters, imports, and functions, and updating Base64 usage and shared preferences editing. 2025-11-06 20:51:11 +01:00
a10602c6a5 feat: Remove unused PatternInput.kt file
This commit removes the  file as the  Composable function was not used anywhere in the project.
2025-11-06 19:24:27 +01:00
62ff3594d4 chore: Commit remaining changes 2025-11-06 19:18:15 +01:00
a5bed3031a Refactor: Clean up unused code and fix warnings
This commit addresses several linter warnings and removes unused code to improve maintainability.

- Removed unused function  and property  in .
- Removed unused parameter  from , , and .
- Fixed a variable shadowing issue with  in .
- Inlined a redundant variable in .
2025-11-06 19:16:38 +01:00
20d9139256 Fix: Remove redundant explicit type argument in AppShell.kt 2025-11-06 17:28:20 +01:00
cf799eba12 Refactor: State management in AppShell.kt 2025-11-06 17:24:08 +01:00
fd04f9b5d9 fix: Restore Log import in AppDialogs
Restored the  import in  to fix an "Unresolved reference 'Log'" error that was introduced by a previous commit.
2025-11-06 15:42:10 +01:00
28cc1313b4 refactor: Remove unused Log import from AppDialogs
Removed the unused  import from  to clean up the code.
2025-11-06 15:39:33 +01:00
7c5c31c638 fix: Correct GuidedTourScreen after pager migration
Fixed the build errors in GuidedTourScreen.kt that occurred after migrating to androidx.compose.foundation.pager.
- Added pageCount to rememberPagerState.
- Removed count from HorizontalPager.
- Restored the R import.
2025-11-06 15:35:33 +01:00
12839ea88e refactor: Remove redundant 'jsonString != null' checks
Removed redundant 'if (jsonString != null)' checks in JsonImportExportDialog.kt as jsonString is always non-null at these points, addressing 'Condition is always true' warnings.
2025-11-06 14:53:29 +01:00
fb8e7917bc refactor: Migrate GuidedTourScreen to androidx.compose.foundation.pager
Migrated the GuidedTourScreen from deprecated Accompanist Pager to androidx.compose.foundation.pager.
Removed HorizontalPagerIndicator usage, as a direct replacement is not available and requires custom implementation.
2025-11-06 14:51:46 +01:00
4f0edbca22 refactor: Remove unused import from AppContent
Removed the unused  import from  to clean up the code.
2025-11-06 14:38:17 +01:00
22 changed files with 258 additions and 548 deletions

View File

@@ -37,8 +37,7 @@ Noteshop is a versatile and privacy-focused application for managing your notes,
1. Clone the repository:
```sh
git clone YOUR_GITEA_REPOSITORY_URL_HERE
cd noteshop
git clone https://git.ilunix.de/punix/noteshop.git && cd noteshop
```
2. Build the app using Gradle:
```sh

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
@@ -16,7 +17,8 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.Noteshop">
android:theme="@style/Theme.Noteshop"
tools:targetApi="33">
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -125,11 +125,7 @@ class BiometricAuthenticator(private val context: Context) {
private lateinit var promptInfo: BiometricPrompt.PromptInfo
private val biometricManager = BiometricManager.from(context)
fun isBiometricAuthAvailable(): Boolean {
return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS
}
fun promptBiometricAuth(
title: String,

View File

@@ -2,6 +2,7 @@ package de.lxtools.noteshop.ui
import android.content.Context
import android.content.Intent
import android.util.Base64
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -41,7 +42,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -74,12 +74,11 @@ import de.lxtools.noteshop.ui.recipes.RecipesViewModel
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
import de.lxtools.noteshop.ui.theme.ColorTheme
import de.lxtools.noteshop.ui.webapp.WebAppIntegrationViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.FileOutputStream
import javax.crypto.SecretKey
import android.util.Base64
import javax.crypto.spec.SecretKeySpec
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -98,10 +97,10 @@ fun AppShell(
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showStartupPasswordDialog by rememberSaveable { mutableStateOf(false) }
var startupUnlockErrorMessage by rememberSaveable { mutableStateOf<String?>(null) }
val (showStartupPasswordDialog, setShowStartupPasswordDialog) = rememberSaveable { mutableStateOf(false) }
val (startupUnlockErrorMessage, setStartupUnlockErrorMessage) = rememberSaveable { mutableStateOf<String?>(null) }
var syncFolderUriString by rememberSaveable {
val (syncFolderUriString, setSyncFolderUriString) = rememberSaveable {
mutableStateOf(
sharedPrefs.getString(
"sync_folder_uri",
@@ -114,7 +113,7 @@ fun AppShell(
val listener =
android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
if (key == "sync_folder_uri") {
syncFolderUriString = sharedPreferences.getString(key, null)
setSyncFolderUriString(sharedPreferences.getString(key, null))
}
}
sharedPrefs.registerOnSharedPreferenceChangeListener(listener)
@@ -128,7 +127,7 @@ fun AppShell(
val keyManager = remember { KeyManager(context, canUseBiometrics) }
val fileEncryptor = remember { FileEncryptor() }
var isEncryptionEnabled by rememberSaveable {
val (isEncryptionEnabled, setIsEncryptionEnabled) = rememberSaveable {
mutableStateOf(
sharedPrefs.getBoolean(
"encryption_enabled",
@@ -136,9 +135,9 @@ fun AppShell(
)
)
}
var hasEncryptionPassword by rememberSaveable { mutableStateOf(keyManager.hasKey()) }
var showPasswordDialog by rememberSaveable { mutableStateOf(false) }
var showDeleteConfirmationDialog by remember { mutableStateOf(false) }
val (hasEncryptionPassword, setHasEncryptionPassword) = rememberSaveable { mutableStateOf(keyManager.hasKey()) }
val (showPasswordDialog, setShowPasswordDialog) = rememberSaveable { mutableStateOf(false) }
val (showDeleteConfirmationDialog, setShowDeleteConfirmationDialog) = remember { mutableStateOf(false) }
val onEncryptionToggle: (Boolean) -> Unit = { enabled ->
if (!enabled) { // Turning OFF
@@ -149,8 +148,8 @@ fun AppShell(
if (cipher == null) {
// Fallback for safety, though this state should be unlikely if hasEncryptionPassword is true
keyManager.deleteKey()
hasEncryptionPassword = false
isEncryptionEnabled = false
setHasEncryptionPassword(false)
setIsEncryptionEnabled(false)
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
remove("biometric_unlock_enabled")
@@ -174,8 +173,8 @@ fun AppShell(
// Now, safely delete the key and disable the feature
keyManager.deleteKey()
hasEncryptionPassword = false
isEncryptionEnabled = false
setHasEncryptionPassword(false)
setIsEncryptionEnabled(false)
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
remove("biometric_unlock_enabled")
@@ -188,13 +187,13 @@ fun AppShell(
)
} else {
// No password was ever set, just turn the switch off.
isEncryptionEnabled = false
setIsEncryptionEnabled(false)
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
}
}
} else { // Turning ON
isEncryptionEnabled = true
setIsEncryptionEnabled(true)
sharedPrefs.edit {
putBoolean("encryption_enabled", true)
}
@@ -202,50 +201,10 @@ fun AppShell(
}
val onSetEncryptionPassword: () -> Unit = {
showPasswordDialog = true
setShowPasswordDialog(true)
}
val onRemoveEncryption: () -> Unit = {
val activity = context.findActivity() as FragmentActivity
val cipher = keyManager.getDecryptionCipher()
if (cipher == null) {
// This can happen if no key is set. In this case, just disable the feature.
hasEncryptionPassword = false
isEncryptionEnabled = false
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
remove("biometric_unlock_enabled")
}
}
val crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher!!)
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.remove_encryption),
subtitle = context.getString(R.string.confirm_to_remove_encryption),
fragmentActivity = activity,
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
scope.launch {
keyManager.getSecretKeyFromAuthenticatedCipher(authenticatedCipher)
// Decrypt all items in all ViewModels
// Decryption of individual items is no longer needed.
// Now it's safe to delete the key
keyManager.deleteKey()
hasEncryptionPassword = false
isEncryptionEnabled = false
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
remove("biometric_unlock_enabled")
}
}
}
},
onFailed = {},
onError = { _, _ -> }
)
}
val performAutomaticExport: suspend (Context, SecretKey?) -> Unit =
remember(notesViewModel, shoppingListsViewModel, recipesViewModel, syncFolderUri) {
@@ -515,8 +474,8 @@ fun AppShell(
}
val lifecycleOwner = LocalLifecycleOwner.current
var secretKey by rememberSaveable(stateSaver = SecretKeySaver) { mutableStateOf(null) }
var isDecryptionAttempted by rememberSaveable { mutableStateOf(false) }
val (secretKey, setSecretKey) = rememberSaveable(stateSaver = SecretKeySaver) { mutableStateOf(null) }
val (isDecryptionAttempted, setIsDecryptionAttempted) = rememberSaveable { mutableStateOf(false) }
DisposableEffect(lifecycleOwner, context, scope) {
val observer = LifecycleEventObserver { _, event ->
@@ -541,32 +500,33 @@ fun AppShell(
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
scope.launch {
secretKey =
setSecretKey(
keyManager.getSecretKeyFromAuthenticatedCipher(
authenticatedCipher
)
isDecryptionAttempted = true
)
setIsDecryptionAttempted(true)
}
}
},
onFailed = {},
onError = { _, _ ->
showStartupPasswordDialog = true
setShowStartupPasswordDialog(true)
}
)
} else {
isDecryptionAttempted = true // No key/cipher, proceed
setIsDecryptionAttempted(true) // No key/cipher, proceed
}
} catch (e: Exception) {
Log.e("AppShell", "Error during decryption prompt: ${e.message}")
isDecryptionAttempted = true // Proceed without key
setIsDecryptionAttempted(true) // Proceed without key
}
}
} else {
showStartupPasswordDialog = true
setShowStartupPasswordDialog(true)
}
} else {
isDecryptionAttempted = true // No encryption, proceed
setIsDecryptionAttempted(true) // No encryption, proceed
}
}
@@ -594,7 +554,7 @@ fun AppShell(
}
}
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var currentScreen: Screen by rememberSaveable {
val (currentScreen, setCurrentScreen) = rememberSaveable {
val defaultStartScreenRoute = sharedPrefs.getString("default_start_screen", "last_screen")
val initialScreen = when (defaultStartScreenRoute) {
Screen.ShoppingLists.route -> Screen.ShoppingLists
@@ -624,58 +584,58 @@ fun AppShell(
LaunchedEffect(Unit) {
val firstLaunchCompleted = sharedPrefs.getBoolean("first_launch_completed", false)
if (!firstLaunchCompleted) {
currentScreen = Screen.GuidedTour
setCurrentScreen(Screen.GuidedTour)
sharedPrefs.edit { putBoolean("first_launch_completed", true) }
}
}
var selectedListId: Int? by rememberSaveable { mutableStateOf(null) }
var selectedNoteId: Int? by rememberSaveable { mutableStateOf(null) }
var selectedRecipeId: Int? by rememberSaveable { mutableStateOf(null) }
val (selectedListId, setSelectedListId) = rememberSaveable { mutableStateOf<Int?>(null) }
val (selectedNoteId, setSelectedNoteId) = rememberSaveable { mutableStateOf<Int?>(null) }
val (selectedRecipeId, setSelectedRecipeId) = rememberSaveable { mutableStateOf<Int?>(null) }
var itemWasInitiallyLocked by rememberSaveable { mutableStateOf(false) }
val (itemWasInitiallyLocked, setItemWasInitiallyLocked) = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(selectedNoteId, selectedListId, selectedRecipeId) {
if (selectedNoteId == null && selectedListId == null && selectedRecipeId == null) {
itemWasInitiallyLocked = false
setItemWasInitiallyLocked(false)
}
}
var showListDialog by rememberSaveable { mutableStateOf(false) }
val (showListDialog, setShowListDialog) = rememberSaveable { mutableStateOf(false) }
val listDetails by shoppingListsViewModel.listDetails.collectAsState()
var showNoteDialog by rememberSaveable { mutableStateOf(false) }
val (showNoteDialog, setShowNoteDialog) = rememberSaveable { mutableStateOf(false) }
val noteDetails by notesViewModel.noteDetails.collectAsState()
var showRecipeDialog by rememberSaveable { mutableStateOf(false) }
val (showRecipeDialog, setShowRecipeDialog) = rememberSaveable { mutableStateOf(false) }
val recipeDetails by recipesViewModel.recipeDetails.collectAsState()
var showJsonDialog by rememberSaveable { mutableStateOf(false) }
var showSetPasswordDialog by rememberSaveable { mutableStateOf(false) }
var showSetRecipePasswordDialog by rememberSaveable { mutableStateOf(false) }
var showSetListPasswordDialog by rememberSaveable { mutableStateOf(false) }
val (showJsonDialog, setShowJsonDialog) = rememberSaveable { mutableStateOf(false) }
val (showSetPasswordDialog, setShowSetPasswordDialog) = rememberSaveable { mutableStateOf(false) }
val (showSetRecipePasswordDialog, setShowSetRecipePasswordDialog) = rememberSaveable { mutableStateOf(false) }
val (showSetListPasswordDialog, setShowSetListPasswordDialog) = rememberSaveable { mutableStateOf(false) }
var showUnlockPasswordDialog by rememberSaveable { mutableStateOf(false) }
var unlockErrorMessage by rememberSaveable { mutableStateOf<String?>(null) }
var itemToUnlockId by rememberSaveable { mutableStateOf<Int?>(null) }
var itemToUnlockType by rememberSaveable { mutableStateOf<Screen?>(null) }
val (showUnlockPasswordDialog, setShowUnlockPasswordDialog) = rememberSaveable { mutableStateOf(false) }
val (unlockErrorMessage, setUnlockErrorMessage) = rememberSaveable { mutableStateOf<String?>(null) }
val (itemToUnlockId, setItemToUnlockId) = rememberSaveable { mutableStateOf<Int?>(null) }
val (itemToUnlockType, setItemToUnlockType) = rememberSaveable { mutableStateOf<Screen?>(null) }
var showChooseLockMethodDialog by rememberSaveable { mutableStateOf(false) }
var itemToLockId: Int? by rememberSaveable { mutableStateOf(null) }
var itemToLockType: LockableItemType? by rememberSaveable { mutableStateOf(null) }
val (showChooseLockMethodDialog, setShowChooseLockMethodDialog) = rememberSaveable { mutableStateOf(false) }
val (itemToLockId, setItemToLockId) = rememberSaveable { mutableStateOf<Int?>(null) }
val (itemToLockType, setItemToLockType) = rememberSaveable { mutableStateOf<LockableItemType?>(null) }
val onUnlockItem: (Int, Screen, Int) -> Unit = { id, type, protectionType ->
when (protectionType) {
1 -> { // Password protected
itemToUnlockId = id
itemToUnlockType = type
showUnlockPasswordDialog = true
setItemToUnlockId(id)
setItemToUnlockType(type)
setShowUnlockPasswordDialog(true)
}
2 -> { // Biometric protected
biometricAuthenticator.promptBiometricAuth(
@@ -684,25 +644,25 @@ fun AppShell(
fragmentActivity = context.findActivity() as FragmentActivity,
onSuccess = { _ ->
when (type) {
is Screen.ShoppingListDetail -> selectedListId = id
is Screen.NoteDetail -> selectedNoteId = id
is Screen.RecipeDetail -> selectedRecipeId = id
is Screen.ShoppingListDetail -> setSelectedListId(id)
is Screen.NoteDetail -> setSelectedNoteId(id)
is Screen.RecipeDetail -> setSelectedRecipeId(id)
else -> {}
}
itemWasInitiallyLocked = true
currentScreen = type
setItemWasInitiallyLocked(true)
setCurrentScreen(type)
},
onFailed = {
// Biometric failed, offer password as fallback if desired
itemToUnlockId = id
itemToUnlockType = type
showUnlockPasswordDialog = true
setItemToUnlockId(id)
setItemToUnlockType(type)
setShowUnlockPasswordDialog(true)
},
onError = { _, _ ->
// Biometric error, offer password as fallback if desired
itemToUnlockId = id
itemToUnlockType = type
showUnlockPasswordDialog = true
setItemToUnlockId(id)
setItemToUnlockType(type)
setShowUnlockPasswordDialog(true)
}
)
}
@@ -716,7 +676,7 @@ fun AppShell(
)
.collectAsState(initial = null)
var shoppingListsTitle by remember {
val (shoppingListsTitle, setShoppingListsTitle) = remember {
mutableStateOf(
sharedPrefs.getString(
"shopping_lists_title",
@@ -724,9 +684,9 @@ fun AppShell(
) ?: context.getString(R.string.menu_shopping_lists)
)
}
var showRenameDialog by rememberSaveable { mutableStateOf(false) }
val (showRenameDialog, setShowRenameDialog) = rememberSaveable { mutableStateOf(false) }
var recipesTitle by remember {
val (recipesTitle, setRecipesTitle) = remember {
mutableStateOf(
sharedPrefs.getString(
"recipes_title",
@@ -734,9 +694,9 @@ fun AppShell(
) ?: context.getString(R.string.menu_recipes)
)
}
var showRecipeRenameDialog by rememberSaveable { mutableStateOf(false) }
val (showRecipeRenameDialog, setShowRecipeRenameDialog) = rememberSaveable { mutableStateOf(false) }
var startScreen by remember {
val (startScreen, setStartScreen) = remember {
mutableStateOf(
sharedPrefs.getString(
"default_start_screen",
@@ -764,7 +724,7 @@ fun AppShell(
shoppingListsViewModel.importListFromTxt(listName, content)
}
}
showListDialog = false // Close dialog on success
setShowListDialog(false) // Close dialog on success
}
}
)
@@ -942,19 +902,19 @@ fun AppShell(
AppDrawer(
drawerState = drawerState,
currentScreen = currentScreen,
onScreenChange = { currentScreen = it },
onScreenChange = { setCurrentScreen(it) },
shoppingListsViewModel = shoppingListsViewModel,
notesViewModel = notesViewModel,
shoppingListsTitle = shoppingListsTitle,
recipesTitle = recipesTitle,
onShowRenameDialog = { showRenameDialog = true },
onShowRecipeRenameDialog = { showRecipeRenameDialog = true },
onShowJsonDialog = { showJsonDialog = true },
onShowRenameDialog = { setShowRenameDialog(true) },
onShowRecipeRenameDialog = { setShowRecipeRenameDialog(true) },
onShowJsonDialog = { setShowJsonDialog(true) },
syncFolderUriString = syncFolderUriString,
onShowDeleteConfirmationDialog = { showDeleteConfirmationDialog = true },
onShowDeleteConfirmationDialog = { setShowDeleteConfirmationDialog(true) },
onLaunchSyncFolderChooser = { syncFolderLauncher.launch(null) },
onSetSelectedListId = { selectedListId = it },
onSetSelectedNoteId = { selectedNoteId = it }
onSetSelectedListId = { setSelectedListId(it) },
onSetSelectedNoteId = { setSelectedNoteId(it) }
)
}
) {
@@ -965,31 +925,27 @@ fun AppShell(
topBar = {
AppTopBar(
currentScreen = currentScreen,
onScreenChange = { currentScreen = it },
onScreenChange = { setCurrentScreen(it) },
drawerState = drawerState,
shoppingListsViewModel = shoppingListsViewModel,
notesViewModel = notesViewModel,
recipesViewModel = recipesViewModel,
shoppingListsTitle = shoppingListsTitle,
recipesTitle = recipesTitle,
onShowListDialog = { showListDialog = true },
onShowNoteDialog = { showNoteDialog = true },
onShowRecipeDialog = { showRecipeDialog = true },
onShowListDialog = { setShowListDialog(true) },
onShowNoteDialog = { setShowNoteDialog(true) },
onShowRecipeDialog = { setShowRecipeDialog(true) },
selectedListId = selectedListId,
onSetSelectedListId = { selectedListId = it },
onSetSelectedNoteId = { selectedNoteId = it },
onSetSelectedRecipeId = { selectedRecipeId = it },
onSetSelectedListId = { setSelectedListId(it) },
onSetSelectedNoteId = { setSelectedNoteId(it) },
onSetSelectedRecipeId = { setSelectedRecipeId(it) },
exportLauncher = exportLauncher,
noteExportLauncher = noteExportLauncher,
recipeExportLauncher = recipeExportLauncher,
hasEncryptionPassword = hasEncryptionPassword,
onShowSetPasswordDialog = { showSetPasswordDialog = true },
onShowSetRecipePasswordDialog = { showSetRecipePasswordDialog = true },
onShowSetListPasswordDialog = { showSetListPasswordDialog = true },
onShowChooseLockMethodDialog = { type, id ->
itemToLockType = type
itemToLockId = id
showChooseLockMethodDialog = true
setItemToLockType(type)
setItemToLockId(id)
setShowChooseLockMethodDialog(true)
}
)
},
@@ -1073,19 +1029,19 @@ fun AppShell(
AppContent(
innerPadding = innerPadding,
currentScreen = currentScreen,
onScreenChange = { currentScreen = it },
onScreenChange = { setCurrentScreen(it) },
notesViewModel = notesViewModel,
shoppingListsViewModel = shoppingListsViewModel,
recipesViewModel = recipesViewModel,
selectedListId = selectedListId,
onSetSelectedListId = { selectedListId = it },
onShowListDialog = { showListDialog = true },
onSetSelectedListId = { setSelectedListId(it) },
onShowListDialog = { setShowListDialog(true) },
selectedNoteId = selectedNoteId,
onSetSelectedNoteId = { selectedNoteId = it },
onShowNoteDialog = { showNoteDialog = true },
onSetSelectedNoteId = { setSelectedNoteId(it) },
onShowNoteDialog = { setShowNoteDialog(true) },
selectedRecipeId = selectedRecipeId,
onSetSelectedRecipeId = { selectedRecipeId = it },
onShowRecipeDialog = { showRecipeDialog = true },
onSetSelectedRecipeId = { setSelectedRecipeId(it) },
onShowRecipeDialog = { setShowRecipeDialog(true) },
itemWasInitiallyLocked = itemWasInitiallyLocked,
onUnlockItem = onUnlockItem,
shoppingListsTitle = shoppingListsTitle,
@@ -1096,22 +1052,22 @@ fun AppShell(
isEncryptionEnabled = isEncryptionEnabled,
onEncryptionToggle = onEncryptionToggle,
onSetEncryptionPassword = onSetEncryptionPassword,
onRemoveEncryption = onRemoveEncryption,
hasEncryptionPassword = hasEncryptionPassword,
biometricAuthenticator = biometricAuthenticator,
sharedPrefs = sharedPrefs,
onResetShoppingListsTitle = {
sharedPrefs.edit { remove("shopping_lists_title") }
shoppingListsTitle = context.getString(R.string.menu_shopping_lists)
setShoppingListsTitle(context.getString(R.string.menu_shopping_lists))
},
onResetRecipesTitle = {
sharedPrefs.edit { remove("recipes_title") }
recipesTitle = context.getString(R.string.menu_recipes)
setRecipesTitle(context.getString(R.string.menu_recipes))
},
startScreen = startScreen,
onStartScreenChange = {
sharedPrefs.edit { putString("default_start_screen", it) }
startScreen = it
setStartScreen(it)
},
canUseBiometrics = canUseBiometrics,
webAppIntegrationViewModel = webAppIntegrationViewModel,
@@ -1125,79 +1081,67 @@ fun AppShell(
shoppingListsViewModel = shoppingListsViewModel,
recipesViewModel = recipesViewModel,
showListDialog = showListDialog,
onShowListDialogChange = { showListDialog = it },
onShowListDialogChange = { setShowListDialog(it) },
listDetails = listDetails,
onListDetailsChange = { shoppingListsViewModel.updateListDetails(it) },
onSaveList = { scope.launch { shoppingListsViewModel.saveList() } },
onResetListDetails = { shoppingListsViewModel.resetListDetails() },
onSetListProtection = { password ->
selectedListId?.let { listId ->
shoppingListsViewModel.setProtection(listId, password)
currentScreen = Screen.ShoppingLists
setCurrentScreen(Screen.ShoppingLists)
}
selectedListId = null
showSetListPasswordDialog = false
setSelectedListId(null)
setShowSetListPasswordDialog(false)
},
onSetListProtectionBiometric = { id ->
shoppingListsViewModel.setProtectionBiometric(id)
currentScreen = Screen.ShoppingLists
},
txtImportLauncher = txtImportLauncher,
showNoteDialog = showNoteDialog,
onShowNoteDialogChange = { showNoteDialog = it },
onShowNoteDialogChange = { setShowNoteDialog(it) },
noteDetails = noteDetails,
onNoteDetailsChange = { notesViewModel.updateNoteDetails(it) },
onSaveNote = { scope.launch { notesViewModel.saveNote() } },
onResetNoteDetails = { notesViewModel.resetNoteDetails() },
onSetNoteProtectionBiometric = { id ->
notesViewModel.setProtectionBiometric(id)
currentScreen = Screen.Notes
},
noteImportLauncher = noteImportLauncher,
showRecipeDialog = showRecipeDialog,
onShowRecipeDialogChange = { showRecipeDialog = it },
onShowRecipeDialogChange = { setShowRecipeDialog(it) },
recipeDetails = recipeDetails,
onRecipeDetailsChange = { recipesViewModel.updateRecipeDetails(it) },
onSaveRecipe = { scope.launch { recipesViewModel.saveRecipe() } },
onResetRecipeDetails = { recipesViewModel.resetRecipeDetails() },
onSetRecipeProtectionBiometric = { id ->
recipesViewModel.setProtectionBiometric(id)
currentScreen = Screen.Recipes
},
recipeImportLauncher = recipeImportLauncher,
recipesTitle = recipesTitle,
showJsonDialog = showJsonDialog,
onShowJsonDialogChange = { showJsonDialog = it },
onShowJsonDialogChange = { setShowJsonDialog(it) },
shoppingListsTitle = shoppingListsTitle,
fileEncryptor = fileEncryptor,
keyManager = keyManager,
biometricAuthenticator = biometricAuthenticator,
showSetPasswordDialog = showSetPasswordDialog,
onShowSetPasswordDialogChange = { showSetPasswordDialog = it },
onShowSetPasswordDialogChange = { setShowSetPasswordDialog(it) },
onSetNotePassword = { password ->
notesViewModel.setProtectionPassword(password)
selectedNoteId = null
showSetPasswordDialog = false
currentScreen = Screen.Notes
setSelectedNoteId(null)
setShowSetPasswordDialog(false)
setCurrentScreen(Screen.Notes)
},
showSetRecipePasswordDialog = showSetRecipePasswordDialog,
onShowSetRecipePasswordDialogChange = { showSetRecipePasswordDialog = it },
onShowSetRecipePasswordDialogChange = { setShowSetRecipePasswordDialog(it) },
onSetRecipePassword = { password ->
recipesViewModel.setProtectionPassword(password)
selectedRecipeId = null
showSetRecipePasswordDialog = false
currentScreen = Screen.Recipes
setSelectedRecipeId(null)
setShowSetRecipePasswordDialog(false)
setCurrentScreen(Screen.Recipes)
},
showSetListPasswordDialog = showSetListPasswordDialog,
onShowSetListPasswordDialogChange = { showSetListPasswordDialog = it },
onShowSetListPasswordDialogChange = { setShowSetListPasswordDialog(it) },
showUnlockPasswordDialog = showUnlockPasswordDialog,
onShowUnlockPasswordDialogChange = {
showUnlockPasswordDialog = it
setShowUnlockPasswordDialog(it)
if (!it) {
unlockErrorMessage = null
itemToUnlockId = null
itemToUnlockType = null
setUnlockErrorMessage(null)
setItemToUnlockId(null)
setItemToUnlockType(null)
}
},
onUnlock = { password ->
@@ -1209,44 +1153,44 @@ fun AppShell(
val note = notesViewModel.uiState.value.noteList.find { it.id == itemToUnlockId }
if (note != null && note.protectionHash == hashedPassword) {
isPasswordCorrect = true
selectedNoteId = itemToUnlockId
setSelectedNoteId(itemToUnlockId)
}
}
Screen.RecipeDetail -> {
val recipe = recipesViewModel.uiState.value.recipeList.find { it.id == itemToUnlockId }
if (recipe != null && recipe.protectionHash == hashedPassword) {
isPasswordCorrect = true
selectedRecipeId = itemToUnlockId
setSelectedRecipeId(itemToUnlockId)
}
}
Screen.ShoppingListDetail -> {
val listWithItems = shoppingListsViewModel.uiState.value.shoppingLists.find { it.shoppingList.id == itemToUnlockId }
if (listWithItems != null && listWithItems.shoppingList.protectionHash == hashedPassword) {
isPasswordCorrect = true
selectedListId = itemToUnlockId
setSelectedListId(itemToUnlockId)
}
}
else -> {} // Should not happen
}
if (isPasswordCorrect) {
unlockErrorMessage = null
itemWasInitiallyLocked = true // Mark as initially locked for content decryption
currentScreen = itemToUnlockType!!
itemToUnlockId = null
itemToUnlockType = null
showUnlockPasswordDialog = false
setUnlockErrorMessage(null)
setItemWasInitiallyLocked(true) // Mark as initially locked for content decryption
setCurrentScreen(itemToUnlockType!!)
setItemToUnlockId(null)
setItemToUnlockType(null)
setShowUnlockPasswordDialog(false)
} else {
unlockErrorMessage = context.getString(R.string.incorrect_password)
setUnlockErrorMessage(context.getString(R.string.incorrect_password))
}
},
unlockErrorMessage = unlockErrorMessage,
showPasswordDialog = showPasswordDialog,
onShowPasswordDialogChange = { showPasswordDialog = it },
onHasEncryptionPasswordChange = { hasEncryptionPassword = it },
onShowPasswordDialogChange = { setShowPasswordDialog(it) },
onHasEncryptionPasswordChange = { setHasEncryptionPassword(it) },
sharedPrefs = sharedPrefs,
showDeleteConfirmationDialog = showDeleteConfirmationDialog,
onShowDeleteConfirmationDialogChange = { showDeleteConfirmationDialog = it },
onShowDeleteConfirmationDialogChange = { setShowDeleteConfirmationDialog(it) },
onDeleteSyncFolder = {
val syncFolderUriString = sharedPrefs.getString("sync_folder_uri", null)
if (syncFolderUriString != null) {
@@ -1259,19 +1203,19 @@ fun AppShell(
}
},
showRenameDialog = showRenameDialog,
onShowRenameDialogChange = { showRenameDialog = it },
onShowRenameDialogChange = { setShowRenameDialog(it) },
onRenameShoppingListsTitle = { newTitle ->
sharedPrefs.edit { putString("shopping_lists_title", newTitle) }
shoppingListsTitle = newTitle
setShoppingListsTitle(newTitle)
},
showRecipeRenameDialog = showRecipeRenameDialog,
onShowRecipeRenameDialogChange = { showRecipeRenameDialog = it },
onShowRecipeRenameDialogChange = { setShowRecipeRenameDialog(it) },
onRenameRecipesTitle = { newTitle ->
sharedPrefs.edit { putString("recipes_title", newTitle) }
recipesTitle = newTitle
setRecipesTitle(newTitle)
},
showChooseLockMethodDialog = showChooseLockMethodDialog,
onShowChooseLockMethodDialogChange = { showChooseLockMethodDialog = it },
onShowChooseLockMethodDialogChange = { setShowChooseLockMethodDialog(it) },
onConfirmLockMethod = { lockMethod ->
itemToLockType?.let { type ->
itemToLockId?.let { id ->
@@ -1279,63 +1223,62 @@ fun AppShell(
LockableItemType.SHOPPING_LIST -> {
when (lockMethod) {
LockMethod.PASSWORD -> {
selectedListId = id
showSetListPasswordDialog = true
setSelectedListId(id)
setShowSetListPasswordDialog(true)
}
LockMethod.BIOMETRIC -> {
shoppingListsViewModel.setProtectionBiometric(id)
currentScreen = Screen.ShoppingLists
setCurrentScreen(Screen.ShoppingLists)
}
}
}
LockableItemType.NOTE -> {
when (lockMethod) {
LockMethod.PASSWORD -> {
selectedNoteId = id
showSetPasswordDialog = true
setSelectedNoteId(id)
setShowSetPasswordDialog(true)
}
LockMethod.BIOMETRIC -> {
notesViewModel.setProtectionBiometric(id)
currentScreen = Screen.Notes
setCurrentScreen(Screen.Notes)
}
}
}
LockableItemType.RECIPE -> {
when (lockMethod) {
LockMethod.PASSWORD -> {
selectedRecipeId = id
showSetRecipePasswordDialog = true
setSelectedRecipeId(id)
setShowSetRecipePasswordDialog(true)
}
LockMethod.BIOMETRIC -> {
recipesViewModel.setProtectionBiometric(id)
currentScreen = Screen.Recipes
setCurrentScreen(Screen.Recipes)
}
}
}
}
}
}
showChooseLockMethodDialog = false
itemToLockId = null
itemToLockType = null
setShowChooseLockMethodDialog(false)
setItemToLockId(null)
setItemToLockType(null)
},
canUseBiometrics = canUseBiometrics,
itemToLockType = itemToLockType,
showStartupPasswordDialog = showStartupPasswordDialog,
onShowStartupPasswordDialogChange = { showStartupPasswordDialog = it },
onShowStartupPasswordDialogChange = { setShowStartupPasswordDialog(it) },
onUnlockEncryption = { password ->
scope.launch {
try {
val decryptionCipher = keyManager.getDecryptionCipher()
if (decryptionCipher == null) {
startupUnlockErrorMessage = context.getString(R.string.unlock_failed)
setStartupUnlockErrorMessage(context.getString(R.string.unlock_failed))
return@launch
}
val sharedPrefs = context.getSharedPreferences("encryption_prefs", Context.MODE_PRIVATE)
val encryptedDerivedKeyString = sharedPrefs.getString("encrypted_derived_key", null)
if (encryptedDerivedKeyString == null) {
startupUnlockErrorMessage = context.getString(R.string.unlock_failed)
setStartupUnlockErrorMessage(context.getString(R.string.unlock_failed))
return@launch
}
val encryptedDerivedKey = Base64.decode(encryptedDerivedKeyString, Base64.DEFAULT)
@@ -1348,19 +1291,19 @@ fun AppShell(
Log.d("AppShell_Unlock", "Current PBE Key: ${currentPbeKey.encoded.joinToString()}")
if (decryptedStoredKeyBytes.contentEquals(currentPbeKey.encoded)) {
secretKey = SecretKeySpec(decryptedStoredKeyBytes, "AES")
isDecryptionAttempted = true
showStartupPasswordDialog = false
startupUnlockErrorMessage = null
setSecretKey(SecretKeySpec(decryptedStoredKeyBytes, "AES"))
setIsDecryptionAttempted(true)
setShowStartupPasswordDialog(false)
setStartupUnlockErrorMessage(null)
} else {
startupUnlockErrorMessage = context.getString(R.string.incorrect_password)
setStartupUnlockErrorMessage(context.getString(R.string.incorrect_password))
}
} catch (e: Exception) {
Log.e("AppShell", "Failed to unlock encryption with password", e)
startupUnlockErrorMessage = context.getString(R.string.unlock_failed)
setStartupUnlockErrorMessage(context.getString(R.string.unlock_failed))
}
}
},
startupUnlockErrorMessage = startupUnlockErrorMessage
)
}
}

View File

@@ -30,6 +30,7 @@ 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 androidx.core.content.edit
import androidx.fragment.app.FragmentActivity
import de.lxtools.noteshop.BiometricAuthenticator
import de.lxtools.noteshop.R
@@ -104,7 +105,7 @@ fun EncryptionPasswordDialog(
val encryptedDerivedKey = authorizedCipher.doFinal(pbeKey.encoded)
val iv = authorizedCipher.iv
keyManager.storeEncryptedDerivedKey(encryptedDerivedKey, iv, useBiometrics)
sharedPrefs.edit().putBoolean("biometric_unlock_enabled", useBiometrics).apply()
sharedPrefs.edit { putBoolean("biometric_unlock_enabled", useBiometrics) }
onPasswordSet()
Toast.makeText(context, R.string.encryption_password_set_success, Toast.LENGTH_SHORT).show()
}

View File

@@ -19,14 +19,15 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.HorizontalPagerIndicator
import com.google.accompanist.pager.rememberPagerState
import de.lxtools.noteshop.R
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.pager.HorizontalPager
@Composable
fun GuidedTourScreen(onTourFinished: () -> Unit) {
val pagerState = rememberPagerState()
val pagerState = rememberPagerState(pageCount = { 3 })
Column(
modifier = Modifier.fillMaxSize(),
@@ -34,7 +35,7 @@ fun GuidedTourScreen(onTourFinished: () -> Unit) {
verticalArrangement = Arrangement.Center
) {
HorizontalPager(
count = 3,
state = pagerState,
modifier = Modifier.weight(1f)
) { page ->
@@ -57,12 +58,8 @@ fun GuidedTourScreen(onTourFinished: () -> Unit) {
}
}
HorizontalPagerIndicator(
pagerState = pagerState,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp)
)
// TODO: Implement a custom HorizontalPagerIndicator or use a third-party library
Button(
onClick = onTourFinished,
@@ -70,7 +67,7 @@ fun GuidedTourScreen(onTourFinished: () -> Unit) {
.fillMaxWidth()
.padding(16.dp)
) {
Text(text = if (pagerState.currentPage == 2) "Fertig" else "Überspringen")
Text(text = if (pagerState.currentPage == 2) stringResource(id = R.string.tour_finish) else stringResource(id = R.string.tour_skip))
}
}
}

View File

@@ -1,5 +1,6 @@
package de.lxtools.noteshop.ui
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
@@ -89,14 +90,14 @@ fun JsonImportExportDialog(
scope.launch {
context.contentResolver.openInputStream(it)?.use { inputStream ->
val fileContent = inputStream.readBytes()
var jsonString: String? = null
var jsonString: String?
if (secretKey != null) {
try {
val decryptedBytes = fileEncryptor.decrypt(fileContent, secretKey!!)
jsonString = decryptedBytes.toString(Charsets.UTF_8)
} catch (e: javax.crypto.AEADBadTagException) {
android.util.Log.w("JsonImportExportDialog", "Decryption failed, trying as unencrypted: ${e.message}")
Log.w("JsonImportExportDialog", "Decryption failed, trying as unencrypted: ${e.message}")
// Fallback to unencrypted if decryption fails
jsonString = fileContent.toString(Charsets.UTF_8)
}
@@ -105,20 +106,16 @@ fun JsonImportExportDialog(
jsonString = fileContent.toString(Charsets.UTF_8)
}
if (jsonString != null) {
try {
notesViewModel.importNotesFromJson(jsonString)
android.widget.Toast.makeText(context, R.string.json_import_successful, android.widget.Toast.LENGTH_SHORT).show()
} catch (e: kotlinx.serialization.SerializationException) {
android.util.Log.e("JsonImportExportDialog", "Error parsing notes JSON: ${e.message}")
android.widget.Toast.makeText(context, context.getString(R.string.json_import_failed_invalid_format, e.message), android.widget.Toast.LENGTH_LONG).show()
Toast.makeText(context, R.string.json_import_successful, Toast.LENGTH_SHORT).show()
} catch (e: SerializationException) {
Log.e("JsonImportExportDialog", "Error parsing notes JSON: ${e.message}")
Toast.makeText(context, context.getString(R.string.json_import_failed_invalid_format, e.message), Toast.LENGTH_LONG).show()
} catch (e: Exception) {
android.util.Log.e("JsonImportExportDialog", "Error importing notes: ${e.message}")
android.widget.Toast.makeText(context, context.getString(R.string.json_import_failed_generic, e.message), android.widget.Toast.LENGTH_LONG).show()
Log.e("JsonImportExportDialog", "Error importing notes: ${e.message}")
Toast.makeText(context, context.getString(R.string.json_import_failed_generic, e.message), Toast.LENGTH_LONG).show()
}
} else {
android.widget.Toast.makeText(context, R.string.json_import_failed_no_data, android.widget.Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -133,12 +130,14 @@ fun JsonImportExportDialog(
scope.launch {
val allNotes = notesViewModel.uiState.value.noteList
val content = notesViewModel.exportNotesToJson(allNotes)
context.contentResolver.openOutputStream(it)?.use { outputStream ->
secretKey?.let { key ->
val encryptedContent = fileEncryptor.encrypt(content.toByteArray(), key)
context.contentResolver.openOutputStream(it, "wt")?.use { outputStream ->
if (secretKey != null) {
val encryptedContent = fileEncryptor.encrypt(content.toByteArray(), secretKey!!)
outputStream.write(encryptedContent)
} else {
outputStream.write(content.toByteArray(Charsets.UTF_8))
}
android.widget.Toast.makeText(context, R.string.json_export_successful, android.widget.Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.json_export_successful, Toast.LENGTH_SHORT).show()
}
}
}
@@ -153,12 +152,14 @@ fun JsonImportExportDialog(
scope.launch {
val allShoppingLists = shoppingListsViewModel.uiState.value.shoppingLists
val content = shoppingListsViewModel.exportShoppingListsToJson(allShoppingLists)
context.contentResolver.openOutputStream(it)?.use { outputStream ->
secretKey?.let { key ->
val encryptedContent = fileEncryptor.encrypt(content.toByteArray(), key)
context.contentResolver.openOutputStream(it, "wt")?.use { outputStream ->
if (secretKey != null) {
val encryptedContent = fileEncryptor.encrypt(content.toByteArray(), secretKey!!)
outputStream.write(encryptedContent)
} else {
outputStream.write(content.toByteArray(Charsets.UTF_8))
}
android.widget.Toast.makeText(context, R.string.json_export_successful, android.widget.Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.json_export_successful, Toast.LENGTH_SHORT).show()
}
}
}
@@ -173,14 +174,14 @@ fun JsonImportExportDialog(
scope.launch {
context.contentResolver.openInputStream(it)?.use { inputStream ->
val fileContent = inputStream.readBytes()
var jsonString: String? = null
var jsonString: String?
if (secretKey != null) {
try {
val decryptedBytes = fileEncryptor.decrypt(fileContent, secretKey!!)
jsonString = decryptedBytes.toString(Charsets.UTF_8)
} catch (e: javax.crypto.AEADBadTagException) {
android.util.Log.w("JsonImportExportDialog", "Decryption failed, trying as unencrypted: ${e.message}")
Log.w("JsonImportExportDialog", "Decryption failed, trying as unencrypted: ${e.message}")
// Fallback to unencrypted if decryption fails
jsonString = fileContent.toString(Charsets.UTF_8)
}
@@ -189,20 +190,16 @@ fun JsonImportExportDialog(
jsonString = fileContent.toString(Charsets.UTF_8)
}
if (jsonString != null) {
try {
shoppingListsViewModel.importShoppingListsFromJson(jsonString)
android.widget.Toast.makeText(context, R.string.json_import_successful, android.widget.Toast.LENGTH_SHORT).show()
} catch (e: kotlinx.serialization.SerializationException) {
android.util.Log.e("JsonImportExportDialog", "Error parsing shopping lists JSON: ${e.message}")
android.widget.Toast.makeText(context, context.getString(R.string.json_import_failed_invalid_format, e.message), android.widget.Toast.LENGTH_LONG).show()
Toast.makeText(context, R.string.json_import_successful, Toast.LENGTH_SHORT).show()
} catch (e: SerializationException) {
Log.e("JsonImportExportDialog", "Error parsing shopping lists JSON: ${e.message}")
Toast.makeText(context, context.getString(R.string.json_import_failed_invalid_format, e.message), Toast.LENGTH_LONG).show()
} catch (e: Exception) {
android.util.Log.e("JsonImportExportDialog", "Error importing shopping lists: ${e.message}")
android.widget.Toast.makeText(context, context.getString(R.string.json_import_failed_generic, e.message), android.widget.Toast.LENGTH_LONG).show()
Log.e("JsonImportExportDialog", "Error importing shopping lists: ${e.message}")
Toast.makeText(context, context.getString(R.string.json_import_failed_generic, e.message), Toast.LENGTH_LONG).show()
}
} else {
android.widget.Toast.makeText(context, R.string.json_import_failed_no_data, android.widget.Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -217,12 +214,14 @@ fun JsonImportExportDialog(
scope.launch {
val allRecipes = recipesViewModel.uiState.value.recipeList
val content = recipesViewModel.exportRecipesToJson(allRecipes)
context.contentResolver.openOutputStream(it)?.use { outputStream ->
secretKey?.let { key ->
val encryptedContent = fileEncryptor.encrypt(content.toByteArray(), key)
context.contentResolver.openOutputStream(it, "wt")?.use { outputStream ->
if (secretKey != null) {
val encryptedContent = fileEncryptor.encrypt(content.toByteArray(), secretKey!!)
outputStream.write(encryptedContent)
} else {
outputStream.write(content.toByteArray(Charsets.UTF_8))
}
android.widget.Toast.makeText(context, R.string.json_export_successful, android.widget.Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.json_export_successful, Toast.LENGTH_SHORT).show()
}
}
}
@@ -237,14 +236,14 @@ fun JsonImportExportDialog(
scope.launch {
context.contentResolver.openInputStream(it)?.use { inputStream ->
val fileContent = inputStream.readBytes()
var jsonString: String? = null
var jsonString: String?
if (secretKey != null) {
try {
val decryptedBytes = fileEncryptor.decrypt(fileContent, secretKey!!)
jsonString = decryptedBytes.toString(Charsets.UTF_8)
} catch (e: javax.crypto.AEADBadTagException) {
android.util.Log.w("JsonImportExportDialog", "Decryption failed, trying as unencrypted: ${e.message}")
Log.w("JsonImportExportDialog", "Decryption failed, trying as unencrypted: ${e.message}")
// Fallback to unencrypted if decryption fails
jsonString = fileContent.toString(Charsets.UTF_8)
}
@@ -253,20 +252,16 @@ fun JsonImportExportDialog(
jsonString = fileContent.toString(Charsets.UTF_8)
}
if (jsonString != null) {
try {
recipesViewModel.importRecipesFromJson(jsonString)
android.widget.Toast.makeText(context, R.string.json_import_successful, android.widget.Toast.LENGTH_SHORT).show()
} catch (e: kotlinx.serialization.SerializationException) {
android.util.Log.e("JsonImportExportDialog", "Error parsing recipes JSON: ${e.message}")
android.widget.Toast.makeText(context, context.getString(R.string.json_import_failed_invalid_format, e.message), android.widget.Toast.LENGTH_LONG).show()
Toast.makeText(context, R.string.json_import_successful, Toast.LENGTH_SHORT).show()
} catch (e: SerializationException) {
Log.e("JsonImportExportDialog", "Error parsing recipes JSON: ${e.message}")
Toast.makeText(context, context.getString(R.string.json_import_failed_invalid_format, e.message), Toast.LENGTH_LONG).show()
} catch (e: Exception) {
android.util.Log.e("JsonImportExportDialog", "Error importing recipes: ${e.message}")
android.widget.Toast.makeText(context, context.getString(R.string.json_import_failed_generic, e.message), android.widget.Toast.LENGTH_LONG).show()
Log.e("JsonImportExportDialog", "Error importing recipes: ${e.message}")
Toast.makeText(context, context.getString(R.string.json_import_failed_generic, e.message), Toast.LENGTH_LONG).show()
}
} else {
android.widget.Toast.makeText(context, R.string.json_import_failed_no_data, android.widget.Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -351,4 +346,4 @@ fun JsonImportExportDialog(
}
}
)
}
}

View File

@@ -1,107 +0,0 @@
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

@@ -34,7 +34,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.lxtools.noteshop.R
import androidx.compose.ui.platform.LocalContext
import androidx.biometric.BiometricManager
import androidx.compose.ui.draw.alpha
import de.lxtools.noteshop.ui.theme.*
import androidx.compose.foundation.lazy.LazyRow
@@ -64,7 +63,7 @@ fun SettingsScreen(
isEncryptionEnabled: Boolean,
onEncryptionToggle: (Boolean) -> Unit,
onSetEncryptionPassword: () -> Unit,
onRemoveEncryption: () -> Unit,
hasEncryptionPassword: Boolean,
biometricAuthenticator: BiometricAuthenticator,
sharedPrefs: SharedPreferences,
@@ -85,10 +84,7 @@ fun SettingsScreen(
val currentStartScreenName = startScreenOptions[currentStartScreen] ?: stringResource(R.string.start_screen_last)
var isBiometricUnlockEnabled by remember { mutableStateOf(sharedPrefs.getBoolean("biometric_unlock_enabled", false)) }
val context = LocalContext.current
val biometricManager = BiometricManager.from(context)
val canUseBiometrics = remember {
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
}
val listState = rememberLazyListState()
val authenticateAndProceed: ((() -> Unit) -> Unit) = { successAction ->
@@ -289,9 +285,8 @@ fun SettingsScreen(
Switch(
checked = isBiometricUnlockEnabled,
onCheckedChange = {
val newValue = it
sharedPrefs.edit { putBoolean("biometric_unlock_enabled", newValue) }
isBiometricUnlockEnabled = newValue
sharedPrefs.edit { putBoolean ("biometric_unlock_enabled", it) }
isBiometricUnlockEnabled = it
}
)
}

View File

@@ -1,6 +1,6 @@
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
@@ -54,7 +54,7 @@ fun AppContent(
isEncryptionEnabled: Boolean,
onEncryptionToggle: (Boolean) -> Unit,
onSetEncryptionPassword: () -> Unit,
onRemoveEncryption: () -> Unit,
hasEncryptionPassword: Boolean,
biometricAuthenticator: BiometricAuthenticator,
sharedPrefs: android.content.SharedPreferences,
@@ -117,7 +117,6 @@ fun AppContent(
listId = selectedListId,
viewModel = shoppingListsViewModel,
dynamicStrings = dynamicStrings,
wasInitiallyLocked = itemWasInitiallyLocked,
isContentDecrypted = itemWasInitiallyLocked,
onUnlockClick = {
val list = shoppingListsViewModel.uiState.value.shoppingLists.find { it.shoppingList.id == selectedListId }?.shoppingList
@@ -186,7 +185,7 @@ fun AppContent(
isEncryptionEnabled = isEncryptionEnabled,
onEncryptionToggle = onEncryptionToggle,
onSetEncryptionPassword = onSetEncryptionPassword,
onRemoveEncryption = onRemoveEncryption,
hasEncryptionPassword = hasEncryptionPassword,
biometricAuthenticator = biometricAuthenticator,
sharedPrefs = sharedPrefs,
@@ -201,7 +200,6 @@ fun AppContent(
is Screen.WebAppIntegration -> {
WebAppIntegrationScreen(
viewModel = webAppIntegrationViewModel,
onNavigateUp = { onScreenChange(Screen.Settings) },
padding = innerPadding,
onAuthenticateAndTogglePasswordVisibility = onAuthenticateAndTogglePasswordVisibility
)

View File

@@ -1,6 +1,6 @@
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
@@ -27,11 +27,11 @@ 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 android.util.Log
import androidx.compose.ui.platform.LocalContext
import de.lxtools.noteshop.findActivity
@@ -44,26 +44,26 @@ fun AppDialogs(
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,
@@ -101,7 +101,6 @@ fun AppDialogs(
onShowChooseLockMethodDialogChange: (Boolean) -> Unit,
onConfirmLockMethod: (LockMethod) -> Unit,
canUseBiometrics: Boolean,
itemToLockType: de.lxtools.noteshop.ui.LockableItemType?,
showStartupPasswordDialog: Boolean,
onShowStartupPasswordDialogChange: (Boolean) -> Unit,
onUnlockEncryption: (String) -> Unit,

View File

@@ -68,10 +68,6 @@ fun AppTopBar(
exportLauncher: ActivityResultLauncher<String>,
noteExportLauncher: ActivityResultLauncher<String>,
recipeExportLauncher: ActivityResultLauncher<String>,
hasEncryptionPassword: Boolean,
onShowSetPasswordDialog: () -> Unit,
onShowSetRecipePasswordDialog: () -> Unit,
onShowSetListPasswordDialog: () -> Unit,
onShowChooseLockMethodDialog: (LockableItemType, Int) -> Unit
) {
val scope = rememberCoroutineScope()
@@ -296,7 +292,6 @@ fun AppTopBar(
shoppingListWithItems?.let { listWithItems ->
DropdownMenuItem(
text = { Text(stringResource(R.string.lock)) },
enabled = hasEncryptionPassword,
onClick = {
showMenu = false
onShowChooseLockMethodDialog(LockableItemType.SHOPPING_LIST, listWithItems.shoppingList.id)
@@ -381,7 +376,6 @@ fun AppTopBar(
noteDetails.let {
DropdownMenuItem(
text = { Text(stringResource(R.string.lock)) },
enabled = hasEncryptionPassword,
onClick = {
showMenu = false
onShowChooseLockMethodDialog(LockableItemType.NOTE, it.id)
@@ -468,7 +462,6 @@ fun AppTopBar(
recipeDetails.let {
DropdownMenuItem(
text = { Text(stringResource(R.string.lock)) },
enabled = hasEncryptionPassword,
onClick = {
showMenu = false
onShowChooseLockMethodDialog(LockableItemType.RECIPE, it.id)

View File

@@ -30,13 +30,7 @@ import androidx.compose.runtime.remember
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.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import de.lxtools.noteshop.findActivity
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View File

@@ -82,7 +82,7 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
suspend fun saveNote() {
if (noteDetails.value.isValid()) {
var currentNote = noteDetails.value.toNote()
val currentNote = noteDetails.value.toNote()
// Encryption on save is now handled by the new protection flow.
@@ -140,35 +140,6 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
}
}
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()
}

View File

@@ -1,6 +1,5 @@
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

@@ -80,7 +80,7 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
suspend fun saveRecipe() {
if (recipeDetails.value.isValid()) {
var currentRecipe = recipeDetails.value.toRecipe()
val currentRecipe = recipeDetails.value.toRecipe()
// Encryption on save is now handled by the new protection flow.
@@ -138,35 +138,6 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
}
}
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()
}

View File

@@ -49,19 +49,14 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import de.lxtools.noteshop.R
import de.lxtools.noteshop.data.ShoppingListItem
import de.lxtools.noteshop.security.FileEncryptor
import javax.crypto.SecretKey
import kotlinx.coroutines.launch
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.text.withStyle
import de.lxtools.noteshop.findActivity
import androidx.compose.ui.text.withStyle
import androidx.activity.compose.BackHandler
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -70,18 +65,14 @@ fun ShoppingListDetailScreen(
viewModel: ShoppingListsViewModel,
dynamicStrings: de.lxtools.noteshop.DynamicListStrings,
modifier: Modifier = Modifier,
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()
@@ -100,7 +91,7 @@ fun ShoppingListDetailScreen(
onDispose {
val activity = context.findActivity()
if (!activity.isChangingConfigurations) {
// TODO: Handle lock state correctly
onNavigateBack()
}
}
}

View File

@@ -23,9 +23,8 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
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
import android.util.Base64
class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository, application: Application) : AndroidViewModel(application) {
@@ -193,35 +192,6 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
}
}
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)
}
@@ -474,10 +444,10 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
return@launch
}
val decryptedUsernameBytes = fileEncryptor.decrypt(java.util.Base64.getDecoder().decode(usernameEncrypted), secretKey)
val decryptedUsernameBytes = fileEncryptor.decrypt(Base64.decode(usernameEncrypted, Base64.DEFAULT), secretKey)
username = String(decryptedUsernameBytes, Charsets.UTF_8)
val decryptedPasswordBytes = fileEncryptor.decrypt(java.util.Base64.getDecoder().decode(passwordEncrypted), secretKey)
val decryptedPasswordBytes = fileEncryptor.decrypt(Base64.decode(passwordEncrypted, Base64.DEFAULT), secretKey)
password = String(decryptedPasswordBytes, Charsets.UTF_8)
} catch (e: Exception) {
Toast.makeText(getApplication(), getApplication<Application>().getString(de.lxtools.noteshop.R.string.failed_to_decrypt_credentials_generic, e.message), Toast.LENGTH_LONG).show()

View File

@@ -3,7 +3,7 @@ package de.lxtools.noteshop.ui.webapp
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.foundation.layout.*
@@ -25,7 +25,7 @@ import de.lxtools.noteshop.R
@Composable
fun WebAppIntegrationScreen(
viewModel: WebAppIntegrationViewModel,
onNavigateUp: () -> Unit,
padding: PaddingValues,
onAuthenticateAndTogglePasswordVisibility: (Boolean) -> Unit // New parameter
) {

View File

@@ -13,7 +13,6 @@ import de.lxtools.noteshop.data.NoteshopRepository
import de.lxtools.noteshop.security.FileEncryptor
import de.lxtools.noteshop.security.KeyManager
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
class WebAppIntegrationViewModel(private val repository: NoteshopRepository, application: Application) : AndroidViewModel(application) {
@@ -99,7 +98,7 @@ class WebAppIntegrationViewModel(private val repository: NoteshopRepository, app
val decrypted = fileEncryptor.decrypt(Base64.decode(it, Base64.DEFAULT), secretKey)
password = String(decrypted)
}
} catch (e: Exception) {
} catch (_: Exception) {
Toast.makeText(getApplication(), getApplication<Application>().getString(de.lxtools.noteshop.R.string.failed_to_decrypt_credentials), Toast.LENGTH_SHORT).show()
}
}

View File

@@ -339,4 +339,6 @@
<string name="set_pin">PIN festlegen</string>
<string name="enter_pin">PIN eingeben</string>
<string name="set_pattern">Muster festlegen</string>
<string name="tour_finish">Fertig</string>
<string name="tour_skip">Überspringen</string>
</resources>

View File

@@ -339,4 +339,6 @@
<string name="set_pin">Set PIN</string>
<string name="enter_pin">Enter PIN</string>
<string name="set_pattern">Set Pattern</string>
<string name="tour_finish">Finish</string>
<string name="tour_skip">Skip</string>
</resources>