diff --git a/src/keepass2android/Credentials.cs b/src/keepass2android/Credentials.cs new file mode 100644 index 00000000..8f09f772 --- /dev/null +++ b/src/keepass2android/Credentials.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +using Android.App; +using Android.Content; +using Android.OS; +using Android.Runtime; +using Android.Views; +using Android.Widget; + +namespace keepass2android.AutoFillPlugin +{ + public class Credentials + { + public string User; + public string Password; + public string Url; + } +} \ No newline at end of file diff --git a/src/keepass2android/Kp2aAccessibilityService.cs b/src/keepass2android/Kp2aAccessibilityService.cs new file mode 100644 index 00000000..645b58e0 --- /dev/null +++ b/src/keepass2android/Kp2aAccessibilityService.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +using Android.App; +using Android.Content; +using Android.OS; +using Android.Runtime; +using Android.Views; +using Android.Views.Accessibility; +using Android.Widget; +using KeePassLib; + + +namespace keepass2android.AutoFill +{ + // + [Service(Enabled =true, Permission= "android.permission.BIND_ACCESSIBILITY_SERVICE")] + [IntentFilter(new[] { "android.accessibilityservice.AccessibilityService" })] + [MetaData("android.accessibilityservice", Resource = "@xml/accserviceconfig")] + public class Kp2aAccessibilityService : Android.AccessibilityServices.AccessibilityService + { + private static bool _hasUsedData; + const string _logTag = "KP2AAS"; + private const int autoFillNotificationId = 98810; + private const string androidAppPrefix = "androidapp://"; + + public override void OnCreate() + { + base.OnCreate(); + Android.Util.Log.Debug(_logTag, "OnCreate Service"); + } + + protected override void OnServiceConnected() + { + Android.Util.Log.Debug(_logTag, "service connected"); + base.OnServiceConnected(); + } + + public override void OnAccessibilityEvent(AccessibilityEvent e) + { + + Android.Util.Log.Debug(_logTag, "OnAccEvent"); + if (e.EventType == EventTypes.WindowContentChanged || e.EventType == EventTypes.WindowStateChanged) + { + Android.Util.Log.Debug(_logTag, "event: " + e.EventType + ", package = " + e.PackageName); + if (e.PackageName == "com.android.systemui") + return; //avoid that the notification is cancelled when pulling down notif drawer + var root = RootInActiveWindow; + if ((ExistsNodeOrChildren(root, n => n.WindowId == e.WindowId) && !ExistsNodeOrChildren(root, n => (n.ViewIdResourceName != null) && (n.ViewIdResourceName.StartsWith("com.android.systemui"))))) + { + bool cancelNotification = true; + + 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(); + UrlFromAddressField(ref url, addressField); + + } + else if (root.PackageName == "com.android.browser") + { + var addressField = root.FindAccessibilityNodeInfosByViewId("com.android.browser:id/url").FirstOrDefault(); + UrlFromAddressField(ref url, addressField); + } + + List emptyPasswordFields = GetNodeOrChildren(root, n => { return IsPasswordField(n); }).ToList(); + if (emptyPasswordFields.Any()) + { + if ((LastReceivedCredentialsUser != null) && IsSame(GetCredentialsField(PwDefs.UrlField), url)) + { + Android.Util.Log.Debug ("KP2AAS", "Filling credentials for " + url); + + FillPassword(url, usernameEdit, emptyPasswordFields); + } + else + { + Android.Util.Log.Debug ("KP2AAS", "Notif for " + url ); + if (LastReceivedCredentialsUser != null) + { + Android.Util.Log.Debug ("KP2AAS", GetCredentialsField(PwDefs.UrlField)); + Android.Util.Log.Debug ("KP2AAS", url); + } + + AskFillPassword(url, usernameEdit, emptyPasswordFields); + cancelNotification = false; + } + + } + if (cancelNotification) + { + ((NotificationManager)GetSystemService(NotificationService)).Cancel(autoFillNotificationId); + Android.Util.Log.Debug ("KP2AAS","Cancel notif"); + } + } + + } + GC.Collect(); + + + } + private static void UrlFromAddressField(ref string url, AccessibilityNodeInfo addressField) + { + if (addressField != null) + { + url = addressField.Text; + if (!url.Contains("://")) + url = "http://" + url; + } + + } + + private bool IsSame(string url1, string url2) + { + if (url1.StartsWith ("androidapp://")) + return url1 == url2; + return KeePassLib.Utility.UrlUtil.GetHost (url1) == KeePassLib.Utility.UrlUtil.GetHost (url2); + } + + private static bool IsPasswordField(AccessibilityNodeInfo n) + { + //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) + { + //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. + return (n.ClassName != null) && (n.ClassName.Contains("EditText")); + } + + private void AskFillPassword(string url, AccessibilityNodeInfo usernameEdit, IEnumerable passwordFields) + { + + Intent startKp2aIntent = PackageManager.GetLaunchIntentForPackage(ApplicationContext.PackageName); + if (startKp2aIntent != null) + { + startKp2aIntent.AddCategory(Intent.CategoryLauncher); + startKp2aIntent.AddFlags(ActivityFlags.NewTask | ActivityFlags.ClearTask); + string taskName = "SearchUrlTask"; + startKp2aIntent.PutExtra("KP2A_APPTASK", taskName); + startKp2aIntent.PutExtra("UrlToSearch", url); + } + + + var pending = PendingIntent.GetActivity(this, 0, startKp2aIntent, PendingIntentFlags.UpdateCurrent); + var targetName = url; + + if (url.StartsWith(androidAppPrefix)) + { + var packageName = url.Substring(androidAppPrefix.Length); + try + { + var appInfo = PackageManager.GetApplicationInfo(packageName, 0); + targetName = (string) (appInfo != null ? PackageManager.GetApplicationLabel(appInfo) : packageName); + } + catch (Exception e) + { + Android.Util.Log.Debug(_logTag, e.ToString()); + targetName = packageName; + } + } + else + { + targetName = KeePassLib.Utility.UrlUtil.GetHost(url); + } + + + var builder = new Notification.Builder(this); + //TODO icon + //TODO plugin icon + builder.SetSmallIcon(Resource.Drawable.ic_notify_autofill) + .SetContentText(GetString(Resource.String.NotificationContentText, new Java.Lang.Object[] { targetName })) + .SetContentTitle(GetString(Resource.String.NotificationTitle)) + .SetWhen(Java.Lang.JavaSystem.CurrentTimeMillis()) + .SetVisibility(Android.App.NotificationVisibility.Secret) + .SetContentIntent(pending); + var notificationManager = (NotificationManager)GetSystemService(NotificationService); + notificationManager.Notify(autoFillNotificationId, builder.Build()); + + } + + private void FillPassword(string url, AccessibilityNodeInfo usernameEdit, IEnumerable passwordFields) + { + if ((Keepass2android.Kbbridge.KeyboardData.HasData) && (_hasUsedData == false)) + { + FillDataInTextField(usernameEdit, LastReceivedCredentialsUser); + foreach (var pwd in passwordFields) + FillDataInTextField(pwd, LastReceivedCredentialsPassword); + + _hasUsedData = true; + } + + + + //LookupCredentialsActivity.LastReceivedCredentials = null; + } + + public string LastReceivedCredentialsPassword + { + get { return GetCredentialsField(PwDefs.PasswordField); } + } + + public string GetCredentialsField(string key) + { + var field = Keepass2android.Kbbridge.KeyboardData.AvailableFields + .Cast().SingleOrDefault(x => x.Key == key); + if (field == null) + return null; + return field.Value; + } + + public string LastReceivedCredentialsUser + { + get { return GetCredentialsField(PwDefs.UserNameField); } + } + + private static void FillDataInTextField(AccessibilityNodeInfo edit, string newValue) + { + if (newValue == null) + return; + Bundle b = new Bundle(); + b.PutString(AccessibilityNodeInfo.ActionArgumentSetTextCharsequence, newValue); + edit.PerformAction(Android.Views.Accessibility.Action.SetText, b); + } + + private bool ExistsNodeOrChildren(AccessibilityNodeInfo n, Func p) + { + return GetNodeOrChildren(n, p).Any(); + } + + private IEnumerable GetNodeOrChildren(AccessibilityNodeInfo n, Func p) + { + if (n != null) + { + if (p(n)) + yield return n; + for (int i = 0; i < n.ChildCount; i++) + { + foreach (var x in GetNodeOrChildren(n.GetChild(i), p)) + yield return x; + } + } + + } + + public override void OnInterrupt() + { + + } + + public void OnCancel(IDialogInterface dialog) + { + + } + + public static void NotifyNewData() + { + _hasUsedData = false; + } + } +} \ No newline at end of file diff --git a/src/keepass2android/Resources/drawable-xhdpi/ic_notify_autofill.png b/src/keepass2android/Resources/drawable-xhdpi/ic_notify_autofill.png new file mode 100644 index 00000000..b7ffa486 Binary files /dev/null and b/src/keepass2android/Resources/drawable-xhdpi/ic_notify_autofill.png differ diff --git a/src/keepass2android/Resources/values/strings.xml b/src/keepass2android/Resources/values/strings.xml index 35026397..55ad7872 100644 --- a/src/keepass2android/Resources/values/strings.xml +++ b/src/keepass2android/Resources/values/strings.xml @@ -646,7 +646,12 @@ Disable Ask after error Send error reports - + + Look up credentials + KP2A AutoFillPlugin + Monitors apps and websites for password fields. Offers to look up credentials from Keepass2Android and auto-fill them into the forms. + Keepass2Android AutoFill + AutoFill form for %1$s Version 0.9.8c\n diff --git a/src/keepass2android/Resources/xml/accserviceconfig.xml b/src/keepass2android/Resources/xml/accserviceconfig.xml new file mode 100644 index 00000000..ec890aac --- /dev/null +++ b/src/keepass2android/Resources/xml/accserviceconfig.xml @@ -0,0 +1,10 @@ + + diff --git a/src/keepass2android/keepass2android.csproj b/src/keepass2android/keepass2android.csproj index d1dfb0da..911b0e54 100644 --- a/src/keepass2android/keepass2android.csproj +++ b/src/keepass2android/keepass2android.csproj @@ -128,6 +128,7 @@ + @@ -154,6 +155,7 @@ + @@ -1669,6 +1671,14 @@ + + + + + + Designer + + diff --git a/src/keepass2android/services/CopyToClipboardService.cs b/src/keepass2android/services/CopyToClipboardService.cs index 278913b5..e6a7e6e1 100644 --- a/src/keepass2android/services/CopyToClipboardService.cs +++ b/src/keepass2android/services/CopyToClipboardService.cs @@ -30,6 +30,7 @@ using Android.Preferences; using KeePassLib; using KeePassLib.Utility; using Android.Views.InputMethods; +using keepass2android.AutoFill; using KeePass.Util.Spr; namespace keepass2android @@ -527,6 +528,8 @@ namespace keepass2android kbdataBuilder.Commit(); Keepass2android.Kbbridge.KeyboardData.EntryName = entry.OutputStrings.ReadSafe(PwDefs.TitleField); Keepass2android.Kbbridge.KeyboardData.EntryId = entry.Uuid.ToHexString(); + if (hasData) + Kp2aAccessibilityService.NotifyNewData(); return hasData; #endif