[PM-1129] iOS 16 Third-Party 2FA OTP handling (#2409)

* [EC-980] Added iOS otpauth handler (#2370)

* EC-980 added Bitwarden as otpauth scheme handler

* EC-980 Fix format

* [EC-981] OTP handling - Set to selected cipher (#2404)

* EC-981 Started adding OTP to existing cipher. Reused AutofillCiphersPage for the cipher selection and refactored it so that we have more code reuse

* EC-981 Fix navigation on otp handling

* EC-981 Fix formatting

* EC-981 Added otp cipher selection callout and add close toolbar item when needed

* PM-1131 implemented cipher creation from otp handling flow with otp key filled (#2407)

* PM-1133 Updated empty states for search and cipher selection on otp flow (#2408)
This commit is contained in:
Federico Maccaroni 2023-03-09 11:16:48 -03:00 committed by GitHub
parent 4d2b53c809
commit a18f74a72a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1277 additions and 630 deletions

View File

@ -170,7 +170,7 @@ namespace Bit.Droid
{ {
if (intent?.GetStringExtra("uri") is string uri) if (intent?.GetStringExtra("uri") is string uri)
{ {
_messagingService.Send("popAllAndGoToAutofillCiphers"); _messagingService.Send(App.App.POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE);
if (_appOptions != null) if (_appOptions != null)
{ {
_appOptions.Uri = uri; _appOptions.Uri = uri;
@ -178,7 +178,7 @@ namespace Bit.Droid
} }
else if (intent.GetBooleanExtra("generatorTile", false)) else if (intent.GetBooleanExtra("generatorTile", false))
{ {
_messagingService.Send("popAllAndGoToTabGenerator"); _messagingService.Send(App.App.POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE);
if (_appOptions != null) if (_appOptions != null)
{ {
_appOptions.GeneratorTile = true; _appOptions.GeneratorTile = true;
@ -186,7 +186,7 @@ namespace Bit.Droid
} }
else if (intent.GetBooleanExtra("myVaultTile", false)) else if (intent.GetBooleanExtra("myVaultTile", false))
{ {
_messagingService.Send("popAllAndGoToTabMyVault"); _messagingService.Send(App.App.POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE);
if (_appOptions != null) if (_appOptions != null)
{ {
_appOptions.MyVaultTile = true; _appOptions.MyVaultTile = true;
@ -198,7 +198,7 @@ namespace Bit.Droid
{ {
_appOptions.CreateSend = GetCreateSendRequest(intent); _appOptions.CreateSend = GetCreateSendRequest(intent);
} }
_messagingService.Send("popAllAndGoToTabSend"); _messagingService.Send(App.App.POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE);
} }
else else
{ {

View File

@ -8,6 +8,7 @@ namespace Bit.App.Abstractions
{ {
void Init(Func<AppOptions> getOptionsFunc, IAccountsManagerHost accountsManagerHost); void Init(Func<AppOptions> getOptionsFunc, IAccountsManagerHost accountsManagerHost);
Task NavigateOnAccountChangeAsync(bool? isAuthed = null); Task NavigateOnAccountChangeAsync(bool? isAuthed = null);
Task StartDefaultNavigationFlowAsync(Action<AppOptions> appOptionsAction);
Task LogOutAsync(string userId, bool userInitiated, bool expired); Task LogOutAsync(string userId, bool userInitiated, bool expired);
Task PromptToSwitchToExistingAccountAsync(string userId); Task PromptToSwitchToExistingAccountAsync(string userId);
} }

View File

@ -0,0 +1,9 @@
using System;
namespace Bit.App.Abstractions
{
public interface IDeepLinkContext
{
bool OnNewUri(Uri uri);
}
}

View File

@ -23,6 +23,11 @@ namespace Bit.App
{ {
public partial class App : Application, IAccountsManagerHost public partial class App : Application, IAccountsManagerHost
{ {
public const string POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE = "popAllAndGoToTabGenerator";
public const string POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE = "popAllAndGoToTabMyVault";
public const string POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE = "popAllAndGoToTabSend";
public const string POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE = "popAllAndGoToAutofillCiphers";
private readonly IBroadcasterService _broadcasterService; private readonly IBroadcasterService _broadcasterService;
private readonly IMessagingService _messagingService; private readonly IMessagingService _messagingService;
private readonly IStateService _stateService; private readonly IStateService _stateService;
@ -103,12 +108,18 @@ namespace Bit.App
await Task.Delay(1000); await Task.Delay(1000);
await _accountsManager.NavigateOnAccountChangeAsync(); await _accountsManager.NavigateOnAccountChangeAsync();
} }
else if (message.Command == "popAllAndGoToTabGenerator" || else if (message.Command == POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE ||
message.Command == "popAllAndGoToTabMyVault" || message.Command == POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE ||
message.Command == "popAllAndGoToTabSend" || message.Command == POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE ||
message.Command == "popAllAndGoToAutofillCiphers") message.Command == POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE ||
message.Command == DeepLinkContext.NEW_OTP_MESSAGE)
{ {
Device.BeginInvokeOnMainThread(async () => if (message.Command == DeepLinkContext.NEW_OTP_MESSAGE)
{
Options.OtpData = new OtpData((string)message.Data);
}
await Device.InvokeOnMainThreadAsync(async () =>
{ {
if (Current.MainPage is TabsPage tabsPage) if (Current.MainPage is TabsPage tabsPage)
{ {
@ -116,24 +127,29 @@ namespace Bit.App
{ {
await tabsPage.Navigation.PopModalAsync(false); await tabsPage.Navigation.PopModalAsync(false);
} }
if (message.Command == "popAllAndGoToAutofillCiphers") if (message.Command == POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE)
{ {
Current.MainPage = new NavigationPage(new AutofillCiphersPage(Options)); Current.MainPage = new NavigationPage(new CipherSelectionPage(Options));
} }
else if (message.Command == "popAllAndGoToTabMyVault") else if (message.Command == POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE)
{ {
Options.MyVaultTile = false; Options.MyVaultTile = false;
tabsPage.ResetToVaultPage(); tabsPage.ResetToVaultPage();
} }
else if (message.Command == "popAllAndGoToTabGenerator") else if (message.Command == POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE)
{ {
Options.GeneratorTile = false; Options.GeneratorTile = false;
tabsPage.ResetToGeneratorPage(); tabsPage.ResetToGeneratorPage();
} }
else if (message.Command == "popAllAndGoToTabSend") else if (message.Command == POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE)
{ {
tabsPage.ResetToSendPage(); tabsPage.ResetToSendPage();
} }
else if (message.Command == DeepLinkContext.NEW_OTP_MESSAGE)
{
tabsPage.ResetToVaultPage();
await tabsPage.Navigation.PushModalAsync(new NavigationPage(new CipherSelectionPage(Options)));
}
} }
}); });
} }
@ -494,7 +510,8 @@ namespace Bit.App
Current.MainPage = new NavigationPage(new CipherAddEditPage(appOptions: Options)); Current.MainPage = new NavigationPage(new CipherAddEditPage(appOptions: Options));
break; break;
case NavigationTarget.AutofillCiphers: case NavigationTarget.AutofillCiphers:
Current.MainPage = new NavigationPage(new AutofillCiphersPage(Options)); case NavigationTarget.OtpCipherSelection:
Current.MainPage = new NavigationPage(new CipherSelectionPage(Options));
break; break;
case NavigationTarget.SendAddEdit: case NavigationTarget.SendAddEdit:
Current.MainPage = new NavigationPage(new SendAddEditPage(Options)); Current.MainPage = new NavigationPage(new SendAddEditPage(Options));

View File

@ -1,5 +1,6 @@
using System; using System;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.App.Models namespace Bit.App.Models
{ {
@ -23,6 +24,7 @@ namespace Bit.App.Models
public Tuple<SendType, string, byte[], string> CreateSend { get; set; } public Tuple<SendType, string, byte[], string> CreateSend { get; set; }
public bool CopyInsteadOfShareAfterSaving { get; set; } public bool CopyInsteadOfShareAfterSaving { get; set; }
public bool HideAccountSwitcher { get; set; } public bool HideAccountSwitcher { get; set; }
public OtpData? OtpData { get; set; }
public void SetAllFrom(AppOptions o) public void SetAllFrom(AppOptions o)
{ {
@ -48,6 +50,7 @@ namespace Bit.App.Models
CreateSend = o.CreateSend; CreateSend = o.CreateSend;
CopyInsteadOfShareAfterSaving = o.CopyInsteadOfShareAfterSaving; CopyInsteadOfShareAfterSaving = o.CopyInsteadOfShareAfterSaving;
HideAccountSwitcher = o.HideAccountSwitcher; HideAccountSwitcher = o.HideAccountSwitcher;
OtpData = o.OtpData;
} }
} }
} }

View File

@ -1,91 +1,29 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Bit.App.Abstractions;
using Bit.App.Controls;
using Bit.App.Models; using Bit.App.Models;
using Bit.App.Resources; using Bit.App.Resources;
using Bit.App.Utilities; using Bit.App.Utilities;
using Bit.Core; using Bit.Core;
using Bit.Core.Abstractions;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.View; using Bit.Core.Models.View;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Xamarin.CommunityToolkit.ObjectModel;
using Xamarin.Forms; using Xamarin.Forms;
namespace Bit.App.Pages namespace Bit.App.Pages
{ {
public class AutofillCiphersPageViewModel : BaseViewModel public class AutofillCiphersPageViewModel : CipherSelectionPageViewModel
{ {
private readonly IPlatformUtilsService _platformUtilsService; private CipherType? _fillType;
private readonly IDeviceActionService _deviceActionService;
private readonly IAutofillHandler _autofillHandler;
private readonly ICipherService _cipherService;
private readonly IStateService _stateService;
private readonly IPasswordRepromptService _passwordRepromptService;
private readonly IMessagingService _messagingService;
private readonly ILogger _logger;
private bool _showNoData;
private bool _showList;
private string _noDataText;
private bool _websiteIconsEnabled;
public AutofillCiphersPageViewModel()
{
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
_cipherService = ServiceContainer.Resolve<ICipherService>("cipherService");
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
_autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
_passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService");
_messagingService = ServiceContainer.Resolve<IMessagingService>("messagingService");
_logger = ServiceContainer.Resolve<ILogger>("logger");
GroupedItems = new ObservableRangeCollection<IGroupingsPageListItem>();
CipherOptionsCommand = new Command<CipherView>(CipherOptionsAsync);
AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger)
{
AllowAddAccountRow = false
};
}
public string Name { get; set; }
public string Uri { get; set; } public string Uri { get; set; }
public Command CipherOptionsCommand { get; set; }
public bool LoadedOnce { get; set; }
public ObservableRangeCollection<IGroupingsPageListItem> GroupedItems { get; set; }
public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; }
public bool ShowNoData public override void Init(AppOptions appOptions)
{
get => _showNoData;
set => SetProperty(ref _showNoData, value);
}
public bool ShowList
{
get => _showList;
set => SetProperty(ref _showList, value);
}
public string NoDataText
{
get => _noDataText;
set => SetProperty(ref _noDataText, value);
}
public bool WebsiteIconsEnabled
{
get => _websiteIconsEnabled;
set => SetProperty(ref _websiteIconsEnabled, value);
}
public void Init(AppOptions appOptions)
{ {
Uri = appOptions?.Uri; Uri = appOptions?.Uri;
_fillType = appOptions.FillType;
string name = null; string name = null;
if (Uri?.StartsWith(Constants.AndroidAppProtocol) ?? false) if (Uri?.StartsWith(Constants.AndroidAppProtocol) ?? false)
{ {
@ -104,14 +42,11 @@ namespace Bit.App.Pages
NoDataText = string.Format(AppResources.NoItemsForUri, Name ?? "--"); NoDataText = string.Format(AppResources.NoItemsForUri, Name ?? "--");
} }
public async Task LoadAsync() protected override async Task<List<GroupingsPageListGroup>> LoadGroupedItemsAsync()
{ {
LoadedOnce = true;
ShowList = false;
ShowNoData = false;
WebsiteIconsEnabled = !(await _stateService.GetDisableFaviconAsync()).GetValueOrDefault();
var groupedItems = new List<GroupingsPageListGroup>(); var groupedItems = new List<GroupingsPageListGroup>();
var ciphers = await _cipherService.GetAllDecryptedByUrlAsync(Uri, null); var ciphers = await _cipherService.GetAllDecryptedByUrlAsync(Uri, null);
var matching = ciphers.Item1?.Select(c => new GroupingsPageListItem { Cipher = c }).ToList(); var matching = ciphers.Item1?.Select(c => new GroupingsPageListItem { Cipher = c }).ToList();
var hasMatching = matching?.Any() ?? false; var hasMatching = matching?.Any() ?? false;
if (matching?.Any() ?? false) if (matching?.Any() ?? false)
@ -119,6 +54,7 @@ namespace Bit.App.Pages
groupedItems.Add( groupedItems.Add(
new GroupingsPageListGroup(matching, AppResources.MatchingItems, matching.Count, false, true)); new GroupingsPageListGroup(matching, AppResources.MatchingItems, matching.Count, false, true));
} }
var fuzzy = ciphers.Item2?.Select(c => var fuzzy = ciphers.Item2?.Select(c =>
new GroupingsPageListItem { Cipher = c, FuzzyAutofill = true }).ToList(); new GroupingsPageListItem { Cipher = c, FuzzyAutofill = true }).ToList();
if (fuzzy?.Any() ?? false) if (fuzzy?.Any() ?? false)
@ -128,123 +64,88 @@ namespace Bit.App.Pages
!hasMatching)); !hasMatching));
} }
// TODO: refactor this return groupedItems;
if (Device.RuntimePlatform == Device.Android
||
GroupedItems.Any())
{
var items = new List<IGroupingsPageListItem>();
foreach (var itemGroup in groupedItems)
{
items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount));
items.AddRange(itemGroup);
}
GroupedItems.ReplaceRange(items);
}
else
{
// HACK: we need this on iOS, so that it doesn't crash when adding coming from an empty list
var first = true;
var items = new List<IGroupingsPageListItem>();
foreach (var itemGroup in groupedItems)
{
if (!first)
{
items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount));
}
else
{
first = false;
}
items.AddRange(itemGroup);
}
if (groupedItems.Any())
{
GroupedItems.ReplaceRange(new List<IGroupingsPageListItem> { new GroupingsPageHeaderListItem(groupedItems[0].Name, groupedItems[0].ItemCount) });
GroupedItems.AddRange(items);
}
else
{
GroupedItems.Clear();
}
}
ShowList = groupedItems.Any();
ShowNoData = !ShowList;
} }
public async Task SelectCipherAsync(CipherView cipher, bool fuzzy) protected override async Task SelectCipherAsync(IGroupingsPageListItem item)
{ {
if (cipher == null) if (!(item is GroupingsPageListItem listItem) || listItem.Cipher is null)
{ {
return; return;
} }
var cipher = listItem.Cipher;
if (_deviceActionService.SystemMajorVersion() < 21) if (_deviceActionService.SystemMajorVersion() < 21)
{ {
await AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService); await AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService);
return;
} }
else
if (cipher.Reprompt != CipherRepromptType.None && !await _passwordRepromptService.ShowPasswordPromptAsync())
{ {
if (cipher.Reprompt != CipherRepromptType.None && !await _passwordRepromptService.ShowPasswordPromptAsync()) return;
}
var autofillResponse = AppResources.Yes;
if (listItem.FuzzyAutofill)
{
var options = new List<string> { AppResources.Yes };
if (cipher.Type == CipherType.Login &&
Xamarin.Essentials.Connectivity.NetworkAccess != Xamarin.Essentials.NetworkAccess.None)
{ {
return; options.Add(AppResources.YesAndSave);
} }
var autofillResponse = AppResources.Yes; autofillResponse = await _deviceActionService.DisplayAlertAsync(null,
if (fuzzy) string.Format(AppResources.BitwardenAutofillServiceMatchConfirm, Name), AppResources.No,
options.ToArray());
}
if (autofillResponse == AppResources.YesAndSave && cipher.Type == CipherType.Login)
{
var uris = cipher.Login?.Uris?.ToList();
if (uris == null)
{ {
var options = new List<string> { AppResources.Yes }; uris = new List<LoginUriView>();
if (cipher.Type == CipherType.Login &&
Xamarin.Essentials.Connectivity.NetworkAccess != Xamarin.Essentials.NetworkAccess.None)
{
options.Add(AppResources.YesAndSave);
}
autofillResponse = await _deviceActionService.DisplayAlertAsync(null,
string.Format(AppResources.BitwardenAutofillServiceMatchConfirm, Name), AppResources.No,
options.ToArray());
} }
if (autofillResponse == AppResources.YesAndSave && cipher.Type == CipherType.Login) uris.Add(new LoginUriView
{ {
var uris = cipher.Login?.Uris?.ToList(); Uri = Uri,
if (uris == null) Match = null
});
cipher.Login.Uris = uris;
try
{
await _deviceActionService.ShowLoadingAsync(AppResources.Saving);
await _cipherService.SaveWithServerAsync(await _cipherService.EncryptAsync(cipher));
await _deviceActionService.HideLoadingAsync();
}
catch (ApiException e)
{
await _deviceActionService.HideLoadingAsync();
if (e?.Error != null)
{ {
uris = new List<LoginUriView>(); await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
} AppResources.AnErrorHasOccurred);
uris.Add(new LoginUriView
{
Uri = Uri,
Match = null
});
cipher.Login.Uris = uris;
try
{
await _deviceActionService.ShowLoadingAsync(AppResources.Saving);
await _cipherService.SaveWithServerAsync(await _cipherService.EncryptAsync(cipher));
await _deviceActionService.HideLoadingAsync();
}
catch (ApiException e)
{
await _deviceActionService.HideLoadingAsync();
if (e?.Error != null)
{
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
AppResources.AnErrorHasOccurred);
}
} }
} }
if (autofillResponse == AppResources.Yes || autofillResponse == AppResources.YesAndSave) }
{ if (autofillResponse == AppResources.Yes || autofillResponse == AppResources.YesAndSave)
_autofillHandler.Autofill(cipher); {
} _autofillHandler.Autofill(cipher);
} }
} }
private async void CipherOptionsAsync(CipherView cipher) protected override async Task AddCipherAsync()
{ {
if ((Page as BaseContentPage).DoOnce()) if (_fillType.HasValue && _fillType != CipherType.Login)
{ {
await AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService); var pageForOther = new CipherAddEditPage(type: _fillType, fromAutofill: true);
await Page.Navigation.PushModalAsync(new NavigationPage(pageForOther));
return;
} }
var pageForLogin = new CipherAddEditPage(null, CipherType.Login, uri: Uri, name: Name,
fromAutofill: true);
await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin));
} }
} }
} }

