Merge branch 'develop' into release/4.2.0

This commit is contained in:
J-Jamet
2025-09-12 16:14:19 +02:00
12 changed files with 206 additions and 104 deletions

View File

@@ -1,7 +1,12 @@
KeePassDX(4.2.0) 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) * 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) * 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 * Small fixes
KeePassDX(4.1.7) KeePassDX(4.1.7)

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.kunzisoft.keepass" applicationId "com.kunzisoft.keepass"
minSdkVersion 19 minSdkVersion 19
targetSdkVersion 35 targetSdkVersion 35
versionCode = 140 versionCode = 142
versionName = "4.2.0beta01" versionName = "4.2.0beta02"
multiDexEnabled true multiDexEnabled true
testApplicationId = "com.kunzisoft.keepass.tests" testApplicationId = "com.kunzisoft.keepass.tests"

View File

@@ -43,6 +43,7 @@ import androidx.biometric.BiometricManager
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@@ -108,7 +109,11 @@ class MainCredentialActivity : DatabaseModeActivity() {
private var deviceUnlockFragment: DeviceUnlockFragment? = null private var deviceUnlockFragment: DeviceUnlockFragment? = null
private val mDatabaseFileViewModel: DatabaseFileViewModel by viewModels() 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) private val mPasswordActivityEducation = PasswordActivityEducation(this)
@@ -176,7 +181,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
// Listen password checkbox to init advanced unlock and confirmation button // Listen password checkbox to init advanced unlock and confirmation button
mainCredentialView?.onConditionToStoreCredentialChanged = { _, verified -> mainCredentialView?.onConditionToStoreCredentialChanged = { _, verified ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
mDeviceUnlockViewModel.checkConditionToStoreCredential( mDeviceUnlockViewModel?.checkConditionToStoreCredential(
condition = verified condition = verified
) )
} }
@@ -241,21 +246,22 @@ class MainCredentialActivity : DatabaseModeActivity() {
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
mDeviceUnlockViewModel.uiState.collect { uiState -> mDeviceUnlockViewModel?.let { deviceUnlockViewModel ->
deviceUnlockViewModel.uiState.collect { uiState ->
// New value received // New value received
uiState.credentialRequiredCipher?.let { cipher -> uiState.credentialRequiredCipher?.let { cipher ->
mDeviceUnlockViewModel.encryptCredential( deviceUnlockViewModel.encryptCredential(
credential = getCredentialForEncryption(), credential = getCredentialForEncryption(),
cipher = cipher cipher = cipher
) )
} }
uiState.cipherEncryptDatabase?.let { cipherEncryptDatabase -> uiState.cipherEncryptDatabase?.let { cipherEncryptDatabase ->
onCredentialEncrypted(cipherEncryptDatabase) onCredentialEncrypted(cipherEncryptDatabase)
mDeviceUnlockViewModel.consumeCredentialEncrypted() deviceUnlockViewModel.consumeCredentialEncrypted()
} }
uiState.cipherDecryptDatabase?.let { cipherDecryptDatabase -> uiState.cipherDecryptDatabase?.let { cipherDecryptDatabase ->
onCredentialDecrypted(cipherDecryptDatabase) onCredentialDecrypted(cipherDecryptDatabase)
mDeviceUnlockViewModel.consumeCredentialDecrypted() deviceUnlockViewModel.consumeCredentialDecrypted()
} }
uiState.exception?.let { error -> uiState.exception?.let { error ->
Snackbar.make( Snackbar.make(
@@ -263,7 +269,8 @@ class MainCredentialActivity : DatabaseModeActivity() {
deviceUnlockError(error, this@MainCredentialActivity), deviceUnlockError(error, this@MainCredentialActivity),
Snackbar.LENGTH_LONG Snackbar.LENGTH_LONG
).asError().show() ).asError().show()
mDeviceUnlockViewModel.exceptionShown() deviceUnlockViewModel.exceptionShown()
}
} }
} }
} }
@@ -516,7 +523,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
} else { } else {
// Init Biometric elements // Init Biometric elements
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
mDeviceUnlockViewModel.connect(databaseFileUri) mDeviceUnlockViewModel?.connect(databaseFileUri)
} }
} }
@@ -660,7 +667,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
try { try {
menu.findItem(R.id.menu_open_file_read_mode_key) menu.findItem(R.id.menu_open_file_read_mode_key)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Unable to find read mode menu") Log.e(TAG, "Unable to find read mode menu", e)
} }
performedNextEducation(menu) performedNextEducation(menu)
}, },
@@ -689,7 +696,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
}) })
} }
} }
} catch (ignored: Exception) {} } catch (_: Exception) {}
} }
} }
@@ -726,7 +733,7 @@ class MainCredentialActivity : DatabaseModeActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
mDeviceUnlockViewModel.disconnect() mDeviceUnlockViewModel?.disconnect()
} }
} }

