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())) }