diff --git a/CHANGELOG b/CHANGELOG index 8b605e22a..e17546cc7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +KeePassDX(2.9.19) + * Fix search slowdown #964 + * Fix closing notification after lock request #965 + * Better temp advanced unlocking code implementation #965 + * Fix OTP token generation #967 + KeePassDX(2.9.18) * Move groups #658 * Improve autofill recognition #960 diff --git a/app/build.gradle b/app/build.gradle index a8e6a68b6..8687ebaea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.kunzisoft.keepass" minSdkVersion 15 targetSdkVersion 30 - versionCode = 72 - versionName = "2.9.18" + versionCode = 73 + versionName = "2.9.19" multiDexEnabled true testApplicationId = "com.kunzisoft.keepass.tests" diff --git a/app/src/androidTest/java/com/kunzisoft/keepass/tests/utils/UUIDTest.kt b/app/src/androidTest/java/com/kunzisoft/keepass/tests/utils/UUIDTest.kt new file mode 100644 index 000000000..18ee0f6ce --- /dev/null +++ b/app/src/androidTest/java/com/kunzisoft/keepass/tests/utils/UUIDTest.kt @@ -0,0 +1,15 @@ +package com.kunzisoft.keepass.tests.utils + +import com.kunzisoft.keepass.utils.UuidUtil +import junit.framework.TestCase +import java.util.* + +class UUIDTest: TestCase() { + + fun testUUID() { + val randomUUID = UUID.randomUUID() + val hexStringUUID = UuidUtil.toHexString(randomUUID) + val retrievedUUID = UuidUtil.fromHexString(hexStringUUID) + assertEquals(randomUUID, retrievedUUID) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/adapters/SearchEntryCursorAdapter.kt b/app/src/main/java/com/kunzisoft/keepass/adapters/SearchEntryCursorAdapter.kt index 14d2ce0d0..f5d121d73 100644 --- a/app/src/main/java/com/kunzisoft/keepass/adapters/SearchEntryCursorAdapter.kt +++ b/app/src/main/java/com/kunzisoft/keepass/adapters/SearchEntryCursorAdapter.kt @@ -106,7 +106,6 @@ class SearchEntryCursorAdapter(private val context: Context, private fun getEntryFrom(cursor: Cursor): Entry? { return database.createEntry()?.apply { - database.startManageEntry(this) entryKDB?.let { entryKDB -> (cursor as EntryCursorKDB).populateEntry(entryKDB, { standardIconId -> @@ -127,7 +126,6 @@ class SearchEntryCursorAdapter(private val context: Context, } ) } - database.stopManageEntry(this) } } @@ -150,12 +148,14 @@ class SearchEntryCursorAdapter(private val context: Context, if (searchGroup != null) { // Search in hide entries but not meta-stream for (entry in searchGroup.getFilteredChildEntries(Group.ChildFilter.getDefaults(context))) { + database.startManageEntry(entry) entry.entryKDB?.let { cursorKDB?.addEntry(it) } entry.entryKDBX?.let { cursorKDBX?.addEntry(it) } + database.stopManageEntry(entry) } } diff --git a/app/src/main/java/com/kunzisoft/keepass/app/database/CipherDatabaseAction.kt b/app/src/main/java/com/kunzisoft/keepass/app/database/CipherDatabaseAction.kt index f49d8a1d9..4ede01649 100644 --- a/app/src/main/java/com/kunzisoft/keepass/app/database/CipherDatabaseAction.kt +++ b/app/src/main/java/com/kunzisoft/keepass/app/database/CipherDatabaseAction.kt @@ -19,10 +19,7 @@ */ package com.kunzisoft.keepass.app.database -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection +import android.content.* import android.net.Uri import android.os.IBinder import android.util.Log @@ -42,66 +39,95 @@ class CipherDatabaseAction(context: Context) { // Temp DAO to easily remove content if object no longer in memory private var useTempDao = PreferencesUtil.isTempAdvancedUnlockEnable(applicationContext) - private val mIntentAdvancedUnlockService = Intent(applicationContext, - AdvancedUnlockNotificationService::class.java) private var mBinder: AdvancedUnlockNotificationService.AdvancedUnlockBinder? = null private var mServiceConnection: ServiceConnection? = null - private var mDatabaseListeners = LinkedList() + private var mDatabaseListeners = LinkedList() + private var mAdvancedUnlockBroadcastReceiver = AdvancedUnlockNotificationService.AdvancedUnlockReceiver { + deleteAll() + removeAllDataAndDetach() + } fun reloadPreferences() { useTempDao = PreferencesUtil.isTempAdvancedUnlockEnable(applicationContext) } @Synchronized - private fun attachService(performedAction: () -> Unit) { - // Check if a service is currently running else do nothing - if (mBinder != null) { + private fun serviceActionTask(startService: Boolean = false, performedAction: () -> Unit) { + // Check if a service is currently running else call action without info + if (startService && mServiceConnection == null) { + attachService(performedAction) + } else { performedAction.invoke() - } else if (mServiceConnection == null) { - mServiceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, serviceBinder: IBinder?) { - mBinder = (serviceBinder as AdvancedUnlockNotificationService.AdvancedUnlockBinder) - performedAction.invoke() - } - - override fun onServiceDisconnected(name: ComponentName?) { - mBinder = null - mServiceConnection = null - mDatabaseListeners.forEach { - it.onDatabaseCleared() - } - } - } - applicationContext.bindService(mIntentAdvancedUnlockService, - mServiceConnection!!, - Context.BIND_ABOVE_CLIENT) - if (mBinder == null) { - try { - applicationContext.startService(mIntentAdvancedUnlockService) - } catch (e: Exception) { - Log.e(TAG, "Unable to start cipher action", e) - } - } } } - fun registerDatabaseListener(listener: DatabaseListener) { - mDatabaseListeners.add(listener) + @Synchronized + private fun attachService(performedAction: () -> Unit) { + applicationContext.registerReceiver(mAdvancedUnlockBroadcastReceiver, IntentFilter().apply { + addAction(AdvancedUnlockNotificationService.REMOVE_ADVANCED_UNLOCK_KEY_ACTION) + }) + + mServiceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, serviceBinder: IBinder?) { + mBinder = (serviceBinder as AdvancedUnlockNotificationService.AdvancedUnlockBinder) + performedAction.invoke() + } + + override fun onServiceDisconnected(name: ComponentName?) { + onClear() + } + } + try { + AdvancedUnlockNotificationService.bindService(applicationContext, + mServiceConnection!!, + Context.BIND_AUTO_CREATE) + } catch (e: Exception) { + Log.e(TAG, "Unable to start cipher action", e) + performedAction.invoke() + } } - fun unregisterDatabaseListener(listener: DatabaseListener) { - mDatabaseListeners.remove(listener) + @Synchronized + private fun detachService() { + try { + applicationContext.unregisterReceiver(mAdvancedUnlockBroadcastReceiver) + } catch (e: Exception) {} + + mServiceConnection?.let { + AdvancedUnlockNotificationService.unbindService(applicationContext, it) + } } - interface DatabaseListener { - fun onDatabaseCleared() + private fun removeAllDataAndDetach() { + detachService() + onClear() + } + + fun registerDatabaseListener(listenerCipher: CipherDatabaseListener) { + mDatabaseListeners.add(listenerCipher) + } + + fun unregisterDatabaseListener(listenerCipher: CipherDatabaseListener) { + mDatabaseListeners.remove(listenerCipher) + } + + private fun onClear() { + mBinder = null + mServiceConnection = null + mDatabaseListeners.forEach { + it.onCipherDatabaseCleared() + } + } + + interface CipherDatabaseListener { + fun onCipherDatabaseCleared() } fun getCipherDatabase(databaseUri: Uri, cipherDatabaseResultListener: (CipherDatabaseEntity?) -> Unit) { if (useTempDao) { - attachService { + serviceActionTask { cipherDatabaseResultListener.invoke(mBinder?.getCipherDatabase(databaseUri)) } } else { @@ -126,7 +152,8 @@ class CipherDatabaseAction(context: Context) { fun addOrUpdateCipherDatabase(cipherDatabaseEntity: CipherDatabaseEntity, cipherDatabaseResultListener: (() -> Unit)? = null) { if (useTempDao) { - attachService { + // The only case to create service (not needed to get an info) + serviceActionTask(true) { mBinder?.addOrUpdateCipherDatabase(cipherDatabaseEntity) cipherDatabaseResultListener?.invoke() } @@ -151,7 +178,7 @@ class CipherDatabaseAction(context: Context) { fun deleteByDatabaseUri(databaseUri: Uri, cipherDatabaseResultListener: (() -> Unit)? = null) { if (useTempDao) { - attachService { + serviceActionTask { mBinder?.deleteByDatabaseUri(databaseUri) cipherDatabaseResultListener?.invoke() } @@ -168,14 +195,19 @@ class CipherDatabaseAction(context: Context) { } fun deleteAll() { - attachService { - mBinder?.deleteAll() + if (useTempDao) { + serviceActionTask { + mBinder?.deleteAll() + } } + // To erase the residues IOActionTask( { cipherDatabaseDao.deleteAll() } ).execute() + // Unbind + removeAllDataAndDetach() } companion object : SingletonHolderParameter(::CipherDatabaseAction) { diff --git a/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockFragment.kt b/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockFragment.kt index 83b77a33b..8e238ba04 100644 --- a/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockFragment.kt @@ -36,7 +36,6 @@ import com.kunzisoft.keepass.activities.stylish.StylishFragment import com.kunzisoft.keepass.app.database.CipherDatabaseAction import com.kunzisoft.keepass.database.exception.IODatabaseException import com.kunzisoft.keepass.education.PasswordActivityEducation -import com.kunzisoft.keepass.services.AdvancedUnlockNotificationService import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.view.AdvancedUnlockInfoView @@ -68,7 +67,7 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU private lateinit var cipherDatabaseAction : CipherDatabaseAction - private var cipherDatabaseListener: CipherDatabaseAction.DatabaseListener? = null + private var cipherDatabaseListener: CipherDatabaseAction.CipherDatabaseListener? = null // Only to fix multiple fingerprint menu #332 private var mAllowAdvancedUnlockMenu = false @@ -402,9 +401,10 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU fun connect(databaseUri: Uri) { showViews(true) this.databaseFileUri = databaseUri - cipherDatabaseListener = object: CipherDatabaseAction.DatabaseListener { - override fun onDatabaseCleared() { - deleteEncryptedDatabaseKey() + cipherDatabaseListener = object: CipherDatabaseAction.CipherDatabaseListener { + override fun onCipherDatabaseCleared() { + advancedUnlockManager?.closeBiometricPrompt() + checkUnlockAvailability() } } cipherDatabaseAction.apply { @@ -435,14 +435,12 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU @RequiresApi(Build.VERSION_CODES.M) fun deleteEncryptedDatabaseKey() { - allowOpenBiometricPrompt = false - mAdvancedUnlockInfoView?.setIconViewClickListener(false, null) advancedUnlockManager?.closeBiometricPrompt() databaseFileUri?.let { databaseUri -> cipherDatabaseAction.deleteByDatabaseUri(databaseUri) { checkUnlockAvailability() } - } + } ?: checkUnlockAvailability() } override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { @@ -479,7 +477,6 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU mBuilderListener?.retrieveCredentialForEncryption()?.let { credential -> advancedUnlockManager?.encryptData(credential) } - AdvancedUnlockNotificationService.startServiceForTimeout(requireContext()) } Mode.EXTRACT_CREDENTIAL -> { // retrieve the encrypted value from preferences diff --git a/app/src/main/java/com/kunzisoft/keepass/database/element/Database.kt b/app/src/main/java/com/kunzisoft/keepass/database/element/Database.kt index fa97f074e..b7ca9a4ca 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/element/Database.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/element/Database.kt @@ -557,7 +557,6 @@ class Database { searchInOther = true searchInUUIDs = false searchInTags = false - ignoreCase = true }, omitBackup, max) } diff --git a/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseKDBX.kt b/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseKDBX.kt index fa8728905..067e1438c 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseKDBX.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseKDBX.kt @@ -37,6 +37,7 @@ import com.kunzisoft.keepass.database.element.DeletedObject import com.kunzisoft.keepass.database.element.binary.BinaryData import com.kunzisoft.keepass.database.element.database.DatabaseKDB.Companion.BACKUP_FOLDER_TITLE import com.kunzisoft.keepass.database.element.entry.EntryKDBX +import com.kunzisoft.keepass.database.element.entry.FieldReferencesEngine import com.kunzisoft.keepass.database.element.group.GroupKDBX import com.kunzisoft.keepass.database.element.icon.IconImageCustom import com.kunzisoft.keepass.database.element.icon.IconImageStandard @@ -75,6 +76,7 @@ class DatabaseKDBX : DatabaseVersioned { private var kdfList: MutableList = ArrayList() private var numKeyEncRounds: Long = 0 var publicCustomData = VariantDictionary() + private val mFieldReferenceEngine = FieldReferencesEngine(this) var kdbxVersion = UnsignedInt(0) var name = "" @@ -333,6 +335,19 @@ class DatabaseKDBX : DatabaseVersioned { return customData.isNotEmpty() } + fun getEntryByCustomData(customDataValue: String): EntryKDBX? { + return entryIndexes.values.find { entry -> + entry.customData.containsValue(customDataValue) + } + } + + /** + * Retrieve the value of a field reference + */ + fun getFieldReferenceValue(textReference: String, recursionLevel: Int): String { + return mFieldReferenceEngine.compile(textReference, recursionLevel) + } + @Throws(IOException::class) public override fun getMasterKey(key: String?, keyInputStream: InputStream?): ByteArray { @@ -654,9 +669,20 @@ class DatabaseKDBX : DatabaseVersioned { this.deletedObjects.add(deletedObject) } + override fun addEntryTo(newEntry: EntryKDBX, parent: GroupKDBX?) { + super.addEntryTo(newEntry, parent) + mFieldReferenceEngine.clear() + } + + override fun updateEntry(entry: EntryKDBX) { + super.updateEntry(entry) + mFieldReferenceEngine.clear() + } + override fun removeEntryFrom(entryToRemove: EntryKDBX, parent: GroupKDBX?) { super.removeEntryFrom(entryToRemove, parent) deletedObjects.add(DeletedObject(entryToRemove.id)) + mFieldReferenceEngine.clear() } override fun undoDeleteEntryFrom(entry: EntryKDBX, origParent: GroupKDBX?) { @@ -727,6 +753,7 @@ class DatabaseKDBX : DatabaseVersioned { override fun clearCache() { try { super.clearCache() + mFieldReferenceEngine.clear() attachmentPool.clear() } catch (e: Exception) { Log.e(TAG, "Unable to clear cache", e) diff --git a/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseVersioned.kt b/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseVersioned.kt index 118cd9926..6d812120b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseVersioned.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseVersioned.kt @@ -67,7 +67,7 @@ abstract class DatabaseVersioned< var changeDuplicateId = false private var groupIndexes = LinkedHashMap, Group>() - private var entryIndexes = LinkedHashMap, Entry>() + protected var entryIndexes = LinkedHashMap, Entry>() abstract val version: String @@ -271,6 +271,26 @@ abstract class DatabaseVersioned< return this.entryIndexes[id] } + fun getEntryByTitle(title: String): Entry? { + return this.entryIndexes.values.find { entry -> entry.title.equals(title, true) } + } + + fun getEntryByUsername(username: String): Entry? { + return this.entryIndexes.values.find { entry -> entry.username.equals(username, true) } + } + + fun getEntryByURL(url: String): Entry? { + return this.entryIndexes.values.find { entry -> entry.url.equals(url, true) } + } + + fun getEntryByPassword(password: String): Entry? { + return this.entryIndexes.values.find { entry -> entry.password.equals(password, true) } + } + + fun getEntryByNotes(notes: String): Entry? { + return this.entryIndexes.values.find { entry -> entry.notes.equals(notes, true) } + } + fun addEntryIndex(entry: Entry) { val entryId = entry.nodeId if (entryIndexes.containsKey(entryId)) { @@ -336,14 +356,14 @@ abstract class DatabaseVersioned< removeGroupIndex(groupToRemove) } - fun addEntryTo(newEntry: Entry, parent: Group?) { + open fun addEntryTo(newEntry: Entry, parent: Group?) { // Add entry to parent parent?.addChildEntry(newEntry) newEntry.parent = parent addEntryIndex(newEntry) } - fun updateEntry(entry: Entry) { + open fun updateEntry(entry: Entry) { updateEntryIndex(entry) } diff --git a/app/src/main/java/com/kunzisoft/keepass/database/element/entry/EntryKDBX.kt b/app/src/main/java/com/kunzisoft/keepass/database/element/entry/EntryKDBX.kt index 772887d61..e8b32df9b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/element/entry/EntryKDBX.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/element/entry/EntryKDBX.kt @@ -146,53 +146,73 @@ class EntryKDBX : EntryVersioned, NodeKDBXInte return NodeIdUUID(nodeId.id) } + override val type: Type + get() = Type.ENTRY + /** * Decode a reference key with the FieldReferencesEngine * @param decodeRef * @param key * @return */ - private fun decodeRefKey(decodeRef: Boolean, key: String): String { + private fun decodeRefKey(decodeRef: Boolean, key: String, recursionLevel: Int): String { return fields[key]?.toString()?.let { text -> return if (decodeRef) { - if (mDatabase == null) text else FieldReferencesEngine().compile(text, this, mDatabase!!) + mDatabase?.getFieldReferenceValue(text, recursionLevel) ?: text } else text } ?: "" } + fun decodeTitleKey(recursionLevel: Int): String { + return decodeRefKey(mDecodeRef, STR_TITLE, recursionLevel) + } + override var title: String - get() = decodeRefKey(mDecodeRef, STR_TITLE) + get() = decodeTitleKey(0) set(value) { val protect = mDatabase != null && mDatabase!!.memoryProtection.protectTitle fields[STR_TITLE] = ProtectedString(protect, value) } - override val type: Type - get() = Type.ENTRY + fun decodeUsernameKey(recursionLevel: Int): String { + return decodeRefKey(mDecodeRef, STR_USERNAME, recursionLevel) + } override var username: String - get() = decodeRefKey(mDecodeRef, STR_USERNAME) + get() = decodeUsernameKey(0) set(value) { val protect = mDatabase != null && mDatabase!!.memoryProtection.protectUserName fields[STR_USERNAME] = ProtectedString(protect, value) } + fun decodePasswordKey(recursionLevel: Int): String { + return decodeRefKey(mDecodeRef, STR_PASSWORD, recursionLevel) + } + override var password: String - get() = decodeRefKey(mDecodeRef, STR_PASSWORD) + get() = decodePasswordKey(0) set(value) { val protect = mDatabase != null && mDatabase!!.memoryProtection.protectPassword fields[STR_PASSWORD] = ProtectedString(protect, value) } + fun decodeUrlKey(recursionLevel: Int): String { + return decodeRefKey(mDecodeRef, STR_URL, recursionLevel) + } + override var url - get() = decodeRefKey(mDecodeRef, STR_URL) + get() = decodeUrlKey(0) set(value) { val protect = mDatabase != null && mDatabase!!.memoryProtection.protectUrl fields[STR_URL] = ProtectedString(protect, value) } + fun decodeNotesKey(recursionLevel: Int): String { + return decodeRefKey(mDecodeRef, STR_NOTES, recursionLevel) + } + override var notes: String - get() = decodeRefKey(mDecodeRef, STR_NOTES) + get() = decodeNotesKey(0) set(value) { val protect = mDatabase != null && mDatabase!!.memoryProtection.protectNotes fields[STR_NOTES] = ProtectedString(protect, value) @@ -245,7 +265,7 @@ class EntryKDBX : EntryVersioned, NodeKDBXInte field.clear() for ((key, value) in fields) { if (!isStandardField(key)) { - field[key] = ProtectedString(value.isProtected, decodeRefKey(mDecodeRef, key)) + field[key] = ProtectedString(value.isProtected, decodeRefKey(mDecodeRef, key, 0)) } } return field diff --git a/app/src/main/java/com/kunzisoft/keepass/database/element/entry/FieldReferencesEngine.kt b/app/src/main/java/com/kunzisoft/keepass/database/element/entry/FieldReferencesEngine.kt index 798e42e5e..d60f36275 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/element/entry/FieldReferencesEngine.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/element/entry/FieldReferencesEngine.kt @@ -19,344 +19,124 @@ */ package com.kunzisoft.keepass.database.element.entry -import com.kunzisoft.keepass.database.action.node.NodeHandler import com.kunzisoft.keepass.database.element.database.DatabaseKDBX -import com.kunzisoft.keepass.database.search.SearchHelper -import com.kunzisoft.keepass.database.search.SearchParameters +import com.kunzisoft.keepass.database.element.node.NodeIdUUID import com.kunzisoft.keepass.utils.UuidUtil import java.util.* -class FieldReferencesEngine { +class FieldReferencesEngine(private val mDatabase: DatabaseKDBX) { - fun compile(text: String, entry: EntryKDBX, database: DatabaseKDBX): String { - return compileInternal(text, SprContextKDBX(database, entry), 0) + // Key : @: + // Value : content + private var refsCache: MutableMap = HashMap() + + fun clear() { + refsCache.clear() } - private fun compileInternal(text: String?, sprContextKDBX: SprContextKDBX?, recursionLevel: Int): String { - if (text == null) { - return "" - } - if (sprContextKDBX == null) { - return "" - } + fun compile(textReference: String, recursionLevel: Int): String { return if (recursionLevel >= MAX_RECURSION_DEPTH) { "" - } else fillRefPlaceholders(text, sprContextKDBX, recursionLevel) - + } else + fillReferencesPlaceholders(textReference, recursionLevel) } - private fun fillRefPlaceholders(textReference: String, contextKDBX: SprContextKDBX, recursionLevel: Int): String { - var text = textReference - - if (contextKDBX.databaseKDBX == null) { - return text - } + /** + * Manage placeholders with {REF:@:} + */ + private fun fillReferencesPlaceholders(textReference: String, recursionLevel: Int): String { + var textValue = textReference var offset = 0 - for (i in 0..19) { - text = fillRefsUsingCache(text, contextKDBX) + var numberInlineRef = 0 + while (textValue.contains(STR_REF_START) + && numberInlineRef <= MAX_INLINE_REF) { + numberInlineRef++ - val start = text.indexOf(STR_REF_START, offset, true) + textValue = fillReferencesUsingCache(textValue) + + val start = textValue.indexOf(STR_REF_START, offset, true) if (start < 0) { break } - val end = text.indexOf(STR_REF_END, start + 1, true) + val end = textValue.indexOf(STR_REF_END, offset, true) if (end <= start) { break } - val fullRef = text.substring(start, end + 1) - val result = findRefTarget(fullRef, contextKDBX) + val reference = textValue.substring(start + STR_REF_START.length, end) + val fullReference = "$STR_REF_START$reference$STR_REF_END" - if (result != null) { - val found = result.entry - found?.stopToManageFieldReferences() - val wanted = result.wanted - - var data: String? = null - when (wanted) { - 'T' -> data = found?.title - 'U' -> data = found?.username - 'A' -> data = found?.url - 'P' -> data = found?.password - 'N' -> data = found?.notes - 'I' -> data = found?.nodeId.toString() - } - - if (data != null && found != null) { - val subCtx = SprContextKDBX(contextKDBX) - subCtx.entryKDBX = found - - val innerContent = compileInternal(data, subCtx, recursionLevel + 1) - addRefsToCache(fullRef, innerContent, contextKDBX) - text = fillRefsUsingCache(text, contextKDBX) - } else { - offset = start + 1 + if (!refsCache.containsKey(fullReference)) { + val result = findReferenceTarget(reference) + val entryFound = result.entry + val newRecursionLevel = recursionLevel + 1 + val data: String? = when (result.wanted) { + 'T' -> entryFound?.decodeTitleKey(newRecursionLevel) + 'U' -> entryFound?.decodeUsernameKey(newRecursionLevel) + 'A' -> entryFound?.decodeUrlKey(newRecursionLevel) + 'P' -> entryFound?.decodePasswordKey(newRecursionLevel) + 'N' -> entryFound?.decodeNotesKey(newRecursionLevel) + 'I' -> UuidUtil.toHexString(entryFound?.nodeId?.id) + else -> null } + refsCache[fullReference] = data + textValue = fillReferencesUsingCache(textValue) } + offset = end } - - return text + return textValue } - private fun findRefTarget(fullReference: String?, contextKDBX: SprContextKDBX): TargetResult? { - var fullRef: String? = fullReference ?: return null - - fullRef = fullRef!!.toUpperCase(Locale.ENGLISH) - if (!fullRef.startsWith(STR_REF_START) || !fullRef.endsWith(STR_REF_END)) { - return null - } - - val ref = fullRef.substring(STR_REF_START.length, fullRef.length - STR_REF_END.length) - if (ref.length <= 4) { - return null - } - if (ref[1] != '@') { - return null - } - if (ref[3] != ':') { - return null - } - - val scan = Character.toUpperCase(ref[2]) - val wanted = Character.toUpperCase(ref[0]) - - val searchParameters = SearchParameters() - searchParameters.setupNone() - - searchParameters.searchQuery = ref.substring(4) - when (scan) { - 'T' -> searchParameters.searchInTitles = true - 'U' -> searchParameters.searchInUserNames = true - 'A' -> searchParameters.searchInUrls = true - 'P' -> searchParameters.searchInPasswords = true - 'N' -> searchParameters.searchInNotes = true - 'I' -> searchParameters.searchInUUIDs = true - 'O' -> searchParameters.searchInOther = true - else -> return null - } - - val list = ArrayList() - searchEntries(contextKDBX, searchParameters, list) - - return if (list.size > 0) { - TargetResult(list[0], wanted) - } else null - - } - - private fun addRefsToCache(ref: String?, value: String?, ctx: SprContextKDBX?) { - if (ref == null) { - return - } - if (value == null) { - return - } - if (ctx == null) { - return - } - - if (!ctx.refsCache.containsKey(ref)) { - ctx.refsCache[ref] = value - } - } - - private fun fillRefsUsingCache(text: String, sprContextKDBX: SprContextKDBX): String { + private fun fillReferencesUsingCache(text: String): String { var newText = text - for ((key, value) in sprContextKDBX.refsCache) { - newText = text.replace(key, value, true) + for ((key, value) in refsCache) { + // Replace by key if value not found + newText = newText.replace(key, value ?: key, true) } return newText } - private fun searchEntries(contextKDBX: SprContextKDBX, - searchParameters: SearchParameters?, - listStorage: MutableList?) { + private fun findReferenceTarget(reference: String): TargetResult { - val root = contextKDBX.databaseKDBX?.rootGroup - if (searchParameters == null) { - return + val targetResult = TargetResult(null, 'J') + + if (reference.length <= 4) { + return targetResult } - if (listStorage == null) { - return + if (reference[1] != '@') { + return targetResult + } + if (reference[3] != ':') { + return targetResult } - val terms = splitStringTerms(searchParameters.searchQuery) - if (terms.size <= 1 || searchParameters.regularExpression) { - root!!.doForEachChild(EntryKDBXSearchHandler(searchParameters, listStorage), null) - return - } - - // Search longest term first - val stringLengthComparator = Comparator { lhs, rhs -> lhs.length - rhs.length } - Collections.sort(terms, stringLengthComparator) - - val fullSearch = searchParameters.searchQuery - var childEntries: List? = root!!.getChildEntries() - for (i in terms.indices) { - val pgNew = ArrayList() - - searchParameters.searchQuery = terms[i] - - var negate = false - if (searchParameters.searchQuery.startsWith("-")) { - searchParameters.searchQuery = searchParameters.searchQuery.substring(1) - negate = searchParameters.searchQuery.isNotEmpty() - } - - if (!root.doForEachChild(EntryKDBXSearchHandler(searchParameters, pgNew), null)) { - childEntries = null - break - } - - childEntries = if (negate) { - val complement = ArrayList() - for (entry in childEntries!!) { - if (!pgNew.contains(entry)) { - complement.add(entry) - } - } - complement - } else { - pgNew - } - } - - if (childEntries != null) { - listStorage.addAll(childEntries) - } - searchParameters.searchQuery = fullSearch - } - - /** - * Create a list of String by split text when ' ', '\t', '\r' or '\n' is found - */ - private fun splitStringTerms(text: String?): List { - val list = ArrayList() - if (text == null) { - return list - } - - val stringBuilder = StringBuilder() - var quoted = false - - for (element in text) { - - if ((element == ' ' || element == '\t' || element == '\r' || element == '\n') && !quoted) { - - val len = stringBuilder.length - when { - len > 0 -> { - list.add(stringBuilder.toString()) - stringBuilder.delete(0, len) - } - element == '\"' -> quoted = !quoted - else -> stringBuilder.append(element) + targetResult.wanted = Character.toUpperCase(reference[0]) + val searchIn = Character.toUpperCase(reference[2]) + val searchQuery = reference.substring(4) + targetResult.entry = when (searchIn) { + 'T' -> mDatabase.getEntryByTitle(searchQuery) + 'U' -> mDatabase.getEntryByUsername(searchQuery) + 'A' -> mDatabase.getEntryByURL(searchQuery) + 'P' -> mDatabase.getEntryByPassword(searchQuery) + 'N' -> mDatabase.getEntryByNotes(searchQuery) + 'I' -> { + UuidUtil.fromHexString(searchQuery)?.let { uuid -> + mDatabase.getEntryById(NodeIdUUID(uuid)) } } + 'O' -> mDatabase.getEntryByCustomData(searchQuery) + else -> null } - - if (stringBuilder.isNotEmpty()) { - list.add(stringBuilder.toString()) - } - - return list + return targetResult } - inner class TargetResult(var entry: EntryKDBX?, var wanted: Char) - - private inner class SprContextKDBX { - - var databaseKDBX: DatabaseKDBX? = null - var entryKDBX: EntryKDBX - var refsCache: MutableMap = HashMap() - - constructor(databaseKDBX: DatabaseKDBX, entry: EntryKDBX) { - this.databaseKDBX = databaseKDBX - this.entryKDBX = entry - } - - constructor(source: SprContextKDBX) { - this.databaseKDBX = source.databaseKDBX - this.entryKDBX = source.entryKDBX - this.refsCache = source.refsCache - } - } - - private class EntryKDBXSearchHandler(private val mSearchParametersKDBX: SearchParameters, - private val mListStorage: MutableList) - : NodeHandler() { - - override fun operate(node: EntryKDBX): Boolean { - if (mSearchParametersKDBX.excludeExpired - && node.isCurrentlyExpires) { - return true - } - if (searchStrings(node)) { - mListStorage.add(node) - return true - } - if (searchInGroupNames(node)) { - mListStorage.add(node) - return true - } - if (searchInUUID(node)) { - mListStorage.add(node) - return true - } - return true - } - - private fun searchStrings(entry: EntryKDBX): Boolean { - var searchFound = false - // Search all strings in the KDBX entry - EntryFieldsLoop@ for((key, value) in entry.fields) { - if (entryKDBXKeyIsAllowedToSearch(key, mSearchParametersKDBX)) { - val currentString = value.toString() - if (SearchHelper.checkSearchQuery(currentString, mSearchParametersKDBX)) { - searchFound = true - break@EntryFieldsLoop - } - } - } - return searchFound - } - - private fun entryKDBXKeyIsAllowedToSearch(key: String, searchParameters: SearchParameters): Boolean { - return when (key) { - EntryKDBX.STR_TITLE -> searchParameters.searchInTitles - EntryKDBX.STR_USERNAME -> searchParameters.searchInUserNames - EntryKDBX.STR_PASSWORD -> searchParameters.searchInPasswords - EntryKDBX.STR_URL -> searchParameters.searchInUrls - EntryKDBX.STR_NOTES -> searchParameters.searchInNotes - else -> searchParameters.searchInOther - } - } - - private fun searchInGroupNames(entry: EntryKDBX): Boolean { - if (mSearchParametersKDBX.searchInGroupNames) { - val parent = entry.parent - if (parent != null) { - return parent.title - .contains(mSearchParametersKDBX.searchQuery, - mSearchParametersKDBX.ignoreCase) - } - } - return false - } - - private fun searchInUUID(entry: EntryKDBX): Boolean { - if (mSearchParametersKDBX.searchInUUIDs) { - return UuidUtil.toHexString(entry.id) - .contains(mSearchParametersKDBX.searchQuery, true) - } - return false - } - } + private data class TargetResult(var entry: EntryKDBX?, var wanted: Char) companion object { - private const val MAX_RECURSION_DEPTH = 12 + private const val MAX_RECURSION_DEPTH = 10 + private const val MAX_INLINE_REF = 10 private const val STR_REF_START = "{REF:" private const val STR_REF_END = "}" } diff --git a/app/src/main/java/com/kunzisoft/keepass/database/element/group/GroupVersionedInterface.kt b/app/src/main/java/com/kunzisoft/keepass/database/element/group/GroupVersionedInterface.kt index 5eac3138d..1043698bd 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/element/group/GroupVersionedInterface.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/element/group/GroupVersionedInterface.kt @@ -67,6 +67,26 @@ interface GroupVersionedInterface, return true } + fun searchChildEntry(criteria: (entry: Entry) -> Boolean): Entry? { + return searchChildEntry(this, criteria) + } + + private fun searchChildEntry(rootGroup: GroupVersionedInterface, + criteria: (entry: Entry) -> Boolean): Entry? { + for (childEntry in rootGroup.getChildEntries()) { + if (criteria.invoke(childEntry)) { + return childEntry + } + } + for (group in rootGroup.getChildGroups()) { + val searchChildEntry = searchChildEntry(group, criteria) + if (searchChildEntry != null) { + return searchChildEntry + } + } + return null + } + fun searchChildGroup(criteria: (group: Group) -> Boolean): Group? { return searchChildGroup(this, criteria) } diff --git a/app/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt b/app/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt index 8e11aa3e6..75b30734f 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt @@ -29,7 +29,8 @@ import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_FIELD import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.timeout.TimeoutHelper -import com.kunzisoft.keepass.utils.StringUtil.removeDiacriticalMarks +import com.kunzisoft.keepass.utils.StringUtil.flattenToAscii +import com.kunzisoft.keepass.utils.UuidUtil class SearchHelper { @@ -76,11 +77,7 @@ class SearchHelper { private fun entryContainsString(database: Database, entry: Entry, searchParameters: SearchParameters): Boolean { - val searchQuery = searchParameters.searchQuery - // Entry don't contains string if the search string is empty - if (searchQuery.isEmpty()) - return false - + // To search in field references database.startManageEntry(entry) // Search all strings in the entry val searchFound = searchInEntry(entry, searchParameters) @@ -89,41 +86,6 @@ class SearchHelper { return searchFound } - private fun searchInEntry(entry: Entry, - searchParameters: SearchParameters): Boolean { - // Search all strings in the KDBX entry - if (searchParameters.searchInTitles) { - if (checkSearchQuery(entry.title, searchParameters)) - return true - } - if (searchParameters.searchInUserNames) { - if (checkSearchQuery(entry.username, searchParameters)) - return true - } - if (searchParameters.searchInPasswords) { - if (checkSearchQuery(entry.password, searchParameters)) - return true - } - if (searchParameters.searchInUrls) { - if (checkSearchQuery(entry.url, searchParameters)) - return true - } - if (searchParameters.searchInNotes) { - if (checkSearchQuery(entry.notes, searchParameters)) - return true - } - if (searchParameters.searchInOther) { - entry.getExtraFields().forEach { field -> - if (field.name != OTP_FIELD - || (field.name == OTP_FIELD && searchParameters.searchInOTP)) { - if (checkSearchQuery(field.protectedValue.toString(), searchParameters)) - return true - } - } - } - return false - } - companion object { const val MAX_SEARCH_ENTRY = 10 @@ -162,16 +124,66 @@ class SearchHelper { } } - fun checkSearchQuery(stringToCheck: String, searchParameters: SearchParameters): Boolean { - if (stringToCheck.isNotEmpty() - && stringToCheck - .removeDiacriticalMarks() - .contains(searchParameters.searchQuery - .removeDiacriticalMarks(), - searchParameters.ignoreCase)) { - return true + /** + * Return true if the search query in search parameters is found in available parameters + */ + fun searchInEntry(entry: Entry, + searchParameters: SearchParameters): Boolean { + val searchQuery = searchParameters.searchQuery + // Entry don't contains string if the search string is empty + if (searchQuery.isEmpty()) + return false + + // Search all strings in the KDBX entry + if (searchParameters.searchInTitles) { + if (checkSearchQuery(entry.title, searchParameters)) + return true + } + if (searchParameters.searchInUserNames) { + if (checkSearchQuery(entry.username, searchParameters)) + return true + } + if (searchParameters.searchInPasswords) { + if (checkSearchQuery(entry.password, searchParameters)) + return true + } + if (searchParameters.searchInUrls) { + if (checkSearchQuery(entry.url, searchParameters)) + return true + } + if (searchParameters.searchInNotes) { + if (checkSearchQuery(entry.notes, searchParameters)) + return true + } + if (searchParameters.searchInUUIDs) { + val hexString = UuidUtil.toHexString(entry.nodeId.id) + if (hexString != null && hexString.contains(searchQuery, true)) + return true + } + if (searchParameters.searchInOther) { + entry.getExtraFields().forEach { field -> + if (field.name != OTP_FIELD + || (field.name == OTP_FIELD && searchParameters.searchInOTP)) { + if (checkSearchQuery(field.protectedValue.toString(), searchParameters)) + return true + } + } } return false } + + private fun checkSearchQuery(stringToCheck: String, searchParameters: SearchParameters): Boolean { + /* + // TODO Search settings + var regularExpression = false + var ignoreCase = true + var flattenToASCII = true + var excludeExpired = false + var searchOnlyInCurrentGroup = false + */ + return stringToCheck.isNotEmpty() + && stringToCheck.flattenToAscii().contains( + searchParameters.searchQuery.flattenToAscii(), true) + } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt b/app/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt index b2b0d915b..e03e05834 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt @@ -23,57 +23,15 @@ package com.kunzisoft.keepass.database.search * Parameters for searching strings in the database. */ class SearchParameters { - var searchQuery: String = "" - var regularExpression = false var searchInTitles = true var searchInUserNames = true var searchInPasswords = false var searchInUrls = true - var searchInGroupNames = false var searchInNotes = true var searchInOTP = false var searchInOther = true var searchInUUIDs = false var searchInTags = true - var ignoreCase = true - var ignoreExpired = false - var excludeExpired = false - - constructor() - - /** - * Copy search parameters - * @param source - */ - constructor(source: SearchParameters) { - this.regularExpression = source.regularExpression - this.searchInTitles = source.searchInTitles - this.searchInUserNames = source.searchInUserNames - this.searchInPasswords = source.searchInPasswords - this.searchInUrls = source.searchInUrls - this.searchInGroupNames = source.searchInGroupNames - this.searchInNotes = source.searchInNotes - this.searchInOTP = source.searchInOTP - this.searchInOther = source.searchInOther - this.searchInUUIDs = source.searchInUUIDs - this.searchInTags = source.searchInTags - this.ignoreCase = source.ignoreCase - this.ignoreExpired = source.ignoreExpired - this.excludeExpired = source.excludeExpired - } - - fun setupNone() { - searchInTitles = false - searchInUserNames = false - searchInPasswords = false - searchInUrls = false - searchInGroupNames = false - searchInNotes = false - searchInOTP = false - searchInOther = false - searchInUUIDs = false - searchInTags = false - } } diff --git a/app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt b/app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt index 5244fac28..5b34eabc9 100644 --- a/app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt +++ b/app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt @@ -93,7 +93,7 @@ data class OtpElement(var otpModel: OtpModel = OtpModel()) { value } else { TokenCalculator.HOTP_INITIAL_COUNTER - throw IllegalArgumentException() + throw NumberFormatException() } } @@ -186,7 +186,7 @@ data class OtpElement(var otpModel: OtpModel = OtpModel()) { } companion object { - const val MIN_HOTP_COUNTER = 1 + const val MIN_HOTP_COUNTER = 0 const val MAX_HOTP_COUNTER = Long.MAX_VALUE const val MIN_TOTP_PERIOD = 1 diff --git a/app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt b/app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt index 204288c3e..2d4fbeb9c 100644 --- a/app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt +++ b/app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt @@ -295,22 +295,30 @@ object OtpEntryFields { secretHexField != null -> otpElement.setHexSecret(secretHexField) secretBase32Field != null -> otpElement.setBase32Secret(secretBase32Field) secretBase64Field != null -> otpElement.setBase64Secret(secretBase64Field) - lengthField != null -> otpElement.digits = lengthField.toIntOrNull() ?: OTP_DEFAULT_DIGITS - periodField != null -> otpElement.period = periodField.toIntOrNull() ?: TOTP_DEFAULT_PERIOD - algorithmField != null -> otpElement.algorithm = - when (algorithmField.toUpperCase(Locale.ENGLISH)) { - TIMEOTP_ALGORITHM_SHA1_VALUE -> HashAlgorithm.SHA1 - TIMEOTP_ALGORITHM_SHA256_VALUE -> HashAlgorithm.SHA256 - TIMEOTP_ALGORITHM_SHA512_VALUE -> HashAlgorithm.SHA512 - else -> HashAlgorithm.SHA1 - } else -> return false } + otpElement.type = OtpType.TOTP + if (lengthField != null) { + otpElement.digits = lengthField.toIntOrNull() ?: OTP_DEFAULT_DIGITS + } + if (lengthField != null) { + otpElement.digits = lengthField.toIntOrNull() ?: OTP_DEFAULT_DIGITS + } + if (periodField != null) { + otpElement.period = periodField.toIntOrNull() ?: TOTP_DEFAULT_PERIOD + } + if (algorithmField != null) { + otpElement.algorithm = + when (algorithmField.toUpperCase(Locale.ENGLISH)) { + TIMEOTP_ALGORITHM_SHA1_VALUE -> HashAlgorithm.SHA1 + TIMEOTP_ALGORITHM_SHA256_VALUE -> HashAlgorithm.SHA256 + TIMEOTP_ALGORITHM_SHA512_VALUE -> HashAlgorithm.SHA512 + else -> HashAlgorithm.SHA1 + } + } } catch (exception: Exception) { return false } - - otpElement.type = OtpType.TOTP return true } @@ -321,10 +329,10 @@ object OtpEntryFields { return try { // KeeOtp string format val query = breakDownKeyValuePairs(plainText) + otpElement.type = OtpType.TOTP otpElement.setBase32Secret(query[SEED_KEY] ?: "") otpElement.digits = query[DIGITS_KEY]?.toIntOrNull() ?: OTP_DEFAULT_DIGITS otpElement.period = query[STEP_KEY]?.toIntOrNull() ?: TOTP_DEFAULT_PERIOD - otpElement.type = OtpType.TOTP true } catch (exception: Exception) { false @@ -351,6 +359,7 @@ object OtpEntryFields { // malformed return false } + otpElement.type = OtpType.TOTP otpElement.period = matcher.group(1)?.toIntOrNull() ?: TOTP_DEFAULT_PERIOD matcher.group(2)?.let { secondMatcher -> try { @@ -365,7 +374,6 @@ object OtpEntryFields { } catch (exception: Exception) { return false } - otpElement.type = OtpType.TOTP return true } @@ -374,6 +382,7 @@ object OtpEntryFields { val secretHexField = getField(HMACOTP_SECRET_HEX_FIELD) val secretBase32Field = getField(HMACOTP_SECRET_BASE32_FIELD) val secretBase64Field = getField(HMACOTP_SECRET_BASE64_FIELD) + val secretCounterField = getField(HMACOTP_SECRET_COUNTER_FIELD) try { when { secretField != null -> otpElement.setUTF8Secret(secretField) @@ -382,16 +391,13 @@ object OtpEntryFields { secretBase64Field != null -> otpElement.setBase64Secret(secretBase64Field) else -> return false } - - val secretCounterField = getField(HMACOTP_SECRET_COUNTER_FIELD) + otpElement.type = OtpType.HOTP if (secretCounterField != null) { otpElement.counter = secretCounterField.toLongOrNull() ?: HOTP_INITIAL_COUNTER } } catch (exception: Exception) { return false } - - otpElement.type = OtpType.HOTP return true } diff --git a/app/src/main/java/com/kunzisoft/keepass/services/AdvancedUnlockNotificationService.kt b/app/src/main/java/com/kunzisoft/keepass/services/AdvancedUnlockNotificationService.kt index 7d3d42235..9f63b9ce2 100644 --- a/app/src/main/java/com/kunzisoft/keepass/services/AdvancedUnlockNotificationService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/services/AdvancedUnlockNotificationService.kt @@ -1,8 +1,7 @@ package com.kunzisoft.keepass.services import android.app.PendingIntent -import android.content.Context -import android.content.Intent +import android.content.* import android.net.Uri import android.os.Binder import android.os.IBinder @@ -46,58 +45,46 @@ class AdvancedUnlockNotificationService : NotificationService() { return getString(R.string.advanced_unlock) } - override fun onBind(intent: Intent): IBinder? { - super.onBind(intent) - return mActionTaskBinder + override fun onCreate() { + super.onCreate() + mTempCipherDao = ArrayList() } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) + override fun onBind(intent: Intent): IBinder { + super.onBind(intent) - val deleteIntent = Intent(this, AdvancedUnlockNotificationService::class.java).apply { - action = ACTION_REMOVE_KEYS - } - val pendingDeleteIntent = PendingIntent.getService(this, 0, deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val pendingDeleteIntent = PendingIntent.getBroadcast(this, + 4577, Intent(REMOVE_ADVANCED_UNLOCK_KEY_ACTION), 0) val biometricUnlockEnabled = PreferencesUtil.isBiometricUnlockEnable(this) - val notificationBuilder = buildNewNotification().apply { + val notificationBuilder = buildNewNotification().apply { setSmallIcon(if (biometricUnlockEnabled) { R.drawable.notification_ic_fingerprint_unlock_24dp } else { R.drawable.notification_ic_device_unlock_24dp }) - intent?.let { - setContentTitle(getString(R.string.advanced_unlock)) - } + setContentTitle(getString(R.string.advanced_unlock)) setContentText(getString(R.string.advanced_unlock_tap_delete)) setContentIntent(pendingDeleteIntent) // Unfortunately swipe is disabled in lollipop+ setDeleteIntent(pendingDeleteIntent) } - when (intent?.action) { - ACTION_TIMEOUT -> { - val notificationTimeoutMilliSecs = PreferencesUtil.getAdvancedUnlockTimeout(this) - // Not necessarily a foreground service - if (mTimerJob == null && notificationTimeoutMilliSecs != TimeoutHelper.NEVER) { - defineTimerJob(notificationBuilder, notificationTimeoutMilliSecs) { - stopSelf() - } - } else { - startForeground(notificationId, notificationBuilder.build()) - } + val notificationTimeoutMilliSecs = PreferencesUtil.getAdvancedUnlockTimeout(this) + // Not necessarily a foreground service + if (mTimerJob == null && notificationTimeoutMilliSecs != TimeoutHelper.NEVER) { + defineTimerJob(notificationBuilder, notificationTimeoutMilliSecs) { + sendBroadcast(Intent(REMOVE_ADVANCED_UNLOCK_KEY_ACTION)) } - ACTION_REMOVE_KEYS -> { - stopSelf() - } - else -> {} + } else { + startForeground(notificationId, notificationBuilder.build()) } - return START_STICKY + return mActionTaskBinder } - override fun onCreate() { - super.onCreate() - mTempCipherDao = ArrayList() + override fun onUnbind(intent: Intent?): Boolean { + stopSelf() + return super.onUnbind(intent) } override fun onDestroy() { @@ -105,22 +92,32 @@ class AdvancedUnlockNotificationService : NotificationService() { super.onDestroy() } - companion object { - private const val CHANNEL_ADVANCED_UNLOCK_ID = "com.kunzisoft.keepass.notification.channel.unlock" - - private const val ACTION_TIMEOUT = "ACTION_TIMEOUT" - private const val ACTION_REMOVE_KEYS = "ACTION_REMOVE_KEYS" - - fun startServiceForTimeout(context: Context) { - if (PreferencesUtil.isTempAdvancedUnlockEnable(context)) { - context.startService(Intent(context, AdvancedUnlockNotificationService::class.java).apply { - action = ACTION_TIMEOUT - }) + class AdvancedUnlockReceiver(var removeKeyAction: () -> Unit): BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + intent.action?.let { + when (it) { + REMOVE_ADVANCED_UNLOCK_KEY_ACTION -> { + removeKeyAction.invoke() + } + } } } + } - fun stopService(context: Context) { - context.stopService(Intent(context, AdvancedUnlockNotificationService::class.java)) + companion object { + private const val CHANNEL_ADVANCED_UNLOCK_ID = "com.kunzisoft.keepass.notification.channel.unlock" + const val REMOVE_ADVANCED_UNLOCK_KEY_ACTION = "com.kunzisoft.keepass.REMOVE_ADVANCED_UNLOCK_KEY" + + // Only one service connection + fun bindService(context: Context, serviceConnection: ServiceConnection, flags: Int) { + context.bindService(Intent(context, + AdvancedUnlockNotificationService::class.java), + serviceConnection, + flags) + } + + fun unbindService(context: Context, serviceConnection: ServiceConnection) { + context.unbindService(serviceConnection) } } } \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/services/DatabaseTaskNotificationService.kt b/app/src/main/java/com/kunzisoft/keepass/services/DatabaseTaskNotificationService.kt index 13cc63187..b24a1015d 100644 --- a/app/src/main/java/com/kunzisoft/keepass/services/DatabaseTaskNotificationService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/services/DatabaseTaskNotificationService.kt @@ -200,10 +200,6 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress if (intentAction == null && !mDatabase.loaded) { stopSelf() } - if (intentAction == ACTION_DATABASE_CLOSE) { - // Send lock action - sendBroadcast(Intent(LOCK_ACTION)) - } val actionRunnable: ActionRunnable? = when (intentAction) { ACTION_DATABASE_CREATE_TASK -> buildDatabaseCreateActionTask(intent) @@ -378,10 +374,8 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress ReadOnlyHelper.putReadOnlyInIntent(this, mDatabase.isReadOnly) }, PendingIntent.FLAG_UPDATE_CURRENT) - val deleteIntent = Intent(this, DatabaseTaskNotificationService::class.java).apply { - action = ACTION_DATABASE_CLOSE - } - val pendingDeleteIntent = PendingIntent.getService(this, 0, deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val pendingDeleteIntent = PendingIntent.getBroadcast(this, + 4576, Intent(LOCK_ACTION), 0) // Add actions in notifications notificationBuilder.apply { setContentText(mDatabase.name + " (" + mDatabase.version + ")") @@ -877,7 +871,6 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress const val ACTION_DATABASE_UPDATE_PARALLELISM_TASK = "ACTION_DATABASE_UPDATE_PARALLELISM_TASK" const val ACTION_DATABASE_UPDATE_ITERATIONS_TASK = "ACTION_DATABASE_UPDATE_ITERATIONS_TASK" const val ACTION_DATABASE_SAVE = "ACTION_DATABASE_SAVE" - const val ACTION_DATABASE_CLOSE = "ACTION_DATABASE_CLOSE" const val DATABASE_TASK_TITLE_KEY = "DATABASE_TASK_TITLE_KEY" const val DATABASE_TASK_MESSAGE_KEY = "DATABASE_TASK_MESSAGE_KEY" diff --git a/app/src/main/java/com/kunzisoft/keepass/services/LockNotificationService.kt b/app/src/main/java/com/kunzisoft/keepass/services/LockNotificationService.kt index e1865cf1e..8a9b9e855 100644 --- a/app/src/main/java/com/kunzisoft/keepass/services/LockNotificationService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/services/LockNotificationService.kt @@ -57,8 +57,8 @@ abstract class LockNotificationService : NotificationService() { } override fun onDestroy() { - unregisterLockReceiver(mLockReceiver) + mLockReceiver = null super.onDestroy() } diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt index ab6bcb22b..d73945abd 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt @@ -45,7 +45,6 @@ import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction import com.kunzisoft.keepass.biometric.AdvancedUnlockManager import com.kunzisoft.keepass.education.Education import com.kunzisoft.keepass.icons.IconPackChooser -import com.kunzisoft.keepass.services.AdvancedUnlockNotificationService import com.kunzisoft.keepass.settings.preference.IconPackListPreference import com.kunzisoft.keepass.settings.preferencedialogfragment.DurationDialogFragmentCompat import com.kunzisoft.keepass.utils.UriUtil @@ -374,7 +373,6 @@ class NestedAppSettingsFragment : NestedSettingsFragment() { } }) } - AdvancedUnlockNotificationService.stopService(activity.applicationContext) CipherDatabaseAction.getInstance(activity.applicationContext).deleteAll() } .setNegativeButton(resources.getString(android.R.string.cancel) diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/StringUtil.kt b/app/src/main/java/com/kunzisoft/keepass/utils/StringUtil.kt index d6c7f7c23..3f9aebce8 100644 --- a/app/src/main/java/com/kunzisoft/keepass/utils/StringUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/utils/StringUtil.kt @@ -12,9 +12,19 @@ object StringUtil { return this.replace("[\\r|\\n|\\t|\\s|\\u00A0]+".toRegex(), "") } - fun String.removeDiacriticalMarks(): String { - return "\\p{InCombiningDiacriticalMarks}+".toRegex() - .replace(Normalizer.normalize(this, Normalizer.Form.NFD), "") + fun String.flattenToAscii(): String { + var string = this + val out = CharArray(string.length) + string = Normalizer.normalize(string, Normalizer.Form.NFD) + var j = 0 + var i = 0 + val n = string.length + while (i < n) { + val c = string[i] + if (c <= '\u007F') out[j++] = c + ++i + } + return String(out) } fun ByteArray.toHexString() = joinToString("") { "%02X".format(it) } diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/UuidUtil.java b/app/src/main/java/com/kunzisoft/keepass/utils/UuidUtil.java index 8a8718747..53b7383ac 100644 --- a/app/src/main/java/com/kunzisoft/keepass/utils/UuidUtil.java +++ b/app/src/main/java/com/kunzisoft/keepass/utils/UuidUtil.java @@ -19,33 +19,74 @@ */ package com.kunzisoft.keepass.utils; +import androidx.annotation.Nullable; + import java.util.UUID; import static com.kunzisoft.keepass.utils.StreamBytesUtilsKt.uuidTo16Bytes; public class UuidUtil { - public static String toHexString(UUID uuid) { + public static @Nullable String toHexString(@Nullable UUID uuid) { if (uuid == null) { return null; } + try { + byte[] buf = uuidTo16Bytes(uuid); - byte[] buf = uuidTo16Bytes(uuid); + int len = buf.length; + if (len == 0) { + return ""; + } - int len = buf.length; - if (len == 0) { return ""; } + StringBuilder sb = new StringBuilder(); - StringBuilder sb = new StringBuilder(); + short bt; + char high, low; + for (byte b : buf) { + bt = (short) (b & 0xFF); + high = (char) (bt >>> 4); + low = (char) (bt & 0x0F); + sb.append(byteToChar(high)); + sb.append(byteToChar(low)); + } - short bt; - char high, low; - for (byte b : buf) { - bt = (short) (b & 0xFF); - high = (char) (bt >>> 4); - low = (char) (bt & 0x0F); - sb.append(byteToChar(high)); - sb.append(byteToChar(low)); + return sb.toString(); + } catch (Exception e) { + return null; } + } - return sb.toString(); + public static @Nullable UUID fromHexString(@Nullable String hexString) { + if (hexString == null) + return null; + + if (hexString.length() != 32) + return null; + + char[] charArray = hexString.toLowerCase().toCharArray(); + char[] leastSignificantChars = new char[16]; + char[] mostSignificantChars = new char[16]; + + for (int i = 31; i >= 0; i = i-2) { + if (i >= 16) { + mostSignificantChars[32-i] = charArray[i]; + mostSignificantChars[31-i] = charArray[i-1]; + } else { + leastSignificantChars[16-i] = charArray[i]; + leastSignificantChars[15-i] = charArray[i-1]; + } + } + StringBuilder standardUUIDString = new StringBuilder(); + standardUUIDString.append(leastSignificantChars); + standardUUIDString.append(mostSignificantChars); + standardUUIDString.insert(8, '-'); + standardUUIDString.insert(13, '-'); + standardUUIDString.insert(18, '-'); + standardUUIDString.insert(23, '-'); + try { + return UUID.fromString(standardUUIDString.toString()); + } catch (Exception e) { + return null; + } } // Use short to represent unsigned byte diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 809dbbb88..62458a681 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -118,8 +118,8 @@ Nie Keine Suchergebnisse Bitte einen Webbrowser installieren, um diese URL zu öffnen. - Recycle bin und Backup nicht durchsuchen - Die Gruppen „Backup“ und „Recycle bin“ werden bei der Suche nicht berücksichtigt + Papierkorb und Backup nicht durchsuchen + Die Gruppen „Backup“ und „Papierkorb“ werden bei der Suche nicht berücksichtigt Schnellsuche Beim Öffnen einer Datenbank eine Suche veranlassen Neue Datenbank anlegen … @@ -167,7 +167,7 @@ Dateiname Dieses Feature konnte nicht gestartet werden. Ermöglicht Ihre Biometrie zu scannen, um die Datenbank zu öffnen. - Erweiterte Entsperrung + Moderne Entsperrung Biometrische Entsperrung Sperren Erlaubte Zeichen für Passwortgenerator festlegen @@ -353,12 +353,12 @@ Aktualisieren Felder schließen Es ist nicht möglich, eine Datenbank mit diesem Passwort und dieser Schlüsseldatei zu erstellen. - Erweitertes Entsperren + Modernes Entsperren Biometrisch Aktivieren Deaktivieren Abfrage automatisch öffnen - Automatisch nach der erweiterten Entsperrung fragen, wenn die Datenbank dafür eingerichtet ist + Automatisch moderne Entsperrung abfragen, wenn die Datenbank dafür eingerichtet ist Hauptschlüssel Sicherheit Verlauf @@ -385,7 +385,7 @@ Problem lösen, indem neue UUIDs für Duplikate generiert werden und danach fortfahren\? Datenbank geöffnet Eintragsfelder mithilfe der Zwischenablage des Geräts kopieren - Erweitertes Entsperren verwenden, um eine Datenbank einfacher zu öffnen. + Modernes Entsperren verwenden, um eine Datenbank einfacher zu öffnen. Datenkompression Datenkompression reduziert die Datenbankgröße Maximale Anzahl @@ -506,22 +506,22 @@ Gemeinsam genutzte Informationen speichern Sollen alle ausgewählten Knoten wirklich aus dem Papierkorb gelöscht werden\? Der Feldname existiert bereits. - Warnung: Sie müssen sich immer noch an Ihr Masterpasswort erinnern, wenn Sie die erweiterte Entsperrerkennung verwenden. - Öffne den erweiterten Entsperrdialog zum Speichern von Anmeldeinformationen - Öffnen des erweiterten Entsperrdialogs zum Entsperren der Datenbank + Warnung: Sie müssen sich immer noch an Ihr Masterpasswort erinnern, wenn Sie die moderne Entsperrerkennung verwenden. + Zum Speichern der Anmeldeinformationen Dialog zum modernen Entsperren öffnen + Dialog zum modernen Entsperren der Datenbank öffnen Schlüssel für modernes Entsperren löschen - Erweiterte Entsperrerkennung + Moderne Entsperrerkennung Verknüpfen Sie Ihr Passwort mit Ihren gescannten Biometriedaten oder Daten zur Geräteanmeldung, um schnell Ihre Datenbank zu entsperren. Erweiterte Entsperrung der Datenbank Verfallzeit der erweiterten Entsperrung - Dauer der erweiterten Entsperrung bevor sein Inhalt gelöscht wird - Verfall der erweiterten Entsperrung - Keinen verschlüsselten Inhalt speichern, um erweiterte Entsperrung zu benutzen - Temporäre erweiterte Entsperrung + Laufzeit der modernen Entsperrung bevor ihr Inhalt gelöscht wird + Verfall der modernen Entsperrung + Keinen verschlüsselten Inhalt speichern, um moderne Entsperrung zu benutzen + Temporär moderne Entsperrung Erlaubt Ihn die Geräteanmeldedaten zum Öffnen der Datenbank zu verwenden - Drücken, um erweiterte Entsperrschlüssel zu löschen + Drücken, um Schlüssel für modernes Entsperren zu löschen Inhalt - Öffne Datenbank mit erweiterter Entsperrerkennung + Datenbank mit moderner Entsperrerkennung öffnen Eingabetaste Rücktaste Wähle Eintrag @@ -531,11 +531,11 @@ Geräteanmeldedaten entsperren Geräteanmeldedaten Geben Sie das Passwort ein und klicken Sie dann auf diesen Knopf. - Initialisieren des erweitertes Entsperren Dialogs fehlgeschlagen. - Erweitertes Entsperren Fehler: %1$s - Konnte den Abdruck des erweiterten Entsperrens nicht erkennen - Kann den Schlüssel zum erweiterten Entsperren nicht lesen. Bitte löschen sie ihn und wiederholen sie Prozedur zum Erkennen des Entsperrens. - Extrahiere Datenbankanmeldedaten mit Daten aus erweitertem Entsperren + Dialog für modernes Entsperren konnte nicht gestartet werden. + Fehler beim modernen Entsperren: %1$s + Abdruck zum modernen Entsperren nicht erkannt + Schlüssel zum modernen Entsperren nicht lesbar. Bitte löschen Sie ihn und wiederholen Sie die Prozedur zur Entsperrerkennung. + Datenbankanmeldedaten mit Daten aus moderner Entsperrung extrahieren Die Liste kann nicht ordnungsgemäß neu erstellt werden. Datenbank-URI kann nicht abgerufen werden. Datenbank neu laden @@ -549,4 +549,13 @@ Beim Ausführen einer Aktion in der Datenbank ist ein Fehler aufgetreten. Der vorhandene Einmalpassworttyp wird von diesem Formular nicht erkannt, seine Validierung erzeugt das Token möglicherweise nicht mehr korrekt. Informationen zu Einmalpasswörtern + Auf die Datei kann nicht zugegriffen werden. Schließen Sie die Datenbank und öffnen Sie die Datei erneut. + Fehler beim Exportieren der App-Eigenschaften + App-Eigenschaften exportiert + Fehler beim Importieren der App-Eigenschaften + App-Eigenschaften importiert + Erstellen einer Datei zum Exportieren von App-Eigenschaften + App-Eigenschaften exportieren + Wählen Sie eine Datei zum Importieren von App-Eigenschaften + Sie können hier keine Gruppe verschieben. \ No newline at end of file diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 03ad78a99..9fdda7504 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -560,4 +560,6 @@ Επιλέξτε ένα αρχείο για εισαγωγή ιδιοτήτων εφαρμογής Εισαγωγή ιδιοτήτων εφαρμογής Παρουσιάστηκε σφάλμα κατά την εκτέλεση μιας ενέργειας στη βάση δεδομένων. + Δεν μπορείτε να μετακινήσετε μια ομάδα εδώ. + Αυτή η λέξη είναι δεσμευμένη και δεν μπορεί να χρησιμοποιηθεί. \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d75eaf632..4bd9e2545 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -568,4 +568,6 @@ Sélectionner un fichier pour importer les propriétés de l\'application Importation des propriétés de l\'appli Une erreur s\'est produite lors de l\'exécution d\'une action sur la base de données. + Vous ne pouvez pas déplacer un groupe ici. + Ce mot est réservé et ne peut pas être utilisé. \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c50cdc9dc..efacdee98 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -68,7 +68,7 @@ Assicurati che il percorso sia corretto. Inserisci un nome. Memoria insufficiente per caricare l\'intero database. - Deve essere selezionato almeno un tipo di generazione password. + Selezionare almeno un tipo di generazione della password. Le password non corrispondono. «Livello» troppo alto. Impostato a 2147483648. Ogni stringa deve avere un nome. @@ -563,4 +563,5 @@ Esporta le proprietà dell\'app Seleziona un file da cui importare le proprietà dell\'app Importa le proprietà dell\'app + Questa parola è riservata e non può essere usata. \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7a41e14ab..cfa7c1ccc 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -562,4 +562,6 @@ Selecteer een bestand om app-eigenschappen te importeren App-eigenschappen importeren Er is een fout opgetreden bij het uitvoeren van een actie op de database. + Je kunt hier geen groep verplaatsen. + Dit woord is gereserveerd en kan niet worden gebruikt. \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 39c180d63..a303ca9a0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -550,14 +550,16 @@ Данные файла уже существует. Ошибка при удалении данных файла. Свойства - Ошибка при экспорте свойств приложения - Свойства приложения экспортированы - Ошибка при импорте свойств приложения - Свойства приложения импортированы - Свойства KeePassDX для управления настройками приложения - Создайте файл для экспорта свойств приложения - Экспорт свойств приложения - Выберите файл для импорта свойств приложения - Импортировать свойства приложения + Ошибка при экспорте настроек приложения + Настройки приложения экспортированы + Ошибка при импорте настроек приложения + Настройки приложения импортированы + Управление настройками приложения KeePassDX + Создать файл настроек приложения + Экспорт настроек + Импортировать настройки приложения из файла + Импорт настроек Произошла ошибка при выполнении действия с базой. + Сюда группу переместить невозможно. + Это слово зарезервировано и не может быть использовано. \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index d64ca71b6..4f184d230 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -555,4 +555,6 @@ Uygulama özelliklerini içe aktarmak için bir dosya seçin Uygulama özelliklerini içe aktar Veri tabanında bir eylem gerçekleştirilirken bir hata oluştu. + Bir grubu buraya taşıyamazsınız. + Bu sözcük ayrılmıştır ve kullanılamaz. \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f5df1f917..f1c73e0dd 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -560,4 +560,6 @@ Виберіть файл для імпорту властивостей застосунку Імпорт властивостей застосунку Під час виконання дії з базою даних сталася помилка. + Ви не можете перемістити групу сюди. + Це слово зарезервоване, його не можна використовувати. \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 683f10dcc..f28203dc8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -560,4 +560,6 @@ 选择一个文件来导入应用属性 导入应用属性 对数据库执行操作时发生了一个错误。 + 你不能把一个组移动到此处。 + 这个单词是保留的,不能使用。 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/73.txt b/fastlane/metadata/android/en-US/changelogs/73.txt new file mode 100644 index 000000000..fb1c35e5a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/73.txt @@ -0,0 +1,4 @@ + * Fix search slowdown #964 + * Fix closing notification after lock request #965 + * Better temp advanced unlocking code implementation #965 + * Fix OTP token generation #967 \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/changelogs/73.txt b/fastlane/metadata/android/fr-FR/changelogs/73.txt new file mode 100644 index 000000000..ce7da5396 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/73.txt @@ -0,0 +1,4 @@ + * Correction du ralentissement de la recherche #964 + * Correction de la fermeture de notification après une requête de verrouillage #965 + * Meilleure implémentation du déverrouillage avancé temporaire #965 + * Correction de la génération des jetons de mots de passe uniques #967 \ No newline at end of file