refactoring of autofill implementation, extracted some pieces to be independant of Android framework, added some xUnit tests
This commit is contained in:
@@ -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>();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user