View File

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Bit.App.Abstractions;
using Bit.App.Lists.ItemViewModels.CustomFields; using Bit.App.Lists.ItemViewModels.CustomFields;
using Bit.App.Models; using Bit.App.Models;
using Bit.App.Resources; using Bit.App.Resources;
@ -30,6 +31,7 @@ namespace Bit.App.Pages
private readonly IClipboardService _clipboardService; private readonly IClipboardService _clipboardService;
private readonly IAutofillHandler _autofillHandler; private readonly IAutofillHandler _autofillHandler;
private readonly IWatchDeviceService _watchDeviceService; private readonly IWatchDeviceService _watchDeviceService;
private readonly IAccountsManager _accountsManager;
private bool _showNotesSeparator; private bool _showNotesSeparator;
private bool _showPassword; private bool _showPassword;
@ -44,6 +46,8 @@ namespace Bit.App.Pages
private bool _hasCollections; private bool _hasCollections;
private string _previousCipherId; private string _previousCipherId;
private List<Core.Models.View.CollectionView> _writeableCollections; private List<Core.Models.View.CollectionView> _writeableCollections;
private bool _fromOtp;
protected override string[] AdditionalPropertiesToRaiseOnCipherChanged => new string[] protected override string[] AdditionalPropertiesToRaiseOnCipherChanged => new string[]
{ {
nameof(IsLogin), nameof(IsLogin),
@ -82,6 +86,8 @@ namespace Bit.App.Pages
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService"); _clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
_autofillHandler = ServiceContainer.Resolve<IAutofillHandler>(); _autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
_watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>(); _watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>();
_accountsManager = ServiceContainer.Resolve<IAccountsManager>();
GeneratePasswordCommand = new Command(GeneratePassword); GeneratePasswordCommand = new Command(GeneratePassword);
TogglePasswordCommand = new Command(TogglePassword); TogglePasswordCommand = new Command(TogglePassword);
@ -302,6 +308,7 @@ namespace Bit.App.Pages
public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow; public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow;
public bool HasTotpValue => IsLogin && !string.IsNullOrEmpty(Cipher?.Login?.Totp); public bool HasTotpValue => IsLogin && !string.IsNullOrEmpty(Cipher?.Login?.Totp);
public string SetupTotpText => $"{BitwardenIcons.Camera} {AppResources.SetupTotp}"; public string SetupTotpText => $"{BitwardenIcons.Camera} {AppResources.SetupTotp}";
public void Init() public void Init()
{ {
PageTitle = EditMode && !CloneMode ? AppResources.EditItem : AppResources.AddItem; PageTitle = EditMode && !CloneMode ? AppResources.EditItem : AppResources.AddItem;
@ -309,6 +316,8 @@ namespace Bit.App.Pages
public async Task<bool> LoadAsync(AppOptions appOptions = null) public async Task<bool> LoadAsync(AppOptions appOptions = null)
{ {
_fromOtp = appOptions?.OtpData != null;
var myEmail = await _stateService.GetEmailAsync(); var myEmail = await _stateService.GetEmailAsync();
OwnershipOptions.Add(new KeyValuePair<string, string>(myEmail, null)); OwnershipOptions.Add(new KeyValuePair<string, string>(myEmail, null));
var orgs = await _organizationService.GetAllAsync(); var orgs = await _organizationService.GetAllAsync();
@ -358,6 +367,10 @@ namespace Bit.App.Pages
Cipher.OrganizationId = OrganizationId; Cipher.OrganizationId = OrganizationId;
} }
} }
if (appOptions?.OtpData != null && Cipher.Type == CipherType.Login)
{
Cipher.Login.Totp = appOptions.OtpData.Value.Uri;
}
} }
else else
{ {
@ -380,6 +393,7 @@ namespace Bit.App.Pages
Cipher.Type = appOptions.SaveType.GetValueOrDefault(Cipher.Type); Cipher.Type = appOptions.SaveType.GetValueOrDefault(Cipher.Type);
Cipher.Login.Username = appOptions.SaveUsername; Cipher.Login.Username = appOptions.SaveUsername;
Cipher.Login.Password = appOptions.SavePassword; Cipher.Login.Password = appOptions.SavePassword;
Cipher.Login.Totp = appOptions.OtpData?.Uri;
Cipher.Card.Code = appOptions.SaveCardCode; Cipher.Card.Code = appOptions.SaveCardCode;
if (int.TryParse(appOptions.SaveCardExpMonth, out int month) && month <= 12 && month >= 1) if (int.TryParse(appOptions.SaveCardExpMonth, out int month) && month <= 12 && month >= 1)
{ {
@ -424,6 +438,11 @@ namespace Bit.App.Pages
{ {
Fields.ResetWithRange(Cipher.Fields?.Select(f => _customFieldItemFactory.CreateCustomFieldItem(f, true, Cipher, null, null, FieldOptionsCommand))); Fields.ResetWithRange(Cipher.Fields?.Select(f => _customFieldItemFactory.CreateCustomFieldItem(f, true, Cipher, null, null, FieldOptionsCommand)));
} }
if (appOptions?.OtpData != null)
{
_platformUtilsService.ShowToast(null, AppResources.AuthenticatorKey, AppResources.AuthenticatorKeyAdded);
}
} }
if (EditMode && _previousCipherId != CipherId) if (EditMode && _previousCipherId != CipherId)
@ -517,6 +536,10 @@ namespace Bit.App.Pages
// Close and go back to app // Close and go back to app
_autofillHandler.CloseAutofill(); _autofillHandler.CloseAutofill();
} }
else if (_fromOtp)
{
await _accountsManager.StartDefaultNavigationFlowAsync(op => op.OtpData = null);
}
else else
{ {
if (CloneMode) if (CloneMode)

View File

@ -1,30 +1,26 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<pages:BaseContentPage xmlns="http://xamarin.com/schemas/2014/forms" <pages:BaseContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Bit.App.Pages.AutofillCiphersPage" xmlns:xct="http://xamarin.com/schemas/2020/toolkit"
x:Class="Bit.App.Pages.CipherSelectionPage"
xmlns:pages="clr-namespace:Bit.App.Pages" xmlns:pages="clr-namespace:Bit.App.Pages"
xmlns:u="clr-namespace:Bit.App.Utilities" xmlns:u="clr-namespace:Bit.App.Utilities"
xmlns:controls="clr-namespace:Bit.App.Controls" xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:effects="clr-namespace:Bit.App.Effects;assembly=BitwardenApp" xmlns:effects="clr-namespace:Bit.App.Effects;assembly=BitwardenApp"
x:DataType="pages:AutofillCiphersPageViewModel" x:DataType="pages:CipherSelectionPageViewModel"
Title="{Binding PageTitle}" Title="{Binding PageTitle}"
x:Name="_page"> x:Name="_page">
<ContentPage.BindingContext>
<pages:AutofillCiphersPageViewModel />
</ContentPage.BindingContext>
<ContentPage.ToolbarItems> <ContentPage.ToolbarItems>
<controls:ExtendedToolbarItem <controls:ExtendedToolbarItem
x:Name="_accountAvatar" x:Name="_accountAvatar"
IconImageSource="{Binding AvatarImageSource}" IconImageSource="{Binding AvatarImageSource}"
Command="{Binding Source={x:Reference _accountListOverlay}, Path=ToggleVisibililtyCommand}" Command="{Binding Source={x:Reference _accountListOverlay}, Path=ToggleVisibililtyCommand}"
Order="Primary" Order="Primary"
Priority="-1" Priority="-2"
UseOriginalImage="True" UseOriginalImage="True"
AutomationProperties.IsInAccessibleTree="True" AutomationProperties.IsInAccessibleTree="True"
AutomationProperties.Name="{u:I18n Account}" /> AutomationProperties.Name="{u:I18n Account}" />
<ToolbarItem Icon="search.png" Clicked="Search_Clicked" <ToolbarItem IconImageSource="search.png" Clicked="Search_Clicked"
AutomationProperties.IsInAccessibleTree="True" AutomationProperties.IsInAccessibleTree="True"
AutomationProperties.Name="{u:I18n Search}" /> AutomationProperties.Name="{u:I18n Search}" />
</ContentPage.ToolbarItems> </ContentPage.ToolbarItems>
@ -32,6 +28,21 @@
<ContentPage.Resources> <ContentPage.Resources>
<ResourceDictionary> <ResourceDictionary>
<u:InverseBoolConverter x:Key="inverseBool" /> <u:InverseBoolConverter x:Key="inverseBool" />
<controls:SelectionChangedEventArgsConverter x:Key="SelectionChangedEventArgsConverter" />
<ToolbarItem
x:Name="_closeItem"
x:Key="_closeItem"
Text="{u:I18n Close}"
Clicked="CloseItem_Clicked"
Order="Primary"
Priority="-1" />
<ToolbarItem x:Name="_addItem" x:Key="addItem"
IconImageSource="plus.png"
Command="{Binding AddCipherCommand}"
Order="Primary"
AutomationProperties.IsInAccessibleTree="True"
AutomationProperties.Name="{u:I18n AddItem}" />
<DataTemplate x:Key="cipherTemplate" <DataTemplate x:Key="cipherTemplate"
x:DataType="pages:GroupingsPageListItem"> x:DataType="pages:GroupingsPageListItem">
@ -70,23 +81,44 @@
Padding="20, 0" Padding="20, 0"
Spacing="20" Spacing="20"
IsVisible="{Binding ShowNoData}"> IsVisible="{Binding ShowNoData}">
<Image
Source="empty_items_state" />
<Label <Label
Text="{Binding NoDataText}" Text="{Binding NoDataText}"
HorizontalTextAlignment="Center"></Label> HorizontalTextAlignment="Center"></Label>
<Button <Button
Text="{u:I18n AddAnItem}" Text="{u:I18n AddAnItem}"
Clicked="AddButton_Clicked"></Button> Command="{Binding AddCipherCommand}" />
</StackLayout> </StackLayout>
<Frame
IsVisible="{Binding ShowCallout}"
Padding="10"
Margin="20, 10"
HasShadow="False"
BackgroundColor="Transparent"
BorderColor="{DynamicResource PrimaryColor}">
<Label
Text="{u:I18n AddTheKeyToAnExistingOrNewItem}"
StyleClass="text-muted, text-sm, text-bold"
HorizontalTextAlignment="Center" />
</Frame>
<controls:ExtendedCollectionView <controls:ExtendedCollectionView
IsVisible="{Binding ShowList}" IsVisible="{Binding ShowList}"
ItemsSource="{Binding GroupedItems}" ItemsSource="{Binding GroupedItems}"
VerticalOptions="FillAndExpand" VerticalOptions="FillAndExpand"
ItemTemplate="{StaticResource listItemDataTemplateSelector}" ItemTemplate="{StaticResource listItemDataTemplateSelector}"
SelectionMode="Single" SelectionMode="Single"
SelectionChanged="RowSelected"
StyleClass="list, list-platform" StyleClass="list, list-platform"
ExtraDataForLogging="Autofill Ciphers Page" /> ExtraDataForLogging="Autofill Ciphers Page">
<controls:ExtendedCollectionView.Behaviors>
<xct:EventToCommandBehavior
EventName="SelectionChanged"
Command="{Binding SelectCipherCommand}"
EventArgsConverter="{StaticResource SelectionChangedEventArgsConverter}" />
</controls:ExtendedCollectionView.Behaviors>
</controls:ExtendedCollectionView>
</StackLayout> </StackLayout>
</ResourceDictionary> </ResourceDictionary>
</ContentPage.Resources> </ContentPage.Resources>
@ -104,9 +136,10 @@
<!-- Android FAB --> <!-- Android FAB -->
<Button <Button
x:Name="_fab" x:Name="_fab"
Image="plus.png" ImageSource="plus.png"
Clicked="AddButton_Clicked" Command="{Binding AddCipherCommand}"
Style="{StaticResource btn-fab}" Style="{StaticResource btn-fab}"
IsVisible="{OnPlatform iOS=false, Android=true}"
AbsoluteLayout.LayoutFlags="PositionProportional" AbsoluteLayout.LayoutFlags="PositionProportional"
AbsoluteLayout.LayoutBounds="1, 1, AutoSize, AutoSize"> AbsoluteLayout.LayoutBounds="1, 1, AutoSize, AutoSize">
<Button.Effects> <Button.Effects>

View File

@ -1,7 +1,6 @@
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Bit.App.Controls; using Bit.App.Abstractions;
using Bit.App.Models; using Bit.App.Models;
using Bit.App.Utilities; using Bit.App.Utilities;
using Bit.Core.Abstractions; using Bit.Core.Abstractions;
@ -12,27 +11,46 @@ using Xamarin.Forms;
namespace Bit.App.Pages namespace Bit.App.Pages
{ {
public partial class AutofillCiphersPage : BaseContentPage public partial class CipherSelectionPage : BaseContentPage
{ {
private readonly AppOptions _appOptions; private readonly AppOptions _appOptions;
private readonly IBroadcasterService _broadcasterService; private readonly IBroadcasterService _broadcasterService;
private readonly ISyncService _syncService; private readonly ISyncService _syncService;
private readonly IVaultTimeoutService _vaultTimeoutService; private readonly IVaultTimeoutService _vaultTimeoutService;
private readonly IAccountsManager _accountsManager;
private AutofillCiphersPageViewModel _vm; private readonly CipherSelectionPageViewModel _vm;
public AutofillCiphersPage(AppOptions appOptions) public CipherSelectionPage(AppOptions appOptions)
{ {
_appOptions = appOptions; _appOptions = appOptions;
if (appOptions?.OtpData is null)
{
BindingContext = new AutofillCiphersPageViewModel();
}
else
{
BindingContext = new OTPCipherSelectionPageViewModel();
}
InitializeComponent(); InitializeComponent();
if (Device.RuntimePlatform == Device.iOS)
{
ToolbarItems.Add(_closeItem);
ToolbarItems.Add(_addItem);
}
SetActivityIndicator(_mainContent); SetActivityIndicator(_mainContent);
_vm = BindingContext as AutofillCiphersPageViewModel; _vm = BindingContext as CipherSelectionPageViewModel;
_vm.Page = this; _vm.Page = this;
_vm.Init(appOptions); _vm.Init(appOptions);
_broadcasterService = ServiceContainer.Resolve<IBroadcasterService>("broadcasterService"); _broadcasterService = ServiceContainer.Resolve<IBroadcasterService>("broadcasterService");
_syncService = ServiceContainer.Resolve<ISyncService>("syncService"); _syncService = ServiceContainer.Resolve<ISyncService>("syncService");
_vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService"); _vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService");
_accountsManager = ServiceContainer.Resolve<IAccountsManager>();
} }
protected async override void OnAppearing() protected async override void OnAppearing()
@ -51,10 +69,16 @@ namespace Bit.App.Pages
return; return;
} }
_accountAvatar?.OnAppearing(); // TODO: There's currently an issue on iOS where the toolbar item is not getting updated
_vm.AvatarImageSource = await GetAvatarImageSourceAsync(); // as the others somehow. Removing this so at least we get the circle with ".." instead
// of a white circle
if (Device.RuntimePlatform != Device.iOS)
{
_accountAvatar?.OnAppearing();
_vm.AvatarImageSource = await GetAvatarImageSourceAsync();
}
_broadcasterService.Subscribe(nameof(AutofillCiphersPage), async (message) => _broadcasterService.Subscribe(nameof(CipherSelectionPage), async (message) =>
{ {
try try
{ {
@ -116,40 +140,44 @@ namespace Bit.App.Pages
_accountAvatar?.OnDisappearing(); _accountAvatar?.OnDisappearing();
} }
private async void RowSelected(object sender, SelectionChangedEventArgs e) private void AddButton_Clicked(object sender, System.EventArgs e)
{ {
((ExtendedCollectionView)sender).SelectedItem = null;
if (!DoOnce()) if (!DoOnce())
{ {
return; return;
} }
if (e.CurrentSelection?.FirstOrDefault() is GroupingsPageListItem item && item.Cipher != null)
if (_vm is AutofillCiphersPageViewModel autofillVM)
{ {
await _vm.SelectCipherAsync(item.Cipher, item.FuzzyAutofill); AddFromAutofill(autofillVM).FireAndForget();
} }
} }
private async void AddButton_Clicked(object sender, System.EventArgs e) private async Task AddFromAutofill(AutofillCiphersPageViewModel autofillVM)
{ {
if (!DoOnce())
{
return;
}
if (_appOptions.FillType.HasValue && _appOptions.FillType != CipherType.Login) if (_appOptions.FillType.HasValue && _appOptions.FillType != CipherType.Login)
{ {
var pageForOther = new CipherAddEditPage(type: _appOptions.FillType, fromAutofill: true); var pageForOther = new CipherAddEditPage(type: _appOptions.FillType, fromAutofill: true);
await Navigation.PushModalAsync(new NavigationPage(pageForOther)); await Navigation.PushModalAsync(new NavigationPage(pageForOther));
return; return;
} }
var pageForLogin = new CipherAddEditPage(null, CipherType.Login, uri: _vm.Uri, name: _vm.Name, var pageForLogin = new CipherAddEditPage(null, CipherType.Login, uri: autofillVM.Uri, name: _vm.Name,
fromAutofill: true); fromAutofill: true);
await Navigation.PushModalAsync(new NavigationPage(pageForLogin)); await Navigation.PushModalAsync(new NavigationPage(pageForLogin));
} }
private void Search_Clicked(object sender, System.EventArgs e) private void Search_Clicked(object sender, EventArgs e)
{ {
var page = new CiphersPage(null, autofillUrl: _vm.Uri); var page = new CiphersPage(null, appOptions: _appOptions);
Application.Current.MainPage = new NavigationPage(page); Navigation.PushModalAsync(new NavigationPage(page)).FireAndForget();
}
void CloseItem_Clicked(object sender, EventArgs e)
{
if (DoOnce())
{
_accountsManager.StartDefaultNavigationFlowAsync(op => op.OtpData = null).FireAndForget();
}
} }
} }
} }

View File

@ -0,0 +1,167 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using Bit.App.Abstractions;
using Bit.App.Controls;
using Bit.App.Models;
using Bit.App.Resources;
using Bit.App.Utilities;
using Bit.Core;
using Bit.Core.Abstractions;
using Bit.Core.Models.View;
using Bit.Core.Utilities;
using Xamarin.CommunityToolkit.ObjectModel;
using Xamarin.Forms;
namespace Bit.App.Pages
{
public abstract class CipherSelectionPageViewModel : BaseViewModel
{
protected readonly IPlatformUtilsService _platformUtilsService;
protected readonly IDeviceActionService _deviceActionService;
protected readonly IAutofillHandler _autofillHandler;
protected readonly ICipherService _cipherService;
protected readonly IStateService _stateService;
protected readonly IPasswordRepromptService _passwordRepromptService;
protected readonly IMessagingService _messagingService;
protected readonly ILogger _logger;
protected bool _showNoData;
protected bool _showList;
protected string _noDataText;
protected bool _websiteIconsEnabled;
public CipherSelectionPageViewModel()
{
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>();
_cipherService = ServiceContainer.Resolve<ICipherService>();
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>();
_autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
_stateService = ServiceContainer.Resolve<IStateService>();
_passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>();
_messagingService = ServiceContainer.Resolve<IMessagingService>();
_logger = ServiceContainer.Resolve<ILogger>();
GroupedItems = new ObservableRangeCollection<IGroupingsPageListItem>();
CipherOptionsCommand = new AsyncCommand<CipherView>(cipher => AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService),
onException: ex => HandleException(ex),
allowsMultipleExecutions: false);
SelectCipherCommand = new AsyncCommand<IGroupingsPageListItem>(SelectCipherAsync,
onException: ex => HandleException(ex),
allowsMultipleExecutions: false);
AddCipherCommand = new AsyncCommand(AddCipherAsync,
onException: ex => HandleException(ex),
allowsMultipleExecutions: false);
AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger)
{
AllowAddAccountRow = false
};
}
public string Name { get; set; }
public bool LoadedOnce { get; set; }
public ObservableRangeCollection<IGroupingsPageListItem> GroupedItems { get; set; }
public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; }
public ICommand CipherOptionsCommand { get; set; }
public ICommand SelectCipherCommand { get; set; }
public ICommand AddCipherCommand { get; set; }
public bool ShowNoData
{
get => _showNoData;
set => SetProperty(ref _showNoData, value, additionalPropertyNames: new string[] { nameof(ShowCallout) });
}
public bool ShowList
{
get => _showList;
set => SetProperty(ref _showList, value);
}
public string NoDataText
{
get => _noDataText;
set => SetProperty(ref _noDataText, value);
}
public bool WebsiteIconsEnabled
{
get => _websiteIconsEnabled;
set => SetProperty(ref _websiteIconsEnabled, value);
}
public virtual bool ShowCallout => false;
public abstract void Init(Models.AppOptions options);
public async Task LoadAsync()
{
LoadedOnce = true;
ShowList = false;
ShowNoData = false;
WebsiteIconsEnabled = !(await _stateService.GetDisableFaviconAsync()).GetValueOrDefault();
var groupedItems = await LoadGroupedItemsAsync();
// TODO: refactor this
if (Device.RuntimePlatform == Device.Android
||
GroupedItems.Any())
{
var items = new List<IGroupingsPageListItem>();
foreach (var itemGroup in groupedItems)
{
items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount));
items.AddRange(itemGroup);
}
GroupedItems.ReplaceRange(items);
}
else
{
// HACK: we need this on iOS, so that it doesn't crash when adding coming from an empty list
var first = true;
var items = new List<IGroupingsPageListItem>();
foreach (var itemGroup in groupedItems)
{
if (!first)
{
items.Add(new GroupingsPageHeaderListItem(itemGroup.Name, itemGroup.ItemCount));
}
else
{
first = false;
}
items.AddRange(itemGroup);
}
await Device.InvokeOnMainThreadAsync(() =>
{
if (groupedItems.Any())
{
GroupedItems.ReplaceRange(new List<IGroupingsPageListItem> { new GroupingsPageHeaderListItem(groupedItems[0].Name, groupedItems[0].ItemCount) });
GroupedItems.AddRange(items);
}
else
{
GroupedItems.Clear();
}
});
}
await Device.InvokeOnMainThreadAsync(() =>
{
ShowList = groupedItems.Any();
ShowNoData = !ShowList;
});
}
protected abstract Task<List<GroupingsPageListGroup>> LoadGroupedItemsAsync();
protected abstract Task SelectCipherAsync(IGroupingsPageListItem item);
protected abstract Task AddCipherAsync();
}
}