View File

@@ -176,21 +176,14 @@ class SortDialogFragment : DatabaseDialogFragment() {
return bundle 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, fun getInstance(sortNodeEnum: SortNodeEnum,
ascending: Boolean, ascending: Boolean,
groupsBefore: Boolean, groupsBefore: Boolean,
recycleBinBottom: Boolean): SortDialogFragment { recycleBinBottom: Boolean?): SortDialogFragment {
val bundle = buildBundle(sortNodeEnum, ascending, groupsBefore) val bundle = buildBundle(sortNodeEnum, ascending, groupsBefore)
recycleBinBottom?.let {
bundle.putBoolean(SORT_RECYCLE_BIN_BOTTOM_BUNDLE_KEY, recycleBinBottom) bundle.putBoolean(SORT_RECYCLE_BIN_BOTTOM_BUNDLE_KEY, recycleBinBottom)
}
val fragment = SortDialogFragment() val fragment = SortDialogFragment()
fragment.arguments = bundle fragment.arguments = bundle
return fragment return fragment

View File

@@ -76,9 +76,6 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen
private var specialMode: SpecialMode = SpecialMode.DEFAULT private var specialMode: SpecialMode = SpecialMode.DEFAULT
private var mRecycleBinEnable: Boolean = false
private var mRecycleBin: Group? = null
private var mRecycleViewScrollListener = object : RecyclerView.OnScrollListener() { private var mRecycleViewScrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState) super.onScrollStateChanged(recyclerView, newState)
@@ -102,21 +99,14 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen
R.id.menu_sort -> { R.id.menu_sort -> {
context?.let { context -> context?.let { context ->
val sortDialogFragment: SortDialogFragment = val sortDialogFragment: SortDialogFragment =
if (mRecycleBinEnable) {
SortDialogFragment.getInstance( SortDialogFragment.getInstance(
PreferencesUtil.getListSort(context), PreferencesUtil.getListSort(context),
PreferencesUtil.getAscendingSort(context), PreferencesUtil.getAscendingSort(context),
PreferencesUtil.getGroupsBeforeSort(context), PreferencesUtil.getGroupsBeforeSort(context),
if (mDatabase?.isRecycleBinEnabled == true) {
PreferencesUtil.getRecycleBinBottomSort(context) PreferencesUtil.getRecycleBinBottomSort(context)
} else null
) )
} else {
SortDialogFragment.getInstance(
PreferencesUtil.getListSort(context),
PreferencesUtil.getAscendingSort(context),
PreferencesUtil.getGroupsBeforeSort(context)
)
}
sortDialogFragment.show(childFragmentManager, "sortDialog") sortDialogFragment.show(childFragmentManager, "sortDialog")
} }
true true
@@ -165,9 +155,6 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen
} }
override fun onDatabaseRetrieved(database: ContextualDatabase?) { override fun onDatabaseRetrieved(database: ContextualDatabase?) {
mRecycleBinEnable = database?.isRecycleBinEnabled == true
mRecycleBin = database?.recycleBin
context?.let { context -> context?.let { context ->
database?.let { database -> database?.let { database ->
mAdapter = NodesAdapter(context, database).apply { mAdapter = NodesAdapter(context, database).apply {
@@ -312,6 +299,11 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen
} }
} }
private fun containsRecycleBin(nodes: List<Node>): Boolean {
return mDatabase?.isRecycleBinEnabled == true
&& nodes.any { it == mDatabase?.recycleBin }
}
fun actionNodesCallback(database: ContextualDatabase, fun actionNodesCallback(database: ContextualDatabase,
nodes: List<Node>, nodes: List<Node>,
menuListener: NodesActionMenuListener?, menuListener: NodesActionMenuListener?,
@@ -336,8 +328,7 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen
// Open and Edit for a single item // Open and Edit for a single item
if (nodes.size == 1) { if (nodes.size == 1) {
// Edition // Edition
if (database.isReadOnly if (database.isReadOnly || containsRecycleBin(nodes)) {
|| (mRecycleBinEnable && nodes[0] == mRecycleBin)) {
menu?.removeItem(R.id.menu_edit) menu?.removeItem(R.id.menu_edit)
} }
} else { } else {
@@ -357,8 +348,7 @@ class GroupFragment : DatabaseFragment(), SortDialogFragment.SortSelectionListen
} }
// Deletion // Deletion
if (database.isReadOnly if (database.isReadOnly || containsRecycleBin(nodes)) {
|| (mRecycleBinEnable && nodes.any { it == mRecycleBin })) {
menu?.removeItem(R.id.menu_delete) menu?.removeItem(R.id.menu_delete)
} }
} }

View File

@@ -22,6 +22,7 @@ package com.kunzisoft.keepass.database.merge
import com.kunzisoft.keepass.database.element.Attachment import com.kunzisoft.keepass.database.element.Attachment
import com.kunzisoft.keepass.database.element.CustomData import com.kunzisoft.keepass.database.element.CustomData
import com.kunzisoft.keepass.database.element.DateInstant 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.DatabaseKDB
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX import com.kunzisoft.keepass.database.element.database.DatabaseKDBX
import com.kunzisoft.keepass.database.element.entry.EntryKDB 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.NodeId
import com.kunzisoft.keepass.database.element.node.NodeIdInt import com.kunzisoft.keepass.database.element.node.NodeIdInt
import com.kunzisoft.keepass.database.element.node.NodeIdUUID import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.database.element.node.NodeVersioned
import com.kunzisoft.keepass.utils.readAllBytes import com.kunzisoft.keepass.utils.readAllBytes
import java.io.IOException import java.io.IOException
import java.util.* import java.util.UUID
class DatabaseKDBXMerger(private var database: DatabaseKDBX) { 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 * Try to take into account the modification date of each element
* To make a merge as accurate as possible * To make a merge as accurate as possible
*/ */
@@ -302,30 +304,111 @@ class DatabaseKDBXMerger(private var database: DatabaseKDBX) {
} }
// Manage deleted objects // Manage deleted objects
databaseToMerge.deletedObjects.forEach { deletedObject -> val deletedObjects = databaseToMerge.deletedObjects
val deletedObjectId = deletedObject.uuid deletedObjects.forEach { deletedObject ->
val databaseEntry = database.getEntryById(deletedObjectId) deleteEntry(deletedObject)
val databaseGroup = database.getGroupById(deletedObjectId) deleteGroup(deletedObject, deletedObjects)
val databaseIcon = database.iconsManager.getIcon(deletedObjectId) deleteIcon(deletedObject)
val databaseIconModificationTime = databaseIcon?.lastModificationTime // 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 if (databaseEntry != null
&& deletedObject.deletionTime.isAfter(databaseEntry.lastModificationTime)) { && deletedEntry.deletionTime.isAfter(databaseEntry.lastModificationTime)) {
database.removeEntryFrom(databaseEntry, databaseEntry.parent) database.removeEntryFrom(databaseEntry, databaseEntry.parent)
} }
}
/**
* Check whether a node is in the list of deleted objects
*/
private fun Set<DeletedObject>.containsNode(node: NodeVersioned<UUID, GroupKDBX, EntryKDBX>): Boolean {
return this.any { it.uuid == node.nodeId.id }
}
/**
* Check whether a node is not in the list of deleted objects
*/
private fun Set<DeletedObject>.notContainsNode(node: NodeVersioned<UUID, GroupKDBX, EntryKDBX>): Boolean {
return !this.containsNode(node)
}
/**
* Get the first parent not deleted
*/
private fun firstNotDeletedParent(
node: NodeVersioned<UUID, GroupKDBX, EntryKDBX>,
deletedObjects: Set<DeletedObject>
): 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<DeletedObject>) {
val databaseGroup = database.getGroupById(deletedGroup.uuid)
if (databaseGroup != null if (databaseGroup != null
&& deletedObject.deletionTime.isAfter(databaseGroup.lastModificationTime)) { && deletedGroup.deletionTime.isAfter(databaseGroup.lastModificationTime)) {
// Must be in dedicated list to prevent modification collision
val entriesToMove = mutableListOf<EntryKDBX>()
databaseGroup.getChildEntries().forEach { child ->
// If the child entry is not a deleted object,
if (deletedObjects.notContainsNode(child)) {
entriesToMove.add(child)
}
}
val groupsToMove = mutableListOf<GroupKDBX>()
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) 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 if (databaseIcon != null
&& ( && (databaseIconModificationTime == null
databaseIconModificationTime == null || (deletedIcon.deletionTime.isAfter(databaseIconModificationTime)))
|| (deletedObject.deletionTime.isAfter(databaseIconModificationTime))
)
) { ) {
database.removeCustomIcon(deletedObjectId) database.removeCustomIcon(deletedObjectId)
} }
// Attachments are removed and optimized during the database save
}
} }
/** /**

View File

@@ -154,7 +154,7 @@ class SearchHelper {
if (searchParameters.searchByDomain) { if (searchParameters.searchByDomain) {
try { try {
stringToCheck.inTheSameDomainAs(word, sameSubDomain = true) stringToCheck.inTheSameDomainAs(word, sameSubDomain = true)
} catch (e: Exception) { } catch (_: Exception) {
false false
} }
} else null } else null
@@ -220,11 +220,19 @@ class SearchHelper {
regex.matches(stringToCheck) regex.matches(stringToCheck)
} else { } else {
specialComparison?.invoke(stringToCheck, searchParameters.searchQuery) specialComparison?.invoke(stringToCheck, searchParameters.searchQuery)
?: stringToCheck.contains( ?: run {
searchParameters.searchQuery, // Search with space separator #175
var searchFound = true
searchParameters.searchQuery.split(" ").forEach { word ->
searchFound = searchFound
&& stringToCheck.contains(
word,
!searchParameters.caseSensitive !searchParameters.caseSensitive
) )
} }
searchFound
}
}
} }
} }
} }

View File

@@ -119,19 +119,19 @@ fun <K : Parcelable, V : Parcelable> Parcel.writeParcelableMap(map: Map<K, V>, f
inline fun <reified K : Parcelable, reified V : Parcelable> Parcel.readParcelableMap(): Map<K, V> { inline fun <reified K : Parcelable, reified V : Parcelable> Parcel.readParcelableMap(): Map<K, V> {
val size = readInt() val size = readInt()
val map = HashMap<K, V>(size) val map = HashMap<K, V>(size)
for (i in 0 until size) { (0 until size).forEach { i ->
val key: K? = try { val key: K? = try {
when { when {
SDK_INT >= 33 -> readParcelable(K::class.java.classLoader, K::class.java) SDK_INT >= 33 -> readParcelable(K::class.java.classLoader, K::class.java)
else -> @Suppress("DEPRECATION") readParcelable(K::class.java.classLoader) else -> @Suppress("DEPRECATION") readParcelable(K::class.java.classLoader)
} }
} catch (e: Exception) { null } } catch (_: Exception) { null }
val value: V? = try { val value: V? = try {
when { when {
SDK_INT >= 33 -> readParcelable(V::class.java.classLoader, V::class.java) SDK_INT >= 33 -> readParcelable(V::class.java.classLoader, V::class.java)
else -> @Suppress("DEPRECATION") readParcelable(V::class.java.classLoader) else -> @Suppress("DEPRECATION") readParcelable(V::class.java.classLoader)
} }
} catch (e: Exception) { null } } catch (_: Exception) { null }
if (key != null && value != null) if (key != null && value != null)
map[key] = value map[key] = value
} }
@@ -151,14 +151,14 @@ fun <V : Parcelable> Parcel.writeStringParcelableMap(map: HashMap<String, V>, fl
inline fun <reified V : Parcelable> Parcel.readStringParcelableMap(): LinkedHashMap<String, V> { inline fun <reified V : Parcelable> Parcel.readStringParcelableMap(): LinkedHashMap<String, V> {
val size = readInt() val size = readInt()
val map = LinkedHashMap<String, V>(size) val map = LinkedHashMap<String, V>(size)
for (i in 0 until size) { (0 until size).forEach { i ->
val key: String? = readString() val key: String? = readString()
val value: V? = try { val value: V? = try {
when { when {
SDK_INT >= 33 -> readParcelable(V::class.java.classLoader, V::class.java) SDK_INT >= 33 -> readParcelable(V::class.java.classLoader, V::class.java)
else -> @Suppress("DEPRECATION") readParcelable(V::class.java.classLoader) else -> @Suppress("DEPRECATION") readParcelable(V::class.java.classLoader)
} }
} catch (e: Exception) { null } } catch (_: Exception) { null }
if (key != null && value != null) if (key != null && value != null)
map[key] = value map[key] = value
} }
@@ -178,7 +178,7 @@ fun Parcel.writeStringIntMap(map: LinkedHashMap<String, Int>) {
fun Parcel.readStringIntMap(): LinkedHashMap<String, Int> { fun Parcel.readStringIntMap(): LinkedHashMap<String, Int> {
val size = readInt() val size = readInt()
val map = LinkedHashMap<String, Int>(size) val map = LinkedHashMap<String, Int>(size)
for (i in 0 until size) { (0 until size).forEach { i ->
val key: String? = readString() val key: String? = readString()
val value: Int = readInt() val value: Int = readInt()
if (key != null) if (key != null)
@@ -200,7 +200,7 @@ fun Parcel.writeStringStringMap(map: MutableMap<String, String>) {
fun Parcel.readStringStringMap(): LinkedHashMap<String, String> { fun Parcel.readStringStringMap(): LinkedHashMap<String, String> {
val size = readInt() val size = readInt()
val map = LinkedHashMap<String, String>(size) val map = LinkedHashMap<String, String>(size)
for (i in 0 until size) { (0 until size).forEach { i ->
val key: String? = readString() val key: String? = readString()
val value: String? = readString() val value: String? = readString()
if (key != null && value != null) if (key != null && value != null)

View File

@@ -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

View File

@@ -0,0 +1,2 @@
* Passkeys management #1421 #2097 (Thx @cali-95)
* Small fixes

View File

@@ -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

View File

@@ -0,0 +1,2 @@
* Gestion des Passkeys #1421 #2097 (Thx @cali-95)
* Petites corrections