diff --git a/CHANGELOG b/CHANGELOG index 2c301b32b..d1eeb408e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,12 @@ KeePassDX(4.2.0) - * Updated to API 35 minimum SDK 19 #2073 #2138 #2067 #2133 (Thx @Dev-ClayP) * Passkeys management #1421 #2097 (Thx @cali-95) + +KeePassDX(4.1.8) + * Updated to API 35 minimum SDK 19 #2073 #2138 #2067 #2133 #1687 (Thx @Dev-ClayP) * Remember last read-only state #2099 #2100 (Thx @rmacklin) + * Fix merge deletion #1516 + * Fix space in search #175 + * Fix deletable recycle bin #2163 * Small fixes KeePassDX(4.1.7) diff --git a/app/build.gradle b/app/build.gradle index 56376c82e..a7114c843 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.kunzisoft.keepass" minSdkVersion 19 targetSdkVersion 35 - versionCode = 140 - versionName = "4.2.0beta01" + versionCode = 142 + versionName = "4.2.0beta02" multiDexEnabled true testApplicationId = "com.kunzisoft.keepass.tests" @@ -152,7 +152,7 @@ dependencies { // Credentials Provider implementation "androidx.credentials:credentials:1.2.2" - + // Modules import implementation project(path: ':database') implementation project(path: ':icon-pack') diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt index b40e6b33a..9caf132f0 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt @@ -43,6 +43,7 @@ import androidx.biometric.BiometricManager import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.fragment.app.commit import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.snackbar.Snackbar @@ -108,7 +109,11 @@ class MainCredentialActivity : DatabaseModeActivity() { private var deviceUnlockFragment: DeviceUnlockFragment? = null private val mDatabaseFileViewModel: DatabaseFileViewModel by viewModels() - private val mDeviceUnlockViewModel: DeviceUnlockViewModel by viewModels() + private val mDeviceUnlockViewModel: DeviceUnlockViewModel? by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + ViewModelProvider(this)[DeviceUnlockViewModel::class.java] + } else null + } private val mPasswordActivityEducation = PasswordActivityEducation(this) @@ -176,7 +181,7 @@ class MainCredentialActivity : DatabaseModeActivity() { // Listen password checkbox to init advanced unlock and confirmation button mainCredentialView?.onConditionToStoreCredentialChanged = { _, verified -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - mDeviceUnlockViewModel.checkConditionToStoreCredential( + mDeviceUnlockViewModel?.checkConditionToStoreCredential( condition = verified ) } @@ -241,29 +246,31 @@ class MainCredentialActivity : DatabaseModeActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - mDeviceUnlockViewModel.uiState.collect { uiState -> - // New value received - uiState.credentialRequiredCipher?.let { cipher -> - mDeviceUnlockViewModel.encryptCredential( - credential = getCredentialForEncryption(), - cipher = cipher - ) - } - uiState.cipherEncryptDatabase?.let { cipherEncryptDatabase -> - onCredentialEncrypted(cipherEncryptDatabase) - mDeviceUnlockViewModel.consumeCredentialEncrypted() - } - uiState.cipherDecryptDatabase?.let { cipherDecryptDatabase -> - onCredentialDecrypted(cipherDecryptDatabase) - mDeviceUnlockViewModel.consumeCredentialDecrypted() - } - uiState.exception?.let { error -> - Snackbar.make( - coordinatorLayout, - deviceUnlockError(error, this@MainCredentialActivity), - Snackbar.LENGTH_LONG - ).asError().show() - mDeviceUnlockViewModel.exceptionShown() + mDeviceUnlockViewModel?.let { deviceUnlockViewModel -> + deviceUnlockViewModel.uiState.collect { uiState -> + // New value received + uiState.credentialRequiredCipher?.let { cipher -> + deviceUnlockViewModel.encryptCredential( + credential = getCredentialForEncryption(), + cipher = cipher + ) + } + uiState.cipherEncryptDatabase?.let { cipherEncryptDatabase -> + onCredentialEncrypted(cipherEncryptDatabase) + deviceUnlockViewModel.consumeCredentialEncrypted() + } + uiState.cipherDecryptDatabase?.let { cipherDecryptDatabase -> + onCredentialDecrypted(cipherDecryptDatabase) + deviceUnlockViewModel.consumeCredentialDecrypted() + } + uiState.exception?.let { error -> + Snackbar.make( + coordinatorLayout, + deviceUnlockError(error, this@MainCredentialActivity), + Snackbar.LENGTH_LONG + ).asError().show() + deviceUnlockViewModel.exceptionShown() + } } } } @@ -516,7 +523,7 @@ class MainCredentialActivity : DatabaseModeActivity() { } else { // Init Biometric elements if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - mDeviceUnlockViewModel.connect(databaseFileUri) + mDeviceUnlockViewModel?.connect(databaseFileUri) } } @@ -660,7 +667,7 @@ class MainCredentialActivity : DatabaseModeActivity() { try { menu.findItem(R.id.menu_open_file_read_mode_key) } catch (e: Exception) { - Log.e(TAG, "Unable to find read mode menu") + Log.e(TAG, "Unable to find read mode menu", e) } performedNextEducation(menu) }, @@ -689,7 +696,7 @@ class MainCredentialActivity : DatabaseModeActivity() { }) } } - } catch (ignored: Exception) {} + } catch (_: Exception) {} } } @@ -726,7 +733,7 @@ class MainCredentialActivity : DatabaseModeActivity() { override fun onDestroy() { super.onDestroy() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - mDeviceUnlockViewModel.disconnect() + mDeviceUnlockViewModel?.disconnect() } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SortDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SortDialogFragment.kt index 45cbb9db1..78972aa76 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SortDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/SortDialogFragment.kt @@ -176,21 +176,14 @@ class SortDialogFragment : DatabaseDialogFragment() { return bundle } - fun getInstance(sortNodeEnum: SortNodeEnum, - ascending: Boolean, - groupsBefore: Boolean): SortDialogFragment { - val bundle = buildBundle(sortNodeEnum, ascending, groupsBefore) - val fragment = SortDialogFragment() - fragment.arguments = bundle - return fragment - } - fun getInstance(sortNodeEnum: SortNodeEnum, ascending: Boolean, groupsBefore: Boolean, - recycleBinBottom: Boolean): SortDialogFragment { + recycleBinBottom: Boolean?): SortDialogFragment { val bundle = buildBundle(sortNodeEnum, ascending, groupsBefore) - bundle.putBoolean(SORT_RECYCLE_BIN_BOTTOM_BUNDLE_KEY, recycleBinBottom) + recycleBinBottom?.let { + bundle.putBoolean(SORT_RECYCLE_BIN_BOTTOM_BUNDLE_KEY, recycleBinBottom) + } val fragment = SortDialogFragment() fragment.arguments = bundle return fragment diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt index 5dd676ea8..8d892dab8 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt @@ -76,9 +76,6 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen private var specialMode: SpecialMode = SpecialMode.DEFAULT - private var mRecycleBinEnable: Boolean = false - private var mRecycleBin: Group? = null - private var mRecycleViewScrollListener = object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) @@ -102,21 +99,14 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen R.id.menu_sort -> { context?.let { context -> val sortDialogFragment: SortDialogFragment = - if (mRecycleBinEnable) { - SortDialogFragment.getInstance( - PreferencesUtil.getListSort(context), - PreferencesUtil.getAscendingSort(context), - PreferencesUtil.getGroupsBeforeSort(context), + SortDialogFragment.getInstance( + PreferencesUtil.getListSort(context), + PreferencesUtil.getAscendingSort(context), + PreferencesUtil.getGroupsBeforeSort(context), + if (mDatabase?.isRecycleBinEnabled == true) { PreferencesUtil.getRecycleBinBottomSort(context) - ) - } else { - SortDialogFragment.getInstance( - PreferencesUtil.getListSort(context), - PreferencesUtil.getAscendingSort(context), - PreferencesUtil.getGroupsBeforeSort(context) - ) - } - + } else null + ) sortDialogFragment.show(childFragmentManager, "sortDialog") } true @@ -165,9 +155,6 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen } override fun onDatabaseRetrieved(database: ContextualDatabase?) { - mRecycleBinEnable = database?.isRecycleBinEnabled == true - mRecycleBin = database?.recycleBin - context?.let { context -> database?.let { database -> mAdapter = NodesAdapter(context, database).apply { @@ -312,6 +299,11 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen } } + private fun containsRecycleBin(nodes: List): Boolean { + return mDatabase?.isRecycleBinEnabled == true + && nodes.any { it == mDatabase?.recycleBin } + } + fun actionNodesCallback(database: ContextualDatabase, nodes: List, menuListener: NodesActionMenuListener?, @@ -336,8 +328,7 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen // Open and Edit for a single item if (nodes.size == 1) { // Edition - if (database.isReadOnly - || (mRecycleBinEnable && nodes[0] == mRecycleBin)) { + if (database.isReadOnly || containsRecycleBin(nodes)) { menu?.removeItem(R.id.menu_edit) } } else { @@ -357,8 +348,7 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen } // Deletion - if (database.isReadOnly - || (mRecycleBinEnable && nodes.any { it == mRecycleBin })) { + if (database.isReadOnly || containsRecycleBin(nodes)) { menu?.removeItem(R.id.menu_delete) } } diff --git a/database/src/main/java/com/kunzisoft/keepass/database/merge/DatabaseKDBXMerger.kt b/database/src/main/java/com/kunzisoft/keepass/database/merge/DatabaseKDBXMerger.kt index 1f83169ee..19e18b9e6 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/merge/DatabaseKDBXMerger.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/merge/DatabaseKDBXMerger.kt @@ -22,6 +22,7 @@ package com.kunzisoft.keepass.database.merge import com.kunzisoft.keepass.database.element.Attachment import com.kunzisoft.keepass.database.element.CustomData import com.kunzisoft.keepass.database.element.DateInstant +import com.kunzisoft.keepass.database.element.DeletedObject import com.kunzisoft.keepass.database.element.database.DatabaseKDB import com.kunzisoft.keepass.database.element.database.DatabaseKDBX import com.kunzisoft.keepass.database.element.entry.EntryKDB @@ -32,9 +33,10 @@ import com.kunzisoft.keepass.database.element.node.NodeHandler import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.database.element.node.NodeIdInt import com.kunzisoft.keepass.database.element.node.NodeIdUUID +import com.kunzisoft.keepass.database.element.node.NodeVersioned import com.kunzisoft.keepass.utils.readAllBytes import java.io.IOException -import java.util.* +import java.util.UUID class DatabaseKDBXMerger(private var database: DatabaseKDBX) { @@ -180,7 +182,7 @@ class DatabaseKDBXMerger(private var database: DatabaseKDBX) { } /** - * Merge a KDB> database in a KDBX database, + * Merge a KDBX database in a KDBX database, * Try to take into account the modification date of each element * To make a merge as accurate as possible */ @@ -302,32 +304,113 @@ class DatabaseKDBXMerger(private var database: DatabaseKDBX) { } // Manage deleted objects - databaseToMerge.deletedObjects.forEach { deletedObject -> - val deletedObjectId = deletedObject.uuid - val databaseEntry = database.getEntryById(deletedObjectId) - val databaseGroup = database.getGroupById(deletedObjectId) - val databaseIcon = database.iconsManager.getIcon(deletedObjectId) - val databaseIconModificationTime = databaseIcon?.lastModificationTime - if (databaseEntry != null - && deletedObject.deletionTime.isAfter(databaseEntry.lastModificationTime)) { - database.removeEntryFrom(databaseEntry, databaseEntry.parent) - } - if (databaseGroup != null - && deletedObject.deletionTime.isAfter(databaseGroup.lastModificationTime)) { - database.removeGroupFrom(databaseGroup, databaseGroup.parent) - } - if (databaseIcon != null - && ( - databaseIconModificationTime == null - || (deletedObject.deletionTime.isAfter(databaseIconModificationTime)) - ) - ) { - database.removeCustomIcon(deletedObjectId) - } + val deletedObjects = databaseToMerge.deletedObjects + deletedObjects.forEach { deletedObject -> + deleteEntry(deletedObject) + deleteGroup(deletedObject, deletedObjects) + deleteIcon(deletedObject) // Attachments are removed and optimized during the database save } } + /** + * Delete an entry from the database with the [deletedEntry] id + */ + private fun deleteEntry(deletedEntry: DeletedObject) { + val databaseEntry = database.getEntryById(deletedEntry.uuid) + if (databaseEntry != null + && deletedEntry.deletionTime.isAfter(databaseEntry.lastModificationTime)) { + database.removeEntryFrom(databaseEntry, databaseEntry.parent) + } + } + + /** + * Check whether a node is in the list of deleted objects + */ + private fun Set.containsNode(node: NodeVersioned): Boolean { + return this.any { it.uuid == node.nodeId.id } + } + + /** + * Check whether a node is not in the list of deleted objects + */ + private fun Set.notContainsNode(node: NodeVersioned): Boolean { + return !this.containsNode(node) + } + + /** + * Get the first parent not deleted + */ + private fun firstNotDeletedParent( + node: NodeVersioned, + deletedObjects: Set + ): GroupKDBX? { + var parent = node.parent + while (parent != null && deletedObjects.containsNode(parent)) { + parent = node.parent + } + return parent + } + + /** + * Delete a group from the database with the [deletedGroup] id + * Recursively check whether a group to be deleted contains a node not to be deleted with [deletedObjects] + * and move it to the first parent that has not been deleted. + */ + private fun deleteGroup(deletedGroup: DeletedObject, deletedObjects: Set) { + val databaseGroup = database.getGroupById(deletedGroup.uuid) + if (databaseGroup != null + && deletedGroup.deletionTime.isAfter(databaseGroup.lastModificationTime)) { + // Must be in dedicated list to prevent modification collision + val entriesToMove = mutableListOf() + databaseGroup.getChildEntries().forEach { child -> + // If the child entry is not a deleted object, + if (deletedObjects.notContainsNode(child)) { + entriesToMove.add(child) + } + } + val groupsToMove = mutableListOf() + databaseGroup.getChildGroups().forEach { child -> + // Move the group to the first parent not deleted + // the deleted objects will take care of remove it later + groupsToMove.add(child) + } + // For each node to move, move it + // try to move the child entry in the first parent not deleted + entriesToMove.forEach { child -> + database.removeEntryFrom(child, child.parent) + database.addEntryTo( + child, + firstNotDeletedParent(databaseGroup, deletedObjects) + ) + } + groupsToMove.forEach { child -> + database.removeGroupFrom(child, child.parent) + database.addGroupTo( + child, + firstNotDeletedParent(databaseGroup, deletedObjects) + ) + } + // Then delete the group + database.removeGroupFrom(databaseGroup, databaseGroup.parent) + } + } + + /** + * Delete an icon from the database with the [deletedIcon] id + */ + private fun deleteIcon(deletedIcon: DeletedObject) { + val deletedObjectId = deletedIcon.uuid + val databaseIcon = database.iconsManager.getIcon(deletedObjectId) + val databaseIconModificationTime = databaseIcon?.lastModificationTime + if (databaseIcon != null + && (databaseIconModificationTime == null + || (deletedIcon.deletionTime.isAfter(databaseIconModificationTime))) + ) { + database.removeCustomIcon(deletedObjectId) + } + } + /** * Merge [customDataToMerge] in [customData] */ diff --git a/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt b/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt index 1187c38ab..15012abad 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt @@ -154,7 +154,7 @@ class SearchHelper { if (searchParameters.searchByDomain) { try { stringToCheck.inTheSameDomainAs(word, sameSubDomain = true) - } catch (e: Exception) { + } catch (_: Exception) { false } } else null @@ -220,10 +220,18 @@ class SearchHelper { regex.matches(stringToCheck) } else { specialComparison?.invoke(stringToCheck, searchParameters.searchQuery) - ?: stringToCheck.contains( - searchParameters.searchQuery, - !searchParameters.caseSensitive - ) + ?: run { + // Search with space separator #175 + var searchFound = true + searchParameters.searchQuery.split(" ").forEach { word -> + searchFound = searchFound + && stringToCheck.contains( + word, + !searchParameters.caseSensitive + ) + } + searchFound + } } } } diff --git a/database/src/main/java/com/kunzisoft/keepass/utils/ParcelableUtil.kt b/database/src/main/java/com/kunzisoft/keepass/utils/ParcelableUtil.kt index 474b0f4c3..6d0f0a487 100644 --- a/database/src/main/java/com/kunzisoft/keepass/utils/ParcelableUtil.kt +++ b/database/src/main/java/com/kunzisoft/keepass/utils/ParcelableUtil.kt @@ -119,19 +119,19 @@ fun Parcel.writeParcelableMap(map: Map, f inline fun Parcel.readParcelableMap(): Map { val size = readInt() val map = HashMap(size) - for (i in 0 until size) { + (0 until size).forEach { i -> val key: K? = try { when { SDK_INT >= 33 -> readParcelable(K::class.java.classLoader, K::class.java) else -> @Suppress("DEPRECATION") readParcelable(K::class.java.classLoader) } - } catch (e: Exception) { null } + } catch (_: Exception) { null } val value: V? = try { when { SDK_INT >= 33 -> readParcelable(V::class.java.classLoader, V::class.java) else -> @Suppress("DEPRECATION") readParcelable(V::class.java.classLoader) } - } catch (e: Exception) { null } + } catch (_: Exception) { null } if (key != null && value != null) map[key] = value } @@ -151,14 +151,14 @@ fun Parcel.writeStringParcelableMap(map: HashMap, fl inline fun Parcel.readStringParcelableMap(): LinkedHashMap { val size = readInt() val map = LinkedHashMap(size) - for (i in 0 until size) { + (0 until size).forEach { i -> val key: String? = readString() val value: V? = try { when { SDK_INT >= 33 -> readParcelable(V::class.java.classLoader, V::class.java) else -> @Suppress("DEPRECATION") readParcelable(V::class.java.classLoader) } - } catch (e: Exception) { null } + } catch (_: Exception) { null } if (key != null && value != null) map[key] = value } @@ -178,7 +178,7 @@ fun Parcel.writeStringIntMap(map: LinkedHashMap) { fun Parcel.readStringIntMap(): LinkedHashMap { val size = readInt() val map = LinkedHashMap(size) - for (i in 0 until size) { + (0 until size).forEach { i -> val key: String? = readString() val value: Int = readInt() if (key != null) @@ -200,7 +200,7 @@ fun Parcel.writeStringStringMap(map: MutableMap) { fun Parcel.readStringStringMap(): LinkedHashMap { val size = readInt() val map = LinkedHashMap(size) - for (i in 0 until size) { + (0 until size).forEach { i -> val key: String? = readString() val value: String? = readString() if (key != null && value != null) diff --git a/fastlane/metadata/android/en-US/changelogs/141.txt b/fastlane/metadata/android/en-US/changelogs/141.txt new file mode 100644 index 000000000..7316a4d1f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/141.txt @@ -0,0 +1,6 @@ + * Updated to API 35 minimum SDK 19 #2073 #2138 #2067 #2133 #1687 (Thx @Dev-ClayP) + * Remember last read-only state #2099 #2100 (Thx @rmacklin) + * Fix merge deletion #1516 + * Fix space in search #175 + * Fix deletable recycle bin #2163 + * Small fixes \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/142.txt b/fastlane/metadata/android/en-US/changelogs/142.txt new file mode 100644 index 000000000..a6cd137fc --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/142.txt @@ -0,0 +1,2 @@ + * Passkeys management #1421 #2097 (Thx @cali-95) + * Small fixes \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/changelogs/141.txt b/fastlane/metadata/android/fr-FR/changelogs/141.txt new file mode 100644 index 000000000..c89f4045d --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/141.txt @@ -0,0 +1,6 @@ + * Mise à jour vers API 35 minimum SDK 19 #2073 #2138 #2067 #2133 #1687 (Thx @Dev-ClayP) + * Sauvegarde du dernier état lecture seule #2099 #2100 (Thx @rmacklin) + * Correction de la suppression lors d'un merge #1516 + * Correction des espaces dans la recherche #175 + * Correction de la poubelle supprimable #2163 + * Petites corrections \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/changelogs/142.txt b/fastlane/metadata/android/fr-FR/changelogs/142.txt new file mode 100644 index 000000000..e42609c91 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/142.txt @@ -0,0 +1,2 @@ + * Gestion des Passkeys #1421 #2097 (Thx @cali-95) + * Petites corrections \ No newline at end of file