Files
keepass2android/src/keepass2android-app/services/AutofillBase/AutofillServiceBase.cs

481 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Android.App;
using Android.App.Slices;
using Android.Content;
using Android.Content.PM;
using Android.Graphics;
using Android.Graphics.Drawables;
using Android.OS;
using Android.Preferences;
using Android.Runtime;
using Android.Service.Autofill;
using Android.Util;
using Android.Views.Autofill;
using Android.Views.InputMethods;
using Android.Widget;
using Android.Widget.Inline;
using AndroidX.AutoFill.Inline;
using AndroidX.AutoFill.Inline.V1;
using Java.Util.Concurrent.Atomic;
using keepass2android.services.AutofillBase.model;
using keepass2android;
using KeePassLib;
using Kp2aAutofillParser;
namespace keepass2android.services.AutofillBase
{
public interface IAutofillIntentBuilder
{
PendingIntent GetAuthPendingIntentForResponse(Context context, string query, string queryDomain, string queryPackage,
bool isManualRequest, bool autoReturnFromQuery, AutofillServiceBase.DisplayWarning warning);
PendingIntent GetAuthPendingIntentForWarning(Context context, PwUuid entryUuid, AutofillServiceBase.DisplayWarning warning);
PendingIntent GetDisablePendingIntentForResponse(Context context, string query,
bool isManualRequest, bool isDisable);
Intent GetRestartAppIntent(Context context);
int AppIconResource { get; }
}
public abstract class AutofillServiceBase: AutofillService
{
private HashSet<string> _internal_blacklistedUris = null;
public HashSet<string> BlacklistedUris
{
get
{
if (_internal_blacklistedUris == null)
{
_internal_blacklistedUris = new HashSet<string>()
{
KeePass.AndroidAppScheme + "android",
KeePass.AndroidAppScheme + "com.android.settings",
KeePass.AndroidAppScheme + this.PackageName
};
}
return _internal_blacklistedUris;
}
}
protected override void AttachBaseContext(Context baseContext)
{
base.AttachBaseContext(LocaleManager.setLocale(baseContext));
}
//use a lock to avoid returning a response several times in buggy Firefox during one connection: this avoids flickering
//and disappearing of the autofill prompt.
//Instead of using a boolean lock, we use a "time-out lock" which is cleared after a few seconds
private DateTime _lockTime = DateTime.MinValue;
private TimeSpan _lockTimeout = TimeSpan.FromSeconds(2);
public AutofillServiceBase()
{
}
public AutofillServiceBase(IntPtr javaReference, JniHandleOwnership transfer)
: base(javaReference, transfer)
{
}
public static HashSet<string> CompatBrowsers = new HashSet<string>
{
"org.mozilla.firefox",
"org.mozilla.firefox_beta",
"com.microsoft.emmx",
"com.android.chrome",
"com.chrome.beta",
"com.android.browser",
"com.brave.browser",
"com.opera.browser",
"com.opera.browser.beta",
"com.opera.mini.native",
"com.chrome.dev",
"com.chrome.canary",
"com.google.android.apps.chrome",
"com.google.android.apps.chrome_dev",
"com.yandex.browser",
"com.sec.android.app.sbrowser",
"com.sec.android.app.sbrowser.beta",
"org.codeaurora.swe.browser",
"com.amazon.cloud9",
"mark.via.gp",
"org.bromite.bromite",
"org.chromium.chrome",
"com.kiwibrowser.browser",
"com.ecosia.android",
"com.opera.mini.native.beta",
"org.mozilla.fennec_aurora",
"org.mozilla.fennec_fdroid",
"com.qwant.liberty",
"com.opera.touch",
"org.mozilla.fenix",
"org.mozilla.fenix.nightly",
"org.mozilla.reference.browser",
"org.mozilla.rocket",
"org.torproject.torbrowser",
"com.vivaldi.browser",
};
public override void OnFillRequest(FillRequest request, CancellationSignal cancellationSignal, FillCallback callback)
{
bool isManual = (request.Flags & FillRequest.FlagManualRequest) != 0;
CommonUtil.logd( "onFillRequest " + (isManual ? "manual" : "auto"));
var structure = request.FillContexts.Last().Structure;
if (_lockTime + _lockTimeout < DateTime.Now)
{
_lockTime = DateTime.Now;
//TODO support package signature verification as soon as this is supported in Keepass storage
var clientState = request.ClientState;
CommonUtil.logd("onFillRequest(): data=" + CommonUtil.BundleToString(clientState));
cancellationSignal.CancelEvent += (sender, e) =>
{
Kp2aLog.Log("Cancel autofill not implemented yet.");
_lockTime = DateTime.MinValue;
};
// Parse AutoFill data in Activity
StructureParser.AutofillTargetId query = null;
var parser = new StructureParser(this, structure);
try
{
query = parser.ParseForFill(isManual);
}
catch (Java.Lang.SecurityException e)
{
Log.Warn(CommonUtil.Tag, "Security exception handling request");
callback.OnFailure(e.Message);
return;
}
InlineSuggestionsRequest inlineSuggestionsRequest = null;
IList<InlinePresentationSpec> inlinePresentationSpecs = null;
if (((int) Build.VERSION.SdkInt >= 30)
&& (PreferenceManager.GetDefaultSharedPreferences(this).GetBoolean(GetString(Resource.String.InlineSuggestions_key), true)))
{
inlineSuggestionsRequest = request.InlineSuggestionsRequest;
inlinePresentationSpecs = inlineSuggestionsRequest?.InlinePresentationSpecs;
}
var autofillIds = parser.AutofillFields.GetAutofillIds();
if (autofillIds.Length != 0 && CanAutofill(query, isManual))
{
var responseBuilder = new FillResponse.Builder();
bool hasEntryDataset = false;
IList<Dataset> entryDatasets = new List<Dataset>();
if (query.IncompatiblePackageAndDomain == false)
{
Kp2aLog.Log("AF: (query.IncompatiblePackageAndDomain == false)");
//domain and package are compatible. Use Domain if available and package otherwise. Can fill without warning.
entryDatasets = BuildEntryDatasets(query.DomainOrPackage, query.WebDomain,
query.PackageName,
autofillIds, parser, DisplayWarning.None,
inlinePresentationSpecs
).Where(ds => ds != null).ToList();
if (entryDatasets.Count > inlineSuggestionsRequest?.MaxSuggestionCount - 2 /*disable dataset and query*/)
{
//we have too many elements. disable inline suggestions
inlinePresentationSpecs = null;
entryDatasets = BuildEntryDatasets(query.DomainOrPackage, query.WebDomain,
query.PackageName,
autofillIds, parser, DisplayWarning.None,
null
).Where(ds => ds != null).ToList();
}
foreach (var entryDataset in entryDatasets
)
{
Kp2aLog.Log("AF: Got EntryDataset " + (entryDataset == null));
responseBuilder.AddDataset(entryDataset);
hasEntryDataset = true;
}
}
{
if (query.WebDomain != null)
AddQueryDataset(query.WebDomain,
query.WebDomain, query.PackageName,
isManual, autofillIds, responseBuilder, !hasEntryDataset,
query.IncompatiblePackageAndDomain
? DisplayWarning.FillDomainInUntrustedApp
: DisplayWarning.None,
AutofillHelper.ExtractSpec(inlinePresentationSpecs, entryDatasets.Count));
else
AddQueryDataset(query.PackageNameWithPseudoSchema,
query.WebDomain, query.PackageName,
isManual, autofillIds, responseBuilder, !hasEntryDataset, DisplayWarning.None,
AutofillHelper.ExtractSpec(inlinePresentationSpecs, entryDatasets.Count));
}
if (!PreferenceManager.GetDefaultSharedPreferences(this)
.GetBoolean(GetString(Resource.String.NoAutofillDisabling_key), false))
AddDisableDataset(query.DomainOrPackage, autofillIds, responseBuilder, isManual, AutofillHelper.ExtractSpec(inlinePresentationSpecs, entryDatasets.Count));
if (PreferenceManager.GetDefaultSharedPreferences(this)
.GetBoolean(GetString(Resource.String.OfferSaveCredentials_key), true))
{
if (!CompatBrowsers.Contains(parser.PackageId))
{
responseBuilder.SetSaveInfo(new SaveInfo.Builder(parser.AutofillFields.SaveType,
parser.AutofillFields.GetAutofillIds()).Build());
}
}
Kp2aLog.Log("return autofill success");
callback.OnSuccess(responseBuilder.Build());
}
else
{
Kp2aLog.Log("cannot autofill");
callback.OnSuccess(null);
}
}
else
{
Kp2aLog.Log("Ignoring onFillRequest as there is another request going on.");
}
}
private List<Dataset> BuildEntryDatasets(string query, string queryDomain, string queryPackage, AutofillId[] autofillIds, StructureParser parser,
DisplayWarning warning, IList<InlinePresentationSpec> inlinePresentationSpecs)
{
List<Dataset> result = new List<Dataset>();
Kp2aLog.Log("AF: BuildEntryDatasets");
Dictionary<PwEntryOutput, FilledAutofillFieldCollection<ViewNodeInputField>> suggestedEntries = GetSuggestedEntries(query);
Kp2aLog.Log("AF: BuildEntryDatasets found " + suggestedEntries.Count + " entries");
int count = 0;
var totpHelper = new Kp2aTotp();
foreach (var kvp in suggestedEntries)
{
var filledAutofillFieldCollection = kvp.Value;
PwEntryOutput entry = kvp.Key;
if (filledAutofillFieldCollection == null)
continue;
var inlinePresentationSpec = AutofillHelper.ExtractSpec(inlinePresentationSpecs, count);
if ((warning == DisplayWarning.None)
&& (totpHelper.TryGetAdapter(entry) == null))
{
//no special dataset, we can immediately return the field collection
FilledAutofillFieldCollection<ViewNodeInputField> partitionData =
AutofillHintsHelper.FilterForPartition(filledAutofillFieldCollection, parser.AutofillFields.FocusedAutofillCanonicalHints);
Kp2aLog.Log("AF: Add dataset");
result.Add(AutofillHelper.NewDataset(this, parser.AutofillFields, partitionData, IntentBuilder,
inlinePresentationSpec));
}
else
{
//return an "auth" dataset (actually for just warning the user in case domain/package dont match and/or to make sure that we open the EntryActivity,
// thus opening the entry notification in case of TOTP)
PendingIntent pendingIntent =
IntentBuilder.GetAuthPendingIntentForWarning(this, entry.Uuid, warning);
var datasetName = filledAutofillFieldCollection.DatasetName;
if (datasetName == null)
{
Kp2aLog.Log("AF: dataset name is null");
continue;
}
RemoteViews presentation =
AutofillHelper.NewRemoteViews(PackageName, datasetName, AppNames.LauncherIcon);
var datasetBuilder = new Dataset.Builder(presentation);
datasetBuilder.SetAuthentication(pendingIntent?.IntentSender);
AutofillHelper.AddInlinePresentation(this, inlinePresentationSpec, datasetName, datasetBuilder, AppNames.LauncherIcon, null);
//need to add placeholders so we can directly fill after ChooseActivity
foreach (var autofillId in autofillIds)
{
datasetBuilder.SetValue(autofillId, AutofillValue.ForText("PLACEHOLDER"));
}
Kp2aLog.Log("AF: Add auth dataset");
result.Add(datasetBuilder.Build());
}
count++;
}
return result;
}
protected abstract Dictionary<PwEntryOutput, FilledAutofillFieldCollection<ViewNodeInputField>> GetSuggestedEntries(string query);
public enum DisplayWarning
{
None,
FillDomainInUntrustedApp, //display a warning that the user is filling credentials for a domain inside an app not marked as trusted browser
}
private void AddQueryDataset(string query, string queryDomain, string queryPackage, bool isManual, AutofillId[] autofillIds, FillResponse.Builder responseBuilder, bool autoReturnFromQuery, DisplayWarning warning, InlinePresentationSpec inlinePresentationSpec)
{
PendingIntent pendingIntent = IntentBuilder.GetAuthPendingIntentForResponse(this, query, queryDomain, queryPackage, isManual, autoReturnFromQuery, warning);
string text = GetString(Resource.String.autofill_sign_in_prompt);
RemoteViews overlayPresentation = AutofillHelper.NewRemoteViews(base.PackageName,
text, AppNames.LauncherIcon);
var datasetBuilder = new Dataset.Builder(overlayPresentation);
datasetBuilder.SetAuthentication(pendingIntent?.IntentSender);
//need to add placeholders so we can directly fill after ChooseActivity
foreach (var autofillId in autofillIds)
{
datasetBuilder.SetValue(autofillId, AutofillValue.ForText("PLACEHOLDER"));
}
AutofillHelper.AddInlinePresentation(this, inlinePresentationSpec, text, datasetBuilder, AppNames.LauncherIcon, pendingIntent);
responseBuilder.AddDataset(datasetBuilder.Build());
}
public static string GetDisplayNameForQuery(string str, Context Context)
{
string displayName = str;
try
{
string appPrefix = KeePass.AndroidAppScheme;
if (str.StartsWith(appPrefix))
{
str = str.Substring(appPrefix.Length);
PackageManager pm = Context.PackageManager;
ApplicationInfo ai;
try
{
ai = pm.GetApplicationInfo(str, 0);
}
catch (PackageManager.NameNotFoundException e)
{
ai = null;
}
displayName = ai != null ? pm.GetApplicationLabel(ai) : str;
}
}
catch (Exception e)
{
Kp2aLog.LogUnexpectedError(e);
}
return displayName;
}
private void AddDisableDataset(string query, AutofillId[] autofillIds, FillResponse.Builder responseBuilder, bool isManual, InlinePresentationSpec inlinePresentationSpec)
{
bool isQueryDisabled = IsQueryDisabled(query);
if (isQueryDisabled && !isManual)
return;
bool isForDisable = !isQueryDisabled;
var pendingIntent = IntentBuilder.GetDisablePendingIntentForResponse(this, query, isManual, isForDisable);
string text = GetString(isForDisable ? Resource.String.autofill_disable : Resource.String.autofill_enable_for, new Java.Lang.Object[] { GetDisplayNameForQuery(query, this) });
RemoteViews presentation = AutofillHelper.NewRemoteViews(base.PackageName,
text, Resource.Drawable.baseline_close_24);
var datasetBuilder = new Dataset.Builder(presentation);
datasetBuilder.SetAuthentication(pendingIntent?.IntentSender);
AutofillHelper.AddInlinePresentation(this, inlinePresentationSpec, text, datasetBuilder, Resource.Drawable.baseline_close_24, pendingIntent);
foreach (var autofillId in autofillIds)
{
datasetBuilder.SetValue(autofillId, AutofillValue.ForText("PLACEHOLDER"));
}
responseBuilder.AddDataset(datasetBuilder.Build());
}
private bool CanAutofill(StructureParser.AutofillTargetId query, bool isManual)
{
if (BlacklistedUris.Contains(query.PackageNameWithPseudoSchema))
return false;
if (!isManual)
{
var isQueryDisabled = IsQueryDisabled(query.DomainOrPackage);
if (isQueryDisabled)
return false;
}
return true;
}
private bool IsQueryDisabled(string query)
{
var prefs = PreferenceManager.GetDefaultSharedPreferences(this);
var disabledValues = prefs.GetStringSet("AutoFillDisabledQueries", new List<string>());
bool isQueryDisabled = disabledValues.Contains(query);
return isQueryDisabled;
}
public override void OnSaveRequest(SaveRequest request, SaveCallback callback)
{
var structure = request.FillContexts?.LastOrDefault()?.Structure;
if (structure == null)
{
return;
}
var parser = new StructureParser(this, structure);
var query = parser.ParseForSave();
try
{
HandleSaveRequest(parser, query);
callback.OnSuccess();
}
catch (Exception e)
{
callback.OnFailure(e.Message);
}
}
protected abstract void HandleSaveRequest(StructureParser parser, StructureParser.AutofillTargetId query);
public override void OnConnected()
{
CommonUtil.logd( "onConnected");
}
public override void OnDisconnected()
{
_lockTime = DateTime.MinValue;
CommonUtil.logd( "onDisconnected");
}
public abstract IAutofillIntentBuilder IntentBuilder{get;}
}
}