mirror of
https://github.com/bitwarden/android.git
synced 2024-12-25 18:38:27 +03:00
initial commit of android credential provider service (wip)
This commit is contained in:
parent
7d79b98bf2
commit
6011b63958
23 changed files with 475 additions and 1 deletions
|
@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "iOS.ShareExtension", "src\i
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "iOS.Autofill", "src\iOS.Autofill\iOS.Autofill.csproj", "{83449CC4-1F76-4CFE-92B1-D2E13A62506F}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "iOS.Autofill", "src\iOS.Autofill\iOS.Autofill.csproj", "{83449CC4-1F76-4CFE-92B1-D2E13A62506F}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Xamarin.AndroidX.Credentials", "src\Xamarin.AndroidX.Credentials\Xamarin.AndroidX.Credentials.csproj", "{1201A3B6-EF37-420D-A8AD-2C322F6D7B2C}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|
|
@ -119,6 +119,7 @@
|
||||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
|
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
|
||||||
<PackageReference Include="Xamarin.AndroidX.AutoFill" Version="1.1.0.18" />
|
<PackageReference Include="Xamarin.AndroidX.AutoFill" Version="1.1.0.18" />
|
||||||
<PackageReference Include="Xamarin.AndroidX.Activity.Ktx" Version="1.7.2.1" />
|
<PackageReference Include="Xamarin.AndroidX.Activity.Ktx" Version="1.7.2.1" />
|
||||||
|
<ProjectReference Include="..\Xamarin.AndroidX.Credentials\Xamarin.AndroidX.Credentials.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' AND !$(DefineConstants.Contains(FDROID))">
|
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' AND !$(DefineConstants.Contains(FDROID))">
|
||||||
<PackageReference Include="Xamarin.GooglePlayServices.SafetyNet" Version="118.0.1.5" />
|
<PackageReference Include="Xamarin.GooglePlayServices.SafetyNet" Version="118.0.1.5" />
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace Bit.Droid.Autofill
|
||||||
|
{
|
||||||
|
public class CredentialProviderConstants
|
||||||
|
{
|
||||||
|
public const string CredentialProviderCipherId = "credentialProviderCipherId";
|
||||||
|
public const string CredentialDataIntentExtra = "CREDENTIAL_DATA";
|
||||||
|
public const string CredentialIdIntentExtra = "credId";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Android.App;
|
||||||
|
using Android.Content.PM;
|
||||||
|
using Android.OS;
|
||||||
|
using AndroidX.Credentials.Provider;
|
||||||
|
using AndroidX.Credentials.WebAuthn;
|
||||||
|
using Bit.Core.Abstractions;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Bit.App.Droid.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Droid.Autofill
|
||||||
|
{
|
||||||
|
[Activity(
|
||||||
|
NoHistory = true,
|
||||||
|
LaunchMode = LaunchMode.SingleTop)]
|
||||||
|
public class CredentialProviderSelectionActivity : MauiAppCompatActivity
|
||||||
|
{
|
||||||
|
protected override void OnCreate(Bundle bundle)
|
||||||
|
{
|
||||||
|
Intent?.Validate();
|
||||||
|
base.OnCreate(bundle);
|
||||||
|
|
||||||
|
var cipherId = Intent?.GetStringExtra(CredentialProviderConstants.CredentialProviderCipherId);
|
||||||
|
if (string.IsNullOrEmpty(cipherId))
|
||||||
|
{
|
||||||
|
SetResult(Result.Canceled);
|
||||||
|
Finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GetCipherAndPerformPasskeyAuthAsync(cipherId).FireAndForget();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GetCipherAndPerformPasskeyAuthAsync(string cipherId)
|
||||||
|
{
|
||||||
|
// TODO this is a work in progress
|
||||||
|
// https://developer.android.com/training/sign-in/credential-provider#passkeys-implement
|
||||||
|
|
||||||
|
var getRequest = PendingIntentHandler.RetrieveProviderGetCredentialRequest(Intent);
|
||||||
|
// var publicKeyRequest = getRequest?.CredentialOptions as PublicKeyCredentialRequestOptions;
|
||||||
|
|
||||||
|
var requestInfo = Intent.GetBundleExtra(CredentialProviderConstants.CredentialDataIntentExtra);
|
||||||
|
var credIdEnc = requestInfo?.GetString(CredentialProviderConstants.CredentialIdIntentExtra);
|
||||||
|
|
||||||
|
var cipherService = ServiceContainer.Resolve<ICipherService>();
|
||||||
|
var cipher = await cipherService.GetAsync(cipherId);
|
||||||
|
var decCipher = await cipher.DecryptAsync();
|
||||||
|
|
||||||
|
var passkey = decCipher.Login.Fido2Credentials.Find(f => f.CredentialId == credIdEnc);
|
||||||
|
|
||||||
|
var credId = Convert.FromBase64String(credIdEnc);
|
||||||
|
// var privateKey = Convert.FromBase64String(passkey.PrivateKey);
|
||||||
|
// var uid = Convert.FromBase64String(passkey.uid);
|
||||||
|
|
||||||
|
var origin = getRequest?.CallingAppInfo.Origin;
|
||||||
|
var packageName = getRequest?.CallingAppInfo.PackageName;
|
||||||
|
|
||||||
|
// --- continue WIP here (save TOTP copy as last step) ---
|
||||||
|
|
||||||
|
// Copy TOTP if needed
|
||||||
|
var autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
|
||||||
|
autofillHandler.Autofill(decCipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
147
src/App/Platforms/Android/Autofill/CredentialProviderService.cs
Normal file
147
src/App/Platforms/Android/Autofill/CredentialProviderService.cs
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
using Android;
|
||||||
|
using Android.App;
|
||||||
|
using Android.Content;
|
||||||
|
using Android.Graphics.Drawables;
|
||||||
|
using Android.OS;
|
||||||
|
using Android.Runtime;
|
||||||
|
using AndroidX.Credentials.Provider;
|
||||||
|
using Bit.Core.Abstractions;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using AndroidX.Credentials.Exceptions;
|
||||||
|
using AndroidX.Credentials.WebAuthn;
|
||||||
|
using Bit.Core.Models.View;
|
||||||
|
using Resource = Microsoft.Maui.Resource;
|
||||||
|
|
||||||
|
namespace Bit.Droid.Autofill
|
||||||
|
{
|
||||||
|
[Service(Permission = Manifest.Permission.BindCredentialProviderService, Label = "Bitwarden", Exported = true)]
|
||||||
|
[IntentFilter(new string[] { "android.service.credentials.CredentialProviderService" })]
|
||||||
|
[MetaData("android.credentials.provider", Resource = "@xml/provider")]
|
||||||
|
[Register("com.x8bit.bitwarden.Autofill.CredentialProviderService")]
|
||||||
|
public class CredentialProviderService : AndroidX.Credentials.Provider.CredentialProviderService
|
||||||
|
{
|
||||||
|
private const string GetPasskeyIntentAction = "PACKAGE_NAME.GET_PASSKEY";
|
||||||
|
private const int UniqueRequestCode = 94556023;
|
||||||
|
|
||||||
|
private ICipherService _cipherService;
|
||||||
|
private IUserVerificationService _userVerificationService;
|
||||||
|
private IVaultTimeoutService _vaultTimeoutService;
|
||||||
|
private LazyResolve<ILogger> _logger = new LazyResolve<ILogger>("logger");
|
||||||
|
|
||||||
|
public override async void OnBeginCreateCredentialRequest(BeginCreateCredentialRequest request,
|
||||||
|
CancellationSignal cancellationSignal, IOutcomeReceiver callback) => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public override async void OnBeginGetCredentialRequest(BeginGetCredentialRequest request,
|
||||||
|
CancellationSignal cancellationSignal, IOutcomeReceiver callback)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_vaultTimeoutService ??= ServiceContainer.Resolve<IVaultTimeoutService>();
|
||||||
|
|
||||||
|
await _vaultTimeoutService.CheckVaultTimeoutAsync();
|
||||||
|
var locked = await _vaultTimeoutService.IsLockedAsync();
|
||||||
|
if (!locked)
|
||||||
|
{
|
||||||
|
var response = await ProcessGetCredentialsRequestAsync(request);
|
||||||
|
callback.OnResult(response);
|
||||||
|
}
|
||||||
|
// TODO handle auth/unlock account flow
|
||||||
|
}
|
||||||
|
catch (GetCredentialException e)
|
||||||
|
{
|
||||||
|
_logger.Value.Exception(e);
|
||||||
|
callback.OnError(e.ErrorMessage ?? "Error getting credentials");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.Value.Exception(e);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BeginGetCredentialResponse> ProcessGetCredentialsRequestAsync(
|
||||||
|
BeginGetCredentialRequest request)
|
||||||
|
{
|
||||||
|
IList<CredentialEntry> credentialEntries = null;
|
||||||
|
|
||||||
|
foreach (var option in request.BeginGetCredentialOptions)
|
||||||
|
{
|
||||||
|
var credentialOption = option as BeginGetPublicKeyCredentialOption;
|
||||||
|
if (credentialOption != null)
|
||||||
|
{
|
||||||
|
credentialEntries ??= new List<CredentialEntry>();
|
||||||
|
((List<CredentialEntry>)credentialEntries).AddRange(
|
||||||
|
await PopulatePasskeyDataAsync(request.CallingAppInfo, credentialOption));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentialEntries == null)
|
||||||
|
{
|
||||||
|
return new BeginGetCredentialResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BeginGetCredentialResponse.Builder()
|
||||||
|
.SetCredentialEntries(credentialEntries)
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<CredentialEntry>> PopulatePasskeyDataAsync(CallingAppInfo callingAppInfo,
|
||||||
|
BeginGetPublicKeyCredentialOption option)
|
||||||
|
{
|
||||||
|
var packageName = callingAppInfo.PackageName;
|
||||||
|
var origin = callingAppInfo.Origin;
|
||||||
|
var signingInfo = callingAppInfo.SigningInfo;
|
||||||
|
|
||||||
|
var request = new PublicKeyCredentialRequestOptions(option.RequestJson);
|
||||||
|
|
||||||
|
var passkeyEntries = new List<CredentialEntry>();
|
||||||
|
|
||||||
|
_cipherService ??= ServiceContainer.Resolve<ICipherService>();
|
||||||
|
var ciphers = await _cipherService.GetAllDecryptedForUrlAsync(origin);
|
||||||
|
if (ciphers == null)
|
||||||
|
{
|
||||||
|
return passkeyEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
var passkeyCiphers = ciphers.Where(cipher => cipher.HasFido2Credential).ToList();
|
||||||
|
if (!passkeyCiphers.Any())
|
||||||
|
{
|
||||||
|
return passkeyEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cipher in passkeyCiphers)
|
||||||
|
{
|
||||||
|
var passkeyEntry = GetPasskey(cipher, option);
|
||||||
|
passkeyEntries.Add(passkeyEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return passkeyEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublicKeyCredentialEntry GetPasskey(CipherView cipher, BeginGetPublicKeyCredentialOption option)
|
||||||
|
{
|
||||||
|
var credDataBundle = new Bundle();
|
||||||
|
credDataBundle.PutString(CredentialProviderConstants.CredentialIdIntentExtra,
|
||||||
|
cipher.Login.MainFido2Credential.CredentialId);
|
||||||
|
|
||||||
|
var intent = new Intent(ApplicationContext, typeof(CredentialProviderSelectionActivity))
|
||||||
|
.SetAction(GetPasskeyIntentAction).SetPackage(Constants.PACKAGE_NAME);
|
||||||
|
intent.PutExtra(CredentialProviderConstants.CredentialDataIntentExtra, credDataBundle);
|
||||||
|
intent.PutExtra(CredentialProviderConstants.CredentialProviderCipherId, cipher.Id);
|
||||||
|
var pendingIntent = PendingIntent.GetActivity(ApplicationContext, UniqueRequestCode, intent,
|
||||||
|
PendingIntentFlags.Mutable | PendingIntentFlags.UpdateCurrent);
|
||||||
|
|
||||||
|
return new PublicKeyCredentialEntry.Builder(
|
||||||
|
ApplicationContext,
|
||||||
|
cipher.Login.Username ?? "No username",
|
||||||
|
pendingIntent,
|
||||||
|
option)
|
||||||
|
.SetDisplayName(cipher.Name)
|
||||||
|
.SetIcon(Icon.CreateWithResource(ApplicationContext, Resource.Drawable.icon))
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnClearCredentialStateRequest(ProviderClearCredentialStateRequest request,
|
||||||
|
CancellationSignal cancellationSignal, IOutcomeReceiver callback) => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
6
src/App/Platforms/Android/Resources/xml/provider.xml
Normal file
6
src/App/Platforms/Android/Resources/xml/provider.xml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<credential-provider xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<capabilities>
|
||||||
|
<capability name="androidx.credentials.TYPE_PUBLIC_KEY_CREDENTIAL" />
|
||||||
|
</capabilities>
|
||||||
|
</credential-provider>
|
|
@ -37,6 +37,23 @@ namespace Bit.Droid.Services
|
||||||
_eventService = eventService;
|
_eventService = eventService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool CredentialProviderServiceEnabled()
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SdkInt < BuildVersionCodes.UpsideDownCake)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// TODO - find a way to programmatically check if the credential provider service is enabled
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool AutofillServiceEnabled()
|
public bool AutofillServiceEnabled()
|
||||||
{
|
{
|
||||||
if (Build.VERSION.SdkInt < BuildVersionCodes.O)
|
if (Build.VERSION.SdkInt < BuildVersionCodes.O)
|
||||||
|
@ -163,7 +180,14 @@ namespace Bit.Droid.Services
|
||||||
return Accessibility.AccessibilityHelpers.OverlayPermitted();
|
return Accessibility.AccessibilityHelpers.OverlayPermitted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void DisableCredentialProviderService()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// TODO - find a way to programmatically disable the provider service, or take the user to the settings page where they can do it
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
public void DisableAutofillService()
|
public void DisableAutofillService()
|
||||||
{
|
{
|
||||||
|
|
|
@ -11,6 +11,7 @@ using Android.Text.Method;
|
||||||
using Android.Views;
|
using Android.Views;
|
||||||
using Android.Views.InputMethods;
|
using Android.Views.InputMethods;
|
||||||
using Android.Widget;
|
using Android.Widget;
|
||||||
|
using AndroidX.Credentials;
|
||||||
using Bit.App.Abstractions;
|
using Bit.App.Abstractions;
|
||||||
using Bit.Core.Resources.Localization;
|
using Bit.Core.Resources.Localization;
|
||||||
using Bit.App.Utilities;
|
using Bit.App.Utilities;
|
||||||
|
@ -490,6 +491,27 @@ namespace Bit.Droid.Services
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OpenCredentialProviderSettings()
|
||||||
|
{
|
||||||
|
var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pendingIntent = CredentialManager.Create(activity).CreateSettingsPendingIntent();
|
||||||
|
pendingIntent.Send();
|
||||||
|
}
|
||||||
|
catch (ActivityNotFoundException)
|
||||||
|
{
|
||||||
|
var alertBuilder = new AlertDialog.Builder(activity);
|
||||||
|
alertBuilder.SetMessage(AppResources.BitwardenCredentialProviderGoToSettings);
|
||||||
|
alertBuilder.SetCancelable(true);
|
||||||
|
alertBuilder.SetPositiveButton(AppResources.Ok, (sender, args) =>
|
||||||
|
{
|
||||||
|
(sender as AlertDialog)?.Cancel();
|
||||||
|
});
|
||||||
|
alertBuilder.Create().Show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void OpenAccessibilitySettings()
|
public void OpenAccessibilitySettings()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
@ -548,6 +570,8 @@ namespace Bit.Droid.Services
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool SupportsCredentialProviderService() => Build.VERSION.SdkInt >= BuildVersionCodes.UpsideDownCake;
|
||||||
|
|
||||||
public bool SupportsAutofillServices() => Build.VERSION.SdkInt >= BuildVersionCodes.O;
|
public bool SupportsAutofillServices() => Build.VERSION.SdkInt >= BuildVersionCodes.O;
|
||||||
|
|
||||||
public bool SupportsInlineAutofill() => Build.VERSION.SdkInt >= BuildVersionCodes.R;
|
public bool SupportsInlineAutofill() => Build.VERSION.SdkInt >= BuildVersionCodes.R;
|
||||||
|
|
|
@ -4,6 +4,7 @@ namespace Bit.Core.Abstractions
|
||||||
{
|
{
|
||||||
public interface IAutofillHandler
|
public interface IAutofillHandler
|
||||||
{
|
{
|
||||||
|
bool CredentialProviderServiceEnabled();
|
||||||
bool AutofillServicesEnabled();
|
bool AutofillServicesEnabled();
|
||||||
bool SupportsAutofillService();
|
bool SupportsAutofillService();
|
||||||
void Autofill(CipherView cipher);
|
void Autofill(CipherView cipher);
|
||||||
|
@ -11,6 +12,7 @@ namespace Bit.Core.Abstractions
|
||||||
bool AutofillAccessibilityServiceRunning();
|
bool AutofillAccessibilityServiceRunning();
|
||||||
bool AutofillAccessibilityOverlayPermitted();
|
bool AutofillAccessibilityOverlayPermitted();
|
||||||
bool AutofillServiceEnabled();
|
bool AutofillServiceEnabled();
|
||||||
|
void DisableCredentialProviderService();
|
||||||
void DisableAutofillService();
|
void DisableAutofillService();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ namespace Bit.App.Abstractions
|
||||||
bool SupportsNfc();
|
bool SupportsNfc();
|
||||||
bool SupportsCamera();
|
bool SupportsCamera();
|
||||||
bool SupportsFido2();
|
bool SupportsFido2();
|
||||||
|
bool SupportsCredentialProviderService();
|
||||||
bool SupportsAutofillServices();
|
bool SupportsAutofillServices();
|
||||||
bool SupportsInlineAutofill();
|
bool SupportsInlineAutofill();
|
||||||
bool SupportsDrawOver();
|
bool SupportsDrawOver();
|
||||||
|
@ -36,6 +37,7 @@ namespace Bit.App.Abstractions
|
||||||
void RateApp();
|
void RateApp();
|
||||||
void OpenAccessibilitySettings();
|
void OpenAccessibilitySettings();
|
||||||
void OpenAccessibilityOverlayPermissionSettings();
|
void OpenAccessibilityOverlayPermissionSettings();
|
||||||
|
void OpenCredentialProviderSettings();
|
||||||
void OpenAutofillSettings();
|
void OpenAutofillSettings();
|
||||||
long GetActiveTime();
|
long GetActiveTime();
|
||||||
void CloseMainApp();
|
void CloseMainApp();
|
||||||
|
|
|
@ -49,6 +49,7 @@
|
||||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
|
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
|
||||||
<PackageReference Include="Xamarin.AndroidX.AutoFill" Version="1.1.0.18" />
|
<PackageReference Include="Xamarin.AndroidX.AutoFill" Version="1.1.0.18" />
|
||||||
<PackageReference Include="Xamarin.AndroidX.Activity.Ktx" Version="1.7.2.1" />
|
<PackageReference Include="Xamarin.AndroidX.Activity.Ktx" Version="1.7.2.1" />
|
||||||
|
<ProjectReference Include="..\Xamarin.AndroidX.Credentials\Xamarin.AndroidX.Credentials.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' AND !$(DefineConstants.Contains(FDROID))">
|
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' AND !$(DefineConstants.Contains(FDROID))">
|
||||||
<PackageReference Include="Xamarin.GooglePlayServices.SafetyNet" Version="118.0.1.5" />
|
<PackageReference Include="Xamarin.GooglePlayServices.SafetyNet" Version="118.0.1.5" />
|
||||||
|
|
|
@ -19,6 +19,15 @@
|
||||||
Text="{u:I18n Autofill}"
|
Text="{u:I18n Autofill}"
|
||||||
StyleClass="settings-header" />
|
StyleClass="settings-header" />
|
||||||
|
|
||||||
|
<controls:SwitchItemView
|
||||||
|
Title="{u:I18n CredentialProviderService}"
|
||||||
|
Subtitle="{u:I18n CredentialProviderServiceExplanationLong}"
|
||||||
|
IsVisible="{Binding SupportsCredentialProviderService}"
|
||||||
|
IsToggled="{Binding UseCredentialProviderService}"
|
||||||
|
AutomationId="CredentialProviderServiceSwitch"
|
||||||
|
StyleClass="settings-item-view"
|
||||||
|
HorizontalOptions="FillAndExpand" />
|
||||||
|
|
||||||
<controls:SwitchItemView
|
<controls:SwitchItemView
|
||||||
Title="{u:I18n AutofillServices}"
|
Title="{u:I18n AutofillServices}"
|
||||||
Subtitle="{u:I18n AutofillServicesExplanationLong}"
|
Subtitle="{u:I18n AutofillServicesExplanationLong}"
|
||||||
|
|
|
@ -6,12 +6,27 @@ namespace Bit.App.Pages
|
||||||
{
|
{
|
||||||
public partial class AutofillSettingsPageViewModel
|
public partial class AutofillSettingsPageViewModel
|
||||||
{
|
{
|
||||||
|
private bool _useCredentialProviderService;
|
||||||
private bool _useAutofillServices;
|
private bool _useAutofillServices;
|
||||||
private bool _useInlineAutofill;
|
private bool _useInlineAutofill;
|
||||||
private bool _useAccessibility;
|
private bool _useAccessibility;
|
||||||
private bool _useDrawOver;
|
private bool _useDrawOver;
|
||||||
private bool _askToAddLogin;
|
private bool _askToAddLogin;
|
||||||
|
|
||||||
|
public bool SupportsCredentialProviderService => DeviceInfo.Platform == DevicePlatform.Android && _deviceActionService.SupportsCredentialProviderService();
|
||||||
|
|
||||||
|
public bool UseCredentialProviderService
|
||||||
|
{
|
||||||
|
get => _useCredentialProviderService;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _useCredentialProviderService, value))
|
||||||
|
{
|
||||||
|
((ICommand)ToggleUseCredentialProviderServiceCommand).Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool SupportsAndroidAutofillServices => DeviceInfo.Platform == DevicePlatform.Android && _deviceActionService.SupportsAutofillServices();
|
public bool SupportsAndroidAutofillServices => DeviceInfo.Platform == DevicePlatform.Android && _deviceActionService.SupportsAutofillServices();
|
||||||
|
|
||||||
public bool UseAutofillServices
|
public bool UseAutofillServices
|
||||||
|
@ -84,6 +99,7 @@ namespace Bit.App.Pages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AsyncRelayCommand ToggleUseCredentialProviderServiceCommand { get; private set; }
|
||||||
public AsyncRelayCommand ToggleUseAutofillServicesCommand { get; private set; }
|
public AsyncRelayCommand ToggleUseAutofillServicesCommand { get; private set; }
|
||||||
public AsyncRelayCommand ToggleUseInlineAutofillCommand { get; private set; }
|
public AsyncRelayCommand ToggleUseInlineAutofillCommand { get; private set; }
|
||||||
public AsyncRelayCommand ToggleUseAccessibilityCommand { get; private set; }
|
public AsyncRelayCommand ToggleUseAccessibilityCommand { get; private set; }
|
||||||
|
@ -93,6 +109,7 @@ namespace Bit.App.Pages
|
||||||
|
|
||||||
private void InitAndroidCommands()
|
private void InitAndroidCommands()
|
||||||
{
|
{
|
||||||
|
ToggleUseCredentialProviderServiceCommand = CreateDefaultAsyncRelayCommand(() => MainThread.InvokeOnMainThreadAsync(() => ToggleUseCredentialProviderService()), () => _inited, allowsMultipleExecutions: false);
|
||||||
ToggleUseAutofillServicesCommand = CreateDefaultAsyncRelayCommand(() => MainThread.InvokeOnMainThreadAsync(() => ToggleUseAutofillServices()), () => _inited, allowsMultipleExecutions: false);
|
ToggleUseAutofillServicesCommand = CreateDefaultAsyncRelayCommand(() => MainThread.InvokeOnMainThreadAsync(() => ToggleUseAutofillServices()), () => _inited, allowsMultipleExecutions: false);
|
||||||
ToggleUseInlineAutofillCommand = CreateDefaultAsyncRelayCommand(() => MainThread.InvokeOnMainThreadAsync(() => ToggleUseInlineAutofillEnabledAsync()), () => _inited, allowsMultipleExecutions: false);
|
ToggleUseInlineAutofillCommand = CreateDefaultAsyncRelayCommand(() => MainThread.InvokeOnMainThreadAsync(() => ToggleUseInlineAutofillEnabledAsync()), () => _inited, allowsMultipleExecutions: false);
|
||||||
ToggleUseAccessibilityCommand = CreateDefaultAsyncRelayCommand(ToggleUseAccessibilityAsync, () => _inited, allowsMultipleExecutions: false);
|
ToggleUseAccessibilityCommand = CreateDefaultAsyncRelayCommand(ToggleUseAccessibilityAsync, () => _inited, allowsMultipleExecutions: false);
|
||||||
|
@ -115,6 +132,9 @@ namespace Bit.App.Pages
|
||||||
|
|
||||||
private async Task UpdateAndroidAutofillSettingsAsync()
|
private async Task UpdateAndroidAutofillSettingsAsync()
|
||||||
{
|
{
|
||||||
|
// TODO - uncomment once _autofillHandler.CredentialProviderServiceEnabled() returns a real value
|
||||||
|
// _useCredentialProviderService =
|
||||||
|
// SupportsCredentialProviderService && _autofillHandler.CredentialProviderServiceEnabled();
|
||||||
_useAutofillServices =
|
_useAutofillServices =
|
||||||
_autofillHandler.SupportsAutofillService() && _autofillHandler.AutofillServiceEnabled();
|
_autofillHandler.SupportsAutofillService() && _autofillHandler.AutofillServiceEnabled();
|
||||||
_useAccessibility = _autofillHandler.AutofillAccessibilityServiceRunning();
|
_useAccessibility = _autofillHandler.AutofillAccessibilityServiceRunning();
|
||||||
|
@ -123,6 +143,7 @@ namespace Bit.App.Pages
|
||||||
|
|
||||||
await MainThread.InvokeOnMainThreadAsync(() =>
|
await MainThread.InvokeOnMainThreadAsync(() =>
|
||||||
{
|
{
|
||||||
|
TriggerPropertyChanged(nameof(UseCredentialProviderService));
|
||||||
TriggerPropertyChanged(nameof(UseAutofillServices));
|
TriggerPropertyChanged(nameof(UseAutofillServices));
|
||||||
TriggerPropertyChanged(nameof(UseAccessibility));
|
TriggerPropertyChanged(nameof(UseAccessibility));
|
||||||
TriggerPropertyChanged(nameof(UseDrawOver));
|
TriggerPropertyChanged(nameof(UseDrawOver));
|
||||||
|
@ -130,6 +151,18 @@ namespace Bit.App.Pages
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ToggleUseCredentialProviderService()
|
||||||
|
{
|
||||||
|
if (UseCredentialProviderService)
|
||||||
|
{
|
||||||
|
_deviceActionService.OpenCredentialProviderSettings();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_autofillHandler.DisableCredentialProviderService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ToggleUseAutofillServices()
|
private void ToggleUseAutofillServices()
|
||||||
{
|
{
|
||||||
if (UseAutofillServices)
|
if (UseAutofillServices)
|
||||||
|
|
|
@ -166,6 +166,15 @@ namespace Bit.Core.Resources.Localization {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Credential Provider service
|
||||||
|
/// </summary>
|
||||||
|
public static string CredentialProviderService {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("CredentialProviderService", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Bitwarden needs attention - See "Auto-fill Accessibility Service" from Bitwarden settings.
|
/// Looks up a localized string similar to Bitwarden needs attention - See "Auto-fill Accessibility Service" from Bitwarden settings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -7667,6 +7676,15 @@ namespace Bit.Core.Resources.Localization {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to We were unable to automatically open the Android credential provider settings menu for you. You can navigate to the credential provider settings menu manually from Android Settings > System > Passwords & accounts > Passwords, passkeys and data services.
|
||||||
|
/// </summary>
|
||||||
|
public static string BitwardenCredentialProviderGoToSettings {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("BitwardenCredentialProviderGoToSettings", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Word separator.
|
/// Looks up a localized string similar to Word separator.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -7685,6 +7703,15 @@ namespace Bit.Core.Resources.Localization {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to The Android Credential Provider is used for managing passkeys for use with websites and other apps on your device.
|
||||||
|
/// </summary>
|
||||||
|
public static string CredentialProviderServiceExplanationLong {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("CredentialProviderServiceExplanationLong", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to {0} hours and one minute.
|
/// Looks up a localized string similar to {0} hours and one minute.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
@ -1191,6 +1191,9 @@ Scanning will happen automatically.</value>
|
||||||
<data name="WindowsHello" xml:space="preserve">
|
<data name="WindowsHello" xml:space="preserve">
|
||||||
<value>Windows Hello</value>
|
<value>Windows Hello</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="BitwardenCredentialProviderGoToSettings" xml:space="preserve">
|
||||||
|
<value>We were unable to automatically open the Android credential provider settings menu for you. You can navigate to the credential provider settings menu manually from Android Settings > System > Passwords & accounts > Passwords, passkeys and data services.</value>
|
||||||
|
</data>
|
||||||
<data name="BitwardenAutofillGoToSettings" xml:space="preserve">
|
<data name="BitwardenAutofillGoToSettings" xml:space="preserve">
|
||||||
<value>We were unable to automatically open the Android autofill settings menu for you. You can navigate to the autofill settings menu manually from Android Settings > System > Languages and input > Advanced > Autofill service.</value>
|
<value>We were unable to automatically open the Android autofill settings menu for you. You can navigate to the autofill settings menu manually from Android Settings > System > Languages and input > Advanced > Autofill service.</value>
|
||||||
</data>
|
</data>
|
||||||
|
@ -1816,6 +1819,9 @@ Scanning will happen automatically.</value>
|
||||||
<data name="AccessibilityDrawOverPermissionAlert" xml:space="preserve">
|
<data name="AccessibilityDrawOverPermissionAlert" xml:space="preserve">
|
||||||
<value>Bitwarden needs attention - Turn on "Draw-Over" in "Auto-fill Services" from Bitwarden Settings</value>
|
<value>Bitwarden needs attention - Turn on "Draw-Over" in "Auto-fill Services" from Bitwarden Settings</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="CredentialProviderService" xml:space="preserve">
|
||||||
|
<value>Credential Provider service</value>
|
||||||
|
</data>
|
||||||
<data name="AutofillServices" xml:space="preserve">
|
<data name="AutofillServices" xml:space="preserve">
|
||||||
<value>Auto-fill services</value>
|
<value>Auto-fill services</value>
|
||||||
</data>
|
</data>
|
||||||
|
@ -2799,6 +2805,9 @@ Do you want to switch to this account?</value>
|
||||||
<data name="XHours" xml:space="preserve">
|
<data name="XHours" xml:space="preserve">
|
||||||
<value>{0} hours</value>
|
<value>{0} hours</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="CredentialProviderServiceExplanationLong" xml:space="preserve">
|
||||||
|
<value>The Android Credential Provider is used for managing passkeys for use with websites and other apps on your device.</value>
|
||||||
|
</data>
|
||||||
<data name="AutofillServicesExplanationLong" xml:space="preserve">
|
<data name="AutofillServicesExplanationLong" xml:space="preserve">
|
||||||
<value>The Android Autofill Framework is used to assist in filling login information into other apps on your device.</value>
|
<value>The Android Autofill Framework is used to assist in filling login information into other apps on your device.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
Additions allow you to add arbitrary C# to the generated classes
|
||||||
|
before they are compiled. This can be helpful for providing convenience
|
||||||
|
methods or adding pure C# classes.
|
||||||
|
|
||||||
|
== Adding Methods to Generated Classes ==
|
||||||
|
|
||||||
|
Let's say the library being bound has a Rectangle class with a constructor
|
||||||
|
that takes an x and y position, and a width and length size. It will look like
|
||||||
|
this:
|
||||||
|
|
||||||
|
public partial class Rectangle
|
||||||
|
{
|
||||||
|
public Rectangle (int x, int y, int width, int height)
|
||||||
|
{
|
||||||
|
// JNI bindings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Imagine we want to add a constructor to this class that takes a Point and
|
||||||
|
Size structure instead of 4 ints. We can add a new file called Rectangle.cs
|
||||||
|
with a partial class containing our new method:
|
||||||
|
|
||||||
|
public partial class Rectangle
|
||||||
|
{
|
||||||
|
public Rectangle (Point location, Size size) :
|
||||||
|
this (location.X, location.Y, size.Width, size.Height)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
At compile time, the additions class will be added to the generated class
|
||||||
|
and the final assembly will a Rectangle class with both constructors.
|
||||||
|
|
||||||
|
|
||||||
|
== Adding C# Classes ==
|
||||||
|
|
||||||
|
Another thing that can be done is adding fully C# managed classes to the
|
||||||
|
generated library. In the above example, let's assume that there isn't a
|
||||||
|
Point class available in Java or our library. The one we create doesn't need
|
||||||
|
to interact with Java, so we'll create it like a normal class in C#.
|
||||||
|
|
||||||
|
By adding a Point.cs file with this class, it will end up in the binding library:
|
||||||
|
|
||||||
|
public class Point
|
||||||
|
{
|
||||||
|
public int X { get; set; }
|
||||||
|
public int Y { get; set; }
|
||||||
|
}
|
15
src/Xamarin.AndroidX.Credentials/Transforms/EnumFields.xml
Normal file
15
src/Xamarin.AndroidX.Credentials/Transforms/EnumFields.xml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<enum-field-mappings>
|
||||||
|
<!--
|
||||||
|
This example converts the constants Fragment_id, Fragment_name,
|
||||||
|
and Fragment_tag from android.support.v4.app.FragmentActivity.FragmentTag
|
||||||
|
to an enum called Android.Support.V4.App.FragmentTagType with values
|
||||||
|
Id, Name, and Tag.
|
||||||
|
|
||||||
|
<mapping jni-class="android/support/v4/app/FragmentActivity$FragmentTag" clr-enum-type="Android.Support.V4.App.FragmentTagType">
|
||||||
|
<field jni-name="Fragment_name" clr-name="Name" value="0" />
|
||||||
|
<field jni-name="Fragment_id" clr-name="Id" value="1" />
|
||||||
|
<field jni-name="Fragment_tag" clr-name="Tag" value="2" />
|
||||||
|
</mapping>
|
||||||
|
-->
|
||||||
|
</enum-field-mappings>
|
||||||
|
|
14
src/Xamarin.AndroidX.Credentials/Transforms/EnumMethods.xml
Normal file
14
src/Xamarin.AndroidX.Credentials/Transforms/EnumMethods.xml
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<enum-method-mappings>
|
||||||
|
<!--
|
||||||
|
This example changes the Java method:
|
||||||
|
android.support.v4.app.Fragment.SavedState.writeToParcel (int flags)
|
||||||
|
to be:
|
||||||
|
android.support.v4.app.Fragment.SavedState.writeToParcel (Android.OS.ParcelableWriteFlags flags)
|
||||||
|
when bound in C#.
|
||||||
|
|
||||||
|
<mapping jni-class="android/support/v4/app/Fragment.SavedState">
|
||||||
|
<method jni-name="writeToParcel" parameter="flags" clr-enum-type="Android.OS.ParcelableWriteFlags" />
|
||||||
|
</mapping>
|
||||||
|
-->
|
||||||
|
</enum-method-mappings>
|
||||||
|
|
12
src/Xamarin.AndroidX.Credentials/Transforms/Metadata.xml
Normal file
12
src/Xamarin.AndroidX.Credentials/Transforms/Metadata.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<metadata>
|
||||||
|
<attr path="/api/package[@name='androidx.credentials']" name="managedName">AndroidX.Credentials</attr>
|
||||||
|
<attr path="/api/package[@name='androidx.credentials.provider']" name="managedName">AndroidX.Credentials.Provider</attr>
|
||||||
|
<attr path="/api/package[@name='androidx.credentials.exceptions']" name="managedName">AndroidX.Credentials.Exceptions</attr>
|
||||||
|
<attr path="/api/package[@name='androidx.credentials.webauthn']" name="managedName">AndroidX.Credentials.WebAuthn</attr>
|
||||||
|
|
||||||
|
<!-- fix companions -->
|
||||||
|
<attr path="/api/package/class[substring(@name,string-length(@name)-9)='.Companion']" name="managedName">CompanionStatic</attr>
|
||||||
|
<remove-node path="/api/package/class[substring(@name,string-length(@name)-9)='.Companion' and count(method)=0 and count(field)=0]" />
|
||||||
|
<attr path="/api/package/class[substring(@name,string-length(@name)-7)='.Default']" name="managedName">DefaultStatic</attr>
|
||||||
|
<remove-node path="/api/package/class[substring(@name,string-length(@name)-7)='.Default' and count(method)=0 and count(field)=0]" />
|
||||||
|
</metadata>
|
|
@ -0,0 +1,18 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0-android</TargetFramework>
|
||||||
|
<SupportedOSPlatformVersion>21</SupportedOSPlatformVersion>
|
||||||
|
|
||||||
|
|
||||||
|
<!--<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">11.0</SupportedOSPlatformVersion>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>-->
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>XamarinBinding.AndroidX.Credentials</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
|
||||||
|
<PackageReference Include="Xamarin.Kotlin.StdLib" Version="1.9.10.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
BIN
src/Xamarin.AndroidX.Credentials/credentials-1.2.0.aar
Normal file
BIN
src/Xamarin.AndroidX.Credentials/credentials-1.2.0.aar
Normal file
Binary file not shown.
|
@ -11,9 +11,11 @@ namespace Bit.iOS.Core.Services
|
||||||
{
|
{
|
||||||
public bool SupportsAutofillService() => false;
|
public bool SupportsAutofillService() => false;
|
||||||
public bool AutofillServiceEnabled() => false;
|
public bool AutofillServiceEnabled() => false;
|
||||||
|
public void DisableCredentialProviderService() => throw new NotImplementedException();
|
||||||
public void Autofill(CipherView cipher) => throw new NotImplementedException();
|
public void Autofill(CipherView cipher) => throw new NotImplementedException();
|
||||||
public bool AutofillAccessibilityOverlayPermitted() => false;
|
public bool AutofillAccessibilityOverlayPermitted() => false;
|
||||||
public bool AutofillAccessibilityServiceRunning() => false;
|
public bool AutofillAccessibilityServiceRunning() => false;
|
||||||
|
public bool CredentialProviderServiceEnabled() => throw new NotImplementedException();
|
||||||
public bool AutofillServicesEnabled() => false;
|
public bool AutofillServicesEnabled() => false;
|
||||||
public void CloseAutofill() => throw new NotImplementedException();
|
public void CloseAutofill() => throw new NotImplementedException();
|
||||||
public void DisableAutofillService() => throw new NotImplementedException();
|
public void DisableAutofillService() => throw new NotImplementedException();
|
||||||
|
|
|
@ -301,6 +301,8 @@ namespace Bit.iOS.Core.Services
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OpenCredentialProviderSettings() => throw new NotImplementedException();
|
||||||
|
|
||||||
public void OpenAutofillSettings()
|
public void OpenAutofillSettings()
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
|
@ -339,6 +341,8 @@ namespace Bit.iOS.Core.Services
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool SupportsCredentialProviderService() => throw new NotImplementedException();
|
||||||
|
|
||||||
public bool SupportsAutofillServices() => UIDevice.CurrentDevice.CheckSystemVersion(12, 0);
|
public bool SupportsAutofillServices() => UIDevice.CurrentDevice.CheckSystemVersion(12, 0);
|
||||||
public bool SupportsInlineAutofill() => false;
|
public bool SupportsInlineAutofill() => false;
|
||||||
public bool SupportsDrawOver() => false;
|
public bool SupportsDrawOver() => false;
|
||||||
|
|
Loading…
Reference in a new issue