View File

@ -81,12 +81,22 @@
VerticalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand"
HorizontalOptions="CenterAndExpand" HorizontalOptions="CenterAndExpand"
HorizontalTextAlignment="Center" /> HorizontalTextAlignment="Center" />
<Label IsVisible="{Binding ShowNoData}" <StackLayout
Text="{u:I18n NoItemsToList}" HorizontalOptions="Center"
Margin="20, 0" VerticalOptions="StartAndExpand"
VerticalOptions="CenterAndExpand" Margin="20, 80, 20, 0"
HorizontalOptions="CenterAndExpand" Spacing="20"
HorizontalTextAlignment="Center" /> IsVisible="{Binding ShowNoData}">
<Image
Source="empty_items_state" />
<Label
Text="{u:I18n ThereAreNoItemsThatMatchTheSearch}"
HorizontalTextAlignment="Center" />
<Button
Text="{u:I18n AddAnItem}"
Command="{Binding AddCipherCommand}"
IsVisible="{Binding ShowAddCipher}"/>
</StackLayout>
<controls:ExtendedCollectionView <controls:ExtendedCollectionView
IsVisible="{Binding ShowList}" IsVisible="{Binding ShowList}"
ItemsSource="{Binding Ciphers}" ItemsSource="{Binding Ciphers}"

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Linq; using System.Linq;
using Bit.App.Controls; using Bit.App.Controls;
using Bit.App.Models;
using Bit.App.Resources; using Bit.App.Resources;
using Bit.Core.Abstractions; using Bit.Core.Abstractions;
using Bit.Core.Models.View; using Bit.Core.Models.View;
@ -17,15 +18,18 @@ namespace Bit.App.Pages
private CiphersPageViewModel _vm; private CiphersPageViewModel _vm;
private bool _hasFocused; private bool _hasFocused;
public CiphersPage(Func<CipherView, bool> filter, string pageTitle = null, string vaultFilterSelection = null, public CiphersPage(Func<CipherView, bool> filter,
string autofillUrl = null, bool deleted = false) string pageTitle = null,
string vaultFilterSelection = null,
bool deleted = false,
AppOptions appOptions = null)
{ {
InitializeComponent(); InitializeComponent();
_vm = BindingContext as CiphersPageViewModel; _vm = BindingContext as CiphersPageViewModel;
_vm.Page = this; _vm.Page = this;
_vm.Filter = filter; _autofillUrl = appOptions?.Uri;
_vm.AutofillUrl = _autofillUrl = autofillUrl; _vm.Prepare(filter, deleted, appOptions);
_vm.Deleted = deleted;
if (pageTitle != null) if (pageTitle != null)
{ {
_vm.PageTitle = string.Format(AppResources.SearchGroup, pageTitle); _vm.PageTitle = string.Format(AppResources.SearchGroup, pageTitle);

View File

@ -3,13 +3,16 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Input;
using Bit.App.Abstractions; using Bit.App.Abstractions;
using Bit.App.Models;
using Bit.App.Resources; using Bit.App.Resources;
using Bit.Core.Abstractions; using Bit.Core.Abstractions;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.View; using Bit.Core.Models.View;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Xamarin.CommunityToolkit.ObjectModel;
using Xamarin.Forms; using Xamarin.Forms;
namespace Bit.App.Pages namespace Bit.App.Pages
@ -31,6 +34,7 @@ namespace Bit.App.Pages
private bool _showNoData; private bool _showNoData;
private bool _showList; private bool _showList;
private bool _websiteIconsEnabled; private bool _websiteIconsEnabled;
private AppOptions _appOptions;
public CiphersPageViewModel() public CiphersPageViewModel()
{ {
@ -46,14 +50,21 @@ namespace Bit.App.Pages
_logger = ServiceContainer.Resolve<ILogger>("logger"); _logger = ServiceContainer.Resolve<ILogger>("logger");
Ciphers = new ExtendedObservableCollection<CipherView>(); Ciphers = new ExtendedObservableCollection<CipherView>();
CipherOptionsCommand = new Command<CipherView>(CipherOptionsAsync); CipherOptionsCommand = new AsyncCommand<CipherView>(cipher => Utilities.AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService),
onException: ex => HandleException(ex),
allowsMultipleExecutions: false);
AddCipherCommand = new AsyncCommand(AddCipherAsync,
onException: ex => HandleException(ex),
allowsMultipleExecutions: false);
} }
public Command CipherOptionsCommand { get; set; } public ICommand CipherOptionsCommand { get; }
public ICommand AddCipherCommand { get; }
public ExtendedObservableCollection<CipherView> Ciphers { get; set; } public ExtendedObservableCollection<CipherView> Ciphers { get; set; }
public Func<CipherView, bool> Filter { get; set; } public Func<CipherView, bool> Filter { get; set; }
public string AutofillUrl { get; set; } public string AutofillUrl { get; set; }
public bool Deleted { get; set; } public bool Deleted { get; set; }
public bool ShowAllIfSearchTextEmpty { get; set; }
protected override ICipherService cipherService => _cipherService; protected override ICipherService cipherService => _cipherService;
protected override IPolicyService policyService => _policyService; protected override IPolicyService policyService => _policyService;
@ -65,7 +76,8 @@ namespace Bit.App.Pages
get => _showNoData; get => _showNoData;
set => SetProperty(ref _showNoData, value, additionalPropertyNames: new string[] set => SetProperty(ref _showNoData, value, additionalPropertyNames: new string[]
{ {
nameof(ShowSearchDirection) nameof(ShowSearchDirection),
nameof(ShowAddCipher)
}); });
} }
@ -80,12 +92,23 @@ namespace Bit.App.Pages
public bool ShowSearchDirection => !ShowList && !ShowNoData; public bool ShowSearchDirection => !ShowList && !ShowNoData;
public bool ShowAddCipher => ShowNoData && _appOptions?.OtpData != null;
public bool WebsiteIconsEnabled public bool WebsiteIconsEnabled
{ {
get => _websiteIconsEnabled; get => _websiteIconsEnabled;
set => SetProperty(ref _websiteIconsEnabled, value); set => SetProperty(ref _websiteIconsEnabled, value);
} }
internal void Prepare(Func<CipherView, bool> filter, bool deleted, AppOptions appOptions)
{
Filter = filter;
AutofillUrl = appOptions?.Uri;
Deleted = deleted;
ShowAllIfSearchTextEmpty = appOptions?.OtpData != null;
_appOptions = appOptions;
}
public async Task InitAsync() public async Task InitAsync()
{ {
await InitVaultFilterAsync(true); await InitVaultFilterAsync(true);
@ -101,25 +124,33 @@ namespace Bit.App.Pages
{ {
List<CipherView> ciphers = null; List<CipherView> ciphers = null;
var searchable = !string.IsNullOrWhiteSpace(searchText) && searchText.Length > 1; var searchable = !string.IsNullOrWhiteSpace(searchText) && searchText.Length > 1;
if (searchable) var shouldShowAllWhenEmpty = ShowAllIfSearchTextEmpty && string.IsNullOrEmpty(searchText);
if (searchable || shouldShowAllWhenEmpty)
{ {
if (timeout != null) if (timeout != null)
{ {
await Task.Delay(timeout.Value); await Task.Delay(timeout.Value);
} }
if (searchText != (Page as CiphersPage).SearchBar.Text) if (searchText != (Page as CiphersPage).SearchBar.Text
&&
!shouldShowAllWhenEmpty)
{ {
return; return;
} }
else
{ previousCts?.Cancel();
previousCts?.Cancel();
}
try try
{ {
var vaultFilteredCiphers = await GetAllCiphersAsync(); var vaultFilteredCiphers = await GetAllCiphersAsync();
ciphers = await _searchService.SearchCiphersAsync(searchText, if (!shouldShowAllWhenEmpty)
Filter ?? (c => c.IsDeleted == Deleted), vaultFilteredCiphers, cts.Token); {
ciphers = await _searchService.SearchCiphersAsync(searchText,
Filter ?? (c => c.IsDeleted == Deleted), vaultFilteredCiphers, cts.Token);
}
else
{
ciphers = vaultFilteredCiphers;
}
cts.Token.ThrowIfCancellationRequested(); cts.Token.ThrowIfCancellationRequested();
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@ -134,8 +165,8 @@ namespace Bit.App.Pages
Device.BeginInvokeOnMainThread(() => Device.BeginInvokeOnMainThread(() =>
{ {
Ciphers.ResetWithRange(ciphers); Ciphers.ResetWithRange(ciphers);
ShowNoData = searchable && Ciphers.Count == 0; ShowNoData = !shouldShowAllWhenEmpty && searchable && Ciphers.Count == 0;
ShowList = searchable && !ShowNoData; ShowList = (searchable || shouldShowAllWhenEmpty) && !ShowNoData;
}); });
}, cts.Token); }, cts.Token);
_searchCancellationTokenSource = cts; _searchCancellationTokenSource = cts;
@ -144,6 +175,7 @@ namespace Bit.App.Pages
public async Task SelectCipherAsync(CipherView cipher) public async Task SelectCipherAsync(CipherView cipher)
{ {
string selection = null; string selection = null;
if (!string.IsNullOrWhiteSpace(AutofillUrl)) if (!string.IsNullOrWhiteSpace(AutofillUrl))
{ {
var options = new List<string> { AppResources.Autofill }; var options = new List<string> { AppResources.Autofill };
@ -156,6 +188,19 @@ namespace Bit.App.Pages
selection = await Page.DisplayActionSheet(AppResources.AutofillOrView, AppResources.Cancel, null, selection = await Page.DisplayActionSheet(AppResources.AutofillOrView, AppResources.Cancel, null,
options.ToArray()); options.ToArray());
} }
if (_appOptions?.OtpData != null)
{
if (cipher.Reprompt != CipherRepromptType.None && !await _passwordRepromptService.ShowPasswordPromptAsync())
{
return;
}
var editCipherPage = new CipherAddEditPage(cipher.Id, appOptions: _appOptions);
await Page.Navigation.PushModalAsync(new NavigationPage(editCipherPage));
return;
}
if (selection == AppResources.View || string.IsNullOrWhiteSpace(AutofillUrl)) if (selection == AppResources.View || string.IsNullOrWhiteSpace(AutofillUrl))
{ {
var page = new CipherDetailsPage(cipher.Id); var page = new CipherDetailsPage(cipher.Id);
@ -205,7 +250,7 @@ namespace Bit.App.Pages
private void PerformSearchIfPopulated() private void PerformSearchIfPopulated()
{ {
if (!string.IsNullOrWhiteSpace((Page as CiphersPage).SearchBar.Text)) if (!string.IsNullOrWhiteSpace((Page as CiphersPage).SearchBar.Text) || ShowAllIfSearchTextEmpty)
{ {
Search((Page as CiphersPage).SearchBar.Text, 200); Search((Page as CiphersPage).SearchBar.Text, 200);
} }
@ -216,12 +261,10 @@ namespace Bit.App.Pages
PerformSearchIfPopulated(); PerformSearchIfPopulated();
} }
private async void CipherOptionsAsync(CipherView cipher) private async Task AddCipherAsync()
{ {
if ((Page as BaseContentPage).DoOnce()) var pageForLogin = new CipherAddEditPage(null, CipherType.Login, name: _appOptions?.OtpData?.Issuer ?? _appOptions?.OtpData?.AccountName, appOptions: _appOptions);
{ await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin));
await Utilities.AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService);
}
} }
} }
} }

View File

@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Bit.App.Resources;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Xamarin.Forms;
namespace Bit.App.Pages
{
public class OTPCipherSelectionPageViewModel : CipherSelectionPageViewModel
{
private readonly ISearchService _searchService = ServiceContainer.Resolve<ISearchService>();
private OtpData _otpData;
private Models.AppOptions _appOptions;
public override bool ShowCallout => !ShowNoData;
public override void Init(Models.AppOptions options)
{
_appOptions = options;
_otpData = options.OtpData.Value;
Name = _otpData.Issuer ?? _otpData.AccountName;
PageTitle = string.Format(AppResources.ItemsForUri, Name ?? "--");
NoDataText = string.Format(AppResources.ThereAreNoItemsInYourVaultThatMatchX, Name ?? "--")
+ Environment.NewLine
+ AppResources.SearchForAnItemOrAddANewItem;
}
protected override async Task<List<GroupingsPageListGroup>> LoadGroupedItemsAsync()
{
var groupedItems = new List<GroupingsPageListGroup>();
var allCiphers = await _cipherService.GetAllDecryptedAsync();
var ciphers = await _searchService.SearchCiphersAsync(_otpData.Issuer ?? _otpData.AccountName,
c => c.Type == CipherType.Login && !c.IsDeleted, allCiphers);
if (ciphers?.Any() ?? false)
{
groupedItems.Add(
new GroupingsPageListGroup(ciphers.Select(c => new GroupingsPageListItem { Cipher = c }).ToList(),
AppResources.MatchingItems,
ciphers.Count,
false,
true));
}
return groupedItems;
}
protected override async Task SelectCipherAsync(IGroupingsPageListItem item)
{
if (!(item is GroupingsPageListItem listItem) || listItem.Cipher is null)
{
return;
}
var cipher = listItem.Cipher;
if (cipher.Reprompt != CipherRepromptType.None && !await _passwordRepromptService.ShowPasswordPromptAsync())
{
return;
}
var editCipherPage = new CipherAddEditPage(cipher.Id, appOptions: _appOptions);
await Page.Navigation.PushModalAsync(new NavigationPage(editCipherPage));
return;
}
protected override async Task AddCipherAsync()
{
var pageForLogin = new CipherAddEditPage(null, CipherType.Login, name: Name, appOptions: _appOptions);
await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin));
}
}
}

