diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4b546b2e1..20c78fb62 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -150,6 +150,13 @@
+
+
+
+
+
+
+
{
if ("text/plain" == intent.type) {
- // Retrieve web domain
- intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
- sharedWebDomain = Uri.parse(it).host
+ // Retrieve web domain or OTP
+ intent.getStringExtra(Intent.EXTRA_TEXT)?.let { extra ->
+ if (OtpEntryFields.isOTPUri(extra))
+ otpString = extra
+ else
+ sharedWebDomain = Uri.parse(extra).host
}
}
}
+ Intent.ACTION_VIEW -> {
+ // Retrieve OTP
+ intent.dataString?.let { extra ->
+ if (OtpEntryFields.isOTPUri(extra))
+ otpString = extra
+ }
+ }
else -> {}
}
- // Build search param
+
+ // Build domain search param
val searchInfo = SearchInfo().apply {
- webDomain = sharedWebDomain
+ this.webDomain = sharedWebDomain
+ this.otpString = otpString
}
SearchInfo.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain ->
searchInfo.webDomain = concreteWebDomain
@@ -68,62 +83,97 @@ class EntrySelectionLauncherActivity : AppCompatActivity() {
}
private fun launch(searchInfo: SearchInfo) {
- // Setting to integrate Magikeyboard
- val searchShareForMagikeyboard = PreferencesUtil.isKeyboardSearchShareEnable(this)
- // If database is open
- val database = Database.getInstance()
- val readOnly = database.isReadOnly
- SearchHelper.checkAutoSearchInfo(this,
- database,
- searchInfo,
- { items ->
- // Items found
- if (searchShareForMagikeyboard) {
- if (items.size == 1) {
- // Automatically populate keyboard
- val entryPopulate = items[0]
- populateKeyboardAndMoveAppToBackground(this,
- entryPopulate,
- intent)
+ if (!searchInfo.containsOnlyNullValues()) {
+ // Setting to integrate Magikeyboard
+ val searchShareForMagikeyboard = PreferencesUtil.isKeyboardSearchShareEnable(this)
+
+ // If database is open
+ val database = Database.getInstance()
+ val readOnly = database.isReadOnly
+ SearchHelper.checkAutoSearchInfo(this,
+ database,
+ searchInfo,
+ { items ->
+ // Items found
+ if (searchInfo.otpString != null) {
+ if (!readOnly) {
+ GroupActivity.launchForSaveResult(this,
+ searchInfo,
+ false)
+ } else {
+ Toast.makeText(applicationContext,
+ R.string.autofill_read_only_save,
+ Toast.LENGTH_LONG)
+ .show()
+ }
+ } else if (searchShareForMagikeyboard) {
+ if (items.size == 1) {
+ // Automatically populate keyboard
+ val entryPopulate = items[0]
+ populateKeyboardAndMoveAppToBackground(this,
+ entryPopulate,
+ intent)
+ } else {
+ // Select the one we want
+ GroupActivity.launchForKeyboardSelectionResult(this,
+ readOnly,
+ searchInfo,
+ true)
+ }
} else {
- // Select the one we want
- GroupActivity.launchForKeyboardSelectionResult(this,
+ GroupActivity.launchForSearchResult(this,
readOnly,
searchInfo,
true)
}
- } else {
- GroupActivity.launchForSearchResult(this,
- readOnly,
- searchInfo,
- true)
+ },
+ {
+ // Show the database UI to select the entry
+ if (searchInfo.otpString != null) {
+ if (!readOnly) {
+ GroupActivity.launchForSaveResult(this,
+ searchInfo,
+ false)
+ } else {
+ Toast.makeText(applicationContext,
+ R.string.autofill_read_only_save,
+ Toast.LENGTH_LONG)
+ .show()
+ }
+ } else if (readOnly || searchShareForMagikeyboard) {
+ GroupActivity.launchForKeyboardSelectionResult(this,
+ readOnly,
+ searchInfo,
+ false)
+ } else {
+ GroupActivity.launchForSaveResult(this,
+ searchInfo,
+ false)
+ }
+ },
+ {
+ // If database not open
+ if (searchInfo.otpString != null) {
+ if (!readOnly) {
+ FileDatabaseSelectActivity.launchForSaveResult(this,
+ searchInfo)
+ } else {
+ Toast.makeText(applicationContext,
+ R.string.autofill_read_only_save,
+ Toast.LENGTH_LONG)
+ .show()
+ }
+ } else if (searchShareForMagikeyboard) {
+ FileDatabaseSelectActivity.launchForKeyboardSelectionResult(this,
+ searchInfo)
+ } else {
+ FileDatabaseSelectActivity.launchForSearchResult(this,
+ searchInfo)
+ }
}
- },
- {
- // Show the database UI to select the entry
- if (readOnly || searchShareForMagikeyboard) {
- GroupActivity.launchForKeyboardSelectionResult(this,
- readOnly,
- searchInfo,
- false)
- } else {
- GroupActivity.launchForSaveResult(this,
- searchInfo,
- false)
- }
- },
- {
- // If database not open
- if (searchShareForMagikeyboard) {
- FileDatabaseSelectActivity.launchForKeyboardSelectionResult(this,
- searchInfo)
- } else {
- FileDatabaseSelectActivity.launchForSearchResult(this,
- searchInfo)
- }
- }
- )
+ )
+ }
finish()
}
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt
index 6fb1dac8f..60058a243 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt
@@ -468,6 +468,19 @@ class FileDatabaseSelectActivity : SpecialModeActivity(),
searchInfo)
}
+ /*
+ * -------------------------
+ * Save Launch
+ * -------------------------
+ */
+
+ fun launchForSaveResult(context: Context,
+ searchInfo: SearchInfo) {
+ EntrySelectionHelper.startActivityForSaveModeResult(context,
+ Intent(context, FileDatabaseSelectActivity::class.java),
+ searchInfo)
+ }
+
/*
* -------------------------
* Keyboard Launch
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt
index eb83127ed..b8b1b57cf 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt
@@ -1370,8 +1370,20 @@ class GroupActivity : LockingActivity(),
}
)
},
- {
- // Nothing with Save Info, only pass by search first
+ { searchInfo ->
+ // Save info used with OTP
+ if (!readOnly) {
+ GroupActivity.launchForSaveResult(activity,
+ searchInfo,
+ false)
+ onLaunchActivitySpecialMode()
+ } else {
+ Toast.makeText(activity.applicationContext,
+ R.string.autofill_read_only_save,
+ Toast.LENGTH_LONG)
+ .show()
+ onCancelSpecialMode()
+ }
},
{ searchInfo ->
SearchHelper.checkAutoSearchInfo(activity,
diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/PasswordActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/PasswordActivity.kt
index 5e2f3ef46..5c9498240 100644
--- a/app/src/main/java/com/kunzisoft/keepass/activities/PasswordActivity.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/activities/PasswordActivity.kt
@@ -795,6 +795,25 @@ open class PasswordActivity : SpecialModeActivity() {
}
}
+ /*
+ * -------------------------
+ * Save Launch
+ * -------------------------
+ */
+
+ @Throws(FileNotFoundException::class)
+ fun launchForSaveResult(activity: Activity,
+ databaseFile: Uri,
+ keyFile: Uri?,
+ searchInfo: SearchInfo) {
+ buildAndLaunchIntent(activity, databaseFile, keyFile) { intent ->
+ EntrySelectionHelper.startActivityForSaveModeResult(
+ activity,
+ intent,
+ searchInfo)
+ }
+ }
+
/*
* -------------------------
* Keyboard Launch
@@ -877,8 +896,11 @@ open class PasswordActivity : SpecialModeActivity() {
searchInfo)
onLaunchActivitySpecialMode()
},
- { // Save Action
- // Not directly used, a search is performed before
+ { searchInfo -> // Save Action
+ PasswordActivity.launchForSaveResult(activity,
+ databaseUri, keyFile,
+ searchInfo)
+ onLaunchActivitySpecialMode()
},
{ searchInfo -> // Keyboard Selection Action
PasswordActivity.launchForKeyboardResult(activity,
diff --git a/app/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt b/app/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt
index caac917e5..283a65bb6 100644
--- a/app/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt
@@ -28,6 +28,7 @@ import com.kunzisoft.keepass.database.element.icon.IconImage
import com.kunzisoft.keepass.database.element.icon.IconImageStandard
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.otp.OtpElement
+import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
import kotlin.collections.ArrayList
@@ -125,7 +126,22 @@ class EntryInfo : Parcelable {
}
fun saveSearchInfo(database: Database?, searchInfo: SearchInfo) {
- searchInfo.webDomain?.let { webDomain ->
+ searchInfo.otpString?.let { otpString ->
+ // Replace the OTP field
+ OtpEntryFields.parseOTPUri(otpString)?.let { otpElement ->
+ if (title.isEmpty())
+ title = otpElement.issuer
+ if (username.isEmpty())
+ username = otpElement.name
+ // Add OTP field
+ val mutableCustomFields = customFields as ArrayList
+ val otpField = OtpEntryFields.buildOtpField(otpElement, null, null)
+ if (mutableCustomFields.contains(otpField)) {
+ mutableCustomFields.remove(otpField)
+ }
+ mutableCustomFields.add(otpField)
+ }
+ } ?: searchInfo.webDomain?.let { webDomain ->
// If unable to save web domain in custom field or URL not populated, save in URL
val scheme = searchInfo.webScheme
val webScheme = if (scheme.isNullOrEmpty()) "http" else scheme
diff --git a/app/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt b/app/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt
index c49d0db2a..ac1f37b3a 100644
--- a/app/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt
@@ -2,8 +2,10 @@ package com.kunzisoft.keepass.model
import android.content.Context
import android.content.res.Resources
+import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
+import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.utils.ObjectNameResource
import kotlinx.coroutines.CoroutineScope
@@ -35,6 +37,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
get() {
return if (webDomain == null) null else field
}
+ var otpString: String? = null
constructor()
@@ -42,6 +45,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
applicationId = toCopy?.applicationId
webDomain = toCopy?.webDomain
webScheme = toCopy?.webScheme
+ otpString = toCopy?.otpString
}
private constructor(parcel: Parcel) {
@@ -51,6 +55,8 @@ class SearchInfo : ObjectNameResource, Parcelable {
webDomain = if (readDomain.isNullOrEmpty()) null else readDomain
val readScheme = parcel.readString()
webScheme = if (readScheme.isNullOrEmpty()) null else readScheme
+ val readOtp = parcel.readString()
+ otpString = if (readOtp.isNullOrEmpty()) null else readOtp
}
override fun describeContents(): Int {
@@ -61,14 +67,23 @@ class SearchInfo : ObjectNameResource, Parcelable {
parcel.writeString(applicationId ?: "")
parcel.writeString(webDomain ?: "")
parcel.writeString(webScheme ?: "")
+ parcel.writeString(otpString ?: "")
}
override fun getName(resources: Resources): String {
+ otpString?.let { otpString ->
+ OtpEntryFields.parseOTPUri(otpString)?.let { otpElement ->
+ return "${otpElement.type} (${Uri.decode(otpElement.issuer)}:${Uri.decode(otpElement.name)})"
+ }
+ }
return toString()
}
fun containsOnlyNullValues(): Boolean {
- return applicationId == null && webDomain == null && webScheme == null
+ return applicationId == null
+ && webDomain == null
+ && webScheme == null
+ && otpString == null
}
override fun equals(other: Any?): Boolean {
@@ -80,6 +95,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
if (applicationId != other.applicationId) return false
if (webDomain != other.webDomain) return false
if (webScheme != other.webScheme) return false
+ if (otpString != other.otpString) return false
return true
}
@@ -88,11 +104,12 @@ class SearchInfo : ObjectNameResource, Parcelable {
var result = applicationId?.hashCode() ?: 0
result = 31 * result + (webDomain?.hashCode() ?: 0)
result = 31 * result + (webScheme?.hashCode() ?: 0)
+ result = 31 * result + (otpString?.hashCode() ?: 0)
return result
}
override fun toString(): String {
- return webDomain ?: applicationId ?: ""
+ return otpString ?: webDomain ?: applicationId ?: ""
}
companion object {
diff --git a/app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt b/app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt
index 0128b0ea4..682f7889e 100644
--- a/app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/otp/OtpElement.kt
@@ -216,13 +216,17 @@ data class OtpElement(var otpModel: OtpModel = OtpModel()) {
return secret.isNotEmpty() && checkBase64Secret(secret)
}
- fun replaceSpaceChars(parameter: String): String {
+ fun removeLineChars(parameter: String): String {
+ return parameter.replace("[\\r|\\n|\\t|\\u00A0]+".toRegex(), "")
+ }
+
+ fun removeSpaceChars(parameter: String): String {
return parameter.replace("[\\r|\\n|\\t|\\s|\\u00A0]+".toRegex(), "")
}
fun replaceBase32Chars(parameter: String): String {
// Add 'A' at end if not Base32 length
- var parameterNewSize = replaceSpaceChars(parameter.toUpperCase(Locale.ENGLISH))
+ var parameterNewSize = removeSpaceChars(parameter.toUpperCase(Locale.ENGLISH))
while (parameterNewSize.length % 8 != 0) {
parameterNewSize += 'A'
}
diff --git a/app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt b/app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt
index b1e13d332..38106feca 100644
--- a/app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt
+++ b/app/src/main/java/com/kunzisoft/keepass/otp/OtpEntryFields.kt
@@ -24,9 +24,9 @@ import android.net.Uri
import android.util.Log
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.model.Field
-import com.kunzisoft.keepass.otp.OtpElement.Companion.replaceSpaceChars
+import com.kunzisoft.keepass.otp.OtpElement.Companion.removeLineChars
+import com.kunzisoft.keepass.otp.OtpElement.Companion.removeSpaceChars
import com.kunzisoft.keepass.otp.TokenCalculator.*
-import java.net.URLEncoder
import java.util.*
import java.util.regex.Pattern
@@ -49,6 +49,9 @@ object OtpEntryFields {
private const val ENCODER_URL_PARAM = "encoder"
private const val COUNTER_URL_PARAM = "counter"
+ // OTPauth URI
+ private const val REGEX_OTP_AUTH = "^(?:otpauth://([ht]otp)/)(?:(?:([^:?#]*): *)?([^:?#]*))(?:\\?([^#]+))$"
+
// Key-values (maybe from plugin or old KeePassXC)
private const val SEED_KEY = "key"
private const val DIGITS_KEY = "size"
@@ -91,7 +94,25 @@ object OtpEntryFields {
// HOTP fields from KeePass 2
if (parseHOTPFromField(getField, otpElement))
return otpElement
+ return null
+ }
+ /**
+ * Tell if [otpUri] is a valid Otp URI
+ */
+ fun isOTPUri(otpUri: String): Boolean {
+ if (Pattern.matches(REGEX_OTP_AUTH, otpUri))
+ return true
+ return false
+ }
+
+ /**
+ * Get OtpElement from [otpUri]
+ */
+ fun parseOTPUri(otpUri: String): OtpElement? {
+ val otpElement = OtpElement()
+ if (parseOTPUri({ key -> if (key == OTP_FIELD) otpUri else null }, otpElement))
+ return otpElement
return null
}
@@ -104,8 +125,8 @@ object OtpEntryFields {
*/
private fun parseOTPUri(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
val otpPlainText = getField(OTP_FIELD)
- if (otpPlainText != null && otpPlainText.isNotEmpty()) {
- val uri = Uri.parse(replaceSpaceChars(otpPlainText))
+ if (otpPlainText != null && otpPlainText.isNotEmpty() && isOTPUri(otpPlainText)) {
+ val uri = Uri.parse(removeSpaceChars(otpPlainText))
if (uri.scheme == null || OTP_SCHEME != uri.scheme!!.toLowerCase(Locale.ENGLISH)) {
Log.e(TAG, "Invalid or missing scheme in uri")
@@ -135,12 +156,19 @@ object OtpEntryFields {
}
val nameParam = validateAndGetNameInPath(uri.path)
- if (nameParam != null && nameParam.isNotEmpty())
- otpElement.name = nameParam
+ if (nameParam != null && nameParam.isNotEmpty()) {
+ val userIdArray = nameParam.split(":", "%3A")
+ if (userIdArray.size > 1) {
+ otpElement.issuer = removeLineChars(userIdArray[0])
+ otpElement.name = removeLineChars(userIdArray[1])
+ } else {
+ otpElement.name = removeLineChars(nameParam)
+ }
+ }
val issuerParam = uri.getQueryParameter(ISSUER_URL_PARAM)
if (issuerParam != null && issuerParam.isNotEmpty())
- otpElement.issuer = issuerParam
+ otpElement.issuer = removeLineChars(issuerParam)
val secretParam = uri.getQueryParameter(SECRET_URL_PARAM)
if (secretParam != null && secretParam.isNotEmpty()) {
@@ -211,15 +239,15 @@ object OtpEntryFields {
}
val issuer =
if (title != null && title.isNotEmpty())
- replaceCharsForUrl(title)
+ encodeParameter(title)
else
- replaceCharsForUrl(otpElement.issuer)
+ encodeParameter(otpElement.issuer)
val accountName =
if (username != null && username.isNotEmpty())
- replaceCharsForUrl(username)
+ encodeParameter(username)
else
- replaceCharsForUrl(otpElement.name)
- val uriString = StringBuilder("otpauth://$otpAuthority/$issuer:$accountName" +
+ encodeParameter(otpElement.name)
+ val uriString = StringBuilder("otpauth://$otpAuthority/$issuer%3A$accountName" +
"?$SECRET_URL_PARAM=${otpElement.getBase32Secret()}" +
"&$counterOrPeriodLabel=$counterOrPeriodValue" +
"&$DIGITS_URL_PARAM=${otpElement.digits}" +
@@ -233,8 +261,8 @@ object OtpEntryFields {
return Uri.parse(uriString.toString())
}
- private fun replaceCharsForUrl(parameter: String): String {
- return URLEncoder.encode(replaceSpaceChars(parameter), "UTF-8")
+ private fun encodeParameter(parameter: String): String {
+ return Uri.encode(OtpElement.removeLineChars(parameter))
}
private fun parseTOTPKeyValues(getField: (id: String) -> String?, otpElement: OtpElement): Boolean {
@@ -321,7 +349,7 @@ object OtpEntryFields {
// 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.
+ null
} else name
}
@@ -338,7 +366,7 @@ object OtpEntryFields {
/**
* Build Otp field from an OtpElement
*/
- fun buildOtpField(otpElement: OtpElement, title: String?, username: String?): Field {
+ fun buildOtpField(otpElement: OtpElement, title: String? = null, username: String? = null): Field {
return Field(OTP_FIELD, ProtectedString(true,
buildOtpUri(otpElement, title, username).toString()))
}