diff --git a/CHANGELOG b/CHANGELOG
index d7cec880d..7d88f9a23 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,8 @@
KeePassDX(2.9.9)
- *
+ * Detect file changes and reload database #794
+ * Inline suggestions autofill with compatible keyboard (Android R) #827
+ * Add Keyfile XML version 2 #844
+ * Fix binaries of 64 bytes #835
KeePassDX(2.9.8)
* Fix specific attachments with kdbx3.1 databases #828
diff --git a/app/build.gradle b/app/build.gradle
index 368d42cbb..d4b530ecc 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -5,7 +5,7 @@ apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 30
- buildToolsVersion '30.0.2'
+ buildToolsVersion '30.0.3'
ndkVersion '21.3.6528147'
defaultConfig {
@@ -110,6 +110,8 @@ dependencies {
// Database
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
+ // Autofill
+ implementation "androidx.autofill:autofill:1.1.0-rc01"
// Crypto
implementation 'org.bouncycastle:bcprov-jdk15on:1.65.01'
// Time
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt
index bfa581810..dd1433aed 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt
@@ -26,6 +26,7 @@ import android.content.Intent
import android.content.IntentSender
import android.os.Build
import android.os.Bundle
+import android.view.inputmethod.InlineSuggestionsRequest
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
@@ -33,6 +34,7 @@ import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.autofill.AutofillHelper
+import com.kunzisoft.keepass.autofill.AutofillHelper.EXTRA_INLINE_SUGGESTIONS_REQUEST
import com.kunzisoft.keepass.autofill.KeeAutofillService
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.search.SearchHelper
@@ -40,7 +42,6 @@ import com.kunzisoft.keepass.model.RegisterInfo
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.utils.LOCK_ACTION
-import com.kunzisoft.keepass.utils.UriUtil
@RequiresApi(api = Build.VERSION_CODES.O)
class AutofillLauncherActivity : AppCompatActivity() {
@@ -84,9 +85,9 @@ class AutofillLauncherActivity : AppCompatActivity() {
private fun launchSelection(searchInfo: SearchInfo) {
// Pass extra for Autofill (EXTRA_ASSIST_STRUCTURE)
- val assistStructure = AutofillHelper.retrieveAssistStructure(intent)
+ val autofillComponent = AutofillHelper.retrieveAutofillComponent(intent)
- if (assistStructure == null) {
+ if (autofillComponent == null) {
setResult(Activity.RESULT_CANCELED)
finish()
} else if (!KeeAutofillService.autofillAllowedFor(searchInfo.applicationId,
@@ -105,21 +106,21 @@ class AutofillLauncherActivity : AppCompatActivity() {
searchInfo,
{ items ->
// Items found
- AutofillHelper.buildResponse(this, items)
+ AutofillHelper.buildResponseAndSetResult(this, items)
finish()
},
{
// Show the database UI to select the entry
GroupActivity.launchForAutofillResult(this,
readOnly,
- assistStructure,
+ autofillComponent,
searchInfo,
false)
},
{
// If database not open
FileDatabaseSelectActivity.launchForAutofillResult(this,
- assistStructure,
+ autofillComponent,
searchInfo)
}
)
@@ -196,7 +197,8 @@ class AutofillLauncherActivity : AppCompatActivity() {
private const val KEY_REGISTER_INFO = "KEY_REGISTER_INFO"
fun getAuthIntentSenderForSelection(context: Context,
- searchInfo: SearchInfo? = null): IntentSender {
+ searchInfo: SearchInfo? = null,
+ inlineSuggestionsRequest: InlineSuggestionsRequest? = null): IntentSender {
return PendingIntent.getActivity(context, 0,
// Doesn't work with Parcelable (don't know why?)
Intent(context, AutofillLauncherActivity::class.java).apply {
@@ -205,6 +207,11 @@ class AutofillLauncherActivity : AppCompatActivity() {
putExtra(KEY_SEARCH_DOMAIN, it.webDomain)
putExtra(KEY_SEARCH_SCHEME, it.webScheme)
}
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ inlineSuggestionsRequest?.let {
+ putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it)
+ }
+ }
},
PendingIntent.FLAG_CANCEL_CURRENT).intentSender
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt
index ef526b953..10005f94a 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt
@@ -39,6 +39,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.appbar.CollapsingToolbarLayout
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
+import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.activities.lock.LockingActivity
import com.kunzisoft.keepass.activities.lock.resetAppTimeoutWhenViewFocusedOrChanged
import com.kunzisoft.keepass.database.element.Attachment
@@ -53,6 +54,7 @@ import com.kunzisoft.keepass.model.StreamDirection
import com.kunzisoft.keepass.notifications.AttachmentFileNotificationService
import com.kunzisoft.keepass.notifications.ClipboardEntryNotificationService
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_ENTRY_HISTORY
+import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RELOAD_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RESTORE_ENTRY_HISTORY
import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.tasks.AttachmentFileBinderManager
@@ -151,6 +153,10 @@ class EntryActivity : LockingActivity() {
if (result.isSuccess)
finish()
}
+ ACTION_DATABASE_RELOAD_TASK -> {
+ // Close the current activity
+ finish()
+ }
}
coordinatorLayout?.showActionError(result)
}
@@ -408,6 +414,9 @@ class EntryActivity : LockingActivity() {
menu.findItem(R.id.menu_save_database)?.isVisible = false
menu.findItem(R.id.menu_edit)?.isVisible = false
}
+ if (mSpecialMode != SpecialMode.DEFAULT) {
+ menu.findItem(R.id.menu_reload_database)?.isVisible = false
+ }
val gotoUrl = menu.findItem(R.id.menu_goto_url)
gotoUrl?.apply {
@@ -501,6 +510,9 @@ class EntryActivity : LockingActivity() {
R.id.menu_save_database -> {
mProgressDatabaseTaskProvider?.startDatabaseSave(!mReadOnly)
}
+ R.id.menu_reload_database -> {
+ mProgressDatabaseTaskProvider?.startDatabaseReload(false)
+ }
android.R.id.home -> finish() // close this activity and return to preview activity (if there is any)
}
return super.onOptionsItemSelected(item)
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt
index 5134f1ffb..90909200d 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt
@@ -21,7 +21,6 @@ package com.kunzisoft.keepass.activities
import android.app.Activity
import android.app.DatePickerDialog
import android.app.TimePickerDialog
-import android.app.assist.AssistStructure
import android.content.Context
import android.content.Intent
import android.net.Uri
@@ -49,6 +48,7 @@ import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.SelectFileHelper
import com.kunzisoft.keepass.activities.lock.LockingActivity
import com.kunzisoft.keepass.activities.lock.resetAppTimeoutWhenViewFocusedOrChanged
+import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.database.element.*
import com.kunzisoft.keepass.database.element.icon.IconImage
@@ -61,6 +61,7 @@ import com.kunzisoft.keepass.notifications.AttachmentFileNotificationService
import com.kunzisoft.keepass.notifications.ClipboardEntryNotificationService
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_ENTRY_TASK
+import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RELOAD_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
import com.kunzisoft.keepass.notifications.KeyboardEntryNotificationService
import com.kunzisoft.keepass.otp.OtpElement
@@ -335,6 +336,10 @@ class EntryEditActivity : LockingActivity(),
Log.e(TAG, "Unable to retrieve entry after database action", e)
}
}
+ ACTION_DATABASE_RELOAD_TASK -> {
+ // Close the current activity
+ finish()
+ }
}
coordinatorLayout?.showActionError(result)
}
@@ -361,7 +366,7 @@ class EntryEditActivity : LockingActivity(),
// Build Autofill response with the entry selected
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
mDatabase?.let { database ->
- AutofillHelper.buildResponse(this@EntryEditActivity,
+ AutofillHelper.buildResponseAndSetResult(this@EntryEditActivity,
entry.getEntryInfo(database))
}
}
@@ -610,13 +615,7 @@ class EntryEditActivity : LockingActivity(),
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
-
- val inflater = menuInflater
- inflater.inflate(R.menu.database, menu)
- // Save database not needed here
- menu.findItem(R.id.menu_save_database)?.isVisible = false
- MenuUtil.contributionMenuInflater(inflater, menu)
-
+ MenuUtil.contributionMenuInflater(menuInflater, menu)
return true
}
@@ -673,9 +672,6 @@ class EntryEditActivity : LockingActivity(),
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
- R.id.menu_save_database -> {
- mProgressDatabaseTaskProvider?.startDatabaseSave(!mReadOnly)
- }
R.id.menu_contribute -> {
MenuUtil.onContributionItemSelected(this)
return true
@@ -909,7 +905,7 @@ class EntryEditActivity : LockingActivity(),
*/
@RequiresApi(api = Build.VERSION_CODES.O)
fun launchForAutofillResult(activity: Activity,
- assistStructure: AssistStructure,
+ autofillComponent: AutofillComponent,
group: Group,
searchInfo: SearchInfo? = null) {
if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) {
@@ -917,7 +913,7 @@ class EntryEditActivity : LockingActivity(),
intent.putExtra(KEY_PARENT, group.nodeId)
AutofillHelper.startActivityForAutofillResult(activity,
intent,
- assistStructure,
+ autofillComponent,
searchInfo)
}
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt
index 17bcea82d..a4d313091 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt
@@ -20,7 +20,6 @@
package com.kunzisoft.keepass.activities
import android.app.Activity
-import android.app.assist.AssistStructure
import android.content.Context
import android.content.Intent
import android.net.Uri
@@ -48,6 +47,7 @@ import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.activities.selection.SpecialModeActivity
import com.kunzisoft.keepass.adapters.FileDatabaseHistoryAdapter
import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
+import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.database.action.ProgressDatabaseTaskProvider
import com.kunzisoft.keepass.database.element.Database
@@ -501,11 +501,11 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
@RequiresApi(api = Build.VERSION_CODES.O)
fun launchForAutofillResult(activity: Activity,
- assistStructure: AssistStructure,
+ autofillComponent: AutofillComponent,
searchInfo: SearchInfo? = null) {
AutofillHelper.startActivityForAutofillResult(activity,
Intent(activity, FileDatabaseSelectActivity::class.java),
- assistStructure,
+ autofillComponent,
searchInfo)
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt
index 28fa61cb8..2ea93ef09 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt
@@ -20,7 +20,6 @@ package com.kunzisoft.keepass.activities
import android.app.Activity
import android.app.SearchManager
-import android.app.assist.AssistStructure
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -52,6 +51,7 @@ import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.activities.lock.LockingActivity
import com.kunzisoft.keepass.activities.lock.resetAppTimeoutWhenViewFocusedOrChanged
import com.kunzisoft.keepass.adapters.SearchEntryCursorAdapter
+import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.element.Entry
@@ -70,6 +70,7 @@ import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Compa
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_GROUP_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_NODES_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_MOVE_NODES_TASK
+import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RELOAD_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_GROUP_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.NEW_NODES_KEY
@@ -228,10 +229,10 @@ class GroupActivity : LockingActivity(),
currentGroup, searchInfo)
onLaunchActivitySpecialMode()
},
- { searchInfo, assistStructure ->
+ { searchInfo, autofillComponent ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
EntryEditActivity.launchForAutofillResult(this@GroupActivity,
- assistStructure,
+ autofillComponent,
currentGroup, searchInfo)
onLaunchActivitySpecialMode()
} else {
@@ -342,6 +343,12 @@ class GroupActivity : LockingActivity(),
}
}
}
+ ACTION_DATABASE_RELOAD_TASK -> {
+ // Reload the current activity
+ startActivity(intent)
+ finish()
+ overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
+ }
}
coordinatorLayout?.showActionError(result)
@@ -665,7 +672,7 @@ class GroupActivity : LockingActivity(),
// Build response with the entry selected
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && mDatabase != null) {
mDatabase?.let { database ->
- AutofillHelper.buildResponse(this,
+ AutofillHelper.buildResponseAndSetResult(this,
entry.getEntryInfo(database))
}
}
@@ -878,6 +885,8 @@ class GroupActivity : LockingActivity(),
}
if (mSpecialMode == SpecialMode.DEFAULT) {
MenuUtil.defaultMenuInflater(inflater, menu)
+ } else {
+ menu.findItem(R.id.menu_reload_database)?.isVisible = false
}
// Menu for recycle bin
@@ -1003,6 +1012,10 @@ class GroupActivity : LockingActivity(),
mProgressDatabaseTaskProvider?.startDatabaseSave(!mReadOnly)
return true
}
+ R.id.menu_reload_database -> {
+ mProgressDatabaseTaskProvider?.startDatabaseReload(false)
+ return true
+ }
R.id.menu_empty_recycle_bin -> {
mCurrentGroup?.getChildren()?.let { listChildren ->
// Automatically delete all elements
@@ -1310,14 +1323,14 @@ class GroupActivity : LockingActivity(),
@RequiresApi(api = Build.VERSION_CODES.O)
fun launchForAutofillResult(activity: Activity,
readOnly: Boolean,
- assistStructure: AssistStructure,
+ autofillComponent: AutofillComponent,
searchInfo: SearchInfo? = null,
autoSearch: Boolean = false) {
checkTimeAndBuildIntent(activity, null, readOnly) { intent ->
intent.putExtra(AUTO_SEARCH_KEY, autoSearch)
AutofillHelper.startActivityForAutofillResult(activity,
intent,
- assistStructure,
+ autofillComponent,
searchInfo)
}
}
@@ -1434,21 +1447,21 @@ class GroupActivity : LockingActivity(),
}
)
},
- { searchInfo, assistStructure ->
+ { searchInfo, autofillComponent ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
SearchHelper.checkAutoSearchInfo(activity,
Database.getInstance(),
searchInfo,
{ items ->
// Response is build
- AutofillHelper.buildResponse(activity, items)
+ AutofillHelper.buildResponseAndSetResult(activity, items)
onValidateSpecialMode()
},
{
// Here no search info found, disable auto search
GroupActivity.launchForAutofillResult(activity,
readOnly,
- assistStructure,
+ autofillComponent,
searchInfo,
false)
onLaunchActivitySpecialMode()
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/PasswordActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/PasswordActivity.kt
index b2b491638..fd514a1a7 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/PasswordActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/PasswordActivity.kt
@@ -20,7 +20,6 @@
package com.kunzisoft.keepass.activities
import android.app.Activity
-import android.app.assist.AssistStructure
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
@@ -49,6 +48,7 @@ import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.activities.lock.LockingActivity
import com.kunzisoft.keepass.activities.selection.SpecialModeActivity
import com.kunzisoft.keepass.app.database.CipherDatabaseEntity
+import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.biometric.AdvancedUnlockFragment
import com.kunzisoft.keepass.database.action.ProgressDatabaseTaskProvider
@@ -720,7 +720,7 @@ open class PasswordActivity : SpecialModeActivity(), AdvancedUnlockFragment.Buil
when (resultCode) {
LockingActivity.RESULT_EXIT_LOCK -> {
clearCredentialsViews()
- Database.getInstance().closeAndClear(UriUtil.getBinaryDir(this))
+ Database.getInstance().clearAndClose(UriUtil.getBinaryDir(this))
}
Activity.RESULT_CANCELED -> {
clearCredentialsViews()
@@ -838,13 +838,13 @@ open class PasswordActivity : SpecialModeActivity(), AdvancedUnlockFragment.Buil
fun launchForAutofillResult(activity: Activity,
databaseFile: Uri,
keyFile: Uri?,
- assistStructure: AssistStructure,
+ autofillComponent: AutofillComponent,
searchInfo: SearchInfo?) {
buildAndLaunchIntent(activity, databaseFile, keyFile) { intent ->
AutofillHelper.startActivityForAutofillResult(
activity,
intent,
- assistStructure,
+ autofillComponent,
searchInfo)
}
}
@@ -902,11 +902,11 @@ open class PasswordActivity : SpecialModeActivity(), AdvancedUnlockFragment.Buil
searchInfo)
onLaunchActivitySpecialMode()
},
- { searchInfo, assistStructure -> // Autofill Selection Action
+ { searchInfo, autofillComponent -> // Autofill Selection Action
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PasswordActivity.launchForAutofillResult(activity,
databaseUri, keyFile,
- assistStructure,
+ autofillComponent,
searchInfo)
onLaunchActivitySpecialMode()
} else {
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseChangedDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseChangedDialogFragment.kt
new file mode 100644
index 000000000..6a83f0df7
--- /dev/null
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseChangedDialogFragment.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2020 Jeremy Jamet / Kunzisoft.
+ *
+ * This file is part of KeePassDX.
+ *
+ * KeePassDX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * KeePassDX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with KeePassDX. If not, see .
+ *
+ */
+package com.kunzisoft.keepass.activities.dialogs
+
+import android.app.Dialog
+import android.os.Bundle
+import android.text.SpannableStringBuilder
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import com.kunzisoft.keepass.R
+import com.kunzisoft.keepass.model.SnapFileDatabaseInfo
+
+
+class DatabaseChangedDialogFragment : DialogFragment() {
+
+ var actionDatabaseListener: ActionDatabaseChangedListener? = null
+
+ override fun onPause() {
+ super.onPause()
+ actionDatabaseListener = null
+ this.dismiss()
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ activity?.let { activity ->
+
+ val oldSnapFileDatabaseInfo: SnapFileDatabaseInfo? = arguments?.getParcelable(OLD_FILE_DATABASE_INFO)
+ val newSnapFileDatabaseInfo: SnapFileDatabaseInfo? = arguments?.getParcelable(NEW_FILE_DATABASE_INFO)
+
+ if (oldSnapFileDatabaseInfo != null && newSnapFileDatabaseInfo != null) {
+ // Use the Builder class for convenient dialog construction
+ val builder = AlertDialog.Builder(activity)
+
+ val stringBuilder = SpannableStringBuilder()
+ if (newSnapFileDatabaseInfo.exists) {
+ stringBuilder.append(getString(R.string.warning_database_info_changed))
+ stringBuilder.append("\n\n" +oldSnapFileDatabaseInfo.toString(activity)
+ + "\n→\n" +
+ newSnapFileDatabaseInfo.toString(activity) + "\n\n")
+ stringBuilder.append(getString(R.string.warning_database_info_changed_options))
+ } else {
+ stringBuilder.append(getString(R.string.warning_database_revoked))
+ }
+ builder.setMessage(stringBuilder)
+ builder.setPositiveButton(android.R.string.ok) { _, _ ->
+ actionDatabaseListener?.validateDatabaseChanged()
+ }
+ return builder.create()
+ }
+ }
+ return super.onCreateDialog(savedInstanceState)
+ }
+
+ interface ActionDatabaseChangedListener {
+ fun validateDatabaseChanged()
+ }
+
+ companion object {
+
+ const val DATABASE_CHANGED_DIALOG_TAG = "databaseChangedDialogFragment"
+ private const val OLD_FILE_DATABASE_INFO = "OLD_FILE_DATABASE_INFO"
+ private const val NEW_FILE_DATABASE_INFO = "NEW_FILE_DATABASE_INFO"
+
+ fun getInstance(oldSnapFileDatabaseInfo: SnapFileDatabaseInfo,
+ newSnapFileDatabaseInfo: SnapFileDatabaseInfo)
+ : DatabaseChangedDialogFragment {
+ val fragment = DatabaseChangedDialogFragment()
+ fragment.arguments = Bundle().apply {
+ putParcelable(OLD_FILE_DATABASE_INFO, oldSnapFileDatabaseInfo)
+ putParcelable(NEW_FILE_DATABASE_INFO, newSnapFileDatabaseInfo)
+ }
+ return fragment
+ }
+ }
+}
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt b/app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt
index 23d9b3d51..c9ef7e7e7 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt
@@ -19,10 +19,10 @@
*/
package com.kunzisoft.keepass.activities.helpers
-import android.app.assist.AssistStructure
import android.content.Context
import android.content.Intent
import android.os.Build
+import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.model.RegisterInfo
import com.kunzisoft.keepass.model.SearchInfo
@@ -106,7 +106,7 @@ object EntrySelectionHelper {
fun retrieveSpecialModeFromIntent(intent: Intent): SpecialMode {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- if (AutofillHelper.retrieveAssistStructure(intent) != null)
+ if (AutofillHelper.retrieveAutofillComponent(intent) != null)
return SpecialMode.SELECTION
}
return intent.getSerializableExtra(KEY_SPECIAL_MODE) as SpecialMode?
@@ -119,7 +119,7 @@ object EntrySelectionHelper {
fun retrieveTypeModeFromIntent(intent: Intent): TypeMode {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- if (AutofillHelper.retrieveAssistStructure(intent) != null)
+ if (AutofillHelper.retrieveAutofillComponent(intent) != null)
return TypeMode.AUTOFILL
}
return intent.getSerializableExtra(KEY_TYPE_MODE) as TypeMode? ?: TypeMode.DEFAULT
@@ -136,7 +136,7 @@ object EntrySelectionHelper {
saveAction: (searchInfo: SearchInfo) -> Unit,
keyboardSelectionAction: (searchInfo: SearchInfo?) -> Unit,
autofillSelectionAction: (searchInfo: SearchInfo?,
- assistStructure: AssistStructure) -> Unit,
+ autofillComponent: AutofillComponent) -> Unit,
autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) {
when (retrieveSpecialModeFromIntent(intent)) {
@@ -167,14 +167,14 @@ object EntrySelectionHelper {
}
SpecialMode.SELECTION -> {
val searchInfo: SearchInfo? = retrieveSearchInfoFromIntent(intent)
- var assistStructureInit = false
+ var autofillComponentInit = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- AutofillHelper.retrieveAssistStructure(intent)?.let { assistStructure ->
- autofillSelectionAction.invoke(searchInfo, assistStructure)
- assistStructureInit = true
+ AutofillHelper.retrieveAutofillComponent(intent)?.let { autofillComponent ->
+ autofillSelectionAction.invoke(searchInfo, autofillComponent)
+ autofillComponentInit = true
}
}
- if (!assistStructureInit) {
+ if (!autofillComponentInit) {
if (intent.getSerializableExtra(KEY_SPECIAL_MODE) != null) {
when (retrieveTypeModeFromIntent(intent)) {
TypeMode.DEFAULT -> {
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/selection/SpecialModeActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/selection/SpecialModeActivity.kt
index 91c8e5b2f..bc9ce9365 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/selection/SpecialModeActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/selection/SpecialModeActivity.kt
@@ -18,7 +18,7 @@ import com.kunzisoft.keepass.view.SpecialModeView
abstract class SpecialModeActivity : StylishActivity() {
protected var mSpecialMode: SpecialMode = SpecialMode.DEFAULT
- protected var mTypeMode: TypeMode = TypeMode.DEFAULT
+ private var mTypeMode: TypeMode = TypeMode.DEFAULT
private var mSpecialModeView: SpecialModeView? = null
diff --git a/app/src/main/java/com/kunzisoft/keepass/app/App.kt b/app/src/main/java/com/kunzisoft/keepass/app/App.kt
index 8094648b6..5207b1e51 100644
--- a/app/src/main/java/com/kunzisoft/keepass/app/App.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/app/App.kt
@@ -34,7 +34,7 @@ class App : MultiDexApplication() {
}
override fun onTerminate() {
- Database.getInstance().closeAndClear(UriUtil.getBinaryDir(this))
+ Database.getInstance().clearAndClose(UriUtil.getBinaryDir(this))
super.onTerminate()
}
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/app/database/FileDatabaseHistoryAction.kt b/app/src/main/java/com/kunzisoft/keepass/app/database/FileDatabaseHistoryAction.kt
index 6b8c22781..9c7242fb9 100644
--- a/app/src/main/java/com/kunzisoft/keepass/app/database/FileDatabaseHistoryAction.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/app/database/FileDatabaseHistoryAction.kt
@@ -47,7 +47,7 @@ class FileDatabaseHistoryAction(private val applicationContext: Context) {
UriUtil.decode(fileDatabaseHistoryEntity?.databaseUri),
fileDatabaseInfo.retrieveDatabaseAlias(fileDatabaseHistoryEntity?.databaseAlias ?: ""),
fileDatabaseInfo.exists,
- fileDatabaseInfo.getModificationString(),
+ fileDatabaseInfo.getLastModificationString(),
fileDatabaseInfo.getSizeString()
)
},
@@ -90,7 +90,7 @@ class FileDatabaseHistoryAction(private val applicationContext: Context) {
UriUtil.decode(fileDatabaseHistoryEntity.databaseUri),
fileDatabaseInfo.retrieveDatabaseAlias(fileDatabaseHistoryEntity.databaseAlias),
fileDatabaseInfo.exists,
- fileDatabaseInfo.getModificationString(),
+ fileDatabaseInfo.getLastModificationString(),
fileDatabaseInfo.getSizeString()
)
)
@@ -152,7 +152,7 @@ class FileDatabaseHistoryAction(private val applicationContext: Context) {
UriUtil.decode(fileDatabaseHistory.databaseUri),
fileDatabaseInfo.retrieveDatabaseAlias(fileDatabaseHistory.databaseAlias),
fileDatabaseInfo.exists,
- fileDatabaseInfo.getModificationString(),
+ fileDatabaseInfo.getLastModificationString(),
fileDatabaseInfo.getSizeString()
)
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt b/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt
new file mode 100644
index 000000000..2043b9705
--- /dev/null
+++ b/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt
@@ -0,0 +1,7 @@
+package com.kunzisoft.keepass.autofill
+
+import android.app.assist.AssistStructure
+import android.view.inputmethod.InlineSuggestionsRequest
+
+data class AutofillComponent(val assistStructure: AssistStructure,
+ val inlineSuggestionsRequest: InlineSuggestionsRequest?)
\ No newline at end of file
diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt b/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt
index 5a1a64d1d..f169c2655 100644
--- a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt
@@ -19,18 +19,27 @@
*/
package com.kunzisoft.keepass.autofill
+import android.annotation.SuppressLint
import android.app.Activity
+import android.app.PendingIntent
import android.app.assist.AssistStructure
import android.content.Context
import android.content.Intent
+import android.graphics.BlendMode
+import android.graphics.drawable.Icon
import android.os.Build
import android.service.autofill.Dataset
import android.service.autofill.FillResponse
+import android.service.autofill.InlinePresentation
import android.util.Log
import android.view.autofill.AutofillManager
import android.view.autofill.AutofillValue
+import android.view.inputmethod.InlineSuggestionsRequest
import android.widget.RemoteViews
+import android.widget.Toast
import androidx.annotation.RequiresApi
+import androidx.autofill.inline.UiVersions
+import androidx.autofill.inline.v1.InlineSuggestionUi
import androidx.core.content.ContextCompat
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
@@ -38,8 +47,11 @@ import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.element.icon.IconImage
import com.kunzisoft.keepass.icons.assignDatabaseIcon
+import com.kunzisoft.keepass.icons.createIconFromDatabaseIcon
import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.SearchInfo
+import com.kunzisoft.keepass.settings.AutofillSettingsActivity
+import com.kunzisoft.keepass.settings.PreferencesUtil
@RequiresApi(api = Build.VERSION_CODES.O)
@@ -47,11 +59,13 @@ object AutofillHelper {
private const val AUTOFILL_RESPONSE_REQUEST_CODE = 8165
- private const val ASSIST_STRUCTURE = AutofillManager.EXTRA_ASSIST_STRUCTURE
+ private const val EXTRA_ASSIST_STRUCTURE = AutofillManager.EXTRA_ASSIST_STRUCTURE
+ const val EXTRA_INLINE_SUGGESTIONS_REQUEST = "com.kunzisoft.keepass.autofill.INLINE_SUGGESTIONS_REQUEST"
- fun retrieveAssistStructure(intent: Intent?): AssistStructure? {
- intent?.let {
- return it.getParcelableExtra(ASSIST_STRUCTURE)
+ fun retrieveAutofillComponent(intent: Intent?): AutofillComponent? {
+ intent?.getParcelableExtra(EXTRA_ASSIST_STRUCTURE)?.let { assistStructure ->
+ return AutofillComponent(assistStructure,
+ intent.getParcelableExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST))
}
return null
}
@@ -68,26 +82,10 @@ object AutofillHelper {
return ""
}
- internal fun addHeader(responseBuilder: FillResponse.Builder,
- packageName: String,
- webDomain: String?,
- applicationId: String?) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- if (webDomain != null) {
- responseBuilder.setHeader(RemoteViews(packageName, R.layout.item_autofill_web_domain).apply {
- setTextViewText(R.id.autofill_web_domain_text, webDomain)
- })
- } else if (applicationId != null) {
- responseBuilder.setHeader(RemoteViews(packageName, R.layout.item_autofill_app_id).apply {
- setTextViewText(R.id.autofill_app_id_text, applicationId)
- })
- }
- }
- }
-
- internal fun buildDataset(context: Context,
+ private fun buildDataset(context: Context,
entryInfo: EntryInfo,
- struct: StructureParser.Result): Dataset? {
+ struct: StructureParser.Result,
+ inlinePresentation: InlinePresentation?): Dataset? {
val title = makeEntryTitle(entryInfo)
val views = newRemoteViews(context, title, entryInfo.icon)
val builder = Dataset.Builder(views)
@@ -100,6 +98,12 @@ object AutofillHelper {
builder.setValue(password, AutofillValue.forText(entryInfo.password))
}
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ inlinePresentation?.let {
+ builder.setInlinePresentation(it)
+ }
+ }
+
return try {
builder.build()
} catch (e: IllegalArgumentException) {
@@ -108,44 +112,116 @@ object AutofillHelper {
}
}
+ @RequiresApi(Build.VERSION_CODES.R)
+ @SuppressLint("RestrictedApi")
+ private fun buildInlinePresentationForEntry(context: Context,
+ inlineSuggestionsRequest: InlineSuggestionsRequest,
+ positionItem: Int,
+ entryInfo: EntryInfo): InlinePresentation? {
+ val inlinePresentationSpecs = inlineSuggestionsRequest.inlinePresentationSpecs
+ val maxSuggestion = inlineSuggestionsRequest.maxSuggestionCount
+
+ if (positionItem <= maxSuggestion-1
+ && inlinePresentationSpecs.size > positionItem) {
+ val inlinePresentationSpec = inlinePresentationSpecs[positionItem]
+
+ // Make sure that the IME spec claims support for v1 UI template.
+ val imeStyle = inlinePresentationSpec.style
+ if (!UiVersions.getVersions(imeStyle).contains(UiVersions.INLINE_UI_VERSION_1))
+ return null
+
+ // Build the content for IME UI
+ val pendingIntent = PendingIntent.getActivity(context,
+ 0,
+ Intent(context, AutofillSettingsActivity::class.java),
+ 0)
+ return InlinePresentation(
+ InlineSuggestionUi.newContentBuilder(pendingIntent).apply {
+ setContentDescription(context.getString(R.string.autofill_sign_in_prompt))
+ setTitle(entryInfo.title)
+ setSubtitle(entryInfo.username)
+ setStartIcon(Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply {
+ setTintBlendMode(BlendMode.DST)
+ })
+ buildIconFromEntry(context, entryInfo)?.let { icon ->
+ setEndIcon(icon.apply {
+ setTintBlendMode(BlendMode.DST)
+ })
+ }
+ }.build().slice, inlinePresentationSpec, false)
+ }
+ return null
+ }
+
+ fun buildResponse(context: Context,
+ entriesInfo: List,
+ parseResult: StructureParser.Result,
+ inlineSuggestionsRequest: InlineSuggestionsRequest?): FillResponse {
+ val responseBuilder = FillResponse.Builder()
+ // Add Header
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ val packageName = context.packageName
+ parseResult.webDomain?.let { webDomain ->
+ responseBuilder.setHeader(RemoteViews(packageName, R.layout.item_autofill_web_domain).apply {
+ setTextViewText(R.id.autofill_web_domain_text, webDomain)
+ })
+ } ?: kotlin.run {
+ parseResult.applicationId?.let { applicationId ->
+ responseBuilder.setHeader(RemoteViews(packageName, R.layout.item_autofill_app_id).apply {
+ setTextViewText(R.id.autofill_app_id_text, applicationId)
+ })
+ }
+ }
+ }
+ // Add inline suggestion for new IME and dataset
+ entriesInfo.forEachIndexed { index, entryInfo ->
+ val inlinePresentation = inlineSuggestionsRequest?.let {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ buildInlinePresentationForEntry(context, inlineSuggestionsRequest, index, entryInfo)
+ } else {
+ null
+ }
+ }
+ responseBuilder.addDataset(buildDataset(context, entryInfo, parseResult, inlinePresentation))
+ }
+ return responseBuilder.build()
+ }
+
/**
* Build the Autofill response for one entry
*/
- fun buildResponse(activity: Activity, entryInfo: EntryInfo) {
- buildResponse(activity, ArrayList().apply { add(entryInfo) })
+ fun buildResponseAndSetResult(activity: Activity, entryInfo: EntryInfo) {
+ buildResponseAndSetResult(activity, ArrayList().apply { add(entryInfo) })
}
/**
* Build the Autofill response for many entry
*/
- fun buildResponse(activity: Activity, entriesInfo: List) {
+ fun buildResponseAndSetResult(activity: Activity, entriesInfo: List) {
if (entriesInfo.isEmpty()) {
activity.setResult(Activity.RESULT_CANCELED)
} else {
var setResultOk = false
- activity.intent?.extras?.let { extras ->
- if (extras.containsKey(ASSIST_STRUCTURE)) {
- activity.intent?.getParcelableExtra(ASSIST_STRUCTURE)?.let { structure ->
- StructureParser(structure).parse()?.let { result ->
- // New Response
- val responseBuilder = FillResponse.Builder()
- entriesInfo.forEach {
- responseBuilder.addDataset(buildDataset(activity, it, result))
- }
- val mReplyIntent = Intent()
- Log.d(activity.javaClass.name, "Successed Autofill auth.")
- mReplyIntent.putExtra(
- AutofillManager.EXTRA_AUTHENTICATION_RESULT,
- responseBuilder.build())
- setResultOk = true
- activity.setResult(Activity.RESULT_OK, mReplyIntent)
- }
+ activity.intent?.getParcelableExtra(EXTRA_ASSIST_STRUCTURE)?.let { structure ->
+ StructureParser(structure).parse()?.let { result ->
+ // New Response
+ val inlineSuggestionsRequest = activity.intent?.getParcelableExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST)
+ val response = buildResponse(activity, entriesInfo, result, inlineSuggestionsRequest)
+ if (inlineSuggestionsRequest != null) {
+ Toast.makeText(activity.applicationContext, R.string.autofill_inline_suggestions_keyboard, Toast.LENGTH_SHORT).show()
}
+ val mReplyIntent = Intent()
+ Log.d(activity.javaClass.name, "Successed Autofill auth.")
+ mReplyIntent.putExtra(
+ AutofillManager.EXTRA_AUTHENTICATION_RESULT,
+ response)
+ setResultOk = true
+ activity.setResult(Activity.RESULT_OK, mReplyIntent)
}
- if (!setResultOk) {
- Log.w(activity.javaClass.name, "Failed Autofill auth.")
- activity.setResult(Activity.RESULT_CANCELED)
- }
+ }
+ if (!setResultOk) {
+ Log.w(activity.javaClass.name, "Failed Autofill auth.")
+ activity.setResult(Activity.RESULT_CANCELED)
}
}
}
@@ -155,10 +231,16 @@ object AutofillHelper {
*/
fun startActivityForAutofillResult(activity: Activity,
intent: Intent,
- assistStructure: AssistStructure,
+ autofillComponent: AutofillComponent,
searchInfo: SearchInfo?) {
EntrySelectionHelper.addSpecialModeInIntent(intent, SpecialMode.SELECTION)
- intent.putExtra(ASSIST_STRUCTURE, assistStructure)
+ intent.putExtra(EXTRA_ASSIST_STRUCTURE, autofillComponent.assistStructure)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ && PreferencesUtil.isAutofillInlineSuggestionsEnable(activity)) {
+ autofillComponent.inlineSuggestionsRequest?.let {
+ intent.putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it)
+ }
+ }
EntrySelectionHelper.addSearchInfoInIntent(intent, searchInfo)
activity.startActivityForResult(intent, AUTOFILL_RESPONSE_REQUEST_CODE)
}
@@ -192,4 +274,11 @@ object AutofillHelper {
}
return presentation
}
+
+ private fun buildIconFromEntry(context: Context, entryInfo: EntryInfo): Icon? {
+ return createIconFromDatabaseIcon(context,
+ Database.getInstance().drawFactory,
+ entryInfo.icon,
+ ContextCompat.getColor(context, R.color.green))
+ }
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt b/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt
index 70801c75c..cccbe277e 100644
--- a/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt
@@ -19,37 +19,50 @@
*/
package com.kunzisoft.keepass.autofill
+import android.app.PendingIntent
+import android.content.Intent
+import android.graphics.BlendMode
+import android.graphics.drawable.Icon
import android.os.Build
import android.os.CancellationSignal
import android.service.autofill.*
import android.util.Log
import android.view.autofill.AutofillId
+import android.view.inputmethod.InlineSuggestionsRequest
import android.widget.RemoteViews
import androidx.annotation.RequiresApi
+import androidx.autofill.inline.UiVersions
+import androidx.autofill.inline.v1.InlineSuggestionUi
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.AutofillLauncherActivity
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.search.SearchHelper
import com.kunzisoft.keepass.model.RegisterInfo
import com.kunzisoft.keepass.model.SearchInfo
+import com.kunzisoft.keepass.settings.AutofillSettingsActivity
import com.kunzisoft.keepass.settings.PreferencesUtil
-import com.kunzisoft.keepass.utils.UriUtil
import java.util.concurrent.atomic.AtomicBoolean
+
@RequiresApi(api = Build.VERSION_CODES.O)
class KeeAutofillService : AutofillService() {
var applicationIdBlocklist: Set? = null
var webDomainBlocklist: Set? = null
var askToSaveData: Boolean = false
+ var autofillInlineSuggestionsEnabled: Boolean = false
private var mLock = AtomicBoolean()
override fun onCreate() {
super.onCreate()
+ getPreferences()
+ }
+ private fun getPreferences() {
applicationIdBlocklist = PreferencesUtil.applicationIdBlocklist(this)
webDomainBlocklist = PreferencesUtil.webDomainBlocklist(this)
- askToSaveData = PreferencesUtil.askToSaveAutofillData(this) // TODO apply when changed
+ askToSaveData = PreferencesUtil.askToSaveAutofillData(this)
+ autofillInlineSuggestionsEnabled = PreferencesUtil.isAutofillInlineSuggestionsEnable(this)
}
override fun onFillRequest(request: FillRequest,
@@ -75,7 +88,16 @@ class KeeAutofillService : AutofillService() {
}
SearchInfo.getConcreteWebDomain(this, searchInfo.webDomain) { webDomainWithoutSubDomain ->
searchInfo.webDomain = webDomainWithoutSubDomain
- launchSelection(searchInfo, parseResult, callback)
+ val inlineSuggestionsRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ && autofillInlineSuggestionsEnabled) {
+ request.inlineSuggestionsRequest
+ } else {
+ null
+ }
+ launchSelection(searchInfo,
+ parseResult,
+ inlineSuggestionsRequest,
+ callback)
}
}
}
@@ -84,39 +106,40 @@ class KeeAutofillService : AutofillService() {
private fun launchSelection(searchInfo: SearchInfo,
parseResult: StructureParser.Result,
+ inlineSuggestionsRequest: InlineSuggestionsRequest?,
callback: FillCallback) {
SearchHelper.checkAutoSearchInfo(this,
Database.getInstance(),
searchInfo,
{ items ->
- val responseBuilder = FillResponse.Builder()
- AutofillHelper.addHeader(responseBuilder, packageName,
- parseResult.webDomain, parseResult.applicationId)
- items.forEach {
- responseBuilder.addDataset(AutofillHelper.buildDataset(this, it, parseResult))
- }
- callback.onSuccess(responseBuilder.build())
+ callback.onSuccess(
+ AutofillHelper.buildResponse(this,
+ items, parseResult, inlineSuggestionsRequest)
+ )
},
{
// Show UI if no search result
- showUIForEntrySelection(parseResult, searchInfo, callback)
+ showUIForEntrySelection(parseResult,
+ searchInfo, inlineSuggestionsRequest, callback)
},
{
// Show UI if database not open
- showUIForEntrySelection(parseResult, searchInfo, callback)
+ showUIForEntrySelection(parseResult,
+ searchInfo, inlineSuggestionsRequest, callback)
}
)
}
private fun showUIForEntrySelection(parseResult: StructureParser.Result,
searchInfo: SearchInfo,
+ inlineSuggestionsRequest: InlineSuggestionsRequest?,
callback: FillCallback) {
parseResult.allAutofillIds().let { autofillIds ->
if (autofillIds.isNotEmpty()) {
// If the entire Autofill Response is authenticated, AuthActivity is used
// to generate Response.
val intentSender = AutofillLauncherActivity.getAuthIntentSenderForSelection(this,
- searchInfo)
+ searchInfo, inlineSuggestionsRequest)
val responseBuilder = FillResponse.Builder()
val remoteViewsUnlock: RemoteViews = if (!parseResult.webDomain.isNullOrEmpty()) {
RemoteViews(packageName, R.layout.item_autofill_unlock_web_domain).apply {
@@ -149,8 +172,45 @@ class KeeAutofillService : AutofillService() {
)
}
}
+
+ // Build inline presentation
+ var inlinePresentation: InlinePresentation? = null
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ && autofillInlineSuggestionsEnabled) {
+ inlineSuggestionsRequest?.let {
+ val inlinePresentationSpecs = inlineSuggestionsRequest.inlinePresentationSpecs
+ if (inlineSuggestionsRequest.maxSuggestionCount > 0
+ && inlinePresentationSpecs.size > 0) {
+ val inlinePresentationSpec = inlinePresentationSpecs[0]
+
+ // Make sure that the IME spec claims support for v1 UI template.
+ val imeStyle = inlinePresentationSpec.style
+ if (UiVersions.getVersions(imeStyle).contains(UiVersions.INLINE_UI_VERSION_1)) {
+ // Build the content for IME UI
+ inlinePresentation = InlinePresentation(
+ InlineSuggestionUi.newContentBuilder(
+ PendingIntent.getActivity(this,
+ 0,
+ Intent(this, AutofillSettingsActivity::class.java),
+ 0)
+ ).apply {
+ setContentDescription(getString(R.string.autofill_sign_in_prompt))
+ setTitle(getString(R.string.autofill_sign_in_prompt))
+ setStartIcon(Icon.createWithResource(this@KeeAutofillService, R.mipmap.ic_launcher_round).apply {
+ setTintBlendMode(BlendMode.DST)
+ })
+ }.build().slice, inlinePresentationSpec, false)
+ }
+ }
+ }
+ }
+
// Build response
- responseBuilder.setAuthentication(autofillIds, intentSender, remoteViewsUnlock)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ responseBuilder.setAuthentication(autofillIds, intentSender, remoteViewsUnlock, inlinePresentation)
+ } else {
+ responseBuilder.setAuthentication(autofillIds, intentSender, remoteViewsUnlock)
+ }
callback.onSuccess(responseBuilder.build())
}
}
@@ -190,6 +250,7 @@ class KeeAutofillService : AutofillService() {
override fun onConnected() {
Log.d(TAG, "onConnected")
+ getPreferences()
}
override fun onDisconnected() {
diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt b/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt
index dd6ee4cb4..c93cb4a36 100644
--- a/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt
@@ -33,7 +33,7 @@ import java.util.*
* Parse AssistStructure and guess username and password fields.
*/
@RequiresApi(api = Build.VERSION_CODES.O)
-internal class StructureParser(private val structure: AssistStructure) {
+class StructureParser(private val structure: AssistStructure) {
private var result: Result? = null
private var usernameNeeded = true
@@ -274,7 +274,7 @@ internal class StructureParser(private val structure: AssistStructure) {
}
@RequiresApi(api = Build.VERSION_CODES.O)
- internal class Result {
+ class Result {
var applicationId: String? = null
var webDomain: String? = null
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/action/CreateDatabaseRunnable.kt b/app/src/main/java/com/kunzisoft/keepass/database/action/CreateDatabaseRunnable.kt
index 7b7f90d0b..f2763f30a 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/action/CreateDatabaseRunnable.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/action/CreateDatabaseRunnable.kt
@@ -26,7 +26,6 @@ import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.utils.UriUtil
-import com.kunzisoft.keepass.utils.closeDatabase
class CreateDatabaseRunnable(context: Context,
private val mDatabase: Database,
@@ -47,7 +46,7 @@ class CreateDatabaseRunnable(context: Context,
createData(mDatabaseUri, databaseName, rootName)
}
} catch (e: Exception) {
- mDatabase.closeAndClear(UriUtil.getBinaryDir(context))
+ mDatabase.clearAndClose(UriUtil.getBinaryDir(context))
setError(e)
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/action/LoadDatabaseRunnable.kt b/app/src/main/java/com/kunzisoft/keepass/database/action/LoadDatabaseRunnable.kt
index 5f9d0ce04..f732f3779 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/action/LoadDatabaseRunnable.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/action/LoadDatabaseRunnable.kt
@@ -31,7 +31,6 @@ import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.tasks.ActionRunnable
import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
import com.kunzisoft.keepass.utils.UriUtil
-import com.kunzisoft.keepass.utils.closeDatabase
class LoadDatabaseRunnable(private val context: Context,
private val mDatabase: Database,
@@ -47,7 +46,7 @@ class LoadDatabaseRunnable(private val context: Context,
override fun onStartRun() {
// Clear before we load
- mDatabase.closeAndClear(UriUtil.getBinaryDir(context))
+ mDatabase.clearAndClose(UriUtil.getBinaryDir(context))
}
override fun onActionRun() {
@@ -59,9 +58,6 @@ class LoadDatabaseRunnable(private val context: Context,
mFixDuplicateUUID,
progressTaskUpdater)
}
- catch (e: DuplicateUuidDatabaseException) {
- setError(e)
- }
catch (e: LoadDatabaseException) {
setError(e)
}
@@ -83,7 +79,7 @@ class LoadDatabaseRunnable(private val context: Context,
// Register the current time to init the lock timer
PreferencesUtil.saveCurrentTime(context)
} else {
- mDatabase.closeAndClear(UriUtil.getBinaryDir(context))
+ mDatabase.clearAndClose(UriUtil.getBinaryDir(context))
}
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/action/ProgressDatabaseTaskProvider.kt b/app/src/main/java/com/kunzisoft/keepass/database/action/ProgressDatabaseTaskProvider.kt
index 279b40e27..534067403 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/action/ProgressDatabaseTaskProvider.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/action/ProgressDatabaseTaskProvider.kt
@@ -26,6 +26,8 @@ import android.net.Uri
import android.os.Bundle
import android.os.IBinder
import androidx.fragment.app.FragmentActivity
+import com.kunzisoft.keepass.activities.dialogs.DatabaseChangedDialogFragment
+import com.kunzisoft.keepass.activities.dialogs.DatabaseChangedDialogFragment.Companion.DATABASE_CHANGED_DIALOG_TAG
import com.kunzisoft.keepass.app.database.CipherDatabaseEntity
import com.kunzisoft.keepass.crypto.keyDerivation.KdfEngine
import com.kunzisoft.keepass.database.element.Entry
@@ -35,6 +37,7 @@ import com.kunzisoft.keepass.database.element.node.Node
import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.database.element.node.Type
import com.kunzisoft.keepass.database.element.security.EncryptionAlgorithm
+import com.kunzisoft.keepass.model.SnapFileDatabaseInfo
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_ASSIGN_PASSWORD_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_COPY_NODES_TASK
@@ -44,6 +47,7 @@ import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Compa
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_ENTRY_HISTORY
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_DELETE_NODES_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_LOAD_TASK
+import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RELOAD_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_MOVE_NODES_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_REMOVE_UNLINKED_DATA_TASK
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_RESTORE_ENTRY_HISTORY
@@ -84,6 +88,7 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
private var serviceConnection: ServiceConnection? = null
private var progressTaskDialogFragment: ProgressTaskDialogFragment? = null
+ private var databaseChangedDialogFragment: DatabaseChangedDialogFragment? = null
private val actionTaskListener = object: DatabaseTaskNotificationService.ActionTaskListener {
override fun onStartAction(titleId: Int?, messageId: Int?, warningId: Int?) {
@@ -101,6 +106,28 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
}
}
+ private val mActionDatabaseListener = object: DatabaseChangedDialogFragment.ActionDatabaseChangedListener {
+ override fun validateDatabaseChanged() {
+ mBinder?.getService()?.saveDatabaseInfo()
+ }
+ }
+
+ private var databaseInfoListener = object: DatabaseTaskNotificationService.DatabaseInfoListener {
+ override fun onDatabaseInfoChanged(previousDatabaseInfo: SnapFileDatabaseInfo,
+ newDatabaseInfo: SnapFileDatabaseInfo) {
+ if (databaseChangedDialogFragment == null) {
+ databaseChangedDialogFragment = activity.supportFragmentManager
+ .findFragmentByTag(DATABASE_CHANGED_DIALOG_TAG) as DatabaseChangedDialogFragment?
+ databaseChangedDialogFragment?.actionDatabaseListener = mActionDatabaseListener
+ }
+ if (progressTaskDialogFragment == null) {
+ databaseChangedDialogFragment = DatabaseChangedDialogFragment.getInstance(previousDatabaseInfo, newDatabaseInfo)
+ databaseChangedDialogFragment?.actionDatabaseListener = mActionDatabaseListener
+ databaseChangedDialogFragment?.show(activity.supportFragmentManager, DATABASE_CHANGED_DIALOG_TAG)
+ }
+ }
+ }
+
private fun startDialog(titleId: Int? = null,
messageId: Int? = null,
warningId: Int? = null) {
@@ -140,11 +167,14 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
override fun onServiceConnected(name: ComponentName?, serviceBinder: IBinder?) {
mBinder = (serviceBinder as DatabaseTaskNotificationService.ActionTaskBinder?)?.apply {
addActionTaskListener(actionTaskListener)
+ addDatabaseFileInfoListener(databaseInfoListener)
getService().checkAction()
+ getService().checkDatabaseInfo()
}
}
override fun onServiceDisconnected(name: ComponentName?) {
+ mBinder?.removeDatabaseFileInfoListener(databaseInfoListener)
mBinder?.removeActionTaskListener(actionTaskListener)
mBinder = null
}
@@ -206,6 +236,7 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
fun unregisterProgressTask() {
stopDialog()
+ mBinder?.removeDatabaseFileInfoListener(databaseInfoListener)
mBinder?.removeActionTaskListener(actionTaskListener)
mBinder = null
@@ -264,6 +295,13 @@ class ProgressDatabaseTaskProvider(private val activity: FragmentActivity) {
, ACTION_DATABASE_LOAD_TASK)
}
+ fun startDatabaseReload(fixDuplicateUuid: Boolean) {
+ start(Bundle().apply {
+ putBoolean(DatabaseTaskNotificationService.FIX_DUPLICATE_UUID_KEY, fixDuplicateUuid)
+ }
+ , ACTION_DATABASE_RELOAD_TASK)
+ }
+
fun startDatabaseAssignPassword(databaseUri: Uri,
masterPasswordChecked: Boolean,
masterPassword: String?,
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/action/ReloadDatabaseRunnable.kt b/app/src/main/java/com/kunzisoft/keepass/database/action/ReloadDatabaseRunnable.kt
new file mode 100644
index 000000000..8951d3615
--- /dev/null
+++ b/app/src/main/java/com/kunzisoft/keepass/database/action/ReloadDatabaseRunnable.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2019 Jeremy Jamet / Kunzisoft.
+ *
+ * This file is part of KeePassDX.
+ *
+ * KeePassDX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * KeePassDX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with KeePassDX. If not, see .
+ *
+ */
+package com.kunzisoft.keepass.database.action
+
+import android.content.Context
+import com.kunzisoft.keepass.database.element.Database
+import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException
+import com.kunzisoft.keepass.database.exception.LoadDatabaseException
+import com.kunzisoft.keepass.settings.PreferencesUtil
+import com.kunzisoft.keepass.tasks.ActionRunnable
+import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
+import com.kunzisoft.keepass.utils.UriUtil
+
+class ReloadDatabaseRunnable(private val context: Context,
+ private val mDatabase: Database,
+ private val progressTaskUpdater: ProgressTaskUpdater?,
+ private val mLoadDatabaseResult: ((Result) -> Unit)?)
+ : ActionRunnable() {
+
+ override fun onStartRun() {
+ // Clear before we load
+ mDatabase.clear(UriUtil.getBinaryDir(context))
+ }
+
+ override fun onActionRun() {
+ try {
+ mDatabase.reloadData(context.contentResolver,
+ UriUtil.getBinaryDir(context),
+ progressTaskUpdater)
+ }
+ catch (e: LoadDatabaseException) {
+ setError(e)
+ }
+
+ if (result.isSuccess) {
+ // Register the current time to init the lock timer
+ PreferencesUtil.saveCurrentTime(context)
+ } else {
+ mDatabase.clearAndClose(UriUtil.getBinaryDir(context))
+ }
+ }
+
+ override fun onFinishRun() {
+ mLoadDatabaseResult?.invoke(result)
+ }
+}
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 444b86ba6..cb4c3c977 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
@@ -31,10 +31,7 @@ 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.security.EncryptionAlgorithm
-import com.kunzisoft.keepass.database.exception.DatabaseOutputException
-import com.kunzisoft.keepass.database.exception.FileNotFoundDatabaseException
-import com.kunzisoft.keepass.database.exception.LoadDatabaseException
-import com.kunzisoft.keepass.database.exception.SignatureDatabaseException
+import com.kunzisoft.keepass.database.exception.*
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDB
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX
import com.kunzisoft.keepass.database.file.input.DatabaseInputKDB
@@ -330,29 +327,11 @@ class Database {
}
@Throws(LoadDatabaseException::class)
- fun loadData(uri: Uri, password: String?, keyfile: Uri?,
- readOnly: Boolean,
- contentResolver: ContentResolver,
- cacheDirectory: File,
- fixDuplicateUUID: Boolean,
- progressTaskUpdater: ProgressTaskUpdater?) {
-
- this.fileUri = uri
- isReadOnly = readOnly
- if (uri.scheme == "file") {
- val file = File(uri.path!!)
- isReadOnly = !file.canWrite()
- }
-
- // Pass KeyFile Uri as InputStreams
+ private fun readDatabaseStream(contentResolver: ContentResolver, uri: Uri,
+ openDatabaseKDB: (InputStream) -> DatabaseKDB,
+ openDatabaseKDBX: (InputStream) -> DatabaseKDBX) {
var databaseInputStream: InputStream? = null
- var keyFileInputStream: InputStream? = null
try {
- // Get keyFile inputStream
- keyfile?.let {
- keyFileInputStream = UriUtil.getUriInputStream(contentResolver, keyfile)
- }
-
// Load Data, pass Uris as InputStreams
val databaseStream = UriUtil.getUriInputStream(contentResolver, uri)
?: throw IOException("Database input stream cannot be retrieve")
@@ -374,22 +353,10 @@ class Database {
when {
// Header of database KDB
- DatabaseHeaderKDB.matchesHeader(sig1, sig2) -> setDatabaseKDB(DatabaseInputKDB(
- cacheDirectory,
- fixDuplicateUUID)
- .openDatabase(databaseInputStream,
- password,
- keyFileInputStream,
- progressTaskUpdater))
+ DatabaseHeaderKDB.matchesHeader(sig1, sig2) -> setDatabaseKDB(openDatabaseKDB(databaseInputStream))
// Header of database KDBX
- DatabaseHeaderKDBX.matchesHeader(sig1, sig2) -> setDatabaseKDBX(DatabaseInputKDBX(
- cacheDirectory,
- fixDuplicateUUID)
- .openDatabase(databaseInputStream,
- password,
- keyFileInputStream,
- progressTaskUpdater))
+ DatabaseHeaderKDBX.matchesHeader(sig1, sig2) -> setDatabaseKDBX(openDatabaseKDBX(databaseInputStream))
// Header not recognized
else -> throw SignatureDatabaseException()
@@ -397,14 +364,90 @@ class Database {
this.mSearchHelper = SearchHelper()
loaded = true
+ } catch (e: LoadDatabaseException) {
+ throw e
+ } finally {
+ databaseInputStream?.close()
+ }
+ }
+ @Throws(LoadDatabaseException::class)
+ fun loadData(uri: Uri, password: String?, keyfile: Uri?,
+ readOnly: Boolean,
+ contentResolver: ContentResolver,
+ cacheDirectory: File,
+ fixDuplicateUUID: Boolean,
+ progressTaskUpdater: ProgressTaskUpdater?) {
+
+ // Save database URI
+ this.fileUri = uri
+
+ // Check if the file is writable
+ this.isReadOnly = readOnly
+
+ // Pass KeyFile Uri as InputStreams
+ var keyFileInputStream: InputStream? = null
+ try {
+ // Get keyFile inputStream
+ keyfile?.let {
+ keyFileInputStream = UriUtil.getUriInputStream(contentResolver, keyfile)
+ }
+
+ // Read database stream for the first time
+ readDatabaseStream(contentResolver, uri,
+ { databaseInputStream ->
+ DatabaseInputKDB(cacheDirectory)
+ .openDatabase(databaseInputStream,
+ password,
+ keyFileInputStream,
+ progressTaskUpdater,
+ fixDuplicateUUID)
+ },
+ { databaseInputStream ->
+ DatabaseInputKDBX(cacheDirectory)
+ .openDatabase(databaseInputStream,
+ password,
+ keyFileInputStream,
+ progressTaskUpdater,
+ fixDuplicateUUID)
+ }
+ )
+ } catch (e: FileNotFoundException) {
+ Log.e(TAG, "Unable to load keyfile", e)
+ throw FileNotFoundDatabaseException()
} catch (e: LoadDatabaseException) {
throw e
} catch (e: Exception) {
- throw FileNotFoundDatabaseException()
+ throw LoadDatabaseException(e)
} finally {
keyFileInputStream?.close()
- databaseInputStream?.close()
+ }
+ }
+
+ @Throws(LoadDatabaseException::class)
+ fun reloadData(contentResolver: ContentResolver,
+ cacheDirectory: File,
+ progressTaskUpdater: ProgressTaskUpdater?) {
+
+ // Retrieve the stream from the old database URI
+ fileUri?.let { oldDatabaseUri ->
+ readDatabaseStream(contentResolver, oldDatabaseUri,
+ { databaseInputStream ->
+ DatabaseInputKDB(cacheDirectory)
+ .openDatabase(databaseInputStream,
+ masterKey,
+ progressTaskUpdater)
+ },
+ { databaseInputStream ->
+ DatabaseInputKDBX(cacheDirectory)
+ .openDatabase(databaseInputStream,
+ masterKey,
+ progressTaskUpdater)
+ }
+ )
+ } ?: run {
+ Log.e(TAG, "Database URI is null, database cannot be reloaded")
+ throw IODatabaseException()
}
}
@@ -531,7 +574,7 @@ class Database {
this.fileUri = uri
}
- fun closeAndClear(filesDirectory: File? = null) {
+ fun clear(filesDirectory: File? = null) {
drawFactory.clearCache()
// Delete the cache of the database if present
mDatabaseKDB?.clearCache()
@@ -544,7 +587,10 @@ class Database {
} catch (e: Exception) {
Log.e(TAG, "Unable to clear the directory cache.", e)
}
+ }
+ fun clearAndClose(filesDirectory: File? = null) {
+ clear(filesDirectory)
this.mDatabaseKDB = null
this.mDatabaseKDBX = null
this.fileUri = null
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseKDB.kt b/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseKDB.kt
index 3f1461d48..8678e84ce 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseKDB.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/element/database/DatabaseKDB.kt
@@ -163,10 +163,6 @@ class DatabaseKDB : DatabaseVersioned() {
finalKey = messageDigest.digest()
}
- override fun loadXmlKeyFile(keyInputStream: InputStream): ByteArray? {
- return null
- }
-
override fun createGroup(): GroupKDB {
return GroupKDB()
}
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 d960aa316..a5efbbd36 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
@@ -43,10 +43,12 @@ import com.kunzisoft.keepass.database.element.security.MemoryProtectionConfig
import com.kunzisoft.keepass.database.exception.UnknownKDF
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX.Companion.FILE_VERSION_32_3
import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX.Companion.FILE_VERSION_32_4
+import com.kunzisoft.keepass.utils.StringUtil.hexStringToByteArray
+import com.kunzisoft.keepass.utils.StringUtil.removeSpaceChars
+import com.kunzisoft.keepass.utils.StringUtil.toHexString
import com.kunzisoft.keepass.utils.UnsignedInt
import com.kunzisoft.keepass.utils.VariantDictionary
import org.w3c.dom.Node
-import org.w3c.dom.Text
import java.io.File
import java.io.IOException
import java.io.InputStream
@@ -180,7 +182,8 @@ class DatabaseKDBX : DatabaseVersioned {
when (oldCompression) {
CompressionAlgorithm.None -> {
when (newCompression) {
- CompressionAlgorithm.None -> {}
+ CompressionAlgorithm.None -> {
+ }
CompressionAlgorithm.GZip -> {
// Only in databaseV3.1, in databaseV4 the header is zipped during the save
if (kdbxVersion.toKotlinLong() < FILE_VERSION_32_4.toKotlinLong()) {
@@ -198,7 +201,8 @@ class DatabaseKDBX : DatabaseVersioned {
CompressionAlgorithm.None -> {
decompressAllBinaries()
}
- CompressionAlgorithm.GZip -> {}
+ CompressionAlgorithm.GZip -> {
+ }
}
}
}
@@ -384,30 +388,73 @@ class DatabaseKDBX : DatabaseVersioned {
val documentBuilder = documentBuilderFactory.newDocumentBuilder()
val doc = documentBuilder.parse(keyInputStream)
+ var xmlKeyFileVersion = 1F
+
val docElement = doc.documentElement
- if (docElement == null || !docElement.nodeName.equals(RootElementName, ignoreCase = true)) {
+ val keyFileChildNodes = docElement.childNodes
+ // Root node
+ if (docElement == null
+ || !docElement.nodeName.equals(XML_NODE_ROOT_NAME, ignoreCase = true)) {
return null
}
-
- val children = docElement.childNodes
- if (children.length < 2) {
+ if (keyFileChildNodes.length < 2)
return null
- }
-
- for (i in 0 until children.length) {
- val child = children.item(i)
-
- if (child.nodeName.equals(KeyElementName, ignoreCase = true)) {
- val keyChildren = child.childNodes
- for (j in 0 until keyChildren.length) {
- val keyChild = keyChildren.item(j)
- if (keyChild.nodeName.equals(KeyDataElementName, ignoreCase = true)) {
- val children2 = keyChild.childNodes
- for (k in 0 until children2.length) {
- val text = children2.item(k)
- if (text.nodeType == Node.TEXT_NODE) {
- val txt = text as Text
- return Base64.decode(txt.nodeValue, BASE_64_FLAG)
+ for (keyFileChildPosition in 0 until keyFileChildNodes.length) {
+ val keyFileChildNode = keyFileChildNodes.item(keyFileChildPosition)
+ //
+ if (keyFileChildNode.nodeName.equals(XML_NODE_META_NAME, ignoreCase = true)) {
+ val metaChildNodes = keyFileChildNode.childNodes
+ for (metaChildPosition in 0 until metaChildNodes.length) {
+ val metaChildNode = metaChildNodes.item(metaChildPosition)
+ //
+ if (metaChildNode.nodeName.equals(XML_NODE_VERSION_NAME, ignoreCase = true)) {
+ val versionChildNodes = metaChildNode.childNodes
+ for (versionChildPosition in 0 until versionChildNodes.length) {
+ val versionChildNode = versionChildNodes.item(versionChildPosition)
+ if (versionChildNode.nodeType == Node.TEXT_NODE) {
+ val versionText = versionChildNode.textContent.removeSpaceChars()
+ try {
+ xmlKeyFileVersion = versionText.toFloat()
+ Log.i(TAG, "Reading XML KeyFile version : $xmlKeyFileVersion")
+ } catch (e: Exception) {
+ Log.e(TAG, "XML Keyfile version cannot be read : $versionText")
+ }
+ }
+ }
+ }
+ }
+ }
+ //
+ if (keyFileChildNode.nodeName.equals(XML_NODE_KEY_NAME, ignoreCase = true)) {
+ val keyChildNodes = keyFileChildNode.childNodes
+ for (keyChildPosition in 0 until keyChildNodes.length) {
+ val keyChildNode = keyChildNodes.item(keyChildPosition)
+ //
+ if (keyChildNode.nodeName.equals(XML_NODE_DATA_NAME, ignoreCase = true)) {
+ var hashString : String? = null
+ if (keyChildNode.hasAttributes()) {
+ val dataNodeAttributes = keyChildNode.attributes
+ hashString = dataNodeAttributes
+ .getNamedItem(XML_ATTRIBUTE_DATA_HASH).nodeValue
+ }
+ val dataChildNodes = keyChildNode.childNodes
+ for (dataChildPosition in 0 until dataChildNodes.length) {
+ val dataChildNode = dataChildNodes.item(dataChildPosition)
+ if (dataChildNode.nodeType == Node.TEXT_NODE) {
+ val dataString = dataChildNode.textContent.removeSpaceChars()
+ when (xmlKeyFileVersion) {
+ 1F -> {
+ // No hash in KeyFile XML version 1
+ }
+ 2F -> {
+ if (hashString != null
+ && checkKeyFileHash(dataString, hashString))
+ Log.i(TAG, "Successful key file hash check.")
+ else
+ Log.e(TAG, "Unable to check the hash of the key file.")
+ }
+ }
+ return Base64.decode(dataString, BASE_64_FLAG)
}
}
}
@@ -417,10 +464,26 @@ class DatabaseKDBX : DatabaseVersioned {
} catch (e: Exception) {
return null
}
-
return null
}
+ private fun checkKeyFileHash(data: String, hash: String): Boolean {
+ val digest: MessageDigest?
+ var success = false
+ try {
+ digest = MessageDigest.getInstance("SHA-256")
+ digest?.reset()
+ // hexadecimal encoding of the first 4 bytes of the SHA-256 hash of the key.
+ val dataDigest = digest.digest(data.hexStringToByteArray())
+ .copyOfRange(0, 4)
+ .toHexString()
+ success = dataDigest == hash
+ } catch (e: NoSuchAlgorithmException) {
+ e.printStackTrace()
+ }
+ return success
+ }
+
override fun newGroupId(): NodeIdUUID {
var newId: NodeIdUUID
do {
@@ -634,11 +697,12 @@ class DatabaseKDBX : DatabaseVersioned {
private const val DEFAULT_HISTORY_MAX_ITEMS = 10 // -1 unlimited
private const val DEFAULT_HISTORY_MAX_SIZE = (6 * 1024 * 1024).toLong() // -1 unlimited
- private const val RootElementName = "KeyFile"
- //private const val MetaElementName = "Meta";
- //private const val VersionElementName = "Version";
- private const val KeyElementName = "Key"
- private const val KeyDataElementName = "Data"
+ private const val XML_NODE_ROOT_NAME = "KeyFile"
+ private const val XML_NODE_META_NAME = "Meta";
+ private const val XML_NODE_VERSION_NAME = "Version";
+ private const val XML_NODE_KEY_NAME = "Key"
+ private const val XML_NODE_DATA_NAME = "Data"
+ private const val XML_ATTRIBUTE_DATA_HASH = "Hash"
const val BASE_64_FLAG = Base64.NO_WRAP
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 dd2a0ea35..c2035f53d 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
@@ -27,7 +27,10 @@ import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.database.element.node.Type
import com.kunzisoft.keepass.database.element.security.EncryptionAlgorithm
import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException
-import java.io.*
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.UnsupportedEncodingException
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.util.*
@@ -124,42 +127,30 @@ abstract class DatabaseVersioned<
@Throws(IOException::class)
protected fun getFileKey(keyInputStream: InputStream): ByteArray {
- val keyByteArrayOutputStream = ByteArrayOutputStream()
- keyInputStream.copyTo(keyByteArrayOutputStream)
- val keyData = keyByteArrayOutputStream.toByteArray()
+ val keyData = keyInputStream.readBytes()
- val keyByteArrayInputStream = ByteArrayInputStream(keyData)
- val key = loadXmlKeyFile(keyByteArrayInputStream)
- if (key != null) {
- return key
+ // Check 32 bits key file
+ if (keyData.size == 32) {
+ return keyData
}
- when (keyData.size.toLong()) {
- 32L -> return keyData
- 64L -> try {
- return hexStringToByteArray(String(keyData))
- } catch (e: IndexOutOfBoundsException) {
- // Key is not base 64, treat it as binary data
- }
+ // Check XML key file
+ val xmlKeyByteArray = loadXmlKeyFile(ByteArrayInputStream(keyData))
+ if (xmlKeyByteArray != null) {
+ return xmlKeyByteArray
}
- val messageDigest: MessageDigest
+ // Hash file as binary data
try {
- messageDigest = MessageDigest.getInstance("SHA-256")
+ return MessageDigest.getInstance("SHA-256").digest(keyData)
} catch (e: NoSuchAlgorithmException) {
throw IOException("SHA-256 not supported")
}
-
- try {
- messageDigest.update(keyData)
- } catch (e: Exception) {
- println(e.toString())
- }
-
- return messageDigest.digest()
}
- protected abstract fun loadXmlKeyFile(keyInputStream: InputStream): ByteArray?
+ protected open fun loadXmlKeyFile(keyInputStream: InputStream): ByteArray? {
+ return null
+ }
open fun validatePasswordEncoding(password: String?, containsKeyFile: Boolean): Boolean {
if (password == null && !containsKeyFile)
@@ -391,16 +382,5 @@ abstract class DatabaseVersioned<
private const val TAG = "DatabaseVersioned"
val UUID_ZERO = UUID(0, 0)
-
- fun hexStringToByteArray(s: String): ByteArray {
- val len = s.length
- val data = ByteArray(len / 2)
- var i = 0
- while (i < len) {
- data[i / 2] = ((Character.digit(s[i], 16) shl 4) + Character.digit(s[i + 1], 16)).toByte()
- i += 2
- }
- return data
- }
}
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInput.kt b/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInput.kt
index 8da334872..ad819cde5 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInput.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInput.kt
@@ -41,6 +41,13 @@ abstract class DatabaseInput>
abstract fun openDatabase(databaseInputStream: InputStream,
password: String?,
keyInputStream: InputStream?,
- progressTaskUpdater: ProgressTaskUpdater?): PwDb
+ progressTaskUpdater: ProgressTaskUpdater?,
+ fixDuplicateUUID: Boolean = false): PwDb
+
+ @Throws(LoadDatabaseException::class)
+ abstract fun openDatabase(databaseInputStream: InputStream,
+ masterKey: ByteArray,
+ progressTaskUpdater: ProgressTaskUpdater?,
+ fixDuplicateUUID: Boolean = false): PwDb
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInputKDB.kt b/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInputKDB.kt
index dd5ac5b6b..f2b67e788 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInputKDB.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInputKDB.kt
@@ -45,8 +45,7 @@ import javax.crypto.spec.SecretKeySpec
/**
* Load a KDB database file.
*/
-class DatabaseInputKDB(cacheDirectory: File,
- private val fixDuplicateUUID: Boolean = false)
+class DatabaseInputKDB(cacheDirectory: File)
: DatabaseInput(cacheDirectory) {
private lateinit var mDatabaseToOpen: DatabaseKDB
@@ -55,7 +54,28 @@ class DatabaseInputKDB(cacheDirectory: File,
override fun openDatabase(databaseInputStream: InputStream,
password: String?,
keyInputStream: InputStream?,
- progressTaskUpdater: ProgressTaskUpdater?): DatabaseKDB {
+ progressTaskUpdater: ProgressTaskUpdater?,
+ fixDuplicateUUID: Boolean): DatabaseKDB {
+ return openDatabase(databaseInputStream, progressTaskUpdater, fixDuplicateUUID) {
+ mDatabaseToOpen.retrieveMasterKey(password, keyInputStream)
+ }
+ }
+
+ @Throws(LoadDatabaseException::class)
+ override fun openDatabase(databaseInputStream: InputStream,
+ masterKey: ByteArray,
+ progressTaskUpdater: ProgressTaskUpdater?,
+ fixDuplicateUUID: Boolean): DatabaseKDB {
+ return openDatabase(databaseInputStream, progressTaskUpdater, fixDuplicateUUID) {
+ mDatabaseToOpen.masterKey = masterKey
+ }
+ }
+
+ @Throws(LoadDatabaseException::class)
+ private fun openDatabase(databaseInputStream: InputStream,
+ progressTaskUpdater: ProgressTaskUpdater?,
+ fixDuplicateUUID: Boolean,
+ assignMasterKey: (() -> Unit)? = null): DatabaseKDB {
try {
// Load entire file, most of it's encrypted.
@@ -84,7 +104,7 @@ class DatabaseInputKDB(cacheDirectory: File,
mDatabaseToOpen = DatabaseKDB()
mDatabaseToOpen.changeDuplicateId = fixDuplicateUUID
- mDatabaseToOpen.retrieveMasterKey(password, keyInputStream)
+ assignMasterKey?.invoke()
// Select algorithm
when {
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInputKDBX.kt b/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInputKDBX.kt
index 85b237aec..14276b2a9 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInputKDBX.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/file/input/DatabaseInputKDBX.kt
@@ -63,8 +63,7 @@ import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import kotlin.math.min
-class DatabaseInputKDBX(cacheDirectory: File,
- private val fixDuplicateUUID: Boolean = false)
+class DatabaseInputKDBX(cacheDirectory: File)
: DatabaseInput(cacheDirectory) {
private var randomStream: StreamCipher? = null
@@ -98,12 +97,30 @@ class DatabaseInputKDBX(cacheDirectory: File,
override fun openDatabase(databaseInputStream: InputStream,
password: String?,
keyInputStream: InputStream?,
- progressTaskUpdater: ProgressTaskUpdater?): DatabaseKDBX {
+ progressTaskUpdater: ProgressTaskUpdater?,
+ fixDuplicateUUID: Boolean): DatabaseKDBX {
+ return openDatabase(databaseInputStream, progressTaskUpdater, fixDuplicateUUID) {
+ mDatabase.retrieveMasterKey(password, keyInputStream)
+ }
+ }
+ @Throws(LoadDatabaseException::class)
+ override fun openDatabase(databaseInputStream: InputStream,
+ masterKey: ByteArray,
+ progressTaskUpdater: ProgressTaskUpdater?,
+ fixDuplicateUUID: Boolean): DatabaseKDBX {
+ return openDatabase(databaseInputStream, progressTaskUpdater, fixDuplicateUUID) {
+ mDatabase.masterKey = masterKey
+ }
+ }
+
+ @Throws(LoadDatabaseException::class)
+ private fun openDatabase(databaseInputStream: InputStream,
+ progressTaskUpdater: ProgressTaskUpdater?,
+ fixDuplicateUUID: Boolean,
+ assignMasterKey: (() -> Unit)? = null): DatabaseKDBX {
try {
- // TODO performance
progressTaskUpdater?.updateMessage(R.string.retrieving_db_key)
-
mDatabase = DatabaseKDBX()
mDatabase.changeDuplicateId = fixDuplicateUUID
@@ -116,9 +133,8 @@ class DatabaseInputKDBX(cacheDirectory: File,
hashOfHeader = headerAndHash.hash
val pbHeader = headerAndHash.header
- mDatabase.retrieveMasterKey(password, keyInputStream)
+ assignMasterKey?.invoke()
mDatabase.makeFinalKey(header.masterSeed)
- // TODO performance
progressTaskUpdater?.updateMessage(R.string.decrypting_db)
val engine: CipherEngine
@@ -436,8 +452,6 @@ class DatabaseInputKDBX(cacheDirectory: File,
val strData = readString(xpp)
if (strData.isNotEmpty()) {
customIconData = Base64.decode(strData, BASE_64_FLAG)
- } else {
- assert(false)
}
} else {
readUnknown(xpp)
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseHeaderOutput.kt b/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseHeaderOutput.kt
deleted file mode 100644
index 4cb13458d..000000000
--- a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseHeaderOutput.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright 2019 Jeremy Jamet / Kunzisoft.
- *
- * This file is part of KeePassDX.
- *
- * KeePassDX is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * KeePassDX is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with KeePassDX. If not, see .
- *
- */
-package com.kunzisoft.keepass.database.file.output
-
-open class DatabaseHeaderOutput {
- var hashOfHeader: ByteArray? = null
- protected set
-}
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseHeaderOutputKDBX.kt b/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseHeaderOutputKDBX.kt
index bd18d8207..de76b662d 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseHeaderOutputKDBX.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseHeaderOutputKDBX.kt
@@ -40,13 +40,16 @@ import javax.crypto.spec.SecretKeySpec
class DatabaseHeaderOutputKDBX @Throws(DatabaseOutputException::class)
constructor(private val databaseKDBX: DatabaseKDBX,
private val header: DatabaseHeaderKDBX,
- outputStream: OutputStream) : DatabaseHeaderOutput() {
+ outputStream: OutputStream) {
private val los: LittleEndianDataOutputStream
private val mos: MacOutputStream
private val dos: DigestOutputStream
lateinit var headerHmac: ByteArray
+ var hashOfHeader: ByteArray? = null
+ private set
+
init {
val md: MessageDigest
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseInnerHeaderOutputKDBX.kt b/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseInnerHeaderOutputKDBX.kt
deleted file mode 100644
index fb3666e90..000000000
--- a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseInnerHeaderOutputKDBX.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright 2019 Jeremy Jamet / Kunzisoft.
- *
- * This file is part of KeePassDX.
- *
- * KeePassDroid is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * KeePassDroid is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with KeePassDroid. If not, see .
- *
- */
-package com.kunzisoft.keepass.database.file.output
-
-import com.kunzisoft.keepass.database.element.database.DatabaseKDBX
-import com.kunzisoft.keepass.database.element.database.DatabaseKDBX.Companion.BUFFER_SIZE_BYTES
-import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX
-import com.kunzisoft.keepass.stream.LittleEndianDataOutputStream
-import com.kunzisoft.keepass.stream.readBytes
-import com.kunzisoft.keepass.utils.UnsignedInt
-import java.io.IOException
-import java.io.OutputStream
-import kotlin.experimental.or
-
-class DatabaseInnerHeaderOutputKDBX(private val database: DatabaseKDBX,
- private val header: DatabaseHeaderKDBX,
- outputStream: OutputStream) {
-
- private val dataOutputStream: LittleEndianDataOutputStream = LittleEndianDataOutputStream(outputStream)
-
- @Throws(IOException::class)
- fun output() {
- dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.InnerRandomStreamID)
- dataOutputStream.writeInt(4)
- if (header.innerRandomStream == null)
- throw IOException("Can't write innerRandomStream")
- dataOutputStream.writeUInt(header.innerRandomStream!!.id)
-
- val streamKeySize = header.innerRandomStreamKey.size
- dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.InnerRandomstreamKey)
- dataOutputStream.writeInt(streamKeySize)
- dataOutputStream.write(header.innerRandomStreamKey)
-
- database.binaryPool.doForEachOrderedBinary { _, keyBinary ->
- val protectedBinary = keyBinary.binary
- // Force decompression to add binary in header
- protectedBinary.decompress()
- // Write type binary
- dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.Binary)
- // Write size
- dataOutputStream.writeUInt(UnsignedInt.fromKotlinLong(protectedBinary.length() + 1))
- // Write protected flag
- var flag = DatabaseHeaderKDBX.KdbxBinaryFlags.None
- if (protectedBinary.isProtected) {
- flag = flag or DatabaseHeaderKDBX.KdbxBinaryFlags.Protected
- }
- dataOutputStream.writeByte(flag)
-
- protectedBinary.getInputDataStream().use { inputStream ->
- inputStream.readBytes(BUFFER_SIZE_BYTES) { buffer ->
- dataOutputStream.write(buffer)
- }
- }
- }
-
- dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.EndOfHeader)
- dataOutputStream.writeInt(0)
- }
-}
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutput.kt b/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutput.kt
index 4c0951393..4d456e538 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutput.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutput.kt
@@ -26,7 +26,7 @@ import java.io.OutputStream
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
-abstract class DatabaseOutput protected constructor(protected var mOS: OutputStream) {
+abstract class DatabaseOutput protected constructor(protected var mOutputStream: OutputStream) {
@Throws(DatabaseOutputException::class)
protected open fun setIVs(header: Header): SecureRandom {
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutputKDB.kt b/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutputKDB.kt
index f71345bf8..417865c21 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutputKDB.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutputKDB.kt
@@ -63,7 +63,7 @@ class DatabaseOutputKDB(private val mDatabaseKDB: DatabaseKDB,
// and remove any orphaned nodes that are no longer part of the tree hierarchy
sortGroupsForOutput()
- val header = outputHeader(mOS)
+ val header = outputHeader(mOutputStream)
val finalKey = getFinalKey(header)
@@ -85,7 +85,7 @@ class DatabaseOutputKDB(private val mDatabaseKDB: DatabaseKDB,
cipher.init(Cipher.ENCRYPT_MODE,
SecretKeySpec(finalKey, "AES"),
IvParameterSpec(header.encryptionIV))
- val cos = CipherOutputStream(mOS, cipher)
+ val cos = CipherOutputStream(mOutputStream, cipher)
val bos = BufferedOutputStream(cos)
outputPlanGroupAndEntries(bos)
bos.flush()
diff --git a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutputKDBX.kt b/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutputKDBX.kt
index 965d55a19..9fb0f0d37 100644
--- a/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutputKDBX.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/database/file/output/DatabaseOutputKDBX.kt
@@ -46,6 +46,7 @@ import com.kunzisoft.keepass.database.file.DatabaseHeaderKDBX
import com.kunzisoft.keepass.database.file.DatabaseKDBXXML
import com.kunzisoft.keepass.database.file.DateKDBXUtil
import com.kunzisoft.keepass.stream.*
+import com.kunzisoft.keepass.utils.UnsignedInt
import org.bouncycastle.crypto.StreamCipher
import org.joda.time.DateTime
import org.xmlpull.v1.XmlSerializer
@@ -57,6 +58,7 @@ import java.util.*
import java.util.zip.GZIPOutputStream
import javax.crypto.Cipher
import javax.crypto.CipherOutputStream
+import kotlin.experimental.or
class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
@@ -80,20 +82,19 @@ class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
throw DatabaseOutputException("No such cipher", e)
}
- header = outputHeader(mOS)
+ header = outputHeader(mOutputStream)
val osPlain: OutputStream
osPlain = if (header!!.version.toKotlinLong() < DatabaseHeaderKDBX.FILE_VERSION_32_4.toKotlinLong()) {
- val cos = attachStreamEncryptor(header!!, mOS)
+ val cos = attachStreamEncryptor(header!!, mOutputStream)
cos.write(header!!.streamStartBytes)
HashedBlockOutputStream(cos)
} else {
- mOS.write(hashOfHeader!!)
- mOS.write(headerHmac!!)
+ mOutputStream.write(hashOfHeader!!)
+ mOutputStream.write(headerHmac!!)
-
- attachStreamEncryptor(header!!, HmacBlockOutputStream(mOS, mDatabaseKDBX.hmacKey!!))
+ attachStreamEncryptor(header!!, HmacBlockOutputStream(mOutputStream, mDatabaseKDBX.hmacKey!!))
}
val osXml: OutputStream
@@ -104,8 +105,7 @@ class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
}
if (header!!.version.toKotlinLong() >= DatabaseHeaderKDBX.FILE_VERSION_32_4.toKotlinLong()) {
- val ihOut = DatabaseInnerHeaderOutputKDBX(mDatabaseKDBX, header!!, osXml)
- ihOut.output()
+ outputInnerHeader(mDatabaseKDBX, header!!, osXml)
}
outputDatabase(osXml)
@@ -121,6 +121,49 @@ class DatabaseOutputKDBX(private val mDatabaseKDBX: DatabaseKDBX,
}
}
+ @Throws(IOException::class)
+ private fun outputInnerHeader(database: DatabaseKDBX,
+ header: DatabaseHeaderKDBX,
+ outputStream: OutputStream) {
+ val dataOutputStream = LittleEndianDataOutputStream(outputStream)
+
+ dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.InnerRandomStreamID)
+ dataOutputStream.writeInt(4)
+ if (header.innerRandomStream == null)
+ throw IOException("Can't write innerRandomStream")
+ dataOutputStream.writeUInt(header.innerRandomStream!!.id)
+
+ val streamKeySize = header.innerRandomStreamKey.size
+ dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.InnerRandomstreamKey)
+ dataOutputStream.writeInt(streamKeySize)
+ dataOutputStream.write(header.innerRandomStreamKey)
+
+ database.binaryPool.doForEachOrderedBinary { _, keyBinary ->
+ val protectedBinary = keyBinary.binary
+ // Force decompression to add binary in header
+ protectedBinary.decompress()
+ // Write type binary
+ dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.Binary)
+ // Write size
+ dataOutputStream.writeUInt(UnsignedInt.fromKotlinLong(protectedBinary.length() + 1))
+ // Write protected flag
+ var flag = DatabaseHeaderKDBX.KdbxBinaryFlags.None
+ if (protectedBinary.isProtected) {
+ flag = flag or DatabaseHeaderKDBX.KdbxBinaryFlags.Protected
+ }
+ dataOutputStream.writeByte(flag)
+
+ protectedBinary.getInputDataStream().use { inputStream ->
+ inputStream.readBytes(BUFFER_SIZE_BYTES) { buffer ->
+ dataOutputStream.write(buffer)
+ }
+ }
+ }
+
+ dataOutputStream.writeByte(DatabaseHeaderKDBX.PwDbInnerHeaderV4Fields.EndOfHeader)
+ dataOutputStream.writeInt(0)
+ }
+
@Throws(IllegalArgumentException::class, IllegalStateException::class, IOException::class)
private fun outputDatabase(outputStream: OutputStream) {
diff --git a/app/src/main/java/com/kunzisoft/keepass/icons/IconDrawableFactory.kt b/app/src/main/java/com/kunzisoft/keepass/icons/IconDrawableFactory.kt
index 691273b25..add39ee52 100644
--- a/app/src/main/java/com/kunzisoft/keepass/icons/IconDrawableFactory.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/icons/IconDrawableFactory.kt
@@ -26,9 +26,12 @@ import android.graphics.*
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
+import android.graphics.drawable.Icon
+import android.os.Build
import android.util.Log
import android.widget.ImageView
import android.widget.RemoteViews
+import androidx.annotation.RequiresApi
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.widget.ImageViewCompat
@@ -87,6 +90,22 @@ class IconDrawableFactory {
remoteViews.setImageViewBitmap(imageId, bitmap)
}
+ /**
+ * Utility method to assign a drawable to a icon and tint it
+ */
+ @RequiresApi(Build.VERSION_CODES.M)
+ fun assignDrawableToIcon(superDrawable: SuperDrawable,
+ tintColor: Int = Color.BLACK): Icon {
+ val bitmap = superDrawable.drawable.toBitmap()
+ // Tint bitmap if it's not a custom icon
+ if (superDrawable.tintable && bitmap.isMutable) {
+ Canvas(bitmap).drawBitmap(bitmap, 0.0F, 0.0F, Paint().apply {
+ colorFilter = PorterDuffColorFilter(tintColor, PorterDuff.Mode.SRC_IN)
+ })
+ }
+ return Icon.createWithBitmap(bitmap)
+ }
+
/**
* Get the [SuperDrawable] [icon] (from cache, or build it and add it to the cache if not exists yet), then [tint] it with [tintColor] if needed
*/
@@ -309,3 +328,22 @@ fun RemoteViews.assignDatabaseIcon(context: Context,
Log.e(RemoteViews::class.java.name, "Unable to assign icon in remote view", e)
}
}
+
+@RequiresApi(Build.VERSION_CODES.M)
+fun createIconFromDatabaseIcon(context: Context,
+ iconFactory: IconDrawableFactory,
+ icon: IconImage,
+ tintColor: Int = Color.BLACK): Icon? {
+ try {
+ return iconFactory.assignDrawableToIcon(
+ iconFactory.getIconSuperDrawable(context,
+ icon,
+ 24,
+ true,
+ tintColor),
+ tintColor)
+ } catch (e: Exception) {
+ Log.e(RemoteViews::class.java.name, "Unable to assign icon in remote view", e)
+ }
+ return null
+}
diff --git a/app/src/main/java/com/kunzisoft/keepass/model/SnapFileDatabaseInfo.kt b/app/src/main/java/com/kunzisoft/keepass/model/SnapFileDatabaseInfo.kt
new file mode 100644
index 000000000..4ef9acb3e
--- /dev/null
+++ b/app/src/main/java/com/kunzisoft/keepass/model/SnapFileDatabaseInfo.kt
@@ -0,0 +1,62 @@
+package com.kunzisoft.keepass.model
+
+import android.content.Context
+import android.net.Uri
+import android.os.Parcel
+import android.os.Parcelable
+import android.text.format.Formatter
+import com.kunzisoft.keepass.viewmodels.FileDatabaseInfo
+import java.text.DateFormat
+import java.util.*
+
+/**
+ * Utility data class to get FileDatabaseInfo at a `t` time
+ */
+data class SnapFileDatabaseInfo(var fileUri: Uri?,
+ var exists: Boolean,
+ var lastModification: Long?,
+ var size: Long?): Parcelable {
+
+ constructor(parcel: Parcel) : this(
+ parcel.readParcelable(Uri::class.java.classLoader),
+ parcel.readByte() != 0.toByte(),
+ parcel.readValue(Long::class.java.classLoader) as? Long,
+ parcel.readValue(Long::class.java.classLoader) as? Long) {
+ }
+
+ fun toString(context: Context): String {
+ val lastModificationString = DateFormat.getDateTimeInstance()
+ .format(Date(lastModification ?: 0))
+ return "$lastModificationString, " +
+ Formatter.formatFileSize(context, size ?: 0)
+ }
+
+ override fun writeToParcel(parcel: Parcel, flags: Int) {
+ parcel.writeParcelable(fileUri, flags)
+ parcel.writeByte(if (exists) 1 else 0)
+ parcel.writeValue(lastModification)
+ parcel.writeValue(size)
+ }
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ companion object CREATOR : Parcelable.Creator {
+ override fun createFromParcel(parcel: Parcel): SnapFileDatabaseInfo {
+ return SnapFileDatabaseInfo(parcel)
+ }
+
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
+
+ fun fromFileDatabaseInfo(fileDatabaseInfo: FileDatabaseInfo): SnapFileDatabaseInfo {
+ return SnapFileDatabaseInfo(
+ fileDatabaseInfo.fileUri,
+ fileDatabaseInfo.exists,
+ fileDatabaseInfo.getLastModification(),
+ fileDatabaseInfo.getSize())
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/kunzisoft/keepass/notifications/DatabaseTaskNotificationService.kt b/app/src/main/java/com/kunzisoft/keepass/notifications/DatabaseTaskNotificationService.kt
index bbc0e9bf1..3535fba62 100644
--- a/app/src/main/java/com/kunzisoft/keepass/notifications/DatabaseTaskNotificationService.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/notifications/DatabaseTaskNotificationService.kt
@@ -22,9 +22,8 @@ package com.kunzisoft.keepass.notifications
import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
-import android.os.Binder
-import android.os.Bundle
-import android.os.IBinder
+import android.os.*
+import android.util.Log
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.GroupActivity
import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
@@ -40,6 +39,7 @@ import com.kunzisoft.keepass.database.element.database.CompressionAlgorithm
import com.kunzisoft.keepass.database.element.node.Node
import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.database.element.node.Type
+import com.kunzisoft.keepass.model.SnapFileDatabaseInfo
import com.kunzisoft.keepass.tasks.ActionRunnable
import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
import com.kunzisoft.keepass.timeout.TimeoutHelper
@@ -47,6 +47,7 @@ import com.kunzisoft.keepass.utils.DATABASE_START_TASK_ACTION
import com.kunzisoft.keepass.utils.DATABASE_STOP_TASK_ACTION
import com.kunzisoft.keepass.utils.LOCK_ACTION
import com.kunzisoft.keepass.utils.closeDatabase
+import com.kunzisoft.keepass.viewmodels.FileDatabaseInfo
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
@@ -65,6 +66,9 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
private var mAllowFinishAction = AtomicBoolean()
private var mActionRunning = false
+ private var mSnapFileDatabaseInfo: SnapFileDatabaseInfo? = null
+ private var mDatabaseInfoListeners = LinkedList()
+
private var mIconId: Int = R.drawable.notification_ic_database_load
private var mTitleId: Int = R.string.database_opened
private var mMessageId: Int? = null
@@ -93,6 +97,14 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
mAllowFinishAction.set(false)
}
}
+
+ fun addDatabaseFileInfoListener(databaseInfoListener: DatabaseInfoListener) {
+ mDatabaseInfoListeners.add(databaseInfoListener)
+ }
+
+ fun removeDatabaseFileInfoListener(databaseInfoListener: DatabaseInfoListener) {
+ mDatabaseInfoListeners.remove(databaseInfoListener)
+ }
}
interface ActionTaskListener {
@@ -101,6 +113,11 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
fun onStopAction(actionTask: String, result: ActionRunnable.Result)
}
+ interface DatabaseInfoListener {
+ fun onDatabaseInfoChanged(previousDatabaseInfo: SnapFileDatabaseInfo,
+ newDatabaseInfo: SnapFileDatabaseInfo)
+ }
+
/**
* Force to call [ActionTaskListener.onStartAction] if the action is still running
*/
@@ -112,6 +129,31 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
}
}
+ fun checkDatabaseInfo() {
+ mDatabase.fileUri?.let {
+ val previousDatabaseInfo = mSnapFileDatabaseInfo
+ val lastFileDatabaseInfo = SnapFileDatabaseInfo.fromFileDatabaseInfo(
+ FileDatabaseInfo(applicationContext, it))
+ if (previousDatabaseInfo != null) {
+ if (previousDatabaseInfo != lastFileDatabaseInfo) {
+ Log.i(TAG, "Database file modified " +
+ "$previousDatabaseInfo != $lastFileDatabaseInfo ")
+ // Call listener to indicate a change in database info
+ mDatabaseInfoListeners.forEach { listener ->
+ listener.onDatabaseInfoChanged(previousDatabaseInfo, lastFileDatabaseInfo)
+ }
+ }
+ }
+ }
+ }
+
+ fun saveDatabaseInfo() {
+ mDatabase.fileUri?.let {
+ mSnapFileDatabaseInfo = SnapFileDatabaseInfo.fromFileDatabaseInfo(
+ FileDatabaseInfo(applicationContext, it))
+ }
+ }
+
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
return mActionTaskBinder
@@ -138,6 +180,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
val actionRunnable: ActionRunnable? = when (intentAction) {
ACTION_DATABASE_CREATE_TASK -> buildDatabaseCreateActionTask(intent)
ACTION_DATABASE_LOAD_TASK -> buildDatabaseLoadActionTask(intent)
+ ACTION_DATABASE_RELOAD_TASK -> buildDatabaseReloadActionTask()
ACTION_DATABASE_ASSIGN_PASSWORD_TASK -> buildDatabaseAssignPasswordActionTask(intent)
ACTION_DATABASE_CREATE_GROUP_TASK -> buildDatabaseCreateGroupActionTask(intent)
ACTION_DATABASE_UPDATE_GROUP_TASK -> buildDatabaseUpdateGroupActionTask(intent)
@@ -193,6 +236,8 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
}
} finally {
removeIntentData(intent)
+ // Save the database info after performing action
+ saveDatabaseInfo()
TimeoutHelper.releaseTemporarilyDisableTimeout()
if (TimeoutHelper.checkTimeAndLockIfTimeout(this@DatabaseTaskNotificationService)) {
if (!mDatabase.loaded) {
@@ -214,7 +259,9 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
}
return when (intentAction) {
- ACTION_DATABASE_LOAD_TASK, null -> {
+ ACTION_DATABASE_LOAD_TASK,
+ ACTION_DATABASE_RELOAD_TASK,
+ null -> {
START_STICKY
}
else -> {
@@ -248,7 +295,8 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
else -> {
when (intentAction) {
ACTION_DATABASE_CREATE_TASK -> R.string.creating_database
- ACTION_DATABASE_LOAD_TASK -> R.string.loading_database
+ ACTION_DATABASE_LOAD_TASK,
+ ACTION_DATABASE_RELOAD_TASK -> R.string.loading_database
ACTION_DATABASE_SAVE -> R.string.saving_database
else -> {
R.string.command_execution
@@ -258,13 +306,15 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
}
mMessageId = when (intentAction) {
- ACTION_DATABASE_LOAD_TASK -> null
+ ACTION_DATABASE_LOAD_TASK,
+ ACTION_DATABASE_RELOAD_TASK -> null
else -> null
}
mWarningId =
if (!saveAction
- || intentAction == ACTION_DATABASE_LOAD_TASK)
+ || intentAction == ACTION_DATABASE_LOAD_TASK
+ || intentAction == ACTION_DATABASE_RELOAD_TASK)
null
else
R.string.do_not_kill_app
@@ -342,9 +392,9 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
* Execute action with a coroutine
*/
private suspend fun executeAction(progressTaskUpdater: ProgressTaskUpdater,
- onPreExecute: () -> Unit,
- onExecute: (ProgressTaskUpdater?) -> ActionRunnable?,
- onPostExecute: (result: ActionRunnable.Result) -> Unit) {
+ onPreExecute: () -> Unit,
+ onExecute: (ProgressTaskUpdater?) -> ActionRunnable?,
+ onPostExecute: (result: ActionRunnable.Result) -> Unit) {
mAllowFinishAction.set(false)
TimeoutHelper.temporarilyDisableTimeout()
@@ -465,6 +515,17 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
}
}
+ private fun buildDatabaseReloadActionTask(): ActionRunnable {
+ return ReloadDatabaseRunnable(
+ this,
+ mDatabase,
+ this
+ ) { result ->
+ // No need to add each info to reload database
+ result.data = Bundle()
+ }
+ }
+
private fun buildDatabaseAssignPasswordActionTask(intent: Intent): ActionRunnable? {
return if (intent.hasExtra(DATABASE_URI_KEY)
&& intent.hasExtra(MASTER_PASSWORD_CHECKED_KEY)
@@ -770,6 +831,7 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
const val ACTION_DATABASE_CREATE_TASK = "ACTION_DATABASE_CREATE_TASK"
const val ACTION_DATABASE_LOAD_TASK = "ACTION_DATABASE_LOAD_TASK"
+ const val ACTION_DATABASE_RELOAD_TASK = "ACTION_DATABASE_RELOAD_TASK"
const val ACTION_DATABASE_ASSIGN_PASSWORD_TASK = "ACTION_DATABASE_ASSIGN_PASSWORD_TASK"
const val ACTION_DATABASE_CREATE_GROUP_TASK = "ACTION_DATABASE_CREATE_GROUP_TASK"
const val ACTION_DATABASE_UPDATE_GROUP_TASK = "ACTION_DATABASE_UPDATE_GROUP_TASK"
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 682f7889e..bd61b3325 100644
--- a/app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt
@@ -20,6 +20,7 @@
package com.kunzisoft.keepass.otp
import com.kunzisoft.keepass.model.OtpModel
+import com.kunzisoft.keepass.utils.StringUtil.removeSpaceChars
import org.apache.commons.codec.binary.Base32
import org.apache.commons.codec.binary.Base64
import org.apache.commons.codec.binary.Hex
@@ -216,17 +217,9 @@ data class OtpElement(var otpModel: OtpModel = OtpModel()) {
return secret.isNotEmpty() && checkBase64Secret(secret)
}
- fun removeLineChars(parameter: String): String {
- return parameter.replace("[\\r|\\n|\\t|\\u00A0]+".toRegex(), "")
- }
-
- fun removeSpaceChars(parameter: String): String {
- return parameter.replace("[\\r|\\n|\\t|\\s|\\u00A0]+".toRegex(), "")
- }
-
fun replaceBase32Chars(parameter: String): String {
// Add 'A' at end if not Base32 length
- var parameterNewSize = removeSpaceChars(parameter.toUpperCase(Locale.ENGLISH))
+ var parameterNewSize = parameter.toUpperCase(Locale.ENGLISH).removeSpaceChars()
while (parameterNewSize.length % 8 != 0) {
parameterNewSize += 'A'
}
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 38106feca..33977d6a8 100644
--- a/app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt
@@ -24,9 +24,9 @@ import android.net.Uri
import android.util.Log
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.model.Field
-import com.kunzisoft.keepass.otp.OtpElement.Companion.removeLineChars
-import com.kunzisoft.keepass.otp.OtpElement.Companion.removeSpaceChars
import com.kunzisoft.keepass.otp.TokenCalculator.*
+import com.kunzisoft.keepass.utils.StringUtil.removeLineChars
+import com.kunzisoft.keepass.utils.StringUtil.removeSpaceChars
import java.util.*
import java.util.regex.Pattern
@@ -126,7 +126,7 @@ object OtpEntryFields {
private fun parseOTPUri(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
val otpPlainText = getField(OTP_FIELD)
if (otpPlainText != null && otpPlainText.isNotEmpty() && isOTPUri(otpPlainText)) {
- val uri = Uri.parse(removeSpaceChars(otpPlainText))
+ val uri = Uri.parse(otpPlainText.removeSpaceChars())
if (uri.scheme == null || OTP_SCHEME != uri.scheme!!.toLowerCase(Locale.ENGLISH)) {
Log.e(TAG, "Invalid or missing scheme in uri")
@@ -159,16 +159,16 @@ object OtpEntryFields {
if (nameParam != null && nameParam.isNotEmpty()) {
val userIdArray = nameParam.split(":", "%3A")
if (userIdArray.size > 1) {
- otpElement.issuer = removeLineChars(userIdArray[0])
- otpElement.name = removeLineChars(userIdArray[1])
+ otpElement.issuer = userIdArray[0].removeLineChars()
+ otpElement.name = userIdArray[1].removeLineChars()
} else {
- otpElement.name = removeLineChars(nameParam)
+ otpElement.name = nameParam.removeLineChars()
}
}
val issuerParam = uri.getQueryParameter(ISSUER_URL_PARAM)
if (issuerParam != null && issuerParam.isNotEmpty())
- otpElement.issuer = removeLineChars(issuerParam)
+ otpElement.issuer = issuerParam.removeLineChars()
val secretParam = uri.getQueryParameter(SECRET_URL_PARAM)
if (secretParam != null && secretParam.isNotEmpty()) {
@@ -262,7 +262,7 @@ object OtpEntryFields {
}
private fun encodeParameter(parameter: String): String {
- return Uri.encode(OtpElement.removeLineChars(parameter))
+ return Uri.encode(parameter.removeLineChars())
}
private fun parseTOTPKeyValues(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsFragment.kt
index cba9c231d..f3462752b 100644
--- a/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsFragment.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/settings/AutofillSettingsFragment.kt
@@ -19,10 +19,12 @@
*/
package com.kunzisoft.keepass.settings
+import android.os.Build
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SwitchPreference
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.settings.preferencedialogfragment.AutofillBlocklistAppIdPreferenceDialogFragmentCompat
import com.kunzisoft.keepass.settings.preferencedialogfragment.AutofillBlocklistWebDomainPreferenceDialogFragmentCompat
@@ -32,6 +34,11 @@ class AutofillSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
// Load the preferences from an XML resource
setPreferencesFromResource(R.xml.preferences_autofill, rootKey)
+
+ val autofillInlineSuggestionsPreference: SwitchPreference? = findPreference(getString(R.string.autofill_inline_suggestions_key))
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ autofillInlineSuggestionsPreference?.isVisible = false
+ }
}
override fun onDisplayPreferenceDialog(preference: Preference?) {
diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/MainPreferenceFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/MainPreferenceFragment.kt
index d03cd3411..b6b0acb7f 100644
--- a/app/src/main/java/com/kunzisoft/keepass/settings/MainPreferenceFragment.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/settings/MainPreferenceFragment.kt
@@ -103,6 +103,6 @@ class MainPreferenceFragment : PreferenceFragmentCompat() {
}
interface Callback {
- fun onNestedPreferenceSelected(key: NestedSettingsFragment.Screen)
+ fun onNestedPreferenceSelected(key: NestedSettingsFragment.Screen, reload: Boolean = false)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/NestedDatabaseSettingsFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/NestedDatabaseSettingsFragment.kt
index de0bbadda..7fedcd403 100644
--- a/app/src/main/java/com/kunzisoft/keepass/settings/NestedDatabaseSettingsFragment.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/settings/NestedDatabaseSettingsFragment.kt
@@ -552,6 +552,10 @@ class NestedDatabaseSettingsFragment : NestedSettingsFragment() {
settingActivity?.mProgressDatabaseTaskProvider?.startDatabaseSave(!mDatabaseReadOnly)
true
}
+ R.id.menu_reload_database -> {
+ settingActivity?.mProgressDatabaseTaskProvider?.startDatabaseReload(false)
+ return true
+ }
else -> {
// Check the time lock before launching settings
diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt b/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt
index f9c2f415d..f2c166d9e 100644
--- a/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt
@@ -436,13 +436,18 @@ object PreferencesUtil {
context.resources.getBoolean(R.bool.autofill_close_database_default))
}
-
fun isAutofillAutoSearchEnable(context: Context): Boolean {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return prefs.getBoolean(context.getString(R.string.autofill_auto_search_key),
context.resources.getBoolean(R.bool.autofill_auto_search_default))
}
+ fun isAutofillInlineSuggestionsEnable(context: Context): Boolean {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ return prefs.getBoolean(context.getString(R.string.autofill_inline_suggestions_key),
+ context.resources.getBoolean(R.bool.autofill_inline_suggestions_default))
+ }
+
fun isAutofillSaveSearchInfoEnable(context: Context): Boolean {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return prefs.getBoolean(context.getString(R.string.autofill_save_search_info_key),
diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/SettingsActivity.kt b/app/src/main/java/com/kunzisoft/keepass/settings/SettingsActivity.kt
index 4418e86f7..5d4f1b007 100644
--- a/app/src/main/java/com/kunzisoft/keepass/settings/SettingsActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/settings/SettingsActivity.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2017 Brian Pellin, Jeremy Jamet / Kunzisoft.
+ * Copyright 2020 Jeremy Jamet / Kunzisoft.
*
* This file is part of KeePassDX.
*
@@ -21,7 +21,6 @@ package com.kunzisoft.keepass.settings
import android.app.Activity
import android.app.backup.BackupManager
-import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -37,6 +36,7 @@ import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
import com.kunzisoft.keepass.activities.lock.LockingActivity
import com.kunzisoft.keepass.activities.lock.resetAppTimeoutWhenViewFocusedOrChanged
import com.kunzisoft.keepass.database.element.Database
+import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService
import com.kunzisoft.keepass.timeout.TimeoutHelper
import com.kunzisoft.keepass.view.showActionError
@@ -95,12 +95,28 @@ open class SettingsActivity
backupManager = BackupManager(this)
mProgressDatabaseTaskProvider?.onActionFinish = { actionTask, result ->
- // Call result in fragment
- (supportFragmentManager
- .findFragmentByTag(TAG_NESTED) as NestedSettingsFragment?)
- ?.onProgressDialogThreadResult(actionTask, result)
+ when (actionTask) {
+ DatabaseTaskNotificationService.ACTION_DATABASE_RELOAD_TASK -> {
+ // Reload the current activity
+ startActivity(intent)
+ finish()
+ overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
+ }
+ else -> {
+ // Call result in fragment
+ (supportFragmentManager
+ .findFragmentByTag(TAG_NESTED) as NestedSettingsFragment?)
+ ?.onProgressDialogThreadResult(actionTask, result)
+ coordinatorLayout?.showActionError(result)
+ }
+ }
+ }
- coordinatorLayout?.showActionError(result)
+ // To reload the current screen
+ if (intent.extras?.containsKey(FRAGMENT_ARG) == true) {
+ intent.extras?.getString(FRAGMENT_ARG)?.let { fragmentScreenName ->
+ onNestedPreferenceSelected(NestedSettingsFragment.Screen.valueOf(fragmentScreenName), true)
+ }
}
}
@@ -193,25 +209,33 @@ open class SettingsActivity
hideOrShowLockButton(NestedSettingsFragment.Screen.APPLICATION)
}
- private fun replaceFragment(key: NestedSettingsFragment.Screen) {
- supportFragmentManager.beginTransaction()
- .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left,
+ private fun replaceFragment(key: NestedSettingsFragment.Screen, reload: Boolean) {
+ supportFragmentManager.beginTransaction().apply {
+ if (reload) {
+ setCustomAnimations(android.R.anim.fade_in, android.R.anim.fade_out,
R.anim.slide_in_left, R.anim.slide_out_right)
- .replace(R.id.fragment_container, NestedSettingsFragment.newInstance(key, mReadOnly), TAG_NESTED)
- .addToBackStack(TAG_NESTED)
- .commit()
+ } else {
+ setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left,
+ R.anim.slide_in_left, R.anim.slide_out_right)
+ }
+ replace(R.id.fragment_container, NestedSettingsFragment.newInstance(key, mReadOnly), TAG_NESTED)
+ addToBackStack(TAG_NESTED)
+ commit()
+ }
toolbar?.title = NestedSettingsFragment.retrieveTitle(resources, key)
+ // To reload the current screen
+ intent.putExtra(FRAGMENT_ARG, key.name)
hideOrShowLockButton(key)
}
- override fun onNestedPreferenceSelected(key: NestedSettingsFragment.Screen) {
+ override fun onNestedPreferenceSelected(key: NestedSettingsFragment.Screen, reload: Boolean) {
if (mTimeoutEnable)
TimeoutHelper.checkTimeAndLockIfTimeoutOrResetTimeout(this) {
- replaceFragment(key)
+ replaceFragment(key, reload)
}
else
- replaceFragment(key)
+ replaceFragment(key, reload)
}
override fun onSaveInstanceState(outState: Bundle) {
@@ -226,6 +250,7 @@ open class SettingsActivity
private const val SHOW_LOCK = "SHOW_LOCK"
private const val TITLE_KEY = "TITLE_KEY"
private const val TAG_NESTED = "TAG_NESTED"
+ private const val FRAGMENT_ARG = "FRAGMENT_ARG"
fun launch(activity: Activity, readOnly: Boolean, timeoutEnable: Boolean) {
val intent = Intent(activity, SettingsActivity::class.java)
diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt b/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt
index 59c97ac7c..3dcfcf7b4 100644
--- a/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt
@@ -138,5 +138,5 @@ fun Context.closeDatabase() {
cancelAll()
}
// Clear data
- Database.getInstance().closeAndClear(UriUtil.getBinaryDir(this))
+ Database.getInstance().clearAndClose(UriUtil.getBinaryDir(this))
}
\ No newline at end of file
diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/StringUtil.kt b/app/src/main/java/com/kunzisoft/keepass/utils/StringUtil.kt
new file mode 100644
index 000000000..e9ed8ebaa
--- /dev/null
+++ b/app/src/main/java/com/kunzisoft/keepass/utils/StringUtil.kt
@@ -0,0 +1,26 @@
+package com.kunzisoft.keepass.utils
+
+object StringUtil {
+
+ fun String.removeLineChars(): String {
+ return this.replace("[\\r|\\n|\\t|\\u00A0]+".toRegex(), "")
+ }
+
+ fun String.removeSpaceChars(): String {
+ return this.replace("[\\r|\\n|\\t|\\s|\\u00A0]+".toRegex(), "")
+ }
+
+ fun String.hexStringToByteArray(): ByteArray {
+ val len = this.length
+ val data = ByteArray(len / 2)
+ var i = 0
+ while (i < len) {
+ data[i / 2] = ((Character.digit(this[i], 16) shl 4)
+ + Character.digit(this[i + 1], 16)).toByte()
+ i += 2
+ }
+ return data
+ }
+
+ fun ByteArray.toHexString() = joinToString("") { "%02X".format(it) }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/kunzisoft/keepass/viewmodels/FileDatabaseInfo.kt b/app/src/main/java/com/kunzisoft/keepass/viewmodels/FileDatabaseInfo.kt
index 65b8c9e5b..6d9df989c 100644
--- a/app/src/main/java/com/kunzisoft/keepass/viewmodels/FileDatabaseInfo.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/viewmodels/FileDatabaseInfo.kt
@@ -23,7 +23,6 @@ import android.content.Context
import android.net.Uri
import android.text.format.Formatter
import androidx.documentfile.provider.DocumentFile
-import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.utils.UriUtil
import java.io.Serializable
import java.text.DateFormat
@@ -58,7 +57,11 @@ class FileDatabaseInfo : Serializable {
}
private set
- fun getModificationString(): String? {
+ fun getLastModification(): Long? {
+ return documentFile?.lastModified()
+ }
+
+ fun getLastModificationString(): String? {
return documentFile?.lastModified()?.let {
if (it != 0L) {
DateFormat.getDateTimeInstance()
@@ -69,6 +72,10 @@ class FileDatabaseInfo : Serializable {
}
}
+ fun getSize(): Long? {
+ return documentFile?.length()
+ }
+
fun getSizeString(): String? {
return documentFile?.let {
Formatter.formatFileSize(context, it.length())
diff --git a/app/src/main/res/drawable/ic_reload_white_24dp.xml b/app/src/main/res/drawable/ic_reload_white_24dp.xml
new file mode 100644
index 000000000..020472b89
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reload_white_24dp.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/src/main/res/menu/contribution.xml b/app/src/main/res/menu/contribution.xml
index b76875ff9..5c1671456 100644
--- a/app/src/main/res/menu/contribution.xml
+++ b/app/src/main/res/menu/contribution.xml
@@ -22,6 +22,6 @@
\ No newline at end of file
diff --git a/app/src/main/res/menu/database.xml b/app/src/main/res/menu/database.xml
index d94f86817..a7a182b8a 100644
--- a/app/src/main/res/menu/database.xml
+++ b/app/src/main/res/menu/database.xml
@@ -24,4 +24,9 @@
android:title="@string/menu_save_database"
android:orderInCategory="95"
app:showAsAction="ifRoom" />
+
\ No newline at end of file
diff --git a/app/src/main/res/values-v30/donottranslate.xml b/app/src/main/res/values-v30/donottranslate.xml
new file mode 100644
index 000000000..fc50571b3
--- /dev/null
+++ b/app/src/main/res/values-v30/donottranslate.xml
@@ -0,0 +1,22 @@
+
+
+
+ true
+
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index 757f9f374..fd145926f 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -151,6 +151,8 @@
false
autofill_auto_search_key
true
+ autofill_inline_suggestions_key
+ false
autofill_save_search_info_key
true
autofill_ask_to_save_data_key
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index db22f7442..9dd03860d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -185,6 +185,7 @@
Hide password
Lock database
Save database
+ Reload database
Open
Search
Show password
@@ -272,6 +273,9 @@
Remove this data anyway?
It is not recommended to add an empty keyfile.
The content of the keyfile should never be changed, and in the best case, should contain randomly generated data.
+ The information contained in your database file has been modified outside the app.
+ Overwrite the external modifications by saving the database or reload it with the latest changes.
+ Access to the file revoked by the file manager, close the database and reopen it from its location.
Version %1$s
Build %1$s
No biometric or device credential is enrolled.
@@ -428,6 +432,8 @@
Close the database after an autofill selection
Auto search
Automatically suggest search results from the web domain or application ID
+ Inline suggestions
+ Attempt to display autofill suggestions directly from a compatible keyboard
Save search info
Try to save search information when making a manual entry selection
Ask to save data
@@ -439,6 +445,7 @@
Block autofill
Restart the app containing the form to activate the blocking.
Data save is not allowed for a database opened as read-only.
+ Autofill suggestions added.
Allow no master key
Allows tapping the \"Open\" button if no credentials are selected
Delete password
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 76406e20a..ca871ad06 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -465,6 +465,7 @@
- @android:color/transparent
- @null
- true
+ - true
- true
- false
diff --git a/app/src/main/res/xml/dataset_service.xml b/app/src/main/res/xml/dataset_service.xml
index ea2597ec7..6d595b7b5 100644
--- a/app/src/main/res/xml/dataset_service.xml
+++ b/app/src/main/res/xml/dataset_service.xml
@@ -23,7 +23,9 @@ Settings Activity. This is pointed to in the service's meta-data in the applicat
+ android:settingsActivity="com.kunzisoft.keepass.settings.AutofillSettingsActivity"
+ android:supportsInlineSuggestions="true"
+ tools:ignore="UnusedAttribute">
diff --git a/app/src/main/res/xml/preferences_autofill.xml b/app/src/main/res/xml/preferences_autofill.xml
index 53f00db66..5ab891b4c 100644
--- a/app/src/main/res/xml/preferences_autofill.xml
+++ b/app/src/main/res/xml/preferences_autofill.xml
@@ -30,6 +30,11 @@
android:title="@string/autofill_auto_search_title"
android:summary="@string/autofill_auto_search_summary"
android:defaultValue="@bool/autofill_auto_search_default"/>
+
diff --git a/fastlane/metadata/android/en-US/changelogs/53.txt b/fastlane/metadata/android/en-US/changelogs/53.txt
index 42780ecb1..4c35cf140 100644
--- a/fastlane/metadata/android/en-US/changelogs/53.txt
+++ b/fastlane/metadata/android/en-US/changelogs/53.txt
@@ -1 +1,4 @@
- *
\ No newline at end of file
+ * Detect file changes and reload database #794
+ * Inline suggestions autofill with compatible keyboard (Android R) #827
+ * Add Keyfile XML version 2 #844
+ * Fix binaries of 64 bytes #835
\ No newline at end of file
diff --git a/fastlane/metadata/android/fr-FR/changelogs/53.txt b/fastlane/metadata/android/fr-FR/changelogs/53.txt
index 42780ecb1..f14609bfc 100644
--- a/fastlane/metadata/android/fr-FR/changelogs/53.txt
+++ b/fastlane/metadata/android/fr-FR/changelogs/53.txt
@@ -1 +1,4 @@
- *
\ No newline at end of file
+ * Détection des changements de fichiers et rechargement de base de données #794
+ * Remplissage automatique avec suggestions en ligne (Android R) (Android R) #827
+ * Ajout du fichier de clé XML version 2 #844
+ * Correction des binaires de 64 Octets #835
\ No newline at end of file