diff --git a/src/iOS.Autofill/Utilities/AutofillHelpers.cs b/src/iOS.Autofill/Utilities/AutofillHelpers.cs
new file mode 100644
index 000000000..d61f92575
--- /dev/null
+++ b/src/iOS.Autofill/Utilities/AutofillHelpers.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Linq;
+using Bit.App.Resources;
+using Bit.iOS.Core.Utilities;
+using Bit.iOS.Core.Views;
+using Foundation;
+using UIKit;
+
+namespace Bit.iOS.Autofill.Utilities
+{
+ public static class AutofillHelpers
+ {
+ /*
+ public static void TableRowSelected(UITableView tableView, NSIndexPath indexPath,
+ ExtensionTableSource tableSource, CredentialProviderViewController cpViewController,
+ UITableViewController controller, ISettings settings, string loginAddSegue)
+ {
+ tableView.DeselectRow(indexPath, true);
+ tableView.EndEditing(true);
+
+ if(tableSource.Items == null || tableSource.Items.Count() == 0)
+ {
+ controller.PerformSegue(loginAddSegue, tableSource);
+ return;
+ }
+
+ var item = tableSource.Items.ElementAt(indexPath.Row);
+ if(item == null)
+ {
+ cpViewController.CompleteRequest(null);
+ return;
+ }
+
+ if(!string.IsNullOrWhiteSpace(item.Username) && !string.IsNullOrWhiteSpace(item.Password))
+ {
+ string totp = null;
+ if(!settings.GetValueOrDefault(App.Constants.SettingDisableTotpCopy, false))
+ {
+ totp = tableSource.GetTotp(item);
+ }
+
+ cpViewController.CompleteRequest(item.Username, item.Password, totp);
+ }
+ else if(!string.IsNullOrWhiteSpace(item.Username) || !string.IsNullOrWhiteSpace(item.Password) ||
+ !string.IsNullOrWhiteSpace(item.Totp.Value))
+ {
+ var sheet = Dialogs.CreateActionSheet(item.Name, controller);
+ if(!string.IsNullOrWhiteSpace(item.Username))
+ {
+ sheet.AddAction(UIAlertAction.Create(AppResources.CopyUsername, UIAlertActionStyle.Default, a =>
+ {
+ UIPasteboard clipboard = UIPasteboard.General;
+ clipboard.String = item.Username;
+ var alert = Dialogs.CreateMessageAlert(AppResources.CopyUsername);
+ controller.PresentViewController(alert, true, () =>
+ {
+ controller.DismissViewController(true, null);
+ });
+ }));
+ }
+
+ if(!string.IsNullOrWhiteSpace(item.Password))
+ {
+ sheet.AddAction(UIAlertAction.Create(AppResources.CopyPassword, UIAlertActionStyle.Default, a =>
+ {
+ UIPasteboard clipboard = UIPasteboard.General;
+ clipboard.String = item.Password;
+ var alert = Dialogs.CreateMessageAlert(AppResources.CopiedPassword);
+ controller.PresentViewController(alert, true, () =>
+ {
+ controller.DismissViewController(true, null);
+ });
+ }));
+ }
+
+ if(!string.IsNullOrWhiteSpace(item.Totp.Value))
+ {
+ sheet.AddAction(UIAlertAction.Create(AppResources.CopyTotp, UIAlertActionStyle.Default, a =>
+ {
+ var totp = tableSource.GetTotp(item);
+ if(string.IsNullOrWhiteSpace(totp))
+ {
+ return;
+ }
+
+ UIPasteboard clipboard = UIPasteboard.General;
+ clipboard.String = totp;
+ var alert = Dialogs.CreateMessageAlert(AppResources.CopiedTotp);
+ controller.PresentViewController(alert, true, () =>
+ {
+ controller.DismissViewController(true, null);
+ });
+ }));
+ }
+
+ sheet.AddAction(UIAlertAction.Create(AppResources.Cancel, UIAlertActionStyle.Cancel, null));
+ controller.PresentViewController(sheet, true, null);
+ }
+ else
+ {
+ var alert = Dialogs.CreateAlert(null, AppResources.NoUsernamePasswordConfigured, AppResources.Ok);
+ controller.PresentViewController(alert, true, null);
+ }
+ }
+ */
+ }
+}
\ No newline at end of file
diff --git a/src/iOS.Autofill/iOS.Autofill.csproj b/src/iOS.Autofill/iOS.Autofill.csproj
index 50e89a395..ac3e04f28 100644
--- a/src/iOS.Autofill/iOS.Autofill.csproj
+++ b/src/iOS.Autofill/iOS.Autofill.csproj
@@ -66,6 +66,7 @@
+
@@ -85,6 +86,10 @@
+
+ {ee44c6a1-2a85-45fe-8d9b-bf1d5f88809c}
+ App
+
{e71f3053-056c-4381-9638-048ed73bdff6}
iOS.Core
diff --git a/src/iOS.Extension/Models/Context.cs b/src/iOS.Extension/Models/Context.cs
new file mode 100644
index 000000000..20961344e
--- /dev/null
+++ b/src/iOS.Extension/Models/Context.cs
@@ -0,0 +1,17 @@
+using Foundation;
+using Bit.iOS.Core.Models;
+
+namespace Bit.iOS.Extension.Models
+{
+ public class Context : AppExtensionContext
+ {
+ public NSExtensionContext ExtContext { get; set; }
+ public string ProviderType { get; set; }
+ public string LoginTitle { get; set; }
+ public string Username { get; set; }
+ public string Password { get; set; }
+ public string OldPassword { get; set; }
+ public string Notes { get; set; }
+ public PageDetails Details { get; set; }
+ }
+}
diff --git a/src/iOS.Extension/Models/FillScript.cs b/src/iOS.Extension/Models/FillScript.cs
new file mode 100644
index 000000000..0364d14dd
--- /dev/null
+++ b/src/iOS.Extension/Models/FillScript.cs
@@ -0,0 +1,276 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json;
+using System.Text.RegularExpressions;
+
+namespace Bit.iOS.Extension.Models
+{
+ public class FillScript
+ {
+ private static string[] _usernameFieldNames = new[]{ "username", "user name", "email",
+ "email address", "e-mail", "e-mail address", "userid", "user id" };
+
+ public FillScript(PageDetails pageDetails, string fillUsername, string fillPassword,
+ List> fillFields)
+ {
+ if(pageDetails == null)
+ {
+ return;
+ }
+
+ DocumentUUID = pageDetails.DocumentUUID;
+
+ var filledFields = new Dictionary();
+
+ if(fillFields?.Any() ?? false)
+ {
+ var fieldNames = fillFields.Select(f => f.Item1?.ToLower()).ToArray();
+ foreach(var field in pageDetails.Fields.Where(f => f.Viewable))
+ {
+ if(filledFields.ContainsKey(field.OpId))
+ {
+ continue;
+ }
+
+ var matchingIndex = FindMatchingFieldIndex(field, fieldNames);
+ if(matchingIndex > -1)
+ {
+ filledFields.Add(field.OpId, field);
+ Script.Add(new List { "click_on_opid", field.OpId });
+ Script.Add(new List { "fill_by_opid", field.OpId, fillFields[matchingIndex].Item2 });
+ }
+ }
+ }
+
+ if(string.IsNullOrWhiteSpace(fillPassword))
+ {
+ // No password for this login. Maybe they just wanted to auto-fill some custom fields?
+ SetFillScriptForFocus(filledFields);
+ return;
+ }
+
+ List usernames = new List();
+ List passwords = new List();
+
+ var passwordFields = pageDetails.Fields.Where(f => f.Type == "password" && f.Viewable).ToArray();
+ if(!passwordFields.Any())
+ {
+ // not able to find any viewable password fields. maybe there are some "hidden" ones?
+ passwordFields = pageDetails.Fields.Where(f => f.Type == "password").ToArray();
+ }
+
+ foreach(var form in pageDetails.Forms)
+ {
+ var passwordFieldsForForm = passwordFields.Where(f => f.Form == form.Key).ToArray();
+ passwords.AddRange(passwordFieldsForForm);
+
+ if(string.IsNullOrWhiteSpace(fillUsername))
+ {
+ continue;
+ }
+
+ foreach(var pf in passwordFieldsForForm)
+ {
+ var username = FindUsernameField(pageDetails, pf, false, true);
+ if(username == null)
+ {
+ // not able to find any viewable username fields. maybe there are some "hidden" ones?
+ username = FindUsernameField(pageDetails, pf, true, true);
+ }
+
+ if(username != null)
+ {
+ usernames.Add(username);
+ }
+ }
+ }
+
+ if(passwordFields.Any() && !passwords.Any())
+ {
+ // The page does not have any forms with password fields. Use the first password field on the page and the
+ // input field just before it as the username.
+
+ var pf = passwordFields.First();
+ passwords.Add(pf);
+
+ if(!string.IsNullOrWhiteSpace(fillUsername) && pf.ElementNumber > 0)
+ {
+ var username = FindUsernameField(pageDetails, pf, false, false);
+ if(username == null)
+ {
+ // not able to find any viewable username fields. maybe there are some "hidden" ones?
+ username = FindUsernameField(pageDetails, pf, true, false);
+ }
+
+ if(username != null)
+ {
+ usernames.Add(username);
+ }
+ }
+ }
+
+ if(!passwordFields.Any())
+ {
+ // No password fields on this page. Let's try to just fuzzy fill the username.
+ var usernameFieldNamesList = _usernameFieldNames.ToList();
+ foreach(var f in pageDetails.Fields)
+ {
+ if(f.Viewable && (f.Type == "text" || f.Type == "email" || f.Type == "tel") &&
+ FieldIsFuzzyMatch(f, usernameFieldNamesList))
+ {
+ usernames.Add(f);
+ }
+ }
+ }
+
+ foreach(var username in usernames.Where(u => !filledFields.ContainsKey(u.OpId)))
+ {
+ filledFields.Add(username.OpId, username);
+ Script.Add(new List { "click_on_opid", username.OpId });
+ Script.Add(new List { "fill_by_opid", username.OpId, fillUsername });
+ }
+
+ foreach(var password in passwords.Where(p => !filledFields.ContainsKey(p.OpId)))
+ {
+ filledFields.Add(password.OpId, password);
+ Script.Add(new List { "click_on_opid", password.OpId });
+ Script.Add(new List { "fill_by_opid", password.OpId, fillPassword });
+ }
+
+ SetFillScriptForFocus(filledFields);
+ }
+
+ private PageDetails.Field FindUsernameField(PageDetails pageDetails, PageDetails.Field passwordField, bool canBeHidden,
+ bool checkForm)
+ {
+ PageDetails.Field usernameField = null;
+
+ foreach(var f in pageDetails.Fields)
+ {
+ if(f.ElementNumber >= passwordField.ElementNumber)
+ {
+ break;
+ }
+
+ if((!checkForm || f.Form == passwordField.Form)
+ && (canBeHidden || f.Viewable)
+ && f.ElementNumber < passwordField.ElementNumber
+ && (f.Type == "text" || f.Type == "email" || f.Type == "tel"))
+ {
+ usernameField = f;
+
+ if(FindMatchingFieldIndex(f, _usernameFieldNames) > -1)
+ {
+ // We found an exact match. No need to keep looking.
+ break;
+ }
+ }
+ }
+
+ return usernameField;
+ }
+
+ private int FindMatchingFieldIndex(PageDetails.Field field, string[] names)
+ {
+ var matchingIndex = -1;
+ if(!string.IsNullOrWhiteSpace(field.HtmlId))
+ {
+ matchingIndex = Array.IndexOf(names, field.HtmlId.ToLower());
+ }
+ if(matchingIndex < 0 && !string.IsNullOrWhiteSpace(field.HtmlName))
+ {
+ matchingIndex = Array.IndexOf(names, field.HtmlName.ToLower());
+ }
+ if(matchingIndex < 0 && !string.IsNullOrWhiteSpace(field.LabelTag))
+ {
+ matchingIndex = Array.IndexOf(names, CleanLabel(field.LabelTag));
+ }
+ if(matchingIndex < 0 && !string.IsNullOrWhiteSpace(field.Placeholder))
+ {
+ matchingIndex = Array.IndexOf(names, field.Placeholder.ToLower());
+ }
+
+ return matchingIndex;
+ }
+
+ private bool FieldIsFuzzyMatch(PageDetails.Field field, List names)
+ {
+ if(!string.IsNullOrWhiteSpace(field.HtmlId) && FuzzyMatch(names, field.HtmlId.ToLower()))
+ {
+ return true;
+ }
+ if(!string.IsNullOrWhiteSpace(field.HtmlName) && FuzzyMatch(names, field.HtmlName.ToLower()))
+ {
+ return true;
+ }
+ if(!string.IsNullOrWhiteSpace(field.LabelTag) && FuzzyMatch(names, CleanLabel(field.LabelTag)))
+ {
+ return true;
+ }
+ if(!string.IsNullOrWhiteSpace(field.Placeholder) && FuzzyMatch(names, field.Placeholder.ToLower()))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool FuzzyMatch(List options, string value)
+ {
+ if((!options?.Any() ?? true) || string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ return options.Any(o => value.Contains(o));
+ }
+
+ private void SetFillScriptForFocus(IDictionary filledFields)
+ {
+ if(!filledFields.Any())
+ {
+ return;
+ }
+
+ PageDetails.Field lastField = null, lastPasswordField = null;
+ foreach(var field in filledFields)
+ {
+ if(field.Value.Viewable)
+ {
+ lastField = field.Value;
+ if(field.Value.Type == "password")
+ {
+ lastPasswordField = field.Value;
+ }
+ }
+ }
+
+ // Prioritize password field over others.
+ if(lastPasswordField != null)
+ {
+ Script.Add(new List { "focus_by_opid", lastPasswordField.OpId });
+ }
+ else if(lastField != null)
+ {
+ Script.Add(new List { "focus_by_opid", lastField.OpId });
+ }
+ }
+
+ private string CleanLabel(string label)
+ {
+ return Regex.Replace(label, @"(?:\r\n|\r|\n)", string.Empty).Trim().ToLower();
+ }
+
+ [JsonProperty(PropertyName = "script")]
+ public List> Script { get; set; } = new List>();
+ [JsonProperty(PropertyName = "documentUUID")]
+ public object DocumentUUID { get; set; }
+ [JsonProperty(PropertyName = "properties")]
+ public object Properties { get; set; } = new object();
+ [JsonProperty(PropertyName = "options")]
+ public object Options { get; set; } = new { animate = false };
+ [JsonProperty(PropertyName = "metadata")]
+ public object MetaData { get; set; } = new object();
+ }
+}
diff --git a/src/iOS.Extension/Models/PageDetails.cs b/src/iOS.Extension/Models/PageDetails.cs
new file mode 100644
index 000000000..004173bfc
--- /dev/null
+++ b/src/iOS.Extension/Models/PageDetails.cs
@@ -0,0 +1,52 @@
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Bit.iOS.Extension.Models
+{
+ public class PageDetails
+ {
+ public string DocumentUUID { get; set; }
+ public string Title { get; set; }
+ public string Url { get; set; }
+ public string DocumentUrl { get; set; }
+ public string TabUrl { get; set; }
+ public Dictionary Forms { get; set; }
+ public List Fields { get; set; }
+ public long CollectedTimestamp { get; set; }
+ public bool HasPasswordField => Fields.Any(f => f.Type == "password");
+
+ public class Form
+ {
+ public string OpId { get; set; }
+ public string HtmlName { get; set; }
+ public string HtmlId { get; set; }
+ public string HtmlAction { get; set; }
+ public string HtmlMethod { get; set; }
+ }
+
+ public class Field
+ {
+ public string OpId { get; set; }
+ public int ElementNumber { get; set; }
+ public bool Visible { get; set; }
+ public bool Viewable { get; set; }
+ public string HtmlId { get; set; }
+ public string HtmlName { get; set; }
+ public string HtmlClass { get; set; }
+ public string LabelRight { get; set; }
+ public string LabelLeft { get; set; }
+ [JsonProperty("label-tag")]
+ public string LabelTag { get; set; }
+ public string Placeholder { get; set; }
+ public string Type { get; set; }
+ public string Value { get; set; }
+ public bool Disabled { get; set; }
+ public bool Readonly { get; set; }
+ public string OnePasswordFieldType { get; set; }
+ public string Form { get; set; }
+ }
+ }
+
+}
diff --git a/src/iOS.Extension/iOS.Extension.csproj b/src/iOS.Extension/iOS.Extension.csproj
index 1c6623935..d82db32aa 100644
--- a/src/iOS.Extension/iOS.Extension.csproj
+++ b/src/iOS.Extension/iOS.Extension.csproj
@@ -68,6 +68,9 @@
+
+
+
ActionViewController.cs
@@ -84,6 +87,10 @@
+
+ {ee44c6a1-2a85-45fe-8d9b-bf1d5f88809c}
+ App
+
{e71f3053-056c-4381-9638-048ed73bdff6}
iOS.Core
@@ -108,8 +115,6 @@
-
-
-
+
\ No newline at end of file