mirror of
https://github.com/Kunzisoft/KeePassDX.git
synced 2025-12-04 15:49:33 +01:00
Merge branch 'feature/TOTP' into develop
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -38,6 +38,13 @@ proguard/
|
|||||||
# Android Studio captures folder
|
# Android Studio captures folder
|
||||||
captures/
|
captures/
|
||||||
|
|
||||||
|
# Eclipse/VS Code
|
||||||
|
.project
|
||||||
|
.settings/*
|
||||||
|
*/.project
|
||||||
|
*/.classpath
|
||||||
|
*/.settings/*
|
||||||
|
|
||||||
# Intellij
|
# Intellij
|
||||||
*.iml
|
*.iml
|
||||||
.idea/workspace.xml
|
.idea/workspace.xml
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ apply plugin: 'kotlin-kapt'
|
|||||||
android {
|
android {
|
||||||
compileSdkVersion 28
|
compileSdkVersion 28
|
||||||
buildToolsVersion '28.0.3'
|
buildToolsVersion '28.0.3'
|
||||||
ndkVersion "20.0.5594570"
|
ndkVersion "20.1.5948944"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.kunzisoft.keepass"
|
applicationId "com.kunzisoft.keepass"
|
||||||
@@ -107,6 +107,8 @@ dependencies {
|
|||||||
// Apache Commons Collections
|
// Apache Commons Collections
|
||||||
implementation 'commons-collections:commons-collections:3.2.1'
|
implementation 'commons-collections:commons-collections:3.2.1'
|
||||||
implementation 'org.apache.commons:commons-io:1.3.2'
|
implementation 'org.apache.commons:commons-io:1.3.2'
|
||||||
|
// Apache Commons Codec
|
||||||
|
implementation 'commons-codec:commons-codec:1.11'
|
||||||
// Base64
|
// Base64
|
||||||
implementation 'biz.source_code:base64coder:2010-12-19'
|
implementation 'biz.source_code:base64coder:2010-12-19'
|
||||||
// Icon pack
|
// Icon pack
|
||||||
|
|||||||
@@ -24,15 +24,16 @@ import android.graphics.Color
|
|||||||
import android.graphics.drawable.ColorDrawable
|
import android.graphics.drawable.ColorDrawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import com.google.android.material.appbar.CollapsingToolbarLayout
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.widget.Toolbar
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
import android.widget.ProgressBar
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import com.google.android.material.appbar.CollapsingToolbarLayout
|
||||||
import com.kunzisoft.keepass.R
|
import com.kunzisoft.keepass.R
|
||||||
import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
|
import com.kunzisoft.keepass.activities.helpers.ReadOnlyHelper
|
||||||
import com.kunzisoft.keepass.activities.lock.LockingHideActivity
|
import com.kunzisoft.keepass.activities.lock.LockingHideActivity
|
||||||
@@ -58,6 +59,7 @@ class EntryActivity : LockingHideActivity() {
|
|||||||
private var titleIconView: ImageView? = null
|
private var titleIconView: ImageView? = null
|
||||||
private var historyView: View? = null
|
private var historyView: View? = null
|
||||||
private var entryContentsView: EntryContentsView? = null
|
private var entryContentsView: EntryContentsView? = null
|
||||||
|
private var entryProgress: ProgressBar? = null
|
||||||
private var toolbar: Toolbar? = null
|
private var toolbar: Toolbar? = null
|
||||||
|
|
||||||
private var mDatabase: Database? = null
|
private var mDatabase: Database? = null
|
||||||
@@ -101,6 +103,7 @@ class EntryActivity : LockingHideActivity() {
|
|||||||
historyView = findViewById(R.id.history_container)
|
historyView = findViewById(R.id.history_container)
|
||||||
entryContentsView = findViewById(R.id.entry_contents)
|
entryContentsView = findViewById(R.id.entry_contents)
|
||||||
entryContentsView?.applyFontVisibilityToFields(PreferencesUtil.fieldFontIsInVisibility(this))
|
entryContentsView?.applyFontVisibilityToFields(PreferencesUtil.fieldFontIsInVisibility(this))
|
||||||
|
entryProgress = findViewById(R.id.entry_progress)
|
||||||
|
|
||||||
// Init the clipboard helper
|
// Init the clipboard helper
|
||||||
clipboardHelper = ClipboardHelper(this)
|
clipboardHelper = ClipboardHelper(this)
|
||||||
@@ -220,6 +223,17 @@ class EntryActivity : LockingHideActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Assign OTP field
|
||||||
|
entryContentsView?.assignOtp(entry.getOtpElement(), entryProgress,
|
||||||
|
View.OnClickListener {
|
||||||
|
entry.getOtpElement()?.let { otpElement ->
|
||||||
|
clipboardHelper?.timeoutCopyToClipboard(
|
||||||
|
otpElement.token,
|
||||||
|
getString(R.string.copy_field, getString(R.string.entry_otp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
entryContentsView?.assignURL(entry.url)
|
entryContentsView?.assignURL(entry.url)
|
||||||
entryContentsView?.assignComment(entry.notes)
|
entryContentsView?.assignComment(entry.notes)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import android.view.View
|
|||||||
import android.widget.ScrollView
|
import android.widget.ScrollView
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import com.kunzisoft.keepass.R
|
import com.kunzisoft.keepass.R
|
||||||
|
import com.kunzisoft.keepass.activities.dialogs.SetOTPDialogFragment
|
||||||
import com.kunzisoft.keepass.activities.dialogs.GeneratePasswordDialogFragment
|
import com.kunzisoft.keepass.activities.dialogs.GeneratePasswordDialogFragment
|
||||||
import com.kunzisoft.keepass.activities.dialogs.IconPickerDialogFragment
|
import com.kunzisoft.keepass.activities.dialogs.IconPickerDialogFragment
|
||||||
import com.kunzisoft.keepass.activities.lock.LockingHideActivity
|
import com.kunzisoft.keepass.activities.lock.LockingHideActivity
|
||||||
@@ -39,6 +40,8 @@ import com.kunzisoft.keepass.notifications.ClipboardEntryNotificationService
|
|||||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_ENTRY_TASK
|
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_ENTRY_TASK
|
||||||
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
|
import com.kunzisoft.keepass.notifications.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
|
||||||
import com.kunzisoft.keepass.notifications.KeyboardEntryNotificationService
|
import com.kunzisoft.keepass.notifications.KeyboardEntryNotificationService
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement
|
||||||
|
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||||
import com.kunzisoft.keepass.settings.PreferencesUtil
|
import com.kunzisoft.keepass.settings.PreferencesUtil
|
||||||
import com.kunzisoft.keepass.timeout.TimeoutHelper
|
import com.kunzisoft.keepass.timeout.TimeoutHelper
|
||||||
import com.kunzisoft.keepass.utils.MenuUtil
|
import com.kunzisoft.keepass.utils.MenuUtil
|
||||||
@@ -47,7 +50,8 @@ import java.util.*
|
|||||||
|
|
||||||
class EntryEditActivity : LockingHideActivity(),
|
class EntryEditActivity : LockingHideActivity(),
|
||||||
IconPickerDialogFragment.IconPickerListener,
|
IconPickerDialogFragment.IconPickerListener,
|
||||||
GeneratePasswordDialogFragment.GeneratePasswordListener {
|
GeneratePasswordDialogFragment.GeneratePasswordListener,
|
||||||
|
SetOTPDialogFragment.CreateOtpListener {
|
||||||
|
|
||||||
private var mDatabase: Database? = null
|
private var mDatabase: Database? = null
|
||||||
|
|
||||||
@@ -164,7 +168,9 @@ class EntryEditActivity : LockingHideActivity(),
|
|||||||
saveView = findViewById(R.id.entry_edit_save)
|
saveView = findViewById(R.id.entry_edit_save)
|
||||||
saveView?.setOnClickListener { saveEntry() }
|
saveView?.setOnClickListener { saveEntry() }
|
||||||
|
|
||||||
entryEditContentsView?.allowCustomField(mNewEntry?.allowCustomFields() == true) { addNewCustomField() }
|
entryEditContentsView?.allowCustomField(mNewEntry?.allowCustomFields() == true) {
|
||||||
|
addNewCustomField()
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the education views
|
// Verify the education views
|
||||||
entryEditActivityEducation = EntryEditActivityEducation(this)
|
entryEditActivityEducation = EntryEditActivityEducation(this)
|
||||||
@@ -197,7 +203,7 @@ class EntryEditActivity : LockingHideActivity(),
|
|||||||
notes = newEntry.notes
|
notes = newEntry.notes
|
||||||
for (entry in newEntry.customFields.entries) {
|
for (entry in newEntry.customFields.entries) {
|
||||||
post {
|
post {
|
||||||
addNewCustomField(entry.key, entry.value)
|
putCustomField(entry.key, entry.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +223,7 @@ class EntryEditActivity : LockingHideActivity(),
|
|||||||
password = entryView.password
|
password = entryView.password
|
||||||
notes = entryView.notes
|
notes = entryView.notes
|
||||||
entryView.customFields.forEach { customField ->
|
entryView.customFields.forEach { customField ->
|
||||||
addExtraField(customField.name, customField.protectedValue)
|
putExtraField(customField.name, customField.protectedValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,7 +249,7 @@ class EntryEditActivity : LockingHideActivity(),
|
|||||||
* Add a new customized field view and scroll to bottom
|
* Add a new customized field view and scroll to bottom
|
||||||
*/
|
*/
|
||||||
private fun addNewCustomField() {
|
private fun addNewCustomField() {
|
||||||
entryEditContentsView?.addNewCustomField()
|
entryEditContentsView?.addEmptyCustomField()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -304,6 +310,7 @@ class EntryEditActivity : LockingHideActivity(),
|
|||||||
val inflater = menuInflater
|
val inflater = menuInflater
|
||||||
inflater.inflate(R.menu.database_lock, menu)
|
inflater.inflate(R.menu.database_lock, menu)
|
||||||
MenuUtil.contributionMenuInflater(inflater, menu)
|
MenuUtil.contributionMenuInflater(inflater, menu)
|
||||||
|
inflater.inflate(R.menu.edit_entry, menu)
|
||||||
|
|
||||||
entryEditActivityEducation?.let {
|
entryEditActivityEducation?.let {
|
||||||
Handler().post { performedNextEducation(it) }
|
Handler().post { performedNextEducation(it) }
|
||||||
@@ -314,7 +321,7 @@ class EntryEditActivity : LockingHideActivity(),
|
|||||||
|
|
||||||
private fun performedNextEducation(entryEditActivityEducation: EntryEditActivityEducation) {
|
private fun performedNextEducation(entryEditActivityEducation: EntryEditActivityEducation) {
|
||||||
val passwordView = entryEditContentsView?.generatePasswordView
|
val passwordView = entryEditContentsView?.generatePasswordView
|
||||||
val addNewFieldView = entryEditContentsView?.addNewFieldView
|
val addNewFieldView = entryEditContentsView?.addNewFieldButton
|
||||||
|
|
||||||
val generatePasswordEducationPerformed = passwordView != null
|
val generatePasswordEducationPerformed = passwordView != null
|
||||||
&& entryEditActivityEducation.checkAndPerformedGeneratePasswordEducation(
|
&& entryEditActivityEducation.checkAndPerformedGeneratePasswordEducation(
|
||||||
@@ -350,12 +357,28 @@ class EntryEditActivity : LockingHideActivity(),
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.menu_add_otp -> {
|
||||||
|
// Retrieve the current otpElement if exists
|
||||||
|
// and open the dialog to set up the OTP
|
||||||
|
SetOTPDialogFragment.build(mEntry?.getOtpElement()?.otpModel)
|
||||||
|
.show(supportFragmentManager, "addOTPDialog")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
android.R.id.home -> finish()
|
android.R.id.home -> finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.onOptionsItemSelected(item)
|
return super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onOtpCreated(otpElement: OtpElement) {
|
||||||
|
// Update the otp field with otpauth:// url
|
||||||
|
val otpField = OtpEntryFields.buildOtpField(otpElement,
|
||||||
|
mEntry?.title, mEntry?.username)
|
||||||
|
entryEditContentsView?.putCustomField(otpField.name, otpField.protectedValue)
|
||||||
|
mEntry?.putExtraField(otpField.name, otpField.protectedValue)
|
||||||
|
}
|
||||||
|
|
||||||
override fun iconPicked(bundle: Bundle) {
|
override fun iconPicked(bundle: Bundle) {
|
||||||
IconPickerDialogFragment.getIconStandardFromBundle(bundle)?.let { icon ->
|
IconPickerDialogFragment.getIconStandardFromBundle(bundle)?.let { icon ->
|
||||||
temporarilySaveAndShowSelectedIcon(icon)
|
temporarilySaveAndShowSelectedIcon(icon)
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package com.kunzisoft.keepass.activities.dialogs
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.AdapterView
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.Spinner
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
|
import com.kunzisoft.keepass.BuildConfig
|
||||||
|
import com.kunzisoft.keepass.R
|
||||||
|
import com.kunzisoft.keepass.model.OtpModel
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement.Companion.MAX_HOTP_COUNTER
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement.Companion.MAX_OTP_DIGITS
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement.Companion.MAX_TOTP_PERIOD
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_HOTP_COUNTER
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_OTP_DIGITS
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement.Companion.MIN_TOTP_PERIOD
|
||||||
|
import com.kunzisoft.keepass.otp.OtpTokenType
|
||||||
|
import com.kunzisoft.keepass.otp.OtpType
|
||||||
|
import com.kunzisoft.keepass.otp.TokenCalculator
|
||||||
|
|
||||||
|
class SetOTPDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
|
private var mCreateOTPElementListener: CreateOtpListener? = null
|
||||||
|
|
||||||
|
private var mOtpElement: OtpElement = OtpElement()
|
||||||
|
|
||||||
|
private var otpTypeSpinner: Spinner? = null
|
||||||
|
private var otpTokenTypeSpinner: Spinner? = null
|
||||||
|
private var otpSecretContainer: TextInputLayout? = null
|
||||||
|
private var otpSecretTextView: EditText? = null
|
||||||
|
private var otpPeriodContainer: TextInputLayout? = null
|
||||||
|
private var otpPeriodTextView: EditText? = null
|
||||||
|
private var otpCounterContainer: TextInputLayout? = null
|
||||||
|
private var otpCounterTextView: EditText? = null
|
||||||
|
private var otpDigitsContainer: TextInputLayout? = null
|
||||||
|
private var otpDigitsTextView: EditText? = null
|
||||||
|
private var otpAlgorithmSpinner: Spinner? = null
|
||||||
|
|
||||||
|
private var otpTypeAdapter: ArrayAdapter<OtpType>? = null
|
||||||
|
private var otpTokenTypeAdapter: ArrayAdapter<OtpTokenType>? = null
|
||||||
|
private var totpTokenTypeAdapter: ArrayAdapter<OtpTokenType>? = null
|
||||||
|
private var hotpTokenTypeAdapter: ArrayAdapter<OtpTokenType>? = null
|
||||||
|
private var otpAlgorithmAdapter: ArrayAdapter<TokenCalculator.HashAlgorithm>? = null
|
||||||
|
|
||||||
|
private var mManualEvent = false
|
||||||
|
private var touchListener = View.OnTouchListener { _, event ->
|
||||||
|
when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
mManualEvent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mSecretWellFormed = false
|
||||||
|
private var mCounterWellFormed = true
|
||||||
|
private var mPeriodWellFormed = true
|
||||||
|
private var mDigitsWellFormed = true
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
// Verify that the host activity implements the callback interface
|
||||||
|
try {
|
||||||
|
// Instantiate the NoticeDialogListener so we can send events to the host
|
||||||
|
mCreateOTPElementListener = context as CreateOtpListener
|
||||||
|
} catch (e: ClassCastException) {
|
||||||
|
// The activity doesn't implement the interface, throw exception
|
||||||
|
throw ClassCastException(context.toString()
|
||||||
|
+ " must implement " + CreateOtpListener::class.java.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
|
||||||
|
// Retrieve OTP model from instance state
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
if (savedInstanceState.containsKey(KEY_OTP)) {
|
||||||
|
savedInstanceState.getParcelable<OtpModel>(KEY_OTP)?.let { otpModel ->
|
||||||
|
mOtpElement = OtpElement(otpModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
arguments?.apply {
|
||||||
|
if (containsKey(KEY_OTP)) {
|
||||||
|
getParcelable<OtpModel?>(KEY_OTP)?.let { otpModel ->
|
||||||
|
mOtpElement = OtpElement(otpModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activity?.let { activity ->
|
||||||
|
val root = activity.layoutInflater.inflate(R.layout.fragment_set_otp, null) as ViewGroup?
|
||||||
|
otpTypeSpinner = root?.findViewById(R.id.setup_otp_type)
|
||||||
|
otpTokenTypeSpinner = root?.findViewById(R.id.setup_otp_token_type)
|
||||||
|
otpSecretContainer = root?.findViewById(R.id.setup_otp_secret_label)
|
||||||
|
otpSecretTextView = root?.findViewById(R.id.setup_otp_secret)
|
||||||
|
otpAlgorithmSpinner = root?.findViewById(R.id.setup_otp_algorithm)
|
||||||
|
otpPeriodContainer= root?.findViewById(R.id.setup_otp_period_label)
|
||||||
|
otpPeriodTextView = root?.findViewById(R.id.setup_otp_period)
|
||||||
|
otpCounterContainer= root?.findViewById(R.id.setup_otp_counter_label)
|
||||||
|
otpCounterTextView = root?.findViewById(R.id.setup_otp_counter)
|
||||||
|
otpDigitsContainer = root?.findViewById(R.id.setup_otp_digits_label)
|
||||||
|
otpDigitsTextView = root?.findViewById(R.id.setup_otp_digits)
|
||||||
|
|
||||||
|
// To fix init element
|
||||||
|
otpTypeSpinner?.setOnTouchListener(touchListener)
|
||||||
|
otpTokenTypeSpinner?.setOnTouchListener(touchListener)
|
||||||
|
otpAlgorithmSpinner?.setOnTouchListener(touchListener)
|
||||||
|
otpSecretTextView?.setOnTouchListener(touchListener)
|
||||||
|
otpPeriodTextView?.setOnTouchListener(touchListener)
|
||||||
|
otpCounterTextView?.setOnTouchListener(touchListener)
|
||||||
|
otpDigitsTextView?.setOnTouchListener(touchListener)
|
||||||
|
|
||||||
|
// HOTP / TOTP Type selection
|
||||||
|
val otpTypeArray = OtpType.values()
|
||||||
|
otpTypeAdapter = ArrayAdapter<OtpType>(activity,
|
||||||
|
android.R.layout.simple_spinner_item, otpTypeArray).apply {
|
||||||
|
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||||
|
}
|
||||||
|
otpTypeSpinner?.adapter = otpTypeAdapter
|
||||||
|
|
||||||
|
// Otp Token type selection
|
||||||
|
val hotpTokenTypeArray = OtpTokenType.getHotpTokenTypeValues()
|
||||||
|
hotpTokenTypeAdapter = ArrayAdapter(activity,
|
||||||
|
android.R.layout.simple_spinner_item, hotpTokenTypeArray).apply {
|
||||||
|
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||||
|
}
|
||||||
|
val totpTokenTypeArray = OtpTokenType.getTotpTokenTypeValues(BuildConfig.CLOSED_STORE)
|
||||||
|
totpTokenTypeAdapter = ArrayAdapter(activity,
|
||||||
|
android.R.layout.simple_spinner_item, totpTokenTypeArray).apply {
|
||||||
|
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||||
|
}
|
||||||
|
otpTokenTypeAdapter = hotpTokenTypeAdapter
|
||||||
|
otpTokenTypeSpinner?.adapter = otpTokenTypeAdapter
|
||||||
|
|
||||||
|
// OTP Algorithm
|
||||||
|
val otpAlgorithmArray = TokenCalculator.HashAlgorithm.values()
|
||||||
|
otpAlgorithmAdapter = ArrayAdapter<TokenCalculator.HashAlgorithm>(activity,
|
||||||
|
android.R.layout.simple_spinner_item, otpAlgorithmArray).apply {
|
||||||
|
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||||
|
}
|
||||||
|
otpAlgorithmSpinner?.adapter = otpAlgorithmAdapter
|
||||||
|
|
||||||
|
// Set the default value of OTP element
|
||||||
|
upgradeType()
|
||||||
|
upgradeTokenType()
|
||||||
|
upgradeParameters()
|
||||||
|
|
||||||
|
attachListeners()
|
||||||
|
|
||||||
|
val builder = AlertDialog.Builder(activity)
|
||||||
|
builder.apply {
|
||||||
|
setTitle(R.string.entry_setup_otp)
|
||||||
|
setView(root)
|
||||||
|
.setPositiveButton(android.R.string.ok) {_, _ -> }
|
||||||
|
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.create()
|
||||||
|
}
|
||||||
|
return super.onCreateDialog(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
(dialog as AlertDialog).getButton(Dialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
|
if (mSecretWellFormed
|
||||||
|
&& mCounterWellFormed
|
||||||
|
&& mPeriodWellFormed
|
||||||
|
&& mDigitsWellFormed) {
|
||||||
|
mCreateOTPElementListener?.onOtpCreated(mOtpElement)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attachListeners() {
|
||||||
|
// Set Type listener
|
||||||
|
otpTypeSpinner?.onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||||
|
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
if (mManualEvent) {
|
||||||
|
(parent?.selectedItem as OtpType?)?.let {
|
||||||
|
mOtpElement.type = it
|
||||||
|
upgradeTokenType()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set type token listener
|
||||||
|
otpTokenTypeSpinner?.onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||||
|
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
if (mManualEvent) {
|
||||||
|
(parent?.selectedItem as OtpTokenType?)?.let {
|
||||||
|
mOtpElement.tokenType = it
|
||||||
|
upgradeParameters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set algorithm spinner
|
||||||
|
otpAlgorithmSpinner?.onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||||
|
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
if (mManualEvent) {
|
||||||
|
(parent?.selectedItem as TokenCalculator.HashAlgorithm?)?.let {
|
||||||
|
mOtpElement.algorithm = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set secret in OtpElement
|
||||||
|
otpSecretTextView?.addTextChangedListener(object: TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
if (mManualEvent) {
|
||||||
|
s?.toString()?.let { userString ->
|
||||||
|
try {
|
||||||
|
mOtpElement.setBase32Secret(userString)
|
||||||
|
otpSecretContainer?.error = null
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
otpSecretContainer?.error = getString(R.string.error_otp_secret_key)
|
||||||
|
}
|
||||||
|
mSecretWellFormed = otpSecretContainer?.error == null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set counter in OtpElement
|
||||||
|
otpCounterTextView?.addTextChangedListener(object: TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
if (mManualEvent) {
|
||||||
|
s?.toString()?.toLongOrNull()?.let {
|
||||||
|
try {
|
||||||
|
mOtpElement.counter = it
|
||||||
|
otpCounterContainer?.error = null
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
otpCounterContainer?.error = getString(R.string.error_otp_counter,
|
||||||
|
MIN_HOTP_COUNTER, MAX_HOTP_COUNTER)
|
||||||
|
}
|
||||||
|
mCounterWellFormed = otpCounterContainer?.error == null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set period in OtpElement
|
||||||
|
otpPeriodTextView?.addTextChangedListener(object: TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
if (mManualEvent) {
|
||||||
|
s?.toString()?.toIntOrNull()?.let {
|
||||||
|
try {
|
||||||
|
mOtpElement.period = it
|
||||||
|
otpPeriodContainer?.error = null
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
otpPeriodContainer?.error = getString(R.string.error_otp_period,
|
||||||
|
MIN_TOTP_PERIOD, MAX_TOTP_PERIOD)
|
||||||
|
}
|
||||||
|
mPeriodWellFormed = otpPeriodContainer?.error == null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set digits in OtpElement
|
||||||
|
otpDigitsTextView?.addTextChangedListener(object: TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
if (mManualEvent) {
|
||||||
|
s?.toString()?.toIntOrNull()?.let {
|
||||||
|
try {
|
||||||
|
mOtpElement.digits = it
|
||||||
|
otpDigitsContainer?.error = null
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
otpDigitsContainer?.error = getString(R.string.error_otp_digits,
|
||||||
|
MIN_OTP_DIGITS, MAX_OTP_DIGITS)
|
||||||
|
}
|
||||||
|
mDigitsWellFormed = otpDigitsContainer?.error == null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upgradeType() {
|
||||||
|
otpTypeSpinner?.setSelection(OtpType.values().indexOf(mOtpElement.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upgradeTokenType() {
|
||||||
|
when (mOtpElement.type) {
|
||||||
|
OtpType.HOTP -> {
|
||||||
|
otpPeriodContainer?.visibility = View.GONE
|
||||||
|
otpCounterContainer?.visibility = View.VISIBLE
|
||||||
|
otpTokenTypeSpinner?.adapter = hotpTokenTypeAdapter
|
||||||
|
otpTokenTypeSpinner?.setSelection(OtpTokenType
|
||||||
|
.getHotpTokenTypeValues().indexOf(mOtpElement.tokenType))
|
||||||
|
}
|
||||||
|
OtpType.TOTP -> {
|
||||||
|
otpPeriodContainer?.visibility = View.VISIBLE
|
||||||
|
otpCounterContainer?.visibility = View.GONE
|
||||||
|
otpTokenTypeSpinner?.adapter = totpTokenTypeAdapter
|
||||||
|
otpTokenTypeSpinner?.setSelection(OtpTokenType
|
||||||
|
.getTotpTokenTypeValues().indexOf(mOtpElement.tokenType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upgradeParameters() {
|
||||||
|
otpAlgorithmSpinner?.setSelection(TokenCalculator.HashAlgorithm.values()
|
||||||
|
.indexOf(mOtpElement.algorithm))
|
||||||
|
otpSecretTextView?.setText(mOtpElement.getBase32Secret())
|
||||||
|
otpCounterTextView?.setText(mOtpElement.counter.toString())
|
||||||
|
otpPeriodTextView?.setText(mOtpElement.period.toString())
|
||||||
|
otpDigitsTextView?.setText(mOtpElement.digits.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.putParcelable(KEY_OTP, mOtpElement.otpModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateOtpListener {
|
||||||
|
fun onOtpCreated(otpElement: OtpElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val KEY_OTP = "KEY_OTP"
|
||||||
|
|
||||||
|
fun build(otpModel: OtpModel? = null): SetOTPDialogFragment {
|
||||||
|
return SetOTPDialogFragment().apply {
|
||||||
|
if (otpModel != null) {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putParcelable(KEY_OTP, otpModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ class FieldsAdapter(context: Context) : RecyclerView.Adapter<FieldsAdapter.Field
|
|||||||
var fields: MutableList<Field> = ArrayList()
|
var fields: MutableList<Field> = ArrayList()
|
||||||
var onItemClickListener: OnItemClickListener? = null
|
var onItemClickListener: OnItemClickListener? = null
|
||||||
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldViewHolder {
|
||||||
val view = inflater.inflate(R.layout.keyboard_popup_fields_item, parent, false)
|
val view = inflater.inflate(R.layout.keyboard_popup_fields_item, parent, false)
|
||||||
return FieldViewHolder(view)
|
return FieldViewHolder(view)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.kunzisoft.keepass.crypto.keyDerivation
|
package com.kunzisoft.keepass.crypto.keyDerivation
|
||||||
|
|
||||||
import com.kunzisoft.keepass.database.ObjectNameResource
|
import com.kunzisoft.keepass.utils.ObjectNameResource
|
||||||
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ExtraFieldCursor : MatrixCursor(arrayOf(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun populateExtraFieldInEntry(pwEntry: PwEntryV4) {
|
fun populateExtraFieldInEntry(pwEntry: PwEntryV4) {
|
||||||
pwEntry.addExtraField(getString(getColumnIndex(COLUMN_LABEL)),
|
pwEntry.putExtraField(getString(getColumnIndex(COLUMN_LABEL)),
|
||||||
ProtectedString(getInt(getColumnIndex(COLUMN_PROTECTION)) > 0,
|
ProtectedString(getInt(getColumnIndex(COLUMN_PROTECTION)) > 0,
|
||||||
getString(getColumnIndex(COLUMN_VALUE))))
|
getString(getColumnIndex(COLUMN_VALUE))))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import android.os.Parcelable
|
|||||||
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
||||||
import com.kunzisoft.keepass.model.EntryInfo
|
import com.kunzisoft.keepass.model.EntryInfo
|
||||||
import com.kunzisoft.keepass.model.Field
|
import com.kunzisoft.keepass.model.Field
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement
|
||||||
|
import com.kunzisoft.keepass.otp.OtpEntryFields
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.collections.ArrayList
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
@@ -249,12 +251,18 @@ class EntryVersioned : NodeVersioned, PwEntryInterface<GroupVersioned> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add an extra field to the list (standard or custom)
|
* Update or add an extra field to the list (standard or custom)
|
||||||
* @param label Label of field, must be unique
|
* @param label Label of field, must be unique
|
||||||
* @param value Value of field
|
* @param value Value of field
|
||||||
*/
|
*/
|
||||||
fun addExtraField(label: String, value: ProtectedString) {
|
fun putExtraField(label: String, value: ProtectedString) {
|
||||||
pwEntryV4?.addExtraField(label, value)
|
pwEntryV4?.putExtraField(label, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getOtpElement(): OtpElement? {
|
||||||
|
return OtpEntryFields.parseFields { key ->
|
||||||
|
customFields[key]?.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startToManageFieldReferences(db: PwDatabaseV4) {
|
fun startToManageFieldReferences(db: PwDatabaseV4) {
|
||||||
@@ -302,6 +310,10 @@ class EntryVersioned : NodeVersioned, PwEntryInterface<GroupVersioned> {
|
|||||||
------------
|
------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve generated entry info,
|
||||||
|
* Remove parameter fields and add auto generated elements in auto custom fields
|
||||||
|
*/
|
||||||
fun getEntryInfo(database: Database?, raw: Boolean = false): EntryInfo {
|
fun getEntryInfo(database: Database?, raw: Boolean = false): EntryInfo {
|
||||||
val entryInfo = EntryInfo()
|
val entryInfo = EntryInfo()
|
||||||
if (raw)
|
if (raw)
|
||||||
@@ -318,6 +330,10 @@ class EntryVersioned : NodeVersioned, PwEntryInterface<GroupVersioned> {
|
|||||||
entryInfo.customFields.add(
|
entryInfo.customFields.add(
|
||||||
Field(entry.key, entry.value))
|
Field(entry.key, entry.value))
|
||||||
}
|
}
|
||||||
|
// Add otpElement to generate token
|
||||||
|
entryInfo.otpModel = getOtpElement()?.otpModel
|
||||||
|
// Replace parameter fields by generated OTP fields
|
||||||
|
entryInfo.customFields = OtpEntryFields.generateAutoFields(entryInfo.customFields)
|
||||||
if (!raw)
|
if (!raw)
|
||||||
database?.stopManageEntry(this)
|
database?.stopManageEntry(this)
|
||||||
return entryInfo
|
return entryInfo
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ package com.kunzisoft.keepass.database.element
|
|||||||
|
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import com.kunzisoft.keepass.R
|
import com.kunzisoft.keepass.R
|
||||||
import com.kunzisoft.keepass.database.ObjectNameResource
|
import com.kunzisoft.keepass.utils.ObjectNameResource
|
||||||
|
|
||||||
// Note: We can get away with using int's to store unsigned 32-bit ints
|
// Note: We can get away with using int's to store unsigned 32-bit ints
|
||||||
// since we won't do arithmetic on these values (also unlikely to
|
// since we won't do arithmetic on these values (also unlikely to
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import com.kunzisoft.keepass.crypto.engine.AesEngine
|
|||||||
import com.kunzisoft.keepass.crypto.engine.ChaCha20Engine
|
import com.kunzisoft.keepass.crypto.engine.ChaCha20Engine
|
||||||
import com.kunzisoft.keepass.crypto.engine.CipherEngine
|
import com.kunzisoft.keepass.crypto.engine.CipherEngine
|
||||||
import com.kunzisoft.keepass.crypto.engine.TwofishEngine
|
import com.kunzisoft.keepass.crypto.engine.TwofishEngine
|
||||||
import com.kunzisoft.keepass.database.ObjectNameResource
|
import com.kunzisoft.keepass.utils.ObjectNameResource
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ class PwEntryV4 : PwEntry<UUID, UUID, PwGroupV4, PwEntryV4>, PwNodeV4Interface {
|
|||||||
fields.clear()
|
fields.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addExtraField(label: String, value: ProtectedString) {
|
fun putExtraField(label: String, value: ProtectedString) {
|
||||||
fields[label] = value
|
fields[label] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -742,7 +742,7 @@ class ImporterV4(private val streamDir: File,
|
|||||||
return KdbContext.Entry
|
return KdbContext.Entry
|
||||||
} else if (ctx == KdbContext.EntryString && name.equals(PwDatabaseV4XML.ElemString, ignoreCase = true)) {
|
} else if (ctx == KdbContext.EntryString && name.equals(PwDatabaseV4XML.ElemString, ignoreCase = true)) {
|
||||||
if (ctxStringName != null && ctxStringValue != null)
|
if (ctxStringName != null && ctxStringValue != null)
|
||||||
ctxEntry?.addExtraField(ctxStringName!!, ctxStringValue!!)
|
ctxEntry?.putExtraField(ctxStringName!!, ctxStringValue!!)
|
||||||
ctxStringName = null
|
ctxStringName = null
|
||||||
ctxStringValue = null
|
ctxStringValue = null
|
||||||
|
|
||||||
|
|||||||
@@ -100,19 +100,24 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
|||||||
val popupFieldsView = LayoutInflater.from(context)
|
val popupFieldsView = LayoutInflater.from(context)
|
||||||
.inflate(R.layout.keyboard_popup_fields, FrameLayout(context))
|
.inflate(R.layout.keyboard_popup_fields, FrameLayout(context))
|
||||||
|
|
||||||
popupCustomKeys?.dismiss()
|
dismissCustomKeys()
|
||||||
|
popupCustomKeys = PopupWindow(context).apply {
|
||||||
popupCustomKeys = PopupWindow(context)
|
width = WindowManager.LayoutParams.WRAP_CONTENT
|
||||||
popupCustomKeys?.width = WindowManager.LayoutParams.WRAP_CONTENT
|
height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||||
popupCustomKeys?.height = WindowManager.LayoutParams.WRAP_CONTENT
|
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
|
||||||
popupCustomKeys?.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
|
inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED
|
||||||
popupCustomKeys?.inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED
|
contentView = popupFieldsView
|
||||||
popupCustomKeys?.contentView = popupFieldsView
|
}
|
||||||
|
|
||||||
val recyclerView = popupFieldsView.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.keyboard_popup_fields_list)
|
val recyclerView = popupFieldsView.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.keyboard_popup_fields_list)
|
||||||
fieldsAdapter = FieldsAdapter(this)
|
fieldsAdapter = FieldsAdapter(this)
|
||||||
fieldsAdapter?.onItemClickListener = object : FieldsAdapter.OnItemClickListener {
|
fieldsAdapter?.onItemClickListener = object : FieldsAdapter.OnItemClickListener {
|
||||||
override fun onItemClick(item: Field) {
|
override fun onItemClick(item: Field) {
|
||||||
|
if (entryInfoKey?.isAutoGeneratedField(item) == true) {
|
||||||
|
entryInfoKey?.doForAutoGeneratedField(item) { valueGenerated ->
|
||||||
|
currentInputConnection.commitText(valueGenerated, 1)
|
||||||
|
}
|
||||||
|
} else
|
||||||
currentInputConnection.commitText(item.protectedValue.toString(), 1)
|
currentInputConnection.commitText(item.protectedValue.toString(), 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +134,7 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun assignKeyboardView() {
|
private fun assignKeyboardView() {
|
||||||
|
dismissCustomKeys()
|
||||||
if (keyboardView != null) {
|
if (keyboardView != null) {
|
||||||
if (entryInfoKey != null) {
|
if (entryInfoKey != null) {
|
||||||
if (keyboardEntry != null) {
|
if (keyboardEntry != null) {
|
||||||
@@ -138,7 +144,6 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
|||||||
} else {
|
} else {
|
||||||
if (keyboard != null) {
|
if (keyboard != null) {
|
||||||
hideEntryInfo()
|
hideEntryInfo()
|
||||||
dismissCustomKeys()
|
|
||||||
keyboardView?.keyboard = keyboard
|
keyboardView?.keyboard = keyboard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +256,7 @@ class MagikIME : InputMethodService(), KeyboardView.OnKeyboardActionListener {
|
|||||||
KEY_FIELDS -> {
|
KEY_FIELDS -> {
|
||||||
if (entryInfoKey != null) {
|
if (entryInfoKey != null) {
|
||||||
fieldsAdapter?.fields = entryInfoKey!!.customFields
|
fieldsAdapter?.fields = entryInfoKey!!.customFields
|
||||||
|
fieldsAdapter?.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
popupCustomKeys?.showAtLocation(keyboardView, Gravity.END or Gravity.TOP, 0, 0)
|
popupCustomKeys?.showAtLocation(keyboardView, Gravity.END or Gravity.TOP, 0, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package com.kunzisoft.keepass.model
|
|||||||
|
|
||||||
import android.os.Parcel
|
import android.os.Parcel
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement
|
||||||
import java.util.ArrayList
|
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
class EntryInfo : Parcelable {
|
class EntryInfo : Parcelable {
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ class EntryInfo : Parcelable {
|
|||||||
var url: String = ""
|
var url: String = ""
|
||||||
var notes: String = ""
|
var notes: String = ""
|
||||||
var customFields: MutableList<Field> = ArrayList()
|
var customFields: MutableList<Field> = ArrayList()
|
||||||
|
var otpModel: OtpModel? = null
|
||||||
|
|
||||||
constructor()
|
constructor()
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ class EntryInfo : Parcelable {
|
|||||||
url = parcel.readString() ?: url
|
url = parcel.readString() ?: url
|
||||||
notes = parcel.readString() ?: notes
|
notes = parcel.readString() ?: notes
|
||||||
parcel.readList(customFields, Field::class.java.classLoader)
|
parcel.readList(customFields, Field::class.java.classLoader)
|
||||||
|
otpModel = parcel.readParcelable(OtpModel::class.java.classLoader) ?: otpModel
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun describeContents(): Int {
|
override fun describeContents(): Int {
|
||||||
@@ -39,6 +42,7 @@ class EntryInfo : Parcelable {
|
|||||||
parcel.writeString(url)
|
parcel.writeString(url)
|
||||||
parcel.writeString(notes)
|
parcel.writeString(notes)
|
||||||
parcel.writeArray(customFields.toTypedArray())
|
parcel.writeArray(customFields.toTypedArray())
|
||||||
|
parcel.writeParcelable(otpModel, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun containsCustomFieldsProtected(): Boolean {
|
fun containsCustomFieldsProtected(): Boolean {
|
||||||
@@ -49,6 +53,18 @@ class EntryInfo : Parcelable {
|
|||||||
return customFields.any { !it.protectedValue.isProtected }
|
return customFields.any { !it.protectedValue.isProtected }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isAutoGeneratedField(field: Field): Boolean {
|
||||||
|
return field.name == OTP_TOKEN_FIELD
|
||||||
|
}
|
||||||
|
|
||||||
|
fun doForAutoGeneratedField(field: Field, action: (valueGenerated: String) -> Unit) {
|
||||||
|
otpModel?.let {
|
||||||
|
if (field.name == OTP_TOKEN_FIELD) {
|
||||||
|
action.invoke(OtpElement(it).token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@JvmField
|
@JvmField
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class Field : Parcelable {
|
|||||||
var name: String = ""
|
var name: String = ""
|
||||||
var protectedValue: ProtectedString = ProtectedString()
|
var protectedValue: ProtectedString = ProtectedString()
|
||||||
|
|
||||||
constructor(name: String, value: ProtectedString) {
|
constructor(name: String, value: ProtectedString = ProtectedString()) {
|
||||||
this.name = name
|
this.name = name
|
||||||
this.protectedValue = value
|
this.protectedValue = value
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,21 @@ class Field : Parcelable {
|
|||||||
dest.writeParcelable(protectedValue, flags)
|
dest.writeParcelable(protectedValue, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as Field
|
||||||
|
|
||||||
|
if (name != other.name) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return name.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@JvmField
|
@JvmField
|
||||||
|
|||||||
95
app/src/main/java/com/kunzisoft/keepass/model/OtpModel.kt
Normal file
95
app/src/main/java/com/kunzisoft/keepass/model/OtpModel.kt
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package com.kunzisoft.keepass.model
|
||||||
|
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement
|
||||||
|
import com.kunzisoft.keepass.otp.OtpTokenType
|
||||||
|
import com.kunzisoft.keepass.otp.OtpType
|
||||||
|
import com.kunzisoft.keepass.otp.TokenCalculator
|
||||||
|
import com.kunzisoft.keepass.otp.TokenCalculator.OTP_DEFAULT_ALGORITHM
|
||||||
|
|
||||||
|
class OtpModel() : Parcelable {
|
||||||
|
|
||||||
|
var type: OtpType = OtpType.TOTP // ie : HOTP or TOTP
|
||||||
|
var tokenType: OtpTokenType = OtpTokenType.RFC6238
|
||||||
|
var name: String = "OTP" // ie : user@email.com
|
||||||
|
var issuer: String = "None" // ie : Gitlab
|
||||||
|
var secret: ByteArray? = null // Seed
|
||||||
|
var counter: Long = TokenCalculator.HOTP_INITIAL_COUNTER // ie : 5 - only for HOTP
|
||||||
|
var period: Int = TokenCalculator.TOTP_DEFAULT_PERIOD // ie : 30 seconds - only for TOTP
|
||||||
|
var digits: Int = TokenCalculator.OTP_DEFAULT_DIGITS
|
||||||
|
var algorithm: TokenCalculator.HashAlgorithm = OTP_DEFAULT_ALGORITHM
|
||||||
|
|
||||||
|
constructor(parcel: Parcel) : this() {
|
||||||
|
val typeRead = parcel.readInt()
|
||||||
|
type = OtpType.values()[typeRead]
|
||||||
|
tokenType = OtpTokenType.values()[parcel.readInt()]
|
||||||
|
name = parcel.readString() ?: name
|
||||||
|
issuer = parcel.readString() ?: issuer
|
||||||
|
secret = parcel.createByteArray() ?: secret
|
||||||
|
counter = parcel.readLong()
|
||||||
|
period = parcel.readInt()
|
||||||
|
digits = parcel.readInt()
|
||||||
|
algorithm = TokenCalculator.HashAlgorithm.values()[parcel.readInt()]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as OtpElement
|
||||||
|
|
||||||
|
if (type != other.type) return false
|
||||||
|
// Token type is important only if it's a TOTP
|
||||||
|
if (type == OtpType.TOTP && tokenType != other.tokenType) return false
|
||||||
|
if (secret == null || other.secret == null) return false
|
||||||
|
if (!secret!!.contentEquals(other.secret!!)) return false
|
||||||
|
// Counter only for HOTP
|
||||||
|
if (type == OtpType.HOTP && counter != other.counter) return false
|
||||||
|
// Step only for TOTP
|
||||||
|
if (type == OtpType.TOTP && period != other.period) return false
|
||||||
|
if (digits != other.digits) return false
|
||||||
|
if (algorithm != other.algorithm) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun describeContents(): Int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = type.hashCode()
|
||||||
|
result = 31 * result + tokenType.hashCode()
|
||||||
|
result = 31 * result + name.hashCode()
|
||||||
|
result = 31 * result + issuer.hashCode()
|
||||||
|
result = 31 * result + (secret?.contentHashCode() ?: 0)
|
||||||
|
result = 31 * result + counter.hashCode()
|
||||||
|
result = 31 * result + period
|
||||||
|
result = 31 * result + digits
|
||||||
|
result = 31 * result + algorithm.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||||
|
parcel.writeInt(type.ordinal)
|
||||||
|
parcel.writeInt(tokenType.ordinal)
|
||||||
|
parcel.writeString(name)
|
||||||
|
parcel.writeString(issuer)
|
||||||
|
parcel.writeByteArray(secret)
|
||||||
|
parcel.writeLong(counter)
|
||||||
|
parcel.writeInt(period)
|
||||||
|
parcel.writeInt(digits)
|
||||||
|
parcel.writeInt(algorithm.ordinal)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object CREATOR : Parcelable.Creator<OtpModel> {
|
||||||
|
override fun createFromParcel(parcel: Parcel): OtpModel {
|
||||||
|
return OtpModel(parcel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newArray(size: Int): Array<OtpModel?> {
|
||||||
|
return arrayOfNulls(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt
Normal file
215
app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package com.kunzisoft.keepass.otp
|
||||||
|
|
||||||
|
import com.kunzisoft.keepass.model.OtpModel
|
||||||
|
import org.apache.commons.codec.DecoderException
|
||||||
|
import org.apache.commons.codec.binary.Base32
|
||||||
|
import org.apache.commons.codec.binary.Base64
|
||||||
|
import org.apache.commons.codec.binary.Hex
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import java.util.*
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
data class OtpElement(var otpModel: OtpModel = OtpModel()) {
|
||||||
|
|
||||||
|
var type
|
||||||
|
get() = otpModel.type
|
||||||
|
set(value) {
|
||||||
|
otpModel.type = value
|
||||||
|
if (type == OtpType.HOTP) {
|
||||||
|
if (!OtpTokenType.getHotpTokenTypeValues().contains(tokenType))
|
||||||
|
tokenType = OtpTokenType.RFC4226
|
||||||
|
}
|
||||||
|
if (type == OtpType.TOTP) {
|
||||||
|
if (!OtpTokenType.getTotpTokenTypeValues().contains(tokenType))
|
||||||
|
tokenType = OtpTokenType.RFC6238
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenType
|
||||||
|
get() = otpModel.tokenType
|
||||||
|
set(value) {
|
||||||
|
otpModel.tokenType = value
|
||||||
|
when (tokenType) {
|
||||||
|
OtpTokenType.RFC4226 -> {
|
||||||
|
otpModel.algorithm = TokenCalculator.OTP_DEFAULT_ALGORITHM
|
||||||
|
otpModel.digits = TokenCalculator.OTP_DEFAULT_DIGITS
|
||||||
|
otpModel.counter = TokenCalculator.HOTP_INITIAL_COUNTER
|
||||||
|
}
|
||||||
|
OtpTokenType.RFC6238 -> {
|
||||||
|
otpModel.algorithm = TokenCalculator.OTP_DEFAULT_ALGORITHM
|
||||||
|
otpModel.digits = TokenCalculator.OTP_DEFAULT_DIGITS
|
||||||
|
otpModel.period = TokenCalculator.TOTP_DEFAULT_PERIOD
|
||||||
|
}
|
||||||
|
OtpTokenType.STEAM -> {
|
||||||
|
otpModel.algorithm = TokenCalculator.OTP_DEFAULT_ALGORITHM
|
||||||
|
otpModel.digits = TokenCalculator.STEAM_DEFAULT_DIGITS
|
||||||
|
otpModel.period = TokenCalculator.TOTP_DEFAULT_PERIOD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var name
|
||||||
|
get() = otpModel.name
|
||||||
|
set(value) {
|
||||||
|
otpModel.name = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var issuer
|
||||||
|
get() = otpModel.issuer
|
||||||
|
set(value) {
|
||||||
|
otpModel.issuer = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var secret
|
||||||
|
get() = otpModel.secret
|
||||||
|
private set(value) {
|
||||||
|
otpModel.secret = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var counter
|
||||||
|
get() = otpModel.counter
|
||||||
|
set(value) {
|
||||||
|
otpModel.counter = if (value < MIN_HOTP_COUNTER || value > MAX_HOTP_COUNTER) {
|
||||||
|
TokenCalculator.HOTP_INITIAL_COUNTER
|
||||||
|
throw NumberFormatException()
|
||||||
|
} else value
|
||||||
|
}
|
||||||
|
|
||||||
|
var period
|
||||||
|
get() = otpModel.period
|
||||||
|
set(value) {
|
||||||
|
otpModel.period = if (value < MIN_TOTP_PERIOD || value > MAX_TOTP_PERIOD) {
|
||||||
|
TokenCalculator.TOTP_DEFAULT_PERIOD
|
||||||
|
throw NumberFormatException()
|
||||||
|
} else value
|
||||||
|
}
|
||||||
|
|
||||||
|
var digits
|
||||||
|
get() = otpModel.digits
|
||||||
|
set(value) {
|
||||||
|
otpModel.digits = if (value < MIN_OTP_DIGITS|| value > MAX_OTP_DIGITS) {
|
||||||
|
TokenCalculator.OTP_DEFAULT_DIGITS
|
||||||
|
throw NumberFormatException()
|
||||||
|
} else value
|
||||||
|
}
|
||||||
|
|
||||||
|
var algorithm
|
||||||
|
get() = otpModel.algorithm
|
||||||
|
set(value) {
|
||||||
|
otpModel.algorithm = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUTF8Secret(secret: String) {
|
||||||
|
if (secret.isNotEmpty())
|
||||||
|
otpModel.secret = secret.toByteArray(Charset.forName("UTF-8"))
|
||||||
|
else
|
||||||
|
throw DecoderException()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHexSecret(secret: String) {
|
||||||
|
if (secret.isNotEmpty())
|
||||||
|
otpModel.secret = Hex.decodeHex(secret)
|
||||||
|
else
|
||||||
|
throw DecoderException()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBase32Secret(): String {
|
||||||
|
return otpModel.secret?.let {
|
||||||
|
Base32().encodeAsString(it)
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBase32Secret(secret: String) {
|
||||||
|
if (secret.isNotEmpty() && checkBase32Secret(secret))
|
||||||
|
otpModel.secret = Base32().decode(secret.toByteArray())
|
||||||
|
else
|
||||||
|
throw DecoderException()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBase64Secret(secret: String) {
|
||||||
|
if (secret.isNotEmpty() && checkBase64Secret(secret))
|
||||||
|
otpModel.secret = Base64().decode(secret.toByteArray())
|
||||||
|
else
|
||||||
|
throw DecoderException()
|
||||||
|
}
|
||||||
|
|
||||||
|
val token: String
|
||||||
|
get() {
|
||||||
|
if (secret == null)
|
||||||
|
return ""
|
||||||
|
return when (type) {
|
||||||
|
OtpType.HOTP -> TokenCalculator.HOTP(secret, counter, digits, algorithm)
|
||||||
|
OtpType.TOTP -> when (tokenType) {
|
||||||
|
OtpTokenType.STEAM -> TokenCalculator.TOTP_Steam(secret, period, digits, algorithm)
|
||||||
|
else -> TokenCalculator.TOTP_RFC6238(secret, period, digits, algorithm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val secondsRemaining: Int
|
||||||
|
get() = otpModel.period - (System.currentTimeMillis() / 1000 % otpModel.period).toInt()
|
||||||
|
|
||||||
|
fun shouldRefreshToken(): Boolean {
|
||||||
|
return secondsRemaining == otpModel.period
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MIN_HOTP_COUNTER = 1
|
||||||
|
const val MAX_HOTP_COUNTER = Long.MAX_VALUE
|
||||||
|
|
||||||
|
const val MIN_TOTP_PERIOD = 1
|
||||||
|
const val MAX_TOTP_PERIOD = 60
|
||||||
|
|
||||||
|
const val MIN_OTP_DIGITS = 4
|
||||||
|
const val MAX_OTP_DIGITS = 18
|
||||||
|
|
||||||
|
fun checkBase32Secret(secret: String): Boolean {
|
||||||
|
return (Pattern.matches("^(?:[A-Z2-7]{8})*(?:[A-Z2-7]{2}={6}|[A-Z2-7]{4}={4}|[A-Z2-7]{5}={3}|[A-Z2-7]{7}=)?$", secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkBase64Secret(secret: String): Boolean {
|
||||||
|
return (Pattern.matches("^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", secret))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class OtpType {
|
||||||
|
HOTP, // counter based
|
||||||
|
TOTP; // time based
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class OtpTokenType {
|
||||||
|
RFC4226, // HOTP
|
||||||
|
RFC6238, // TOTP
|
||||||
|
|
||||||
|
// Proprietary
|
||||||
|
STEAM; // TOTP Steam
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return when (this) {
|
||||||
|
STEAM -> "steam"
|
||||||
|
else -> super.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getFromString(tokenType: String): OtpTokenType {
|
||||||
|
return when (tokenType.toLowerCase(Locale.ENGLISH)) {
|
||||||
|
"s", "steam" -> STEAM
|
||||||
|
"hotp" -> RFC4226
|
||||||
|
else -> RFC6238
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTotpTokenTypeValues(getProprietaryElements: Boolean = true): Array<OtpTokenType> {
|
||||||
|
return if (getProprietaryElements)
|
||||||
|
arrayOf(RFC6238, STEAM)
|
||||||
|
else
|
||||||
|
arrayOf(RFC6238)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getHotpTokenTypeValues(): Array<OtpTokenType> {
|
||||||
|
return arrayOf(RFC4226)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
386
app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt
Normal file
386
app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||||
|
*
|
||||||
|
* This file is part of KeePass DX.
|
||||||
|
*
|
||||||
|
* KeePass DX 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.
|
||||||
|
*
|
||||||
|
* KeePass DX 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 KeePass DX. If not,
|
||||||
|
* see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
* This code is based on KeePassXC code
|
||||||
|
* https://github.com/keepassxreboot/keepassxc/blob/master/src/totp/totp.cpp
|
||||||
|
* https://github.com/keepassxreboot/keepassxc/blob/master/src/core/Entry.cpp
|
||||||
|
*/
|
||||||
|
package com.kunzisoft.keepass.otp
|
||||||
|
|
||||||
|
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.TokenCalculator.*
|
||||||
|
import java.lang.Exception
|
||||||
|
import java.lang.StringBuilder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.util.*
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
object OtpEntryFields {
|
||||||
|
|
||||||
|
private val TAG = OtpEntryFields::class.java.name
|
||||||
|
|
||||||
|
// Field from KeePassXC
|
||||||
|
private const val OTP_FIELD = "otp"
|
||||||
|
|
||||||
|
// URL parameters (https://github.com/google/google-authenticator/wiki/Key-Uri-Format)
|
||||||
|
private const val OTP_SCHEME = "otpauth"
|
||||||
|
private const val TOTP_AUTHORITY = "totp" // time-based
|
||||||
|
private const val HOTP_AUTHORITY = "hotp" // counter-based
|
||||||
|
private const val ALGORITHM_URL_PARAM = "algorithm"
|
||||||
|
private const val ISSUER_URL_PARAM = "issuer"
|
||||||
|
private const val SECRET_URL_PARAM = "secret"
|
||||||
|
private const val DIGITS_URL_PARAM = "digits"
|
||||||
|
private const val PERIOD_URL_PARAM = "period"
|
||||||
|
private const val ENCODER_URL_PARAM = "encoder"
|
||||||
|
private const val COUNTER_URL_PARAM = "counter"
|
||||||
|
|
||||||
|
// Key-values (maybe from plugin or old KeePassXC)
|
||||||
|
private const val SEED_KEY = "key"
|
||||||
|
private const val DIGITS_KEY = "size"
|
||||||
|
private const val STEP_KEY = "step"
|
||||||
|
|
||||||
|
// HmacOtp KeePass2 values (https://keepass.info/help/base/placeholders.html#hmacotp)
|
||||||
|
private const val HMACOTP_SECRET_FIELD = "HmacOtp-Secret"
|
||||||
|
private const val HMACOTP_SECRET_HEX_FIELD = "HmacOtp-Secret-Hex"
|
||||||
|
private const val HMACOTP_SECRET_BASE32_FIELD = "HmacOtp-Secret-Base32"
|
||||||
|
private const val HMACOTP_SECRET_BASE64_FIELD = "HmacOtp-Secret-Base64"
|
||||||
|
private const val HMACOTP_SECRET_COUNTER_FIELD = "HmacOtp-Counter"
|
||||||
|
|
||||||
|
// Custom fields (maybe from plugin)
|
||||||
|
private const val TOTP_SEED_FIELD = "TOTP Seed"
|
||||||
|
private const val TOTP_SETTING_FIELD = "TOTP Settings"
|
||||||
|
|
||||||
|
// Token field, use dynamically to generate OTP token
|
||||||
|
const val OTP_TOKEN_FIELD = "OTP Token"
|
||||||
|
|
||||||
|
// Logical breakdown of key=value regex. the final string is as follows:
|
||||||
|
// [^&=\s]+=[^&=\s]+(&[^&=\s]+=[^&=\s]+)*
|
||||||
|
private const val validKeyValue = "[^&=\\s]+"
|
||||||
|
private const val validKeyValuePair = "$validKeyValue=$validKeyValue"
|
||||||
|
private const val validKeyValueRegex = "$validKeyValuePair&($validKeyValuePair)*"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse fields of an entry to retrieve an OtpElement
|
||||||
|
*/
|
||||||
|
fun parseFields(getField: (id: String) -> String?): OtpElement? {
|
||||||
|
val otpElement = OtpElement()
|
||||||
|
// OTP (HOTP/TOTP) from URL and field from KeePassXC
|
||||||
|
if (parseOTPUri(getField, otpElement))
|
||||||
|
return otpElement
|
||||||
|
// TOTP from key values (maybe plugin or old KeePassXC)
|
||||||
|
if (parseTOTPKeyValues(getField, otpElement))
|
||||||
|
return otpElement
|
||||||
|
// TOTP from custom field
|
||||||
|
if (parseTOTPFromField(getField, otpElement))
|
||||||
|
return otpElement
|
||||||
|
// HOTP fields from KeePass 2
|
||||||
|
if (parseHOTPFromField(getField, otpElement))
|
||||||
|
return otpElement
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a secret value from a URI. The format will be:
|
||||||
|
*
|
||||||
|
* otpauth://totp/user@example.com?secret=FFF...
|
||||||
|
*
|
||||||
|
* otpauth://hotp/user@example.com?secret=FFF...&counter=123
|
||||||
|
*/
|
||||||
|
private fun parseOTPUri(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||||
|
val otpPlainText = getField(OTP_FIELD)
|
||||||
|
if (otpPlainText != null && otpPlainText.isNotEmpty()) {
|
||||||
|
val uri = Uri.parse(replaceChars(otpPlainText))
|
||||||
|
|
||||||
|
if (uri.scheme == null || OTP_SCHEME != uri.scheme!!.toLowerCase(Locale.ENGLISH)) {
|
||||||
|
Log.e(TAG, "Invalid or missing scheme in uri")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val authority = uri.authority
|
||||||
|
if (TOTP_AUTHORITY == authority) {
|
||||||
|
otpElement.type = OtpType.TOTP
|
||||||
|
|
||||||
|
} else if (HOTP_AUTHORITY == authority) {
|
||||||
|
otpElement.type = OtpType.HOTP
|
||||||
|
|
||||||
|
val counterParameter = uri.getQueryParameter(COUNTER_URL_PARAM)
|
||||||
|
if (counterParameter != null) {
|
||||||
|
try {
|
||||||
|
otpElement.counter = counterParameter.toLongOrNull() ?: HOTP_INITIAL_COUNTER
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
Log.e(TAG, "Invalid counter in uri")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Invalid or missing authority in uri")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val nameParam = validateAndGetNameInPath(uri.path)
|
||||||
|
if (nameParam != null && nameParam.isNotEmpty())
|
||||||
|
otpElement.name = nameParam
|
||||||
|
|
||||||
|
val issuerParam = uri.getQueryParameter(ISSUER_URL_PARAM)
|
||||||
|
if (issuerParam != null && issuerParam.isNotEmpty())
|
||||||
|
otpElement.issuer = issuerParam
|
||||||
|
|
||||||
|
val secretParam = uri.getQueryParameter(SECRET_URL_PARAM)
|
||||||
|
if (secretParam != null && secretParam.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
otpElement.setBase32Secret(secretParam)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.e(TAG, "Unable to retrieve OTP secret.", exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val encoderParam = uri.getQueryParameter(ENCODER_URL_PARAM)
|
||||||
|
if (encoderParam != null && encoderParam.isNotEmpty())
|
||||||
|
otpElement.tokenType = OtpTokenType.getFromString(encoderParam)
|
||||||
|
|
||||||
|
val digitsParam = uri.getQueryParameter(DIGITS_URL_PARAM)
|
||||||
|
if (digitsParam != null && digitsParam.isNotEmpty())
|
||||||
|
otpElement.digits = try {
|
||||||
|
digitsParam.toIntOrNull() ?: OTP_DEFAULT_DIGITS
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.e(TAG, "Unable to retrieve OTP digits.", exception)
|
||||||
|
OTP_DEFAULT_DIGITS
|
||||||
|
}
|
||||||
|
|
||||||
|
val counterParam = uri.getQueryParameter(COUNTER_URL_PARAM)
|
||||||
|
if (counterParam != null && counterParam.isNotEmpty())
|
||||||
|
otpElement.counter = try {
|
||||||
|
counterParam.toLongOrNull() ?: HOTP_INITIAL_COUNTER
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.e(TAG, "Unable to retrieve HOTP counter.", exception)
|
||||||
|
HOTP_INITIAL_COUNTER
|
||||||
|
}
|
||||||
|
|
||||||
|
val stepParam = uri.getQueryParameter(PERIOD_URL_PARAM)
|
||||||
|
if (stepParam != null && stepParam.isNotEmpty())
|
||||||
|
otpElement.period = try {
|
||||||
|
stepParam.toIntOrNull() ?: TOTP_DEFAULT_PERIOD
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.e(TAG, "Unable to retrieve TOTP period.", exception)
|
||||||
|
TOTP_DEFAULT_PERIOD
|
||||||
|
}
|
||||||
|
|
||||||
|
val algorithmParam = uri.getQueryParameter(ALGORITHM_URL_PARAM)
|
||||||
|
if (algorithmParam != null && algorithmParam.isNotEmpty()) {
|
||||||
|
otpElement.algorithm = HashAlgorithm.fromString(algorithmParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildOtpUri(otpElement: OtpElement, title: String?, username: String?): Uri {
|
||||||
|
val counterOrPeriodLabel: String
|
||||||
|
val counterOrPeriodValue: String
|
||||||
|
val otpAuthority: String
|
||||||
|
|
||||||
|
when (otpElement.type) {
|
||||||
|
OtpType.TOTP -> {
|
||||||
|
counterOrPeriodLabel = PERIOD_URL_PARAM
|
||||||
|
counterOrPeriodValue = otpElement.period.toString()
|
||||||
|
otpAuthority = TOTP_AUTHORITY
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
counterOrPeriodLabel = COUNTER_URL_PARAM
|
||||||
|
counterOrPeriodValue = otpElement.counter.toString()
|
||||||
|
otpAuthority = HOTP_AUTHORITY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val issuer =
|
||||||
|
if (title != null && title.isNotEmpty())
|
||||||
|
replaceCharsForUrl(title)
|
||||||
|
else
|
||||||
|
replaceCharsForUrl(otpElement.issuer)
|
||||||
|
val accountName =
|
||||||
|
if (username != null && username.isNotEmpty())
|
||||||
|
replaceCharsForUrl(username)
|
||||||
|
else
|
||||||
|
replaceCharsForUrl(otpElement.name)
|
||||||
|
val uriString = StringBuilder("otpauth://$otpAuthority/$issuer:$accountName" +
|
||||||
|
"?$SECRET_URL_PARAM=${otpElement.getBase32Secret()}" +
|
||||||
|
"&$counterOrPeriodLabel=$counterOrPeriodValue" +
|
||||||
|
"&$DIGITS_URL_PARAM=${otpElement.digits}" +
|
||||||
|
"&$ISSUER_URL_PARAM=$issuer")
|
||||||
|
if (otpElement.tokenType == OtpTokenType.STEAM) {
|
||||||
|
uriString.append("&$ENCODER_URL_PARAM=${otpElement.tokenType}")
|
||||||
|
} else {
|
||||||
|
uriString.append("&$ALGORITHM_URL_PARAM=${otpElement.algorithm}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uri.parse(uriString.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceCharsForUrl(parameter: String): String {
|
||||||
|
return URLEncoder.encode(replaceChars(parameter), "UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceChars(parameter: String): String {
|
||||||
|
return parameter.replace("([\\r|\\n|\\t|\\s|\\u00A0]+)", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTOTPKeyValues(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||||
|
val plainText = getField(OTP_FIELD)
|
||||||
|
if (plainText != null && plainText.isNotEmpty()) {
|
||||||
|
if (Pattern.matches(validKeyValueRegex, plainText)) {
|
||||||
|
try {
|
||||||
|
// KeeOtp string format
|
||||||
|
val query = breakDownKeyValuePairs(plainText)
|
||||||
|
|
||||||
|
var secretString = query[SEED_KEY]
|
||||||
|
if (secretString == null)
|
||||||
|
secretString = ""
|
||||||
|
otpElement.setBase32Secret(secretString)
|
||||||
|
otpElement.digits = query[DIGITS_KEY]?.toIntOrNull() ?: OTP_DEFAULT_DIGITS
|
||||||
|
otpElement.period = query[STEP_KEY]?.toIntOrNull() ?: TOTP_DEFAULT_PERIOD
|
||||||
|
|
||||||
|
otpElement.type = OtpType.TOTP
|
||||||
|
return true
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Malformed
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTOTPFromField(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||||
|
val seedField = getField(TOTP_SEED_FIELD) ?: return false
|
||||||
|
try {
|
||||||
|
otpElement.setBase32Secret(seedField)
|
||||||
|
|
||||||
|
val settingsField = getField(TOTP_SETTING_FIELD)
|
||||||
|
if (settingsField != null) {
|
||||||
|
// Regex match, sync with shortNameToEncoder
|
||||||
|
val pattern = Pattern.compile("(\\d+);((?:\\d+)|S)")
|
||||||
|
val matcher = pattern.matcher(settingsField)
|
||||||
|
if (!matcher.matches()) {
|
||||||
|
// malformed
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
otpElement.period = matcher.group(1).toIntOrNull() ?: TOTP_DEFAULT_PERIOD
|
||||||
|
otpElement.tokenType = OtpTokenType.getFromString(matcher.group(2))
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
otpElement.type = OtpType.TOTP
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHOTPFromField(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
|
||||||
|
val secretField = getField(HMACOTP_SECRET_FIELD)
|
||||||
|
val secretHexField = getField(HMACOTP_SECRET_HEX_FIELD)
|
||||||
|
val secretBase32Field = getField(HMACOTP_SECRET_BASE32_FIELD)
|
||||||
|
val secretBase64Field = getField(HMACOTP_SECRET_BASE64_FIELD)
|
||||||
|
try {
|
||||||
|
when {
|
||||||
|
secretField != null -> otpElement.setUTF8Secret(secretField)
|
||||||
|
secretHexField != null -> otpElement.setHexSecret(secretHexField)
|
||||||
|
secretBase32Field != null -> otpElement.setBase32Secret(secretBase32Field)
|
||||||
|
secretBase64Field != null -> otpElement.setBase64Secret(secretBase64Field)
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val secretCounterField = getField(HMACOTP_SECRET_COUNTER_FIELD)
|
||||||
|
if (secretCounterField != null) {
|
||||||
|
otpElement.counter = secretCounterField.toLongOrNull() ?: HOTP_INITIAL_COUNTER
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
otpElement.type = OtpType.HOTP
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateAndGetNameInPath(path: String?): String? {
|
||||||
|
if (path == null || !path.startsWith("/")) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// path is "/name", so remove leading "/", and trailing white spaces
|
||||||
|
val name = path.substring(1).trim { it <= ' ' }
|
||||||
|
return if (name.isEmpty()) {
|
||||||
|
null // only white spaces.
|
||||||
|
} else name
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun breakDownKeyValuePairs(pairs: String): HashMap<String, String> {
|
||||||
|
val elements = pairs.split("&".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||||
|
val output = HashMap<String, String>()
|
||||||
|
for (element in elements) {
|
||||||
|
val pair = element.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||||
|
output[pair[0]] = pair[1]
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Otp field from an OtpElement
|
||||||
|
*/
|
||||||
|
fun buildOtpField(otpElement: OtpElement, title: String?, username: String?): Field {
|
||||||
|
return Field(OTP_FIELD, ProtectedString(true,
|
||||||
|
buildOtpUri(otpElement, title, username).toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build new generated fields in a new list from [fieldsToParse] in parameter,
|
||||||
|
* Remove parameters fields use to generate auto fields
|
||||||
|
*/
|
||||||
|
fun generateAutoFields(fieldsToParse: MutableList<Field>): MutableList<Field> {
|
||||||
|
val newCustomFields: MutableList<Field> = ArrayList(fieldsToParse)
|
||||||
|
// Remove parameter fields
|
||||||
|
val otpField = Field(OTP_FIELD)
|
||||||
|
val totpSeedField = Field(TOTP_SEED_FIELD)
|
||||||
|
val totpSettingField = Field(TOTP_SETTING_FIELD)
|
||||||
|
val hmacOtpSecretField = Field(HMACOTP_SECRET_FIELD)
|
||||||
|
val hmacOtpSecretHewField = Field(HMACOTP_SECRET_HEX_FIELD)
|
||||||
|
val hmacOtpSecretBase32Field = Field(HMACOTP_SECRET_BASE32_FIELD)
|
||||||
|
val hmacOtpSecretBase64Field = Field(HMACOTP_SECRET_BASE64_FIELD)
|
||||||
|
val hmacOtpSecretCounterField = Field(HMACOTP_SECRET_COUNTER_FIELD)
|
||||||
|
newCustomFields.remove(otpField)
|
||||||
|
newCustomFields.remove(totpSeedField)
|
||||||
|
newCustomFields.remove(totpSettingField)
|
||||||
|
newCustomFields.remove(hmacOtpSecretField)
|
||||||
|
newCustomFields.remove(hmacOtpSecretHewField)
|
||||||
|
newCustomFields.remove(hmacOtpSecretBase32Field)
|
||||||
|
newCustomFields.remove(hmacOtpSecretBase64Field)
|
||||||
|
newCustomFields.remove(hmacOtpSecretCounterField)
|
||||||
|
// Empty auto generated OTP Token field
|
||||||
|
if (fieldsToParse.contains(otpField)
|
||||||
|
|| fieldsToParse.contains(totpSeedField)
|
||||||
|
|| fieldsToParse.contains(hmacOtpSecretField)
|
||||||
|
|| fieldsToParse.contains(hmacOtpSecretHewField)
|
||||||
|
|| fieldsToParse.contains(hmacOtpSecretBase32Field)
|
||||||
|
|| fieldsToParse.contains(hmacOtpSecretBase64Field)
|
||||||
|
)
|
||||||
|
newCustomFields.add(Field(OTP_TOKEN_FIELD))
|
||||||
|
return newCustomFields
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/src/main/java/com/kunzisoft/keepass/otp/TokenCalculator.java
Normal file
132
app/src/main/java/com/kunzisoft/keepass/otp/TokenCalculator.java
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||||
|
*
|
||||||
|
* This file is part of KeePass DX.
|
||||||
|
*
|
||||||
|
* KeePass DX 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.
|
||||||
|
*
|
||||||
|
* KeePass DX 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 KeePass DX. If not,
|
||||||
|
* see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
* This code is based on andOTP code
|
||||||
|
* https://github.com/andOTP/andOTP/blob/master/app/src/main/java/org/shadowice/flocke/andotp/
|
||||||
|
* Utilities/TokenCalculator.java
|
||||||
|
*/
|
||||||
|
package com.kunzisoft.keepass.otp;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.security.InvalidKeyException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.text.NumberFormat;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
public class TokenCalculator {
|
||||||
|
public static final int TOTP_DEFAULT_PERIOD = 30;
|
||||||
|
public static final long HOTP_INITIAL_COUNTER = 1;
|
||||||
|
public static final int OTP_DEFAULT_DIGITS = 6;
|
||||||
|
public static final int STEAM_DEFAULT_DIGITS = 5;
|
||||||
|
public static final HashAlgorithm OTP_DEFAULT_ALGORITHM = HashAlgorithm.SHA1;
|
||||||
|
|
||||||
|
private static final char[] STEAMCHARS = new char[] {
|
||||||
|
'2', '3', '4', '5', '6', '7', '8', '9', 'B', 'C',
|
||||||
|
'D', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q',
|
||||||
|
'R', 'T', 'V', 'W', 'X', 'Y'
|
||||||
|
};
|
||||||
|
|
||||||
|
public enum HashAlgorithm {
|
||||||
|
SHA1, SHA256, SHA512;
|
||||||
|
|
||||||
|
static HashAlgorithm fromString(String hashString) {
|
||||||
|
String hash = hashString.replace("[^a-zA-Z0-9]", "").toUpperCase();
|
||||||
|
try {
|
||||||
|
return valueOf(hash);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return OTP_DEFAULT_ALGORITHM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] generateHash(HashAlgorithm algorithm, byte[] key, byte[] data)
|
||||||
|
throws NoSuchAlgorithmException, InvalidKeyException {
|
||||||
|
String algo = "Hmac" + algorithm.toString();
|
||||||
|
|
||||||
|
Mac mac = Mac.getInstance(algo);
|
||||||
|
mac.init(new SecretKeySpec(key, algo));
|
||||||
|
|
||||||
|
return mac.doFinal(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int TOTP_RFC6238(byte[] secret, int period, long time, int digits, HashAlgorithm algorithm) {
|
||||||
|
int fullToken = TOTP(secret, period, time, algorithm);
|
||||||
|
int div = (int) Math.pow(10, digits);
|
||||||
|
|
||||||
|
return fullToken % div;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String TOTP_RFC6238(byte[] secret, int period, int digits, HashAlgorithm algorithm) {
|
||||||
|
return formatTokenString(TOTP_RFC6238(secret, period, System.currentTimeMillis() / 1000, digits, algorithm), digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String TOTP_Steam(byte[] secret, int period, int digits, HashAlgorithm algorithm) {
|
||||||
|
int fullToken = TOTP(secret, period, System.currentTimeMillis() / 1000, algorithm);
|
||||||
|
|
||||||
|
StringBuilder tokenBuilder = new StringBuilder();
|
||||||
|
|
||||||
|
for (int i = 0; i < digits; i++) {
|
||||||
|
tokenBuilder.append(STEAMCHARS[fullToken % STEAMCHARS.length]);
|
||||||
|
fullToken /= STEAMCHARS.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenBuilder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String HOTP(byte[] secret, long counter, int digits, HashAlgorithm algorithm) {
|
||||||
|
int fullToken = HOTP(secret, counter, algorithm);
|
||||||
|
int div = (int) Math.pow(10, digits);
|
||||||
|
|
||||||
|
return formatTokenString(fullToken % div, digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int TOTP(byte[] key, int period, long time, HashAlgorithm algorithm) {
|
||||||
|
return HOTP(key, time / period, algorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int HOTP(byte[] key, long counter, HashAlgorithm algorithm) {
|
||||||
|
int r = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
byte[] data = ByteBuffer.allocate(8).putLong(counter).array();
|
||||||
|
byte[] hash = generateHash(algorithm, key, data);
|
||||||
|
|
||||||
|
int offset = hash[hash.length - 1] & 0xF;
|
||||||
|
|
||||||
|
int binary = (hash[offset] & 0x7F) << 0x18;
|
||||||
|
binary |= (hash[offset + 1] & 0xFF) << 0x10;
|
||||||
|
binary |= (hash[offset + 2] & 0xFF) << 0x08;
|
||||||
|
binary |= (hash[offset + 3] & 0xFF);
|
||||||
|
|
||||||
|
r = binary;
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String formatTokenString(int token, int digits) {
|
||||||
|
NumberFormat numberFormat = NumberFormat.getInstance(Locale.ENGLISH);
|
||||||
|
numberFormat.setMinimumIntegerDigits(digits);
|
||||||
|
numberFormat.setGroupingUsed(false);
|
||||||
|
|
||||||
|
return numberFormat.format(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.RadioButton
|
import android.widget.RadioButton
|
||||||
|
|
||||||
import com.kunzisoft.keepass.R
|
import com.kunzisoft.keepass.R
|
||||||
import com.kunzisoft.keepass.database.ObjectNameResource
|
import com.kunzisoft.keepass.utils.ObjectNameResource
|
||||||
|
|
||||||
import java.util.ArrayList
|
import java.util.ArrayList
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* along with KeePass DX. If not, see <http://www.gnu.org/licenses/>.
|
* along with KeePass DX. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
package com.kunzisoft.keepass.database
|
package com.kunzisoft.keepass.utils
|
||||||
|
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -36,6 +37,8 @@ import com.kunzisoft.keepass.adapters.EntryHistoryAdapter
|
|||||||
import com.kunzisoft.keepass.database.element.EntryVersioned
|
import com.kunzisoft.keepass.database.element.EntryVersioned
|
||||||
import com.kunzisoft.keepass.database.element.PwDate
|
import com.kunzisoft.keepass.database.element.PwDate
|
||||||
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
import com.kunzisoft.keepass.database.element.security.ProtectedString
|
||||||
|
import com.kunzisoft.keepass.otp.OtpElement
|
||||||
|
import com.kunzisoft.keepass.otp.OtpType
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class EntryContentsView @JvmOverloads constructor(context: Context,
|
class EntryContentsView @JvmOverloads constructor(context: Context,
|
||||||
@@ -54,6 +57,13 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
|
|||||||
private val passwordView: TextView
|
private val passwordView: TextView
|
||||||
private val passwordActionView: ImageView
|
private val passwordActionView: ImageView
|
||||||
|
|
||||||
|
private val otpContainerView: View
|
||||||
|
private val otpLabelView: TextView
|
||||||
|
private val otpView: TextView
|
||||||
|
private val otpActionView: ImageView
|
||||||
|
|
||||||
|
private var otpRunnable: Runnable? = null
|
||||||
|
|
||||||
private val urlContainerView: View
|
private val urlContainerView: View
|
||||||
private val urlView: TextView
|
private val urlView: TextView
|
||||||
|
|
||||||
@@ -93,6 +103,11 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
|
|||||||
passwordView = findViewById(R.id.entry_password)
|
passwordView = findViewById(R.id.entry_password)
|
||||||
passwordActionView = findViewById(R.id.entry_password_action_image)
|
passwordActionView = findViewById(R.id.entry_password_action_image)
|
||||||
|
|
||||||
|
otpContainerView = findViewById(R.id.entry_otp_container)
|
||||||
|
otpLabelView = findViewById(R.id.entry_otp_label)
|
||||||
|
otpView = findViewById(R.id.entry_otp)
|
||||||
|
otpActionView = findViewById(R.id.entry_otp_action_image)
|
||||||
|
|
||||||
urlContainerView = findViewById(R.id.entry_url_container)
|
urlContainerView = findViewById(R.id.entry_url_container)
|
||||||
urlView = findViewById(R.id.entry_url)
|
urlView = findViewById(R.id.entry_url)
|
||||||
|
|
||||||
@@ -199,6 +214,56 @@ class EntryContentsView @JvmOverloads constructor(context: Context,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun assignOtp(otpElement: OtpElement?,
|
||||||
|
otpProgressView: ProgressBar?,
|
||||||
|
onClickListener: OnClickListener) {
|
||||||
|
otpContainerView.removeCallbacks(otpRunnable)
|
||||||
|
|
||||||
|
if (otpElement != null) {
|
||||||
|
otpContainerView.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
if (otpElement.token.isEmpty()) {
|
||||||
|
otpView.text = context.getString(R.string.error_invalid_OTP)
|
||||||
|
otpActionView.setColorFilter(ContextCompat.getColor(context, R.color.grey_dark))
|
||||||
|
assignOtpCopyListener(null)
|
||||||
|
} else {
|
||||||
|
assignOtpCopyListener(onClickListener)
|
||||||
|
otpView.text = otpElement.token
|
||||||
|
otpLabelView.text = otpElement.type.name
|
||||||
|
|
||||||
|
when (otpElement.type) {
|
||||||
|
// Only add token if HOTP
|
||||||
|
OtpType.HOTP -> {
|
||||||
|
otpProgressView?.visibility = View.GONE
|
||||||
|
}
|
||||||
|
// Refresh view if TOTP
|
||||||
|
OtpType.TOTP -> {
|
||||||
|
otpProgressView?.apply {
|
||||||
|
max = otpElement.period
|
||||||
|
progress = otpElement.secondsRemaining
|
||||||
|
visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
otpRunnable = Runnable {
|
||||||
|
if (otpElement.shouldRefreshToken()) {
|
||||||
|
otpView.text = otpElement.token
|
||||||
|
}
|
||||||
|
otpProgressView?.progress = otpElement.secondsRemaining
|
||||||
|
otpContainerView.postDelayed(otpRunnable, 1000)
|
||||||
|
}
|
||||||
|
otpContainerView.post(otpRunnable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
otpContainerView.visibility = View.GONE
|
||||||
|
otpProgressView?.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assignOtpCopyListener(onClickListener: OnClickListener?) {
|
||||||
|
otpActionView.setOnClickListener(onClickListener)
|
||||||
|
}
|
||||||
|
|
||||||
fun assignURL(url: String?) {
|
fun assignURL(url: String?) {
|
||||||
if (url != null && url.isNotEmpty()) {
|
if (url != null && url.isNotEmpty()) {
|
||||||
urlContainerView.visibility = View.VISIBLE
|
urlContainerView.visibility = View.VISIBLE
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import com.kunzisoft.keepass.icons.IconDrawableFactory
|
|||||||
import com.kunzisoft.keepass.icons.assignDatabaseIcon
|
import com.kunzisoft.keepass.icons.assignDatabaseIcon
|
||||||
import com.kunzisoft.keepass.icons.assignDefaultDatabaseIcon
|
import com.kunzisoft.keepass.icons.assignDefaultDatabaseIcon
|
||||||
import com.kunzisoft.keepass.model.Field
|
import com.kunzisoft.keepass.model.Field
|
||||||
import java.util.HashMap
|
|
||||||
|
|
||||||
class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
@@ -37,7 +36,7 @@ class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
|||||||
val generatePasswordView: View
|
val generatePasswordView: View
|
||||||
private val entryCommentView: EditText
|
private val entryCommentView: EditText
|
||||||
private val entryExtraFieldsContainer: ViewGroup
|
private val entryExtraFieldsContainer: ViewGroup
|
||||||
val addNewFieldView: View
|
val addNewFieldButton: View
|
||||||
|
|
||||||
private var iconColor: Int = 0
|
private var iconColor: Int = 0
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
|||||||
generatePasswordView = findViewById(R.id.entry_edit_generate_button)
|
generatePasswordView = findViewById(R.id.entry_edit_generate_button)
|
||||||
entryCommentView = findViewById(R.id.entry_edit_notes)
|
entryCommentView = findViewById(R.id.entry_edit_notes)
|
||||||
entryExtraFieldsContainer = findViewById(R.id.entry_edit_advanced_container)
|
entryExtraFieldsContainer = findViewById(R.id.entry_edit_advanced_container)
|
||||||
addNewFieldView = findViewById(R.id.entry_edit_add_new_field)
|
addNewFieldButton = findViewById(R.id.entry_edit_add_new_field)
|
||||||
|
|
||||||
// Retrieve the textColor to tint the icon
|
// Retrieve the textColor to tint the icon
|
||||||
val taIconColor = context.theme.obtainStyledAttributes(intArrayOf(android.R.attr.textColor))
|
val taIconColor = context.theme.obtainStyledAttributes(intArrayOf(android.R.attr.textColor))
|
||||||
@@ -138,7 +137,7 @@ class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun allowCustomField(allow: Boolean, action: () -> Unit) {
|
fun allowCustomField(allow: Boolean, action: () -> Unit) {
|
||||||
addNewFieldView.apply {
|
addNewFieldButton.apply {
|
||||||
if (allow) {
|
if (allow) {
|
||||||
visibility = View.VISIBLE
|
visibility = View.VISIBLE
|
||||||
setOnClickListener { action.invoke() }
|
setOnClickListener { action.invoke() }
|
||||||
@@ -166,17 +165,42 @@ class EntryEditContentsView @JvmOverloads constructor(context: Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new view to fill in the information of the customized field
|
* Add a new view to fill in the information of the customized field and focus it
|
||||||
*/
|
*/
|
||||||
fun addNewCustomField(name: String = "", value: ProtectedString = ProtectedString(false, "")) {
|
fun addEmptyCustomField() {
|
||||||
val entryEditCustomField = EntryEditCustomField(context).apply {
|
val entryEditCustomField = EntryEditCustomField(context).apply {
|
||||||
setData(name, value)
|
|
||||||
setFontVisibility(fontInVisibility)
|
setFontVisibility(fontInVisibility)
|
||||||
requestFocus()
|
requestFocus()
|
||||||
}
|
}
|
||||||
entryExtraFieldsContainer.addView(entryEditCustomField)
|
entryExtraFieldsContainer.addView(entryEditCustomField)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a custom field or create a new one if doesn't exists
|
||||||
|
*/
|
||||||
|
fun putCustomField(name: String,
|
||||||
|
value: ProtectedString = ProtectedString()) {
|
||||||
|
var updateField = false
|
||||||
|
for (i in 0..entryExtraFieldsContainer.childCount) {
|
||||||
|
try {
|
||||||
|
val extraFieldView = entryExtraFieldsContainer.getChildAt(i) as EntryEditCustomField?
|
||||||
|
if (extraFieldView?.label == name) {
|
||||||
|
extraFieldView.setData(name, value, fontInVisibility)
|
||||||
|
updateField = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch(e: Exception) {
|
||||||
|
// Simply ignore when child view is not a custom field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!updateField) {
|
||||||
|
val entryEditCustomField = EntryEditCustomField(context).apply {
|
||||||
|
setData(name, value, fontInVisibility)
|
||||||
|
}
|
||||||
|
entryExtraFieldsContainer.addView(entryEditCustomField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate or not the entry form
|
* Validate or not the entry form
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -66,13 +66,14 @@ class EntryEditCustomField @JvmOverloads constructor(context: Context,
|
|||||||
protectionCheckView = findViewById(R.id.protection)
|
protectionCheckView = findViewById(R.id.protection)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setData(label: String?, value: ProtectedString?) {
|
fun setData(label: String?, value: ProtectedString?, fontInVisibility: Boolean) {
|
||||||
if (label != null)
|
if (label != null)
|
||||||
labelView.text = label
|
labelView.text = label
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
valueView.setText(value.toString())
|
valueView.setText(value.toString())
|
||||||
protectionCheckView.isChecked = value.isProtected
|
protectionCheckView.isChecked = value.isProtected
|
||||||
}
|
}
|
||||||
|
setFontVisibility(fontInVisibility)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
9
app/src/main/res/drawable/ic_av_timer_white_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_av_timer_white_24dp.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:viewportWidth="24.0"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M11,17c0,0.55 0.45,1 1,1s1,-0.45 1,-1 -0.45,-1 -1,-1 -1,0.45 -1,1zM11,3v4h2L13,5.08c3.39,0.49 6,3.39 6,6.92 0,3.87 -3.13,7 -7,7s-7,-3.13 -7,-7c0,-1.68 0.59,-3.22 1.58,-4.42L12,13l1.41,-1.41 -6.8,-6.8v0.02C4.42,6.45 3,9.05 3,12c0,4.97 4.02,9 9,9 4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9h-1zM18,12c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1 0.45,1 1,1 1,-0.45 1,-1zM6,12c0,0.55 0.45,1 1,1s1,-0.45 1,-1 -0.45,-1 -1,-1 -1,0.45 -1,1z"/>
|
||||||
|
</vector>
|
||||||
@@ -72,6 +72,17 @@
|
|||||||
tools:targetApi="lollipop">
|
tools:targetApi="lollipop">
|
||||||
</androidx.appcompat.widget.Toolbar>
|
</androidx.appcompat.widget.Toolbar>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:visibility="gone"
|
||||||
|
android:id="@+id/entry_progress"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:indeterminate="false"
|
||||||
|
android:progress="10"
|
||||||
|
android:max="30"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="4dp" />
|
||||||
|
|
||||||
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
|||||||
183
app/src/main/res/layout/fragment_set_otp.xml
Normal file
183
app/src/main/res/layout/fragment_set_otp.xml
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
<LinearLayout
|
||||||
|
android:padding="@dimen/default_margin"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:importantForAutofill="noExcludeDescendants"
|
||||||
|
tools:targetApi="o">
|
||||||
|
<androidx.cardview.widget.CardView
|
||||||
|
android:id="@+id/card_view_otp_selection"
|
||||||
|
android:layout_margin="4dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardCornerRadius="4dp">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="@dimen/default_margin"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" >
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/setup_otp_type_label"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
android:text="@string/otp_type"
|
||||||
|
style="@style/KeepassDXStyle.TextAppearance.LabelTextStyle"/>
|
||||||
|
|
||||||
|
<!-- HOTP / TOTP -->
|
||||||
|
<androidx.appcompat.widget.AppCompatSpinner
|
||||||
|
android:id="@+id/setup_otp_type"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/setup_otp_type_label"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
android:minHeight="48dp" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.AppCompatSpinner
|
||||||
|
android:id="@+id/setup_otp_token_type"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="48dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/setup_otp_type_label"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/setup_otp_type"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<!-- Secret -->
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/setup_otp_secret_label"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:passwordToggleEnabled="true"
|
||||||
|
app:passwordToggleTint="?attr/colorAccent">
|
||||||
|
<androidx.appcompat.widget.AppCompatEditText
|
||||||
|
android:id="@+id/setup_otp_secret"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:hint="@string/otp_secret"
|
||||||
|
tools:targetApi="jelly_bean" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</androidx.cardview.widget.CardView>
|
||||||
|
|
||||||
|
<androidx.cardview.widget.CardView
|
||||||
|
android:id="@+id/card_view_otp_advanced"
|
||||||
|
android:layout_margin="4dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardCornerRadius="4dp">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="@dimen/default_margin"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- Algorithm -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/otp_algorithm"
|
||||||
|
style="@style/KeepassDXStyle.TextAppearance.LabelTextStyle"/>
|
||||||
|
<androidx.appcompat.widget.AppCompatSpinner
|
||||||
|
android:id="@+id/setup_otp_algorithm"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="48dp" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<!-- Period / Counter -->
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/setup_otp_period_label"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="visible"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/vertical_guideline">
|
||||||
|
<androidx.appcompat.widget.AppCompatEditText
|
||||||
|
android:id="@+id/setup_otp_period"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="numberSigned"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:hint="@string/otp_period"
|
||||||
|
tools:text="30"
|
||||||
|
android:maxLength="2"
|
||||||
|
android:digits="0123456789"
|
||||||
|
tools:targetApi="jelly_bean" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/setup_otp_counter_label"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/vertical_guideline">
|
||||||
|
<androidx.appcompat.widget.AppCompatEditText
|
||||||
|
android:id="@+id/setup_otp_counter"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="numberSigned"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:hint="@string/otp_counter"
|
||||||
|
tools:text="1"
|
||||||
|
tools:targetApi="jelly_bean" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Guideline
|
||||||
|
android:id="@+id/vertical_guideline"
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
app:layout_constraintGuide_percent="0.5" />
|
||||||
|
|
||||||
|
<!-- Digits -->
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/setup_otp_digits_label"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/vertical_guideline"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent">
|
||||||
|
<androidx.appcompat.widget.AppCompatEditText
|
||||||
|
android:id="@+id/setup_otp_digits"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="numberSigned"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:importantForAutofill="no"
|
||||||
|
android:hint="@string/otp_digits"
|
||||||
|
tools:text="6"
|
||||||
|
android:maxLength="2"
|
||||||
|
android:digits="0123456789"
|
||||||
|
tools:targetApi="jelly_bean" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</androidx.cardview.widget.CardView>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -105,6 +105,38 @@
|
|||||||
android:tint="?attr/colorAccent" />
|
android:tint="?attr/colorAccent" />
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<!-- OTP -->
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/entry_otp_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone">
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/entry_otp_label"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/entry_otp"
|
||||||
|
style="@style/KeepassDXStyle.TextAppearance.LabelTextStyle" />
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView
|
||||||
|
android:id="@+id/entry_otp"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@+id/entry_otp_label"
|
||||||
|
android:layout_toLeftOf="@+id/entry_otp_action_image"
|
||||||
|
android:layout_toStartOf="@+id/entry_otp_action_image"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
style="@style/KeepassDXStyle.TextAppearance.TextEntryItem" />
|
||||||
|
<androidx.appcompat.widget.AppCompatImageView
|
||||||
|
android:id="@+id/entry_otp_action_image"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_alignParentRight="true"
|
||||||
|
android:layout_below="@+id/entry_otp_label"
|
||||||
|
android:src="@drawable/ic_content_copy_white_24dp"
|
||||||
|
android:tint="?attr/colorAccent" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
<!-- URL -->
|
<!-- URL -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/entry_url_container"
|
android:id="@+id/entry_url_container"
|
||||||
|
|||||||
27
app/src/main/res/menu/edit_entry.xml
Normal file
27
app/src/main/res/menu/edit_entry.xml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2019 Jeremy Jamet / Kunzisoft.
|
||||||
|
|
||||||
|
This file is part of KeePass DX.
|
||||||
|
|
||||||
|
KeePass DX 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.
|
||||||
|
|
||||||
|
KeePass DX 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 KeePass DX. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
<item android:id="@+id/menu_add_otp"
|
||||||
|
android:icon="@drawable/ic_av_timer_white_24dp"
|
||||||
|
android:title="@string/entry_setup_otp"
|
||||||
|
android:orderInCategory="91"
|
||||||
|
app:showAsAction="ifRoom" />
|
||||||
|
</menu>
|
||||||
@@ -87,6 +87,14 @@
|
|||||||
<string name="entry_password">Password</string>
|
<string name="entry_password">Password</string>
|
||||||
<string name="entry_save">Save</string>
|
<string name="entry_save">Save</string>
|
||||||
<string name="entry_title">Title</string>
|
<string name="entry_title">Title</string>
|
||||||
|
<string name="entry_setup_otp">Setup One-Time Password</string>
|
||||||
|
<string name="otp_type">OTP type</string>
|
||||||
|
<string name="otp_secret">Secret</string>
|
||||||
|
<string name="otp_period">Period (seconds)</string>
|
||||||
|
<string name="otp_counter">Counter</string>
|
||||||
|
<string name="otp_digits">Digits</string>
|
||||||
|
<string name="otp_algorithm">Algorithm</string>
|
||||||
|
<string name="entry_otp">OTP</string>
|
||||||
<string name="entry_url">URL</string>
|
<string name="entry_url">URL</string>
|
||||||
<string name="entry_user_name">Username</string>
|
<string name="entry_user_name">Username</string>
|
||||||
<string name="error_arc4">The ARCFOUR stream cipher is not supported.</string>
|
<string name="error_arc4">The ARCFOUR stream cipher is not supported.</string>
|
||||||
@@ -94,6 +102,7 @@
|
|||||||
<string name="error_file_not_create">Could not create file:</string>
|
<string name="error_file_not_create">Could not create file:</string>
|
||||||
<string name="error_invalid_db">Could not read database.</string>
|
<string name="error_invalid_db">Could not read database.</string>
|
||||||
<string name="error_invalid_path">Make sure the path is correct.</string>
|
<string name="error_invalid_path">Make sure the path is correct.</string>
|
||||||
|
<string name="error_invalid_OTP">Invalid OTP secret.</string>
|
||||||
<string name="error_no_name">Enter a name.</string>
|
<string name="error_no_name">Enter a name.</string>
|
||||||
<string name="error_nokeyfile">Select a keyfile.</string>
|
<string name="error_nokeyfile">Select a keyfile.</string>
|
||||||
<string name="error_out_of_memory">No memory to load your entire database.</string>
|
<string name="error_out_of_memory">No memory to load your entire database.</string>
|
||||||
@@ -112,6 +121,10 @@
|
|||||||
<string name="error_copy_entry_here">You can not copy an entry here.</string>
|
<string name="error_copy_entry_here">You can not copy an entry here.</string>
|
||||||
<string name="error_copy_group_here">You can not copy a group here.</string>
|
<string name="error_copy_group_here">You can not copy a group here.</string>
|
||||||
<string name="error_create_database_file">Unable to create database with this password and key file.</string>
|
<string name="error_create_database_file">Unable to create database with this password and key file.</string>
|
||||||
|
<string name="error_otp_secret_key">Secret key lust be in Base32 format.</string>
|
||||||
|
<string name="error_otp_counter">Counter must be between %1$d and %2$d.</string>
|
||||||
|
<string name="error_otp_period">Period must be between %1$d and %2$d seconds.</string>
|
||||||
|
<string name="error_otp_digits">Token must contains %1$d to %2$d digits.</string>
|
||||||
<string name="field_name">Field name</string>
|
<string name="field_name">Field name</string>
|
||||||
<string name="field_value">Field value</string>
|
<string name="field_value">Field value</string>
|
||||||
<string name="file_not_found_content">Could not find file. Try reopening it from your file browser.</string>
|
<string name="file_not_found_content">Could not find file. Try reopening it from your file browser.</string>
|
||||||
@@ -400,7 +413,7 @@
|
|||||||
<string name="html_text_dev_feature_upgrade">Do not forget to keep your app up to date by installing new versions.</string>
|
<string name="html_text_dev_feature_upgrade">Do not forget to keep your app up to date by installing new versions.</string>
|
||||||
<string name="download">Download</string>
|
<string name="download">Download</string>
|
||||||
<string name="contribute">Contribute</string>
|
<string name="contribute">Contribute</string>
|
||||||
<!-- Algorithms -->
|
<!-- Encryption Algorithms -->
|
||||||
<string name="encryption_rijndael">Rijndael (AES)</string>
|
<string name="encryption_rijndael">Rijndael (AES)</string>
|
||||||
<string name="encryption_twofish">Twofish</string>
|
<string name="encryption_twofish">Twofish</string>
|
||||||
<string name="encryption_chacha20">ChaCha20</string>
|
<string name="encryption_chacha20">ChaCha20</string>
|
||||||
|
|||||||
@@ -316,6 +316,8 @@
|
|||||||
<style name="KeepassDXStyle.TextAppearance.LabelTextStyle" parent="KeepassDXStyle.TextAppearance">
|
<style name="KeepassDXStyle.TextAppearance.LabelTextStyle" parent="KeepassDXStyle.TextAppearance">
|
||||||
<item name="android:textColor">?attr/colorAccent</item>
|
<item name="android:textColor">?attr/colorAccent</item>
|
||||||
<item name="android:textSize">12sp</item>
|
<item name="android:textSize">12sp</item>
|
||||||
|
<item name="android:paddingLeft">4dp</item>
|
||||||
|
<item name="android:paddingRight">4dp</item>
|
||||||
</style>
|
</style>
|
||||||
<style name="KeepassDXStyle.TextAppearance.LabelTableTextStyle" parent="KeepassDXStyle.TextAppearance">
|
<style name="KeepassDXStyle.TextAppearance.LabelTableTextStyle" parent="KeepassDXStyle.TextAppearance">
|
||||||
<item name="android:textSize">12sp</item>
|
<item name="android:textSize">12sp</item>
|
||||||
|
|||||||
Reference in New Issue
Block a user