refactoring of autofill implementation, extracted some pieces to be independant of Android framework, added some xUnit tests

This commit is contained in:
Philipp Crocoll
2023-02-28 22:31:28 +01:00
parent e350e8788c
commit 914224e4fa
17 changed files with 2096 additions and 481 deletions

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Xml;
using Newtonsoft.Json;
using Formatting = System.Xml.Formatting;
namespace Kp2aAutofillParser
{
@@ -192,54 +194,54 @@ namespace Kp2aAutofillParser
return false;
}
}
class AutofillHintsHelper
public class AutofillHintsHelper
{
const string AutofillHint2faAppOtp = "2faAppOTPCode";
const string AutofillHintBirthDateDay = "birthDateDay";
const string AutofillHintBirthDateFull = "birthDateFull";
const string AutofillHintBirthDateMonth = "birthDateMonth";
const string AutofillHintBirthDateYear = "birthDateYear";
const string AutofillHintCreditCardExpirationDate = "creditCardExpirationDate";
const string AutofillHintCreditCardExpirationDay = "creditCardExpirationDay";
const string AutofillHintCreditCardExpirationMonth = "creditCardExpirationMonth";
const string AutofillHintCreditCardExpirationYear = "creditCardExpirationYear";
const string AutofillHintCreditCardNumber = "creditCardNumber";
const string AutofillHintCreditCardSecurityCode = "creditCardSecurityCode";
const string AutofillHintEmailAddress = "emailAddress";
const string AutofillHintEmailOtp = "emailOTPCode";
const string AutofillHintGender = "gender";
const string AutofillHintName = "name";
const string AutofillHintNewPassword = "newPassword";
const string AutofillHintNewUsername = "newUsername";
const string AutofillHintNotApplicable = "notApplicable";
const string AutofillHintPassword = "password";
const string AutofillHintPersonName = "personName";
const string AutofillHintPersonNameFAMILY = "personFamilyName";
const string AutofillHintPersonNameGIVEN = "personGivenName";
const string AutofillHintPersonNameMIDDLE = "personMiddleName";
const string AutofillHintPersonNameMIDDLE_INITIAL = "personMiddleInitial";
const string AutofillHintPersonNamePREFIX = "personNamePrefix";
const string AutofillHintPersonNameSUFFIX = "personNameSuffix";
const string AutofillHintPhone = "phone";
const string AutofillHintPhoneContryCode = "phoneCountryCode";
const string AutofillHintPostalAddressAPT_NUMBER = "aptNumber";
const string AutofillHintPostalAddressCOUNTRY = "addressCountry";
const string AutofillHintPostalAddressDEPENDENT_LOCALITY = "dependentLocality";
const string AutofillHintPostalAddressEXTENDED_ADDRESS = "extendedAddress";
const string AutofillHintPostalAddressEXTENDED_POSTAL_CODE = "extendedPostalCode";
const string AutofillHintPostalAddressLOCALITY = "addressLocality";
const string AutofillHintPostalAddressREGION = "addressRegion";
const string AutofillHintPostalAddressSTREET_ADDRESS = "streetAddress";
const string AutofillHintPostalCode = "postalCode";
const string AutofillHintPromoCode = "promoCode";
const string AutofillHintSMS_OTP = "smsOTPCode";
const string AutofillHintUPI_VPA = "upiVirtualPaymentAddress";
const string AutofillHintUsername = "username";
const string AutofillHintWifiPassword = "wifiPassword";
const string AutofillHintPhoneNational = "phoneNational";
const string AutofillHintPhoneNumber = "phoneNumber";
const string AutofillHintPhoneNumberDevice = "phoneNumberDevice";
const string AutofillHintPostalAddress = "postalAddress";
public const string AutofillHint2faAppOtp = "2faAppOTPCode";
public const string AutofillHintBirthDateDay = "birthDateDay";
public const string AutofillHintBirthDateFull = "birthDateFull";
public const string AutofillHintBirthDateMonth = "birthDateMonth";
public const string AutofillHintBirthDateYear = "birthDateYear";
public const string AutofillHintCreditCardExpirationDate = "creditCardExpirationDate";
public const string AutofillHintCreditCardExpirationDay = "creditCardExpirationDay";
public const string AutofillHintCreditCardExpirationMonth = "creditCardExpirationMonth";
public const string AutofillHintCreditCardExpirationYear = "creditCardExpirationYear";
public const string AutofillHintCreditCardNumber = "creditCardNumber";
public const string AutofillHintCreditCardSecurityCode = "creditCardSecurityCode";
public const string AutofillHintEmailAddress = "emailAddress";
public const string AutofillHintEmailOtp = "emailOTPCode";
public const string AutofillHintGender = "gender";
public const string AutofillHintName = "name";
public const string AutofillHintNewPassword = "newPassword";
public const string AutofillHintNewUsername = "newUsername";
public const string AutofillHintNotApplicable = "notApplicable";
public const string AutofillHintPassword = "password";
public const string AutofillHintPersonName = "personName";
public const string AutofillHintPersonNameFAMILY = "personFamilyName";
public const string AutofillHintPersonNameGIVEN = "personGivenName";
public const string AutofillHintPersonNameMIDDLE = "personMiddleName";
public const string AutofillHintPersonNameMIDDLE_INITIAL = "personMiddleInitial";
public const string AutofillHintPersonNamePREFIX = "personNamePrefix";
public const string AutofillHintPersonNameSUFFIX = "personNameSuffix";
public const string AutofillHintPhone = "phone";
public const string AutofillHintPhoneContryCode = "phoneCountryCode";
public const string AutofillHintPostalAddressAPT_NUMBER = "aptNumber";
public const string AutofillHintPostalAddressCOUNTRY = "addressCountry";
public const string AutofillHintPostalAddressDEPENDENT_LOCALITY = "dependentLocality";
public const string AutofillHintPostalAddressEXTENDED_ADDRESS = "extendedAddress";
public const string AutofillHintPostalAddressEXTENDED_POSTAL_CODE = "extendedPostalCode";
public const string AutofillHintPostalAddressLOCALITY = "addressLocality";
public const string AutofillHintPostalAddressREGION = "addressRegion";
public const string AutofillHintPostalAddressSTREET_ADDRESS = "streetAddress";
public const string AutofillHintPostalCode = "postalCode";
public const string AutofillHintPromoCode = "promoCode";
public const string AutofillHintSMS_OTP = "smsOTPCode";
public const string AutofillHintUPI_VPA = "upiVirtualPaymentAddress";
public const string AutofillHintUsername = "username";
public const string AutofillHintWifiPassword = "wifiPassword";
public const string AutofillHintPhoneNational = "phoneNational";
public const string AutofillHintPhoneNumber = "phoneNumber";
public const string AutofillHintPhoneNumberDevice = "phoneNumberDevice";
public const string AutofillHintPostalAddress = "postalAddress";
private static readonly HashSet<string> _allSupportedHints = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
@@ -540,8 +542,29 @@ namespace Kp2aAutofillParser
{
bool IsTrustedApp(string packageName);
bool IsTrustedLink(string domain, string targetPackage);
bool IsEnabled();
}
class TimeUtil
{
private static DateTime? m_dtUnixRoot = null;
public static DateTime ConvertUnixTime(double dtUnix)
{
try
{
if (!m_dtUnixRoot.HasValue)
m_dtUnixRoot = (new DateTime(1970, 1, 1, 0, 0, 0, 0,
DateTimeKind.Utc)).ToLocalTime();
return m_dtUnixRoot.Value.AddSeconds(dtUnix);
}
catch (Exception) { Debug.Assert(false); }
return DateTime.UtcNow;
}
}
public class FilledAutofillField<FieldT> where FieldT : InputField
{
private string[] _autofillHints;
@@ -671,7 +694,7 @@ namespace Kp2aAutofillParser
}
/// <summary>
/// Base class for everything that is a input field which might (or might not) be autofilled.
/// Base class for everything that is (or could be) an input field which might (or might not) be autofilled.
/// For testability, this is independent from Android classes like ViewNode
/// </summary>
public abstract class InputField
@@ -687,11 +710,12 @@ namespace Kp2aAutofillParser
public string HtmlInfoTag { get; set; }
public string HtmlInfoTypeAttribute { get; set; }
public abstract void FillFilledAutofillValue(FilledAutofillField<FieldT> filledField);
}
/// <summary>
/// Serializable structure defining the contents of the current view (from an autofill perspective)
/// </summary>
/// <typeparam name="TField"></typeparam>
public class AutofillView<TField> where TField : InputField
{
public List<TField> InputFields { get; set; } = new List<TField>();
@@ -710,8 +734,17 @@ namespace Kp2aAutofillParser
private readonly ILogger _log;
private readonly IKp2aDigitalAssetLinksDataSource _digitalAssetLinksDataSource;
private readonly List<string> _autofillHintsForLogin = new List<string>
{
AutofillHintsHelper.AutofillHintPassword,
AutofillHintsHelper.AutofillHintUsername,
AutofillHintsHelper.AutofillHintEmailAddress
};
public string PackageId { get; set; }
public Dictionary<FieldT, string[]> FieldsMappedToHints = new Dictionary<FieldT, string[]>();
public StructureParserBase(ILogger logger, IKp2aDigitalAssetLinksDataSource digitalAssetLinksDataSource)
{
_log = logger;
@@ -763,151 +796,86 @@ namespace Kp2aAutofillParser
/// <returns>The parse.</returns>
/// <param name="forFill">If set to <c>true</c> for fill.</param>
/// <param name="isManualRequest"></param>
AutofillTargetId Parse(bool forFill, bool isManualRequest, AutofillView<FieldT> autofillView)
protected virtual AutofillTargetId Parse(bool forFill, bool isManualRequest, AutofillView<FieldT> autofillView)
{
AutofillTargetId result = new AutofillTargetId();
AutofillTargetId result = new AutofillTargetId()
{
PackageName = autofillView.PackageId,
WebDomain = autofillView.WebDomain
};
_editTextsWithoutHint.Clear();
_log.Log("parsing autofillStructure...");
//TODO remove from production
_log.Log("will log the autofillStructure...");
string debugInfo = JsonConvert.SerializeObject(autofillView, Formatting.Indented);
_log.Log("will log the autofillStructure... size is " + debugInfo.Length);
_log.Log("This is the autofillStructure: \n\n " + debugInfo);
foreach (var viewNode in autofillView.InputFields)
if (LogAutofillView)
{
string[] viewHints = viewNode.AutofillHints;
if (viewHints != null && viewHints.Length == 1 && viewHints.First() == "off" && viewNode.IsFocused &&
isManualRequest)
viewHints[0] = "on";
/*if (viewHints != null && viewHints.Any())
{
CommonUtil.logd("viewHints=" + viewHints);
CommonUtil.logd("class=" + viewNode.ClassName);
CommonUtil.logd("tag=" + (viewNode?.HtmlInfo?.Tag ?? "(null)"));
}*/
if (IsPassword(viewNode) || HasPasswordHint(viewNode) || (HasUsernameHint(viewNode)))
{
if (forFill)
{
AutofillFields.Add(new AutofillFieldMetadata(viewNode.ViewNode));
}
else
{
FilledAutofillField filledAutofillField = new FilledAutofillField(viewNode.ViewNode);
ClientFormData.Add(filledAutofillField);
}
}
else if (viewNode.ClassName == "android.widget.EditText"
|| viewNode.ClassName == "android.widget.AutoCompleteTextView"
|| viewNode.HtmlInfoTag == "input"
|| ((viewHints?.Length ?? 0) > 0))
{
_log.Log("Found something that looks fillable " + viewNode.ClassName);
}
if (viewHints != null && viewHints.Length > 0 && viewHints.First() != "on" /*if hint is "on", treat as if there is no hint*/)
{
}
else
{
if (viewNode.ClassName == "android.widget.EditText"
|| viewNode.ClassName == "android.widget.AutoCompleteTextView"
|| viewNode.HtmlInfoTag == "input")
{
_editTextsWithoutHint.Add(viewNode);
}
}
string debugInfo = JsonConvert.SerializeObject(autofillView, Newtonsoft.Json.Formatting.Indented);
_log.Log("This is the autofillStructure: \n\n " + debugInfo);
}
List<ViewNodeInputField> passwordFields = new List<ViewNodeInputField>();
List<ViewNodeInputField> usernameFields = new List<ViewNodeInputField>();
if (AutofillFields.Empty)
//go through each input field and determine username/password fields.
//Depending on the target this can require more or less heuristics.
// * if there is a valid & supported autofill hint, we assume that all fields which should be filled do have an appropriate Autofill hint
// * if there is no such autofill hint, we use IsPassword to
HashSet<string> autofillHintsOfAllFields = autofillView.InputFields.Where(f => f.AutofillHints != null)
.SelectMany(f => f.AutofillHints).ToHashSet();
bool hasLoginAutofillHints = autofillHintsOfAllFields.Intersect(_autofillHintsForLogin).Any();
if (hasLoginAutofillHints)
{
passwordFields = _editTextsWithoutHint.Where(IsPassword).ToList();
if (!passwordFields.Any())
foreach (var viewNode in autofillView.InputFields)
{
passwordFields = _editTextsWithoutHint.Where(HasPasswordHint).ToList();
}
usernameFields = _editTextsWithoutHint.Where(HasUsernameHint).ToList();
if (usernameFields.Any() == false)
{
foreach (var passwordField in passwordFields)
string[] viewHints = viewNode.AutofillHints;
if (viewHints == null)
continue;
if (viewHints.Intersect(_autofillHintsForLogin).Any())
{
var usernameField = _editTextsWithoutHint
.TakeWhile(f => f != passwordField).LastOrDefault();
if (usernameField != null)
{
usernameFields.Add(usernameField);
}
}
}
if (usernameFields.Any() == false)
{
//for some pages with two-step login, we don't see a password field and don't display the autofill for non-manual requests. But if the user forces autofill,
//let's assume it is a username field:
if (isManualRequest && !passwordFields.Any() && _editTextsWithoutHint.Count == 1)
{
usernameFields.Add(_editTextsWithoutHint.First());
}
}
}
//force focused fields to be included in autofill fields when request was triggered manually. This allows to fill fields which are "off" or don't have a hint (in case there are hints)
if (isManualRequest)
{
foreach (var editText in _editTextsWithoutHint)
{
if (editText.IsFocused)
{
if (IsPassword(editText) || HasPasswordHint(editText))
passwordFields.Add(editText);
else
usernameFields.Add(editText);
break;
FieldsMappedToHints.Add(viewNode, viewHints);
}
}
}
if (forFill)
{
foreach (var uf in usernameFields)
AutofillFields.Add(new AutofillFieldMetadata(uf.ViewNode, new[] { View.AutofillHintUsername }));
foreach (var pf in passwordFields)
AutofillFields.Add(new AutofillFieldMetadata(pf.ViewNode, new[] { View.AutofillHintPassword }));
}
else
{
foreach (var uf in usernameFields)
ClientFormData.Add(new FilledAutofillField(uf.ViewNode, new[] { View.AutofillHintUsername }));
foreach (var pf in passwordFields)
ClientFormData.Add(new FilledAutofillField(pf.ViewNode, new[] { View.AutofillHintPassword }));
//determine password fields, first by type, then by hint:
List<FieldT> passwordFields = autofillView.InputFields.Where(f => IsEditText(f) && IsPassword(f)).ToList();
if (!passwordFields.Any())
{
passwordFields = autofillView.InputFields.Where(f => IsEditText(f) && HasPasswordHint(f)).ToList();
}
//determine username fields. Try by hint, if that fails use the one before the password
List<FieldT> usernameFields = autofillView.InputFields.Where(f => IsEditText(f) && HasUsernameHint(f)).ToList();
if (!usernameFields.Any())
{
foreach (var passwordField in passwordFields)
{
var lastInputBeforePassword = autofillView.InputFields
.TakeWhile(f => IsEditText(f) && f != passwordField && !passwordFields.Contains(f)).LastOrDefault();
if (lastInputBeforePassword != null)
usernameFields.Add(lastInputBeforePassword);
}
}
//for "heuristic determination" we demand that one of the filled fields is focused:
if (passwordFields.Concat(usernameFields).Any(f => f.IsFocused))
{
foreach (var uf in usernameFields)
FieldsMappedToHints.Add(uf, new string[] { AutofillHintsHelper.AutofillHintUsername });
foreach (var pf in passwordFields)
FieldsMappedToHints.Add(pf, new string[] { AutofillHintsHelper.AutofillHintPassword });
}
}
result.WebDomain = autofillView.WebDomain;
result.PackageName = Structure.ActivityComponent.PackageName;
if (!string.IsNullOrEmpty(autofillView.WebDomain) && !PreferenceManager.GetDefaultSharedPreferences(mContext).GetBoolean(mContext.GetString(Resource.String.NoDalVerification_key), false))
if (!string.IsNullOrEmpty(autofillView.WebDomain) && _digitalAssetLinksDataSource.IsEnabled())
{
result.IncompatiblePackageAndDomain = !kp2aDigitalAssetLinksDataSource.IsTrustedLink(autofillView.WebDomain, result.PackageName);
result.IncompatiblePackageAndDomain = !_digitalAssetLinksDataSource.IsTrustedLink(autofillView.WebDomain, result.PackageName);
if (result.IncompatiblePackageAndDomain)
{
CommonUtil.loge($"DAL verification failed for {result.PackageName}/{result.WebDomain}");
_log.Log($"DAL verification failed for {result.PackageName}/{result.WebDomain}");
}
}
else
@@ -916,29 +884,40 @@ namespace Kp2aAutofillParser
}
return result;
}
private static readonly HashSet<string> _passwordHints = new HashSet<string> { "password", "passwort", "passwordAuto", "pswd" };
public bool LogAutofillView { get; set; }
private bool IsEditText(FieldT f)
{
return (f.ClassName == "android.widget.EditText"
|| f.ClassName == "android.widget.AutoCompleteTextView"
|| f.HtmlInfoTag == "input");
}
private static readonly HashSet<string> _passwordHints = new HashSet<string> { "password", "passwort"
/*, "passwordAuto", "pswd"*/ };
private static bool HasPasswordHint(InputField f)
{
return ContainsAny(f.IdEntry, _passwordHints) ||
ContainsAny(f.Hint, _passwordHints);
return IsAny(f.IdEntry, _passwordHints) ||
IsAny(f.Hint, _passwordHints);
}
private static readonly HashSet<string> _usernameHints = new HashSet<string> { "email", "e-mail", "username" };
private static bool HasUsernameHint(InputField f)
{
return ContainsAny(f.IdEntry, _usernameHints) ||
ContainsAny(f.Hint, _usernameHints);
return IsAny(f.IdEntry, _usernameHints) ||
IsAny(f.Hint, _usernameHints);
}
private static bool ContainsAny(string value, IEnumerable<string> terms)
private static bool IsAny(string value, IEnumerable<string> terms)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var lowerValue = value.ToLowerInvariant();
return terms.Any(t => lowerValue.Contains(t));
return terms.Any(t => lowerValue == t);
}
private static bool IsInputTypeClass(InputTypes inputType, InputTypes inputTypeClass)
@@ -970,12 +949,13 @@ namespace Kp2aAutofillParser
|| IsInputTypeVariation(inputType, InputTypes.TextVariationWebPassword)
)
)
|| (f.AutofillHints != null && f.AutofillHints.First() == "passwordAuto")
|| (f.HtmlInfoTypeAttribute == "password")
);
}
AssistStructure Structure;
private List<ViewNodeInputField> _editTextsWithoutHint = new List<ViewNodeInputField>();