/*
* Copyright 2019 Jeremy Jamet / Kunzisoft.
*
* This file is part of KeePassDX.
*
* KeePassDX is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* KeePassDX is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with KeePassDX. If not, see .
*/
package com.kunzisoft.keepass.autofill
import android.app.assist.AssistStructure
import android.os.Build
import android.text.InputType
import android.util.Log
import android.view.View
import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue
import androidx.annotation.RequiresApi
import java.util.*
import kotlin.collections.ArrayList
/**
* Parse AssistStructure and guess username and password fields.
*/
@RequiresApi(api = Build.VERSION_CODES.O)
class StructureParser(private val structure: AssistStructure) {
private var result: Result? = null
private var usernameIdCandidate: AutofillId? = null
private var usernameValueCandidate: AutofillValue? = null
fun parse(saveValue: Boolean = false): Result? {
try {
result = Result()
result?.apply {
allowSaveValues = saveValue
usernameIdCandidate = null
usernameValueCandidate = null
mainLoop@ for (i in 0 until structure.windowNodeCount) {
val windowNode = structure.getWindowNodeAt(i)
applicationId = windowNode.title.toString().split("/")[0]
Log.d(TAG, "Autofill applicationId: $applicationId")
if (parseViewNode(windowNode.rootViewNode))
break@mainLoop
}
// If not explicit username field found, add the field just before password field.
if (usernameId == null && passwordId != null && usernameIdCandidate != null) {
usernameId = usernameIdCandidate
if (allowSaveValues) {
usernameValue = usernameValueCandidate
}
}
}
return if (result?.passwordId != null || result?.ccnId != null)
result
else
null
} catch (e: Exception) {
return null
}
}
private fun parseViewNode(node: AssistStructure.ViewNode): Boolean {
// remember this
if (node.className == "android.webkit.WebView") {
result?.isWebView = true
}
// Get the domain of a web app
node.webDomain?.let { webDomain ->
if (webDomain.isNotEmpty()) {
result?.webDomain = webDomain
Log.d(TAG, "Autofill domain: $webDomain")
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
node.webScheme?.let { webScheme ->
if (webScheme.isNotEmpty()) {
result?.webScheme = webScheme
Log.d(TAG, "Autofill scheme: $webScheme")
}
}
}
val domainNotEmpty = result?.webDomain?.isNotEmpty() == true
var returnValue = false
// Only parse visible nodes
if (node.visibility == View.VISIBLE) {
if (node.autofillId != null) {
// Parse methods
val hints = node.autofillHints
if (hints != null && hints.isNotEmpty()) {
if (parseNodeByAutofillHint(node))
returnValue = true
} else if (parseNodeByHtmlAttributes(node))
returnValue = true
else if (parseNodeByAndroidInput(node))
returnValue = true
}
// Optimized return but only if domain not empty
if (domainNotEmpty && returnValue)
return true
// Recursive method to process each node
for (i in 0 until node.childCount) {
if (parseViewNode(node.getChildAt(i)))
returnValue = true
if (domainNotEmpty && returnValue)
return true
}
}
return returnValue
}
private fun parseNodeByAutofillHint(node: AssistStructure.ViewNode): Boolean {
val autofillId = node.autofillId
node.autofillHints?.forEach {
when {
it.contains(View.AUTOFILL_HINT_USERNAME, true)
|| it.contains(View.AUTOFILL_HINT_EMAIL_ADDRESS, true)
|| it.contains("email", true)
|| it.contains(View.AUTOFILL_HINT_PHONE, true) -> {
result?.usernameId = autofillId
result?.usernameValue = node.autofillValue
Log.d(TAG, "Autofill username hint")
}
it.contains(View.AUTOFILL_HINT_PASSWORD, true) -> {
result?.passwordId = autofillId
result?.passwordValue = node.autofillValue
Log.d(TAG, "Autofill password hint")
return true
}
it == "cc-name" -> {
Log.d(TAG, "AUTOFILL cc-name hint")
result?.ccNameId = autofillId
result?.ccName = node.autofillValue?.textValue?.toString()
}
it == View.AUTOFILL_HINT_CREDIT_CARD_NUMBER || it == "cc-number" -> {
Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_NUMBER hint")
result?.ccnId = autofillId
result?.ccNumber = node.autofillValue?.textValue?.toString()
}
// expect date string as defined in https://html.spec.whatwg.org, e.g. 2014-12
it == "cc-exp" -> {
Log.d(TAG, "AUTOFILL cc-exp hint")
result?.ccExpDateId = autofillId
node.autofillValue?.let { value ->
if (value.isText && value.textValue.length == 7) {
value.textValue.let { date ->
result?.ccExpirationValue = date.substring(5, 7) + date.substring(2, 4)
}
}
}
}
it == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE -> {
Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE hint")
result?.ccExpDateId = autofillId
node.autofillValue?.let { value ->
if (value.isDate) {
val calendar = Calendar.getInstance()
calendar.clear()
calendar.timeInMillis = value.dateValue
val year = calendar.get(Calendar.YEAR).toString().substring(2,4)
val month = calendar.get(Calendar.MONTH).inc().toString().padStart(2, '0')
result?.ccExpirationValue = month + year
}
}
}
it == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR || it == "cc-exp-year" -> {
Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR hint")
result?.ccExpDateYearId = autofillId
if (node.autofillOptions != null) {
result?.ccExpYearOptions = node.autofillOptions
}
node.autofillValue?.let { value ->
var year = 0
try {
if (value.isText) {
year = value.textValue.toString().toInt()
}
if (value.isList) {
year = node.autofillOptions?.get(value.listValue).toString().toInt()
}
} catch (e: Exception) {
year = 0
}
result?.ccExpDateYearValue = year % 100
}
}
it == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH || it == "cc-exp-month" -> {
Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH hint")
result?.ccExpDateMonthId = autofillId
if (node.autofillOptions != null) {
result?.ccExpMonthOptions = node.autofillOptions
}
node.autofillValue?.let { value ->
var month = 0
if (value.isText) {
try {
month = value.textValue.toString().toInt()
} catch (e: Exception) {
month = 0
}
}
if (value.isList) {
// assume list starts with January (index 0)
month = value.listValue + 1
}
result?.ccExpDateMonthValue = month
}
}
it == View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE || it == "cc-csc" -> {
Log.d(TAG, "AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE hint")
result?.cvvId = autofillId
result?.cvv = node.autofillValue?.textValue?.toString()
}
// Ignore autocomplete="off"
// https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion
it.equals("off", true) ||
it.equals("on", true) -> {
Log.d(TAG, "Autofill web hint")
return parseNodeByHtmlAttributes(node)
}
else -> Log.d(TAG, "Autofill unsupported hint $it")
}
}
return false
}
private fun parseNodeByHtmlAttributes(node: AssistStructure.ViewNode): Boolean {
val autofillId = node.autofillId
val nodHtml = node.htmlInfo
when (nodHtml?.tag?.toLowerCase(Locale.ENGLISH)) {
"input" -> {
nodHtml.attributes?.forEach { pairAttribute ->
when (pairAttribute.first.toLowerCase(Locale.ENGLISH)) {
"type" -> {
when (pairAttribute.second.toLowerCase(Locale.ENGLISH)) {
"tel", "email" -> {
result?.usernameId = autofillId
result?.usernameValue = node.autofillValue
Log.d(TAG, "Autofill username web type: ${node.htmlInfo?.tag} ${node.htmlInfo?.attributes}")
}
"text" -> {
usernameIdCandidate = autofillId
usernameValueCandidate = node.autofillValue
Log.d(TAG, "Autofill username candidate web type: ${node.htmlInfo?.tag} ${node.htmlInfo?.attributes}")
}
"password" -> {
result?.passwordId = autofillId
result?.passwordValue = node.autofillValue
Log.d(TAG, "Autofill password web type: ${node.htmlInfo?.tag} ${node.htmlInfo?.attributes}")
return true
}
}
}
}
}
}
}
return false
}
private fun inputIsVariationType(inputType: Int, vararg type: Int): Boolean {
type.forEach {
if (inputType and InputType.TYPE_MASK_VARIATION == it)
return true
}
return false
}
private fun showHexInputType(inputType: Int): String {
return "0x${"%08x".format(inputType)}"
}
private fun parseNodeByAndroidInput(node: AssistStructure.ViewNode): Boolean {
val autofillId = node.autofillId
val inputType = node.inputType
when (inputType and InputType.TYPE_MASK_CLASS) {
InputType.TYPE_CLASS_TEXT -> {
when {
inputIsVariationType(inputType,
InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS,
InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS) -> {
result?.usernameId = autofillId
result?.usernameValue = node.autofillValue
Log.d(TAG, "Autofill username android text type: ${showHexInputType(inputType)}")
}
inputIsVariationType(inputType,
InputType.TYPE_TEXT_VARIATION_NORMAL,
InputType.TYPE_TEXT_VARIATION_PERSON_NAME,
InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT) -> {
usernameIdCandidate = autofillId
usernameValueCandidate = node.autofillValue
Log.d(TAG, "Autofill username candidate android text type: ${showHexInputType(inputType)}")
}
inputIsVariationType(inputType,
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) -> {
// Some forms used visible password as username
if (usernameCandidate == null && usernameValueCandidate == null) {
usernameCandidate = autofillId
usernameValueCandidate = node.autofillValue
Log.d(TAG, "Autofill visible password android text type (as username): ${showHexInputType(inputType)}")
} else if (result?.passwordId == null && result?.passwordValue == null) {
result?.passwordId = autofillId
result?.passwordValue = node.autofillValue
Log.d(TAG, "Autofill visible password android text type (as password): ${showHexInputType(inputType)}")
usernameNeeded = false
}
}
inputIsVariationType(inputType,
InputType.TYPE_TEXT_VARIATION_PASSWORD,
InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) -> {
result?.passwordId = autofillId
result?.passwordValue = node.autofillValue
Log.d(TAG, "Autofill password android text type: ${showHexInputType(inputType)}")
return true
}
inputIsVariationType(inputType,
InputType.TYPE_TEXT_VARIATION_EMAIL_SUBJECT,
InputType.TYPE_TEXT_VARIATION_FILTER,
InputType.TYPE_TEXT_VARIATION_LONG_MESSAGE,
InputType.TYPE_TEXT_VARIATION_PHONETIC,
InputType.TYPE_TEXT_VARIATION_POSTAL_ADDRESS,
InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE,
InputType.TYPE_TEXT_VARIATION_URI) -> {
// Type not used
}
else -> {
Log.d(TAG, "Autofill unknown android text type: ${showHexInputType(inputType)}")
}
}
}
InputType.TYPE_CLASS_NUMBER -> {
when {
inputIsVariationType(inputType,
InputType.TYPE_NUMBER_VARIATION_NORMAL) -> {
usernameIdCandidate = autofillId
usernameValueCandidate = node.autofillValue
Log.d(TAG, "Autofill username candidate android number type: ${showHexInputType(inputType)}")
}
inputIsVariationType(inputType,
InputType.TYPE_NUMBER_VARIATION_PASSWORD) -> {
result?.passwordId = autofillId
result?.passwordValue = node.autofillValue
Log.d(TAG, "Autofill password android number type: ${showHexInputType(inputType)}")
return true
}
else -> {
Log.d(TAG, "Autofill unknown android number type: ${showHexInputType(inputType)}")
}
}
}
}
return false
}
@RequiresApi(api = Build.VERSION_CODES.O)
class Result {
var isWebView: Boolean = false
var applicationId: String? = null
var webDomain: String? = null
set(value) {
if (field == null)
field = value
}
var webScheme: String? = null
set(value) {
if (field == null)
field = value
}
// if the user selects the credit card expiration date from a list of options
// all options are stored here
var ccExpMonthOptions: Array? = null
var ccExpYearOptions: Array? = null
var usernameId: AutofillId? = null
set(value) {
if (field == null)
field = value
}
var passwordId: AutofillId? = null
set(value) {
if (field == null)
field = value
}
var ccNameId: AutofillId? = null
set(value) {
if (field == null)
field = value
}
var ccnId: AutofillId? = null
set(value) {
if (field == null)
field = value
}
var ccExpDateId: AutofillId? = null
set(value) {
if (field == null)
field = value
}
var ccExpDateYearId: AutofillId? = null
set(value) {
if (field == null)
field = value
}
var ccExpDateMonthId: AutofillId? = null
set(value) {
if (field == null)
field = value
}
var cvvId: AutofillId? = null
set(value) {
if (field == null)
field = value
}
fun allAutofillIds(): Array {
val all = ArrayList()
usernameId?.let {
all.add(it)
}
passwordId?.let {
all.add(it)
}
ccNameId?.let {
all.add(it)
}
ccnId?.let {
all.add(it)
}
cvvId?.let {
all.add(it)
}
return all.toTypedArray()
}
// Only in registration mode
var allowSaveValues = false
var usernameValue: AutofillValue? = null
set(value) {
if (allowSaveValues && field == null)
field = value
}
var passwordValue: AutofillValue? = null
set(value) {
if (allowSaveValues && field == null)
field = value
}
var ccName: String? = null
set(value) {
if (allowSaveValues)
field = value
}
var ccNumber: String? = null
set(value) {
if (allowSaveValues)
field = value
}
// format MMYY
var ccExpirationValue: String? = null
set(value) {
if (allowSaveValues)
field = value
}
// for year of CC expiration date: YY
var ccExpDateYearValue = 0
set(value) {
if (allowSaveValues)
field = value
}
// for month of CC expiration date: MM
var ccExpDateMonthValue = 0
set(value) {
if (allowSaveValues)
field = value
}
// the security code for the credit card (also called CVV)
var cvv: String? = null
set(value) {
if (allowSaveValues)
field = value
}
}
companion object {
private val TAG = StructureParser::class.java.name
}
}