View File

@ -1,7 +1,6 @@
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// <auto-generated> // <auto-generated>
// This code was generated by a tool. // This code was generated by a tool.
// Runtime Version:4.0.30319.42000
// //
// Changes to this file may cause incorrect behavior and will be lost if // Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated. // the code is regenerated.
@ -14,12 +13,10 @@ namespace Bit.App.Resources {
/// <summary> /// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc. /// A strongly-typed resource class, for looking up localized strings, etc.
/// This class was generated by MSBuild using the GenerateResource task.
/// To add or remove a member, edit your .resx file then rerun MSBuild.
/// </summary> /// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Build.Tasks.StronglyTypedResourceBuilder", "15.1.0.0")]
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class AppResources { public class AppResources {
@ -385,6 +382,15 @@ namespace Bit.App.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Add the key to an existing or new item.
/// </summary>
public static string AddTheKeyToAnExistingOrNewItem {
get {
return ResourceManager.GetString("AddTheKeyToAnExistingOrNewItem", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Add TOTP. /// Looks up a localized string similar to Add TOTP.
/// </summary> /// </summary>
@ -5339,6 +5345,15 @@ namespace Bit.App.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Search for an item or add a new item.
/// </summary>
public static string SearchForAnItemOrAddANewItem {
get {
return ResourceManager.GetString("SearchForAnItemOrAddANewItem", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Search {0}. /// Looks up a localized string similar to Search {0}.
/// </summary> /// </summary>
@ -6032,6 +6047,24 @@ namespace Bit.App.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to There are no items in your vault that match &quot;{0}&quot;.
/// </summary>
public static string ThereAreNoItemsInYourVaultThatMatchX {
get {
return ResourceManager.GetString("ThereAreNoItemsInYourVaultThatMatchX", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are no items that match the search.
/// </summary>
public static string ThereAreNoItemsThatMatchTheSearch {
get {
return ResourceManager.GetString("ThereAreNoItemsThatMatchTheSearch", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to 30 days. /// Looks up a localized string similar to 30 days.
/// </summary> /// </summary>

View File

@ -2592,4 +2592,16 @@ Do you want to switch to this account?</value>
<data name="OrganizationSsoIdentifierRequired" xml:space="preserve"> <data name="OrganizationSsoIdentifierRequired" xml:space="preserve">
<value>Organization SSO identifier required.</value> <value>Organization SSO identifier required.</value>
</data> </data>
<data name="AddTheKeyToAnExistingOrNewItem" xml:space="preserve">
<value>Add the key to an existing or new item</value>
</data>
<data name="ThereAreNoItemsInYourVaultThatMatchX" xml:space="preserve">
<value>There are no items in your vault that match "{0}"</value>
</data>
<data name="SearchForAnItemOrAddANewItem" xml:space="preserve">
<value>Search for an item or add a new item</value>
</data>
<data name="ThereAreNoItemsThatMatchTheSearch" xml:space="preserve">
<value>There are no items that match the search</value>
</data>
</root> </root>

View File

@ -0,0 +1,30 @@
using System;
using Bit.App.Abstractions;
using Bit.Core;
using Bit.Core.Abstractions;
namespace Bit.App.Services
{
public class DeepLinkContext : IDeepLinkContext
{
public const string NEW_OTP_MESSAGE = "handleOTPUriMessage";
private readonly IMessagingService _messagingService;
public DeepLinkContext(IMessagingService messagingService)
{
_messagingService = messagingService;
}
public bool OnNewUri(Uri uri)
{
if (uri.Scheme == Constants.OtpAuthScheme)
{
_messagingService.Send(NEW_OTP_MESSAGE, uri.AbsoluteUri);
return true;
}
return false;
}
}
}

View File

@ -7,7 +7,6 @@ using Bit.Core.Abstractions;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Domain; using Bit.Core.Models.Domain;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Xamarin.Essentials;
using Xamarin.Forms; using Xamarin.Forms;
namespace Bit.App.Utilities.AccountManagement namespace Bit.App.Utilities.AccountManagement
@ -58,6 +57,13 @@ namespace Bit.App.Utilities.AccountManagement
_broadcasterService.Subscribe(nameof(AccountsManager), OnMessage); _broadcasterService.Subscribe(nameof(AccountsManager), OnMessage);
} }
public async Task StartDefaultNavigationFlowAsync(Action<AppOptions> appOptionsAction)
{
appOptionsAction(Options);
await NavigateOnAccountChangeAsync();
}
public async Task NavigateOnAccountChangeAsync(bool? isAuthed = null) public async Task NavigateOnAccountChangeAsync(bool? isAuthed = null)
{ {
// TODO: this could be improved by doing chain of responsability pattern // TODO: this could be improved by doing chain of responsability pattern
@ -89,6 +95,10 @@ namespace Bit.App.Utilities.AccountManagement
{ {
_accountsManagerHost.Navigate(NavigationTarget.AutofillCiphers); _accountsManagerHost.Navigate(NavigationTarget.AutofillCiphers);
} }
else if (Options.OtpData != null)
{
_accountsManagerHost.Navigate(NavigationTarget.OtpCipherSelection);
}
else if (Options.CreateSend != null) else if (Options.CreateSend != null)
{ {
_accountsManagerHost.Navigate(NavigationTarget.SendAddEdit); _accountsManagerHost.Navigate(NavigationTarget.SendAddEdit);

View File

@ -430,9 +430,11 @@ namespace Bit.App.Utilities
Application.Current.MainPage = new NavigationPage(new CipherAddEditPage(appOptions: appOptions)); Application.Current.MainPage = new NavigationPage(new CipherAddEditPage(appOptions: appOptions));
return true; return true;
} }
if (appOptions.Uri != null) if (appOptions.Uri != null
||
appOptions.OtpData != null)
{ {
Application.Current.MainPage = new NavigationPage(new AutofillCiphersPage(appOptions)); Application.Current.MainPage = new NavigationPage(new CipherSelectionPage(appOptions));
return true; return true;
} }
if (appOptions.CreateSend != null) if (appOptions.CreateSend != null)

View File

@ -1,4 +1,6 @@
using Bit.App.Lists.ItemViewModels.CustomFields; using Bit.App.Abstractions;
using Bit.App.Lists.ItemViewModels.CustomFields;
using Bit.App.Services;
using Bit.Core.Abstractions; using Bit.Core.Abstractions;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -18,6 +20,7 @@ namespace Bit.App.Utilities
// TODO: This could be further improved by Lazy Registration since it may not be needed at all // TODO: This could be further improved by Lazy Registration since it may not be needed at all
ServiceContainer.Register<ICustomFieldItemFactory>("customFieldItemFactory", new CustomFieldItemFactory(i18nService, eventService)); ServiceContainer.Register<ICustomFieldItemFactory>("customFieldItemFactory", new CustomFieldItemFactory(i18nService, eventService));
ServiceContainer.Register<IDeepLinkContext>(new DeepLinkContext(ServiceContainer.Resolve<IMessagingService>()));
} }
} }
} }

View File

@ -46,6 +46,7 @@
/// which is used to handle Apple Watch state logic /// which is used to handle Apple Watch state logic
/// </summary> /// </summary>
public const string LastUserShouldConnectToWatchKey = "lastUserShouldConnectToWatch"; public const string LastUserShouldConnectToWatchKey = "lastUserShouldConnectToWatch";
public const string OtpAuthScheme = "otpauth";
public const string AppLocaleKey = "appLocale"; public const string AppLocaleKey = "appLocale";
public const string ClearSensitiveFields = "clearSensitiveFields"; public const string ClearSensitiveFields = "clearSensitiveFields";
public const int SelectFileRequestCode = 42; public const int SelectFileRequestCode = 42;

View File

@ -8,6 +8,7 @@
Home, Home,
AddEditCipher, AddEditCipher,
AutofillCiphers, AutofillCiphers,
SendAddEdit SendAddEdit,
OtpCipherSelection
} }
} }

View File

@ -33,39 +33,22 @@ namespace Bit.Core.Services
var isSteamAuth = key?.ToLowerInvariant().StartsWith("steam://") ?? false; var isSteamAuth = key?.ToLowerInvariant().StartsWith("steam://") ?? false;
if (isOtpAuth) if (isOtpAuth)
{ {
var qsParams = CoreHelpers.GetQueryParams(key); var otpData = new OtpData(key.ToLowerInvariant());
if (qsParams.ContainsKey("digits") && qsParams["digits"] != null && if (otpData.Digits > 0)
int.TryParse(qsParams["digits"].Trim(), out var digitParam))
{ {
if (digitParam > 10) digits = Math.Min(otpData.Digits.Value, 10);
{
digits = 10;
}
else if (digitParam > 0)
{
digits = digitParam;
}
} }
if (qsParams.ContainsKey("period") && qsParams["period"] != null && if (otpData.Period.HasValue)
int.TryParse(qsParams["period"].Trim(), out var periodParam) && periodParam > 0)
{ {
period = periodParam; period = otpData.Period.Value;
} }
if (qsParams.ContainsKey("secret") && qsParams["secret"] != null) if (otpData.Secret != null)
{ {
keyB32 = qsParams["secret"]; keyB32 = otpData.Secret;
} }
if (qsParams.ContainsKey("algorithm") && qsParams["algorithm"] != null) if (otpData.Algorithm.HasValue)
{ {
var algParam = qsParams["algorithm"].ToLowerInvariant(); alg = otpData.Algorithm.Value;
if (algParam == "sha256")
{
alg = CryptoHashAlgorithm.Sha256;
}
else if (algParam == "sha512")
{
alg = CryptoHashAlgorithm.Sha512;
}
} }
} }
else if (isSteamAuth) else if (isSteamAuth)

View File

@ -168,10 +168,27 @@ namespace Bit.Core.Utilities
{ {
try try
{ {
if (!Uri.TryCreate(urlString, UriKind.Absolute, out var uri) || string.IsNullOrWhiteSpace(uri.Query)) if (Uri.TryCreate(urlString, UriKind.Absolute, out var uri))
{
return GetQueryParams(uri);
}
}
catch (Exception ex)
{
LoggerHelper.LogEvenIfCantBeResolved(ex);
}
return new Dictionary<string, string>();
}
public static Dictionary<string, string> GetQueryParams(Uri uri)
{
try
{
if (string.IsNullOrWhiteSpace(uri.Query))
{ {
return new Dictionary<string, string>(); return new Dictionary<string, string>();
} }
var queryStringNameValueCollection = HttpUtility.ParseQueryString(uri.Query); var queryStringNameValueCollection = HttpUtility.ParseQueryString(uri.Query);
return queryStringNameValueCollection.AllKeys.Where(k => k != null).ToDictionary(k => k, k => queryStringNameValueCollection[k]); return queryStringNameValueCollection.AllKeys.Where(k => k != null).ToDictionary(k => k, k => queryStringNameValueCollection[k]);
} }

View File

@ -0,0 +1,94 @@
using System;
using System.Linq;
using Bit.Core.Enums;
namespace Bit.Core.Utilities
{
public struct OtpData
{
const string LABEL_SEPARATOR = ":";
public OtpData(string absoluteUri)
{
if (!System.Uri.TryCreate(absoluteUri, UriKind.Absolute, out var uri)
||
uri.Scheme != Constants.OtpAuthScheme)
{
throw new InvalidOperationException("Cannot create OtpData. Invalid OTP uri");
}
Uri = absoluteUri;
AccountName = null;
Issuer = null;
Secret = null;
Digits = null;
Period = null;
Algorithm = null;
var escapedlabel = uri.Segments.Last();
if (escapedlabel != "/")
{
var label = UriExtensions.UnescapeDataString(escapedlabel);
if (label.Contains(LABEL_SEPARATOR))
{
var parts = label.Split(LABEL_SEPARATOR);
AccountName = parts[0].Trim();
Issuer = parts[1].Trim();
}
else
{
AccountName = label.Trim();
}
}
var qsParams = CoreHelpers.GetQueryParams(uri);
if (Issuer is null && qsParams.TryGetValue("issuer", out var issuer))
{
Issuer = issuer;
}
if (qsParams.TryGetValue("secret", out var secret))
{
Secret = secret;
}
if (qsParams.TryGetValue("digits", out var digitParam)
&&
int.TryParse(digitParam?.Trim(), out var digits))
{
Digits = digits;
}
if (qsParams.TryGetValue("period", out var periodParam)
&&
int.TryParse(periodParam?.Trim(), out var period)
&&
period > 0)
{
Period = period;
}
if (qsParams.TryGetValue("algorithm", out var algParam)
&&
algParam?.ToLower() is string alg)
{
if (alg == "sha256")
{
Algorithm = CryptoHashAlgorithm.Sha256;
}
else if (alg == "sha512")
{
Algorithm = CryptoHashAlgorithm.Sha512;
}
}
}
public string Uri { get; }
public string AccountName { get; }
public string Issuer { get; }
public string Secret { get; }
public int? Digits { get; }
public int? Period { get; }
public CryptoHashAlgorithm? Algorithm { get; }
}
}

View File

@ -0,0 +1,18 @@
using System;
namespace Bit.Core.Utilities
{
public static class UriExtensions
{
public static string UnescapeDataString(string uriString)
{
string unescapedUri;
while ((unescapedUri = System.Uri.UnescapeDataString(uriString)) != uriString)
{
uriString = unescapedUri;
}
return unescapedUri;
}
}
}

View File

@ -43,6 +43,8 @@ namespace Bit.iOS
private IStateService _stateService; private IStateService _stateService;
private IEventService _eventService; private IEventService _eventService;
private LazyResolve<IDeepLinkContext> _deepLinkContext = new LazyResolve<IDeepLinkContext>();
public override bool FinishedLaunching(UIApplication app, NSDictionary options) public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{ {
Forms.Init(); Forms.Init();
@ -239,7 +241,7 @@ namespace Bit.iOS
public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options) public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options)
{ {
return Xamarin.Essentials.Platform.OpenUrl(app, url, options); return _deepLinkContext.Value.OnNewUri(url) || Xamarin.Essentials.Platform.OpenUrl(app, url, options);
} }
public override bool ContinueUserActivity(UIApplication application, NSUserActivity userActivity, public override bool ContinueUserActivity(UIApplication application, NSUserActivity userActivity,

View File

@ -29,6 +29,14 @@
<key>CFBundleURLName</key> <key>CFBundleURLName</key>
<string>com.8bit.bitwarden.url</string> <string>com.8bit.bitwarden.url</string>
</dict> </dict>
<dict>
<key>CFBundleURLName</key>
<string>com.8bit.bitwarden</string>
<key>CFBundleURLSchemes</key>
<array>
<string>otpauth</string>
</array>
</dict>
</array> </array>
<key>CFBundleLocalizations</key> <key>CFBundleLocalizations</key>
<array> <array>

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,25 @@
{
"images" : [
{
"filename" : "Empty-items-state.pdf",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Empty-items-state-dark.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -1,528 +1,608 @@
{ {
"images": [ "images" : [
{ {
"filename": "ic_warning-1.pdf", "filename" : "ic_warning-1.pdf",
"idiom": "universal" "idiom" : "universal"
}, },
{ {
"scale": "1x", "appearances" : [
"idiom": "universal"
},
{
"scale": "2x",
"idiom": "universal"
},
{
"scale": "3x",
"idiom": "universal"
},
{
"idiom": "iphone"
},
{
"scale": "1x",
"idiom": "iphone"
},
{
"scale": "2x",
"idiom": "iphone"
},
{
"subtype": "retina4",
"scale": "2x",
"idiom": "iphone"
},
{
"scale": "3x",
"idiom": "iphone"
},
{
"idiom": "ipad"
},
{
"scale": "1x",
"idiom": "ipad"
},
{
"scale": "2x",
"idiom": "ipad"
},
{
"idiom": "watch"
},
{
"scale": "2x",
"idiom": "watch"
},
{
"screenWidth": "{130,145}",
"scale": "2x",
"idiom": "watch"
},
{
"screenWidth": "{146,165}",
"scale": "2x",
"idiom": "watch"
},
{
"idiom": "mac"
},
{
"scale": "1x",
"idiom": "mac"
},
{
"scale": "2x",
"idiom": "mac"
},
{
"idiom": "car"
},
{
"scale": "2x",
"idiom": "car"
},
{
"scale": "3x",
"idiom": "car"
},
{
"appearances": [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "light"
} }
], ],
"idiom": "universal" "idiom" : "universal"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "dark"
} }
], ],
"scale": "1x", "idiom" : "universal"
"idiom": "universal"
}, },
{ {
"appearances": [ "idiom" : "universal",
{ "scale" : "1x"
"appearance": "luminosity",
"value": "dark"
}
],
"scale": "2x",
"idiom": "universal"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "light"
} }
], ],
"scale": "3x", "idiom" : "universal",
"idiom": "universal" "scale" : "1x"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "dark"
} }
], ],
"idiom": "iphone" "idiom" : "universal",
"scale" : "1x"
}, },
{ {
"appearances": [ "idiom" : "universal",
{ "scale" : "2x"
"appearance": "luminosity",
"value": "dark"
}
],
"scale": "1x",
"idiom": "iphone"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "light"
} }
], ],
"scale": "2x", "idiom" : "universal",
"idiom": "iphone" "scale" : "2x"
}, },
{ {
"subtype": "retina4", "appearances" : [
"appearances": [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "dark"
} }
], ],
"scale": "2x", "idiom" : "universal",
"idiom": "iphone" "scale" : "2x"
}, },
{ {
"appearances": [ "idiom" : "universal",
{ "scale" : "3x"
"appearance": "luminosity",
"value": "dark"
}
],
"scale": "3x",
"idiom": "iphone"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "light"
} }
], ],
"idiom": "ipad" "idiom" : "universal",
"scale" : "3x"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "dark"
} }
], ],
"scale": "1x", "idiom" : "universal",
"idiom": "ipad" "scale" : "3x"
}, },
{ {
"appearances": [ "idiom" : "iphone"
{
"appearance": "luminosity",
"value": "dark"
}
],
"scale": "2x",
"idiom": "ipad"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "light"
} }
], ],
"idiom": "watch" "idiom" : "iphone"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "dark"
} }
], ],
"scale": "2x", "idiom" : "iphone"
"idiom": "watch"
}, },
{ {
"screenWidth": "{130,145}", "idiom" : "iphone",
"appearances": [ "scale" : "1x"
{
"appearance": "luminosity",
"value": "dark"
}
],
"scale": "2x",
"idiom": "watch"
}, },
{ {
"screenWidth": "{146,165}", "appearances" : [
"appearances": [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "light"
} }
], ],
"scale": "2x", "idiom" : "iphone",
"idiom": "watch" "scale" : "1x"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "dark"
} }
], ],
"idiom": "mac" "idiom" : "iphone",
"scale" : "1x"
}, },
{ {
"appearances": [ "idiom" : "iphone",
{ "scale" : "2x"
"appearance": "luminosity",
"value": "dark"
}
],
"scale": "1x",
"idiom": "mac"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "light"
} }
], ],
"scale": "2x", "idiom" : "iphone",
"idiom": "mac" "scale" : "2x"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "dark"
} }
], ],
"idiom": "car" "idiom" : "iphone",
"scale" : "2x"
}, },
{ {
"appearances": [ "idiom" : "iphone",
{ "scale" : "3x"
"appearance": "luminosity",
"value": "dark"
}
],
"scale": "2x",
"idiom": "car"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "dark" "value" : "light"
} }
], ],
"scale": "3x", "idiom" : "iphone",
"idiom": "car" "scale" : "3x"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "dark"
} }
], ],
"idiom": "universal" "idiom" : "iphone",
"scale" : "3x"
}, },
{ {
"appearances": [ "idiom" : "iphone",
{ "scale" : "1x",
"appearance": "luminosity", "subtype" : "retina4"
"value": "light"
}
],
"scale": "1x",
"idiom": "universal"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "light"
} }
], ],
"scale": "2x", "idiom" : "iphone",
"idiom": "universal" "scale" : "1x",
"subtype" : "retina4"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "dark"
} }
], ],
"scale": "3x", "idiom" : "iphone",
"idiom": "universal" "scale" : "1x",
"subtype" : "retina4"
}, },
{ {
"appearances": [ "idiom" : "iphone",
{ "scale" : "2x",
"appearance": "luminosity", "subtype" : "retina4"
"value": "light"
}
],
"idiom": "iphone"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "light"
} }
], ],
"scale": "1x", "idiom" : "iphone",
"idiom": "iphone" "scale" : "2x",
"subtype" : "retina4"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "dark"
} }
], ],
"scale": "2x", "idiom" : "iphone",
"idiom": "iphone" "scale" : "2x",
"subtype" : "retina4"
}, },
{ {
"subtype": "retina4", "idiom" : "iphone",
"appearances": [ "scale" : "3x",
{ "subtype" : "retina4"
"appearance": "luminosity",
"value": "light"
}
],
"scale": "2x",
"idiom": "iphone"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "light"
} }
], ],
"scale": "3x", "idiom" : "iphone",
"idiom": "iphone" "scale" : "3x",
"subtype" : "retina4"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "dark"
} }
], ],
"idiom": "ipad" "idiom" : "iphone",
"scale" : "3x",
"subtype" : "retina4"
}, },
{ {
"appearances": [ "idiom" : "ipad"
{
"appearance": "luminosity",
"value": "light"
}
],
"scale": "1x",
"idiom": "ipad"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "light"
} }
], ],
"scale": "2x", "idiom" : "ipad"
"idiom": "ipad"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "dark"
} }
], ],
"idiom": "watch" "idiom" : "ipad"
}, },
{ {
"appearances": [ "idiom" : "ipad",
{ "scale" : "1x"
"appearance": "luminosity",
"value": "light"
}
],
"scale": "2x",
"idiom": "watch"
}, },
{ {
"screenWidth": "{130,145}", "appearances" : [
"appearances": [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "light"
} }
], ],
"scale": "2x", "idiom" : "ipad",
"idiom": "watch" "scale" : "1x"
}, },
{ {
"screenWidth": "{146,165}", "appearances" : [
"appearances": [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "dark"
} }
], ],
"scale": "2x", "idiom" : "ipad",
"idiom": "watch" "scale" : "1x"
}, },
{ {
"appearances": [ "idiom" : "ipad",
{ "scale" : "2x"
"appearance": "luminosity",
"value": "light"
}
],
"idiom": "mac"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "light"
} }
], ],
"scale": "1x", "idiom" : "ipad",
"idiom": "mac" "scale" : "2x"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "dark"
} }
], ],
"scale": "2x", "idiom" : "ipad",
"idiom": "mac" "scale" : "2x"
}, },
{ {
"appearances": [ "idiom" : "car"
{
"appearance": "luminosity",
"value": "light"
}
],
"idiom": "car"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "light"
} }
], ],
"scale": "2x", "idiom" : "car"
"idiom": "car"
}, },
{ {
"appearances": [ "appearances" : [
{ {
"appearance": "luminosity", "appearance" : "luminosity",
"value": "light" "value" : "dark"
} }
], ],
"scale": "3x", "idiom" : "car"
"idiom": "car" },
{
"idiom" : "car",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"idiom" : "car",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "car",
"scale" : "2x"
},
{
"idiom" : "car",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"idiom" : "car",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "car",
"scale" : "3x"
},
{
"idiom" : "mac"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"idiom" : "mac"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "mac"
},
{
"idiom" : "mac",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"idiom" : "mac",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "mac",
"scale" : "1x"
},
{
"idiom" : "mac",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"idiom" : "mac",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "mac",
"scale" : "2x"
},
{
"idiom" : "watch"
},
{
"idiom" : "watch",
"screen-width" : "<=145"
},
{
"idiom" : "watch",
"screen-width" : ">161"
},
{
"idiom" : "watch",
"screen-width" : ">145"
},
{
"idiom" : "watch",
"screen-width" : ">183"
},
{
"idiom" : "watch",
"scale" : "2x"
},
{
"idiom" : "watch",
"scale" : "2x",
"screen-width" : "<=145"
},
{
"idiom" : "watch",
"scale" : "2x",
"screen-width" : ">161"
},
{
"idiom" : "watch",
"scale" : "2x",
"screen-width" : ">145"
},
{
"idiom" : "watch",
"scale" : "2x",
"screen-width" : ">183"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"idiom" : "watch"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "watch"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"idiom" : "watch",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "watch",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"idiom" : "watch",
"scale" : "2x",
"screen-width" : "<=145"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "watch",
"scale" : "2x",
"screen-width" : "<=145"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"idiom" : "watch",
"scale" : "2x",
"screen-width" : ">145"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "watch",
"scale" : "2x",
"screen-width" : ">145"
} }
], ],
"info": { "info" : {
"version": 1, "author" : "xcode",
"author": "xcode" "version" : 1
} }
} }

View File

@ -179,6 +179,10 @@
<BundleResource Include="Resources\generate.png" /> <BundleResource Include="Resources\generate.png" />
<BundleResource Include="Resources\generate%402x.png" /> <BundleResource Include="Resources\generate%402x.png" />
<BundleResource Include="Resources\generate%403x.png" /> <BundleResource Include="Resources\generate%403x.png" />
<ImageAsset Include="Resources\Assets.xcassets\Contents.json" />
<ImageAsset Include="Resources\Assets.xcassets\empty_items_state.imageset\Empty-items-state-dark.pdf" />
<ImageAsset Include="Resources\Assets.xcassets\empty_items_state.imageset\Empty-items-state.pdf" />
<ImageAsset Include="Resources\Assets.xcassets\empty_items_state.imageset\Contents.json" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<InterfaceDefinition Include="LaunchScreen.storyboard" /> <InterfaceDefinition Include="LaunchScreen.storyboard" />