mirror of
https://github.com/bitwarden/android.git
synced 2024-12-24 18:08:26 +03:00
autofill cleanup WIP
This commit is contained in:
parent
759df9bdd5
commit
61e0379eb3
3 changed files with 135 additions and 153 deletions
|
@ -7,58 +7,59 @@ using Android.App;
|
||||||
using Android.Content;
|
using Android.Content;
|
||||||
using Android.OS;
|
using Android.OS;
|
||||||
using Android.Runtime;
|
using Android.Runtime;
|
||||||
using Android.Views;
|
|
||||||
using Android.Widget;
|
|
||||||
using Bit.App.Models;
|
using Bit.App.Models;
|
||||||
|
|
||||||
namespace Bit.Android
|
namespace Bit.Android
|
||||||
{
|
{
|
||||||
//[Activity(Label = "Autofill", LaunchMode = global::Android.Content.PM.LaunchMode.SingleInstance, Theme = "@style/android:Theme.Material.Light")]
|
[Activity(Label = "bitwarden Autofill",
|
||||||
|
LaunchMode = global::Android.Content.PM.LaunchMode.SingleInstance,
|
||||||
|
Theme = "@style/android:Theme.Material.Light")]
|
||||||
public class AutofillActivity : Activity
|
public class AutofillActivity : Activity
|
||||||
{
|
{
|
||||||
|
private string _lastQueriedUri;
|
||||||
|
|
||||||
|
public static Credentials LastCredentials;
|
||||||
|
|
||||||
protected override void OnCreate(Bundle bundle)
|
protected override void OnCreate(Bundle bundle)
|
||||||
{
|
{
|
||||||
base.OnCreate(bundle);
|
base.OnCreate(bundle);
|
||||||
|
_lastQueriedUri = Intent.GetStringExtra("uri");
|
||||||
|
|
||||||
var url = Intent.GetStringExtra("url");
|
var intent = new Intent(this, typeof(AutofillSelectLoginActivity));
|
||||||
_lastQueriedUrl = url;
|
intent.PutExtra("uri", _lastQueriedUri);
|
||||||
|
|
||||||
Intent intent = new Intent(this, typeof(AutofillSelectLoginActivity));
|
|
||||||
intent.PutExtra("url", url);
|
|
||||||
StartActivityForResult(intent, 123);
|
StartActivityForResult(intent, 123);
|
||||||
}
|
}
|
||||||
|
|
||||||
string _lastQueriedUrl;
|
|
||||||
|
|
||||||
protected override void OnActivityResult(int requestCode, [GeneratedEnum] Result resultCode, Intent data)
|
protected override void OnActivityResult(int requestCode, [GeneratedEnum] Result resultCode, Intent data)
|
||||||
{
|
{
|
||||||
var url = data.GetStringExtra("url");
|
|
||||||
var username = data.GetStringExtra("username");
|
|
||||||
var password = data.GetStringExtra("password");
|
|
||||||
|
|
||||||
base.OnActivityResult(requestCode, resultCode, data);
|
base.OnActivityResult(requestCode, resultCode, data);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
LastReceivedCredentials = new Credentials { User = username, Password = password, Url = _lastQueriedUrl };
|
var uri = data.GetStringExtra("uri");
|
||||||
}
|
var username = data.GetStringExtra("username");
|
||||||
catch(Exception e)
|
var password = data.GetStringExtra("password");
|
||||||
|
|
||||||
|
LastCredentials = new Credentials
|
||||||
{
|
{
|
||||||
//Android.Util.Log.Debug("KP2AAS", "Exception while receiving credentials: " + e.ToString());
|
User = username,
|
||||||
|
Password = password,
|
||||||
|
Uri = _lastQueriedUri
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
catch { }
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Finish();
|
Finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Credentials LastReceivedCredentials;
|
|
||||||
|
|
||||||
public class Credentials
|
public class Credentials
|
||||||
{
|
{
|
||||||
public string User;
|
public string User;
|
||||||
public string Password;
|
public string Password;
|
||||||
public string Url;
|
public string Uri;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -9,16 +9,16 @@ using Android.OS;
|
||||||
|
|
||||||
namespace Bit.Android
|
namespace Bit.Android
|
||||||
{
|
{
|
||||||
//[Activity(LaunchMode = global::Android.Content.PM.LaunchMode.SingleInstance)]
|
[Activity(LaunchMode = global::Android.Content.PM.LaunchMode.SingleInstance)]
|
||||||
public class AutofillSelectLoginActivity : Activity
|
public class AutofillSelectLoginActivity : Activity
|
||||||
{
|
{
|
||||||
protected override void OnCreate(Bundle bundle)
|
protected override void OnCreate(Bundle bundle)
|
||||||
{
|
{
|
||||||
base.OnCreate(bundle);
|
base.OnCreate(bundle);
|
||||||
var url = Intent.GetStringExtra("url");
|
var uri = Intent.GetStringExtra("uri");
|
||||||
|
|
||||||
Intent data = new Intent();
|
Intent data = new Intent();
|
||||||
data.PutExtra("url", url);
|
data.PutExtra("uri", uri);
|
||||||
data.PutExtra("username", "user123");
|
data.PutExtra("username", "user123");
|
||||||
data.PutExtra("password", "pass123");
|
data.PutExtra("password", "pass123");
|
||||||
|
|
||||||
|
|
|
@ -1,101 +1,79 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using Android.AccessibilityServices;
|
using Android.AccessibilityServices;
|
||||||
using Android.App;
|
using Android.App;
|
||||||
using Android.Content;
|
using Android.Content;
|
||||||
using Android.Graphics;
|
|
||||||
using Android.OS;
|
using Android.OS;
|
||||||
using Android.Runtime;
|
|
||||||
using Android.Views;
|
|
||||||
using Android.Views.Accessibility;
|
using Android.Views.Accessibility;
|
||||||
using Android.Widget;
|
|
||||||
|
|
||||||
namespace Bit.Android
|
namespace Bit.Android
|
||||||
{
|
{
|
||||||
//[Service(Permission = "android.permission.BIND_ACCESSIBILITY_SERVICE", Label = "bitwarden")]
|
[Service(Permission = "android.permission.BIND_ACCESSIBILITY_SERVICE", Label = "bitwarden")]
|
||||||
//[IntentFilter(new string[] { "android.accessibilityservice.AccessibilityService" })]
|
[IntentFilter(new string[] { "android.accessibilityservice.AccessibilityService" })]
|
||||||
//[MetaData("android.accessibilityservice", Resource = "@xml/accessibilityservice")]
|
[MetaData("android.accessibilityservice", Resource = "@xml/accessibilityservice")]
|
||||||
public class AutofillService : AccessibilityService
|
public class AutofillService : AccessibilityService
|
||||||
{
|
{
|
||||||
private const int autoFillNotificationId = 0;
|
private const int AutoFillNotificationId = 34573;
|
||||||
private const string androidAppPrefix = "androidapp://";
|
private const string AndroidAppProtocol = "androidapp://";
|
||||||
|
private const string SystemUiPackage = "com.android.systemui";
|
||||||
|
private const string ChromePackage = "com.android.chrome";
|
||||||
|
private const string BrowserPackage = "com.android.browser";
|
||||||
|
|
||||||
public override void OnAccessibilityEvent(AccessibilityEvent e)
|
public override void OnAccessibilityEvent(AccessibilityEvent e)
|
||||||
{
|
{
|
||||||
var eventType = e.EventType;
|
var eventType = e.EventType;
|
||||||
System.Diagnostics.Debug.WriteLine(DateTime.UtcNow.ToString() + " Event Type: " + eventType);
|
var packageName = e.PackageName;
|
||||||
var package = e.PackageName;
|
|
||||||
|
if(packageName == SystemUiPackage)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch(eventType)
|
switch(eventType)
|
||||||
{
|
{
|
||||||
case EventTypes.ViewTextSelectionChanged:
|
|
||||||
//if(e.Source.Password && string.IsNullOrWhiteSpace(e.Source.Text))
|
|
||||||
//{
|
|
||||||
// var bundle = new Bundle();
|
|
||||||
// bundle.PutCharSequence(AccessibilityNodeInfo.ActionArgumentSetTextCharsequence, "mypassword");
|
|
||||||
// e.Source.PerformAction(global::Android.Views.Accessibility.Action.SetText, bundle);
|
|
||||||
//}
|
|
||||||
break;
|
|
||||||
case EventTypes.WindowContentChanged:
|
case EventTypes.WindowContentChanged:
|
||||||
case EventTypes.WindowStateChanged:
|
case EventTypes.WindowStateChanged:
|
||||||
if(e.PackageName == "com.android.systemui")
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
var root = RootInActiveWindow;
|
var root = RootInActiveWindow;
|
||||||
if((ExistsNodeOrChildren(root, n => n.WindowId == e.WindowId) && !ExistsNodeOrChildren(root, n => (n.ViewIdResourceName != null) && (n.ViewIdResourceName.StartsWith("com.android.systemui")))))
|
var isChrome = root == null ? false : root.PackageName == ChromePackage;
|
||||||
|
var cancelNotification = true;
|
||||||
|
var avialablePasswordNodes = GetNodeOrChildren(root, n => AvailablePasswordField(n, isChrome));
|
||||||
|
|
||||||
|
if(avialablePasswordNodes.Any() && AnyNodeOrChildren(root, n => n.WindowId == e.WindowId &&
|
||||||
|
!(n.ViewIdResourceName != null && n.ViewIdResourceName.StartsWith(SystemUiPackage))))
|
||||||
{
|
{
|
||||||
bool cancelNotification = true;
|
var uri = string.Concat(AndroidAppProtocol, root.PackageName);
|
||||||
|
if(isChrome)
|
||||||
var allEditTexts = GetNodeOrChildren(root, n => { return IsEditText(n); });
|
|
||||||
|
|
||||||
var usernameEdit = allEditTexts.TakeWhile(edit => (edit.Password == false)).LastOrDefault();
|
|
||||||
|
|
||||||
string searchString = androidAppPrefix + root.PackageName;
|
|
||||||
|
|
||||||
string url = androidAppPrefix + root.PackageName;
|
|
||||||
|
|
||||||
if(root.PackageName == "com.android.chrome")
|
|
||||||
{
|
{
|
||||||
var addressField = root.FindAccessibilityNodeInfosByViewId("com.android.chrome:id/url_bar").FirstOrDefault();
|
var addressNode = root.FindAccessibilityNodeInfosByViewId("com.android.chrome:id/url_bar")
|
||||||
UrlFromAddressField(ref url, addressField);
|
.FirstOrDefault();
|
||||||
|
uri = ExtractUriFromAddressField(uri, addressNode);
|
||||||
|
|
||||||
}
|
}
|
||||||
else if(root.PackageName == "com.android.browser")
|
else if(root.PackageName == BrowserPackage)
|
||||||
{
|
{
|
||||||
var addressField = root.FindAccessibilityNodeInfosByViewId("com.android.browser:id/url").FirstOrDefault();
|
var addressNode = root.FindAccessibilityNodeInfosByViewId("com.android.browser:id/url")
|
||||||
UrlFromAddressField(ref url, addressField);
|
.FirstOrDefault();
|
||||||
|
uri = ExtractUriFromAddressField(uri, addressNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
var emptyPasswordFields = GetNodeOrChildren(root, n => { return IsPasswordField(n); }).ToList();
|
var allEditTexts = GetNodeOrChildren(root, n => EditText(n));
|
||||||
if(emptyPasswordFields.Any())
|
var usernameEditText = allEditTexts.TakeWhile(n => !n.Password).LastOrDefault();
|
||||||
{
|
|
||||||
if((AutofillActivity.LastReceivedCredentials != null) && IsSame(AutofillActivity.LastReceivedCredentials.Url, url))
|
|
||||||
{
|
|
||||||
//Android.Util.Log.Debug("KP2AAS", "Filling credentials for " + url);
|
|
||||||
|
|
||||||
FillPassword(url, usernameEdit, emptyPasswordFields);
|
if(AutofillActivity.LastCredentials != null && SameUri(AutofillActivity.LastCredentials.Uri, uri))
|
||||||
|
{
|
||||||
|
FillCredentials(usernameEditText, avialablePasswordNodes);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
//Android.Util.Log.Debug("KP2AAS", "Notif for " + url);
|
AskFillPassword(uri, usernameEditText, avialablePasswordNodes);
|
||||||
if(AutofillActivity.LastReceivedCredentials != null)
|
|
||||||
{
|
|
||||||
//Android.Util.Log.Debug("KP2AAS", LookupCredentialsActivity.LastReceivedCredentials.Url);
|
|
||||||
//Android.Util.Log.Debug("KP2AAS", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
AskFillPassword(url, usernameEdit, emptyPasswordFields);
|
|
||||||
cancelNotification = false;
|
cancelNotification = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(cancelNotification)
|
if(cancelNotification)
|
||||||
{
|
{
|
||||||
((NotificationManager)GetSystemService(NotificationService)).Cancel(autoFillNotificationId);
|
((NotificationManager)GetSystemService(NotificationService)).Cancel(AutoFillNotificationId);
|
||||||
//Android.Util.Log.Debug("KP2AAS", "Cancel notif");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
@ -108,66 +86,69 @@ namespace Bit.Android
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void UrlFromAddressField(ref string url, AccessibilityNodeInfo addressField)
|
private string ExtractUriFromAddressField(string uri, AccessibilityNodeInfo addressNode)
|
||||||
{
|
{
|
||||||
if(addressField != null)
|
if(addressNode != null)
|
||||||
{
|
{
|
||||||
url = addressField.Text;
|
uri = addressNode.Text;
|
||||||
if(!url.Contains("://"))
|
if(!uri.Contains("://"))
|
||||||
url = "http://" + url;
|
{
|
||||||
|
uri = string.Concat("http://", uri);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsSame(string url1, string url2)
|
private bool SameUri(string uriString1, string uriString2)
|
||||||
{
|
{
|
||||||
if(url1.StartsWith("androidapp://"))
|
Uri uri1, uri2;
|
||||||
return url1 == url2;
|
if(Uri.TryCreate(uriString1, UriKind.RelativeOrAbsolute, out uri1) &&
|
||||||
// TODO: host check
|
Uri.TryCreate(uriString2, UriKind.RelativeOrAbsolute, out uri2) && uri1.Host == uri2.Host)
|
||||||
return url1 == url2;
|
{
|
||||||
//return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsPasswordField(AccessibilityNodeInfo n)
|
return false;
|
||||||
{
|
|
||||||
//if (n.Password) Android.Util.Log.Debug(_logTag, "pwdx with " + (n.Text == null ? "null" : n.Text));
|
|
||||||
var res = n.Password && string.IsNullOrEmpty(n.Text);
|
|
||||||
// if (n.Password) Android.Util.Log.Debug(_logTag, "pwd with " + n.Text + res);
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsEditText(AccessibilityNodeInfo n)
|
private static bool AvailablePasswordField(AccessibilityNodeInfo n, bool isChrome)
|
||||||
{
|
{
|
||||||
//it seems like n.Editable is not a good check as this is false for some fields which are actually editable, at least in tests with Chrome.
|
// chrome sends password field values in many conditions when the field is still actually empty
|
||||||
return (n.ClassName != null) && (n.ClassName.Contains("EditText"));
|
// ex. placeholders, nearby label, etc
|
||||||
|
return n.Password && (isChrome || string.IsNullOrWhiteSpace(n.Text));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AskFillPassword(string url, AccessibilityNodeInfo usernameEdit, IEnumerable<AccessibilityNodeInfo> passwordFields)
|
private static bool EditText(AccessibilityNodeInfo n)
|
||||||
{
|
{
|
||||||
var runSearchIntent = new Intent(this, typeof(AutofillActivity));
|
return n.ClassName != null && n.ClassName.Contains("EditText");
|
||||||
runSearchIntent.PutExtra("url", url);
|
}
|
||||||
runSearchIntent.SetFlags(ActivityFlags.NewTask | ActivityFlags.SingleTop | ActivityFlags.ClearTop);
|
|
||||||
var pending = PendingIntent.GetActivity(this, 0, runSearchIntent, PendingIntentFlags.UpdateCurrent);
|
|
||||||
|
|
||||||
var targetName = url;
|
private void AskFillPassword(string uri, AccessibilityNodeInfo usernameNode,
|
||||||
|
IEnumerable<AccessibilityNodeInfo> passwordNodes)
|
||||||
if(url.StartsWith(androidAppPrefix))
|
|
||||||
{
|
{
|
||||||
var packageName = url.Substring(androidAppPrefix.Length);
|
var intent = new Intent(this, typeof(AutofillActivity));
|
||||||
|
intent.PutExtra("uri", uri);
|
||||||
|
intent.SetFlags(ActivityFlags.NewTask | ActivityFlags.SingleTop | ActivityFlags.ClearTop);
|
||||||
|
var pendingIntent = PendingIntent.GetActivity(this, 0, intent, PendingIntentFlags.UpdateCurrent);
|
||||||
|
|
||||||
|
var targetName = uri;
|
||||||
|
if(uri.StartsWith(AndroidAppProtocol))
|
||||||
|
{
|
||||||
|
var packageName = uri.Substring(AndroidAppProtocol.Length);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var appInfo = PackageManager.GetApplicationInfo(packageName, 0);
|
var appInfo = PackageManager.GetApplicationInfo(packageName, 0);
|
||||||
targetName = (string)(appInfo != null ? PackageManager.GetApplicationLabel(appInfo) : packageName);
|
targetName = appInfo != null ? PackageManager.GetApplicationLabel(appInfo) : packageName;
|
||||||
}
|
}
|
||||||
catch(Exception e)
|
catch
|
||||||
{
|
{
|
||||||
//Android.Util.Log.Debug(_logTag, e.ToString());
|
|
||||||
targetName = packageName;
|
targetName = packageName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
//targetName = KeePassLib.Utility.UrlUtil.GetHost(url);
|
//targetName = KeePassLib.Utility.UrlUtil.GetHost(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -175,44 +156,44 @@ namespace Bit.Android
|
||||||
//TODO icon
|
//TODO icon
|
||||||
//TODO plugin icon
|
//TODO plugin icon
|
||||||
builder.SetSmallIcon(Resource.Drawable.icon)
|
builder.SetSmallIcon(Resource.Drawable.icon)
|
||||||
.SetContentText("Content Text")
|
.SetContentText("Tap this notification to autofill a login from your bitwarden vault.")
|
||||||
.SetContentTitle("Content Title")
|
.SetContentTitle("bitwarden Autofill Service")
|
||||||
.SetWhen(Java.Lang.JavaSystem.CurrentTimeMillis())
|
.SetWhen(Java.Lang.JavaSystem.CurrentTimeMillis())
|
||||||
.SetTicker("Ticker Text")
|
.SetTicker("Tap this notification to autofill a login from your bitwarden vault.")
|
||||||
.SetVisibility(NotificationVisibility.Secret)
|
.SetVisibility(NotificationVisibility.Secret)
|
||||||
.SetContentIntent(pending);
|
.SetContentIntent(pendingIntent);
|
||||||
var notificationManager = (NotificationManager)GetSystemService(NotificationService);
|
var notificationManager = (NotificationManager)GetSystemService(NotificationService);
|
||||||
notificationManager.Notify(autoFillNotificationId, builder.Build());
|
notificationManager.Notify(AutoFillNotificationId, builder.Build());
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FillPassword(string url, AccessibilityNodeInfo usernameEdit, IEnumerable<AccessibilityNodeInfo> passwordFields)
|
private void FillCredentials(AccessibilityNodeInfo usernameNode, IEnumerable<AccessibilityNodeInfo> passwordNodes)
|
||||||
{
|
{
|
||||||
|
FillEditText(usernameNode, AutofillActivity.LastCredentials.User);
|
||||||
FillDataInTextField(usernameEdit, AutofillActivity.LastReceivedCredentials.User);
|
foreach(var pNode in passwordNodes)
|
||||||
foreach(var pwd in passwordFields)
|
|
||||||
FillDataInTextField(pwd, AutofillActivity.LastReceivedCredentials.Password);
|
|
||||||
|
|
||||||
AutofillActivity.LastReceivedCredentials = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void FillDataInTextField(AccessibilityNodeInfo edit, string newValue)
|
|
||||||
{
|
{
|
||||||
Bundle b = new Bundle();
|
FillEditText(pNode, AutofillActivity.LastCredentials.Password);
|
||||||
b.PutString(AccessibilityNodeInfo.ActionArgumentSetTextCharsequence, newValue);
|
|
||||||
edit.PerformAction(global::Android.Views.Accessibility.Action.SetText, b);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ExistsNodeOrChildren(AccessibilityNodeInfo n, Func<AccessibilityNodeInfo, bool> p)
|
AutofillActivity.LastCredentials = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillEditText(AccessibilityNodeInfo editTextNode, string value)
|
||||||
|
{
|
||||||
|
var bundle = new Bundle();
|
||||||
|
bundle.PutString(AccessibilityNodeInfo.ActionArgumentSetTextCharsequence, value);
|
||||||
|
editTextNode.PerformAction(global::Android.Views.Accessibility.Action.SetText, bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool AnyNodeOrChildren(AccessibilityNodeInfo n, Func<AccessibilityNodeInfo, bool> p)
|
||||||
{
|
{
|
||||||
return GetNodeOrChildren(n, p).Any();
|
return GetNodeOrChildren(n, p).Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<AccessibilityNodeInfo> GetNodeOrChildren(AccessibilityNodeInfo n, Func<AccessibilityNodeInfo, bool> p)
|
private IEnumerable<AccessibilityNodeInfo> GetNodeOrChildren(AccessibilityNodeInfo n,
|
||||||
|
Func<AccessibilityNodeInfo, bool> p)
|
||||||
{
|
{
|
||||||
if(n != null)
|
if(n != null)
|
||||||
{
|
{
|
||||||
|
|
||||||
if(p(n))
|
if(p(n))
|
||||||
{
|
{
|
||||||
yield return n;
|
yield return n;
|
||||||
|
@ -220,9 +201,9 @@ namespace Bit.Android
|
||||||
|
|
||||||
for(int i = 0; i < n.ChildCount; i++)
|
for(int i = 0; i < n.ChildCount; i++)
|
||||||
{
|
{
|
||||||
foreach(var x in GetNodeOrChildren(n.GetChild(i), p))
|
foreach(var node in GetNodeOrChildren(n.GetChild(i), p))
|
||||||
{
|
{
|
||||||
yield return x;
|
yield return node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue