diff --git a/src/Android/Android.csproj b/src/Android/Android.csproj index ae832cfb1..076e761fa 100644 --- a/src/Android/Android.csproj +++ b/src/Android/Android.csproj @@ -15,7 +15,7 @@ Properties\AndroidManifest.xml Resources Assets - v10.0 + v11.0 Xamarin.Android.Net.AndroidClientHandler @@ -79,6 +79,7 @@ 1.8.6.7 + 1.5.3.2 diff --git a/src/Android/Autofill/AutofillHelpers.cs b/src/Android/Autofill/AutofillHelpers.cs index cb9008b4c..742fd302b 100644 --- a/src/Android/Autofill/AutofillHelpers.cs +++ b/src/Android/Autofill/AutofillHelpers.cs @@ -1,14 +1,24 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Android.Content; using Android.Service.Autofill; using Android.Widget; using System.Linq; using Android.App; using System.Threading.Tasks; +using Android.App.Slices; +using Android.Graphics; +using Android.Graphics.Drawables; +using Android.OS; +using Android.Runtime; +using Android.Widget.Inline; using Bit.App.Resources; using Bit.Core.Enums; using Android.Views.Autofill; +using AndroidX.AutoFill.Inline; +using AndroidX.AutoFill.Inline.V1; using Bit.Core.Abstractions; +using SaveFlags = Android.Service.Autofill.SaveFlags; namespace Bit.Droid.Autofill { @@ -132,14 +142,49 @@ namespace Bit.Droid.Autofill return new List(); } - public static FillResponse BuildFillResponse(Parser parser, List items, bool locked) + public static FillResponse BuildFillResponse(Parser parser, List items, bool locked, + FillRequest fillRequest = null) { + // Acquire inline presentation specs on Android 11+ + IList inlinePresentationSpecs = null; + var inlinePresentationSpecsCount = 0; + var inlineMaxSuggestedCount = 0; + if (fillRequest != null && (int)Build.VERSION.SdkInt >= 30) + { + var inlineSuggestionsRequest = fillRequest.InlineSuggestionsRequest; + inlineMaxSuggestedCount = inlineSuggestionsRequest?.MaxSuggestionCount ?? 0; + inlinePresentationSpecs = inlineSuggestionsRequest?.InlinePresentationSpecs; + inlinePresentationSpecsCount = inlinePresentationSpecs?.Count ?? 0; + } + + // Build response var responseBuilder = new FillResponse.Builder(); if (items != null && items.Count > 0) { - foreach (var item in items) + var maxItems = items.Count; + if (inlineMaxSuggestedCount > 0) { - var dataset = BuildDataset(parser.ApplicationContext, parser.FieldCollection, item); + // -1 to adjust for 'open vault' option + maxItems = Math.Min(maxItems, inlineMaxSuggestedCount - 1); + } + for (int i = 0; i < maxItems; i++) + { + InlinePresentationSpec inlinePresentationSpec = null; + if (inlinePresentationSpecs != null) + { + if (i < inlinePresentationSpecsCount) + { + inlinePresentationSpec = inlinePresentationSpecs[i]; + } + else + { + // If the max suggestion count is larger than the number of specs in the list, then + // the last spec is used for the remainder of the suggestions + inlinePresentationSpec = inlinePresentationSpecs[inlinePresentationSpecsCount - 1]; + } + } + var dataset = BuildDataset(parser.ApplicationContext, parser.FieldCollection, items[i], + inlinePresentationSpec); if (dataset != null) { responseBuilder.AddDataset(dataset); @@ -147,16 +192,34 @@ namespace Bit.Droid.Autofill } } responseBuilder.AddDataset(BuildVaultDataset(parser.ApplicationContext, parser.FieldCollection, - parser.Uri, locked)); - AddSaveInfo(parser, responseBuilder, parser.FieldCollection); + parser.Uri, locked, inlinePresentationSpecs)); + AddSaveInfo(parser, fillRequest, responseBuilder, parser.FieldCollection); responseBuilder.SetIgnoredIds(parser.FieldCollection.IgnoreAutofillIds.ToArray()); return responseBuilder.Build(); } - public static Dataset BuildDataset(Context context, FieldCollection fields, FilledItem filledItem) + public static Dataset BuildDataset(Context context, FieldCollection fields, FilledItem filledItem, + InlinePresentationSpec inlinePresentationSpec = null) { - var datasetBuilder = new Dataset.Builder( - BuildListView(filledItem.Name, filledItem.Subtitle, filledItem.Icon, context)); + var overlayPresentation = BuildOverlayPresentation( + filledItem.Name, + filledItem.Subtitle, + filledItem.Icon, + context); + + var inlinePresentation = BuildInlinePresentation( + inlinePresentationSpec, + filledItem.Name, + filledItem.Subtitle, + filledItem.Icon, + null, + context); + + var datasetBuilder = new Dataset.Builder(overlayPresentation); + if (inlinePresentation != null) + { + datasetBuilder.SetInlinePresentation(inlinePresentation); + } if (filledItem.ApplyToFields(fields, datasetBuilder)) { return datasetBuilder.Build(); @@ -164,7 +227,8 @@ namespace Bit.Droid.Autofill return null; } - public static Dataset BuildVaultDataset(Context context, FieldCollection fields, string uri, bool locked) + public static Dataset BuildVaultDataset(Context context, FieldCollection fields, string uri, bool locked, + IList inlinePresentationSpecs = null) { var intent = new Intent(context, typeof(MainActivity)); intent.PutExtra("autofillFramework", true); @@ -188,14 +252,26 @@ namespace Bit.Droid.Autofill var pendingIntent = PendingIntent.GetActivity(context, ++_pendingIntentId, intent, PendingIntentFlags.CancelCurrent); - var view = BuildListView( + var overlayPresentation = BuildOverlayPresentation( AppResources.AutofillWithBitwarden, locked ? AppResources.VaultIsLocked : AppResources.GoToMyVault, Resource.Drawable.icon, context); - var datasetBuilder = new Dataset.Builder(view); - datasetBuilder.SetAuthentication(pendingIntent.IntentSender); + var inlinePresentation = BuildInlinePresentation( + inlinePresentationSpecs?.Last(), + AppResources.Bitwarden, + locked ? AppResources.VaultIsLocked : AppResources.MyVault, + Resource.Drawable.icon, + pendingIntent, + context); + + var datasetBuilder = new Dataset.Builder(overlayPresentation); + if (inlinePresentation != null) + { + datasetBuilder.SetInlinePresentation(inlinePresentation); + } + datasetBuilder.SetAuthentication(pendingIntent?.IntentSender); // Dataset must have a value set. We will reset this in the main activity when the real item is chosen. foreach (var autofillId in fields.AutofillIds) @@ -205,7 +281,7 @@ namespace Bit.Droid.Autofill return datasetBuilder.Build(); } - public static RemoteViews BuildListView(string text, string subtext, int iconId, Context context) + public static RemoteViews BuildOverlayPresentation(string text, string subtext, int iconId, Context context) { var packageName = context.PackageName; var view = new RemoteViews(packageName, Resource.Layout.autofill_listitem); @@ -215,11 +291,87 @@ namespace Bit.Droid.Autofill return view; } - public static void AddSaveInfo(Parser parser, FillResponse.Builder responseBuilder, FieldCollection fields) + public static InlinePresentation BuildInlinePresentation(InlinePresentationSpec inlinePresentationSpec, + string text, string subtext, int iconId, PendingIntent pendingIntent, Context context) + { + if ((int)Build.VERSION.SdkInt < 30 || inlinePresentationSpec == null) + { + return null; + } + if (pendingIntent == null) + { + // InlinePresentation requires nonNull pending intent (even though we only utilize one for the + // "my vault" presentation) so we're including an empty one here + pendingIntent = PendingIntent.GetService(context, 0, new Intent(), + PendingIntentFlags.OneShot | PendingIntentFlags.UpdateCurrent); + } + var slice = CreateInlinePresentationSlice( + inlinePresentationSpec, + text, + subtext, + iconId, + "Autofill option", + pendingIntent, + context); + if (slice != null) + { + return new InlinePresentation(slice, inlinePresentationSpec, false); + } + return null; + } + + private static Slice CreateInlinePresentationSlice( + InlinePresentationSpec inlinePresentationSpec, + string text, + string subtext, + int iconId, + string contentDescription, + PendingIntent pendingIntent, + Context context) + { + var imeStyle = inlinePresentationSpec.Style; + if (!UiVersions.GetVersions(imeStyle).Contains(UiVersions.InlineUiVersion1)) + { + return null; + } + var contentBuilder = InlineSuggestionUi.NewContentBuilder(pendingIntent) + .SetContentDescription(contentDescription); + if (!string.IsNullOrWhiteSpace(text)) + { + contentBuilder.SetTitle(text); + } + if (!string.IsNullOrWhiteSpace(subtext)) + { + contentBuilder.SetSubtitle(subtext); + } + if (iconId > 0) + { + var icon = Icon.CreateWithResource(context, iconId); + if (icon != null) + { + if (iconId == Resource.Drawable.icon) + { + // Don't tint our logo + icon.SetTintBlendMode(BlendMode.Dst); + } + contentBuilder.SetStartIcon(icon); + } + } + return contentBuilder.Build().JavaCast()?.Slice; + } + + public static void AddSaveInfo(Parser parser, FillRequest fillRequest, FillResponse.Builder responseBuilder, + FieldCollection fields) { // Docs state that password fields cannot be reliably saved in Compat mode since they will show as // masked values. - var compatBrowser = CompatBrowsers.Contains(parser.PackageName); + bool? compatRequest = null; + if (Build.VERSION.SdkInt >= BuildVersionCodes.Q && fillRequest != null) + { + // Attempt to automatically establish compat request mode on Android 10+ + compatRequest = (fillRequest.Flags | FillRequest.FlagCompatibilityModeRequest) == fillRequest.Flags; + } + var compatBrowser = compatRequest ?? CompatBrowsers.Contains(parser.PackageName); if (compatBrowser && fields.SaveType == SaveDataType.Password) { return; diff --git a/src/Android/Autofill/AutofillService.cs b/src/Android/Autofill/AutofillService.cs index cbbcb2642..4c36fb223 100644 --- a/src/Android/Autofill/AutofillService.cs +++ b/src/Android/Autofill/AutofillService.cs @@ -64,7 +64,7 @@ namespace Bit.Droid.Autofill } // build response - var response = AutofillHelpers.BuildFillResponse(parser, items, locked); + var response = AutofillHelpers.BuildFillResponse(parser, items, locked, request); callback.OnSuccess(response); } diff --git a/src/Android/Autofill/FilledItem.cs b/src/Android/Autofill/FilledItem.cs index d4414fa65..3964f37bd 100644 --- a/src/Android/Autofill/FilledItem.cs +++ b/src/Android/Autofill/FilledItem.cs @@ -221,4 +221,4 @@ namespace Bit.Droid.Autofill return null; } } -} \ No newline at end of file +} diff --git a/src/Android/Properties/AndroidManifest.xml b/src/Android/Properties/AndroidManifest.xml index 2136e1077..f062abfe0 100644 --- a/src/Android/Properties/AndroidManifest.xml +++ b/src/Android/Properties/AndroidManifest.xml @@ -7,7 +7,7 @@ android:installLocation="internalOnly" package="com.x8bit.bitwarden"> - + diff --git a/src/Android/Resources/xml/autofillservice.xml b/src/Android/Resources/xml/autofillservice.xml index 45ca538a7..b75f649a8 100644 --- a/src/Android/Resources/xml/autofillservice.xml +++ b/src/Android/Resources/xml/autofillservice.xml @@ -9,7 +9,8 @@ - ... to keep this list in sync with values in AccessibilityHelpers.SupportedBrowsers [Section A], too. --> - + diff --git a/src/Android/Services/DeviceActionService.cs b/src/Android/Services/DeviceActionService.cs index a1193f220..8e0a44555 100644 --- a/src/Android/Services/DeviceActionService.cs +++ b/src/Android/Services/DeviceActionService.cs @@ -525,7 +525,7 @@ namespace Bit.Droid.Services { return; } - if (activity.Intent.GetBooleanExtra("autofillFramework", false)) + if (activity.Intent?.GetBooleanExtra("autofillFramework", false) ?? false) { if (cipher == null) { @@ -596,7 +596,7 @@ namespace Bit.Droid.Services public void Background() { var activity = (MainActivity)CrossCurrentActivity.Current.Activity; - if (activity.Intent.GetBooleanExtra("autofillFramework", false)) + if (activity.Intent?.GetBooleanExtra("autofillFramework", false) ?? false) { activity.SetResult(Result.Canceled); activity.Finish(); diff --git a/src/App/Pages/Settings/AutofillServiceServicePageViewModel.cs b/src/App/Pages/Settings/AutofillServicePageViewModel.cs similarity index 100% rename from src/App/Pages/Settings/AutofillServiceServicePageViewModel.cs rename to src/App/Pages/Settings/AutofillServicePageViewModel.cs