mirror of
https://github.com/bitwarden/mobile.git
synced 2025-02-12 00:31:25 +01:00
[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:
parent
4d2b53c809
commit
a18f74a72a
@ -170,7 +170,7 @@ namespace Bit.Droid
|
||||
{
|
||||
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)
|
||||
{
|
||||
_appOptions.Uri = uri;
|
||||
@ -178,7 +178,7 @@ namespace Bit.Droid
|
||||
}
|
||||
else if (intent.GetBooleanExtra("generatorTile", false))
|
||||
{
|
||||
_messagingService.Send("popAllAndGoToTabGenerator");
|
||||
_messagingService.Send(App.App.POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE);
|
||||
if (_appOptions != null)
|
||||
{
|
||||
_appOptions.GeneratorTile = true;
|
||||
@ -186,7 +186,7 @@ namespace Bit.Droid
|
||||
}
|
||||
else if (intent.GetBooleanExtra("myVaultTile", false))
|
||||
{
|
||||
_messagingService.Send("popAllAndGoToTabMyVault");
|
||||
_messagingService.Send(App.App.POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE);
|
||||
if (_appOptions != null)
|
||||
{
|
||||
_appOptions.MyVaultTile = true;
|
||||
@ -198,7 +198,7 @@ namespace Bit.Droid
|
||||
{
|
||||
_appOptions.CreateSend = GetCreateSendRequest(intent);
|
||||
}
|
||||
_messagingService.Send("popAllAndGoToTabSend");
|
||||
_messagingService.Send(App.App.POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -8,6 +8,7 @@ namespace Bit.App.Abstractions
|
||||
{
|
||||
void Init(Func<AppOptions> getOptionsFunc, IAccountsManagerHost accountsManagerHost);
|
||||
Task NavigateOnAccountChangeAsync(bool? isAuthed = null);
|
||||
Task StartDefaultNavigationFlowAsync(Action<AppOptions> appOptionsAction);
|
||||
Task LogOutAsync(string userId, bool userInitiated, bool expired);
|
||||
Task PromptToSwitchToExistingAccountAsync(string userId);
|
||||
}
|
||||
|
9
src/App/Abstractions/IDeepLinkContext.cs
Normal file
9
src/App/Abstractions/IDeepLinkContext.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace Bit.App.Abstractions
|
||||
{
|
||||
public interface IDeepLinkContext
|
||||
{
|
||||
bool OnNewUri(Uri uri);
|
||||
}
|
||||
}
|
@ -23,6 +23,11 @@ namespace Bit.App
|
||||
{
|
||||
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 IMessagingService _messagingService;
|
||||
private readonly IStateService _stateService;
|
||||
@ -103,12 +108,18 @@ namespace Bit.App
|
||||
await Task.Delay(1000);
|
||||
await _accountsManager.NavigateOnAccountChangeAsync();
|
||||
}
|
||||
else if (message.Command == "popAllAndGoToTabGenerator" ||
|
||||
message.Command == "popAllAndGoToTabMyVault" ||
|
||||
message.Command == "popAllAndGoToTabSend" ||
|
||||
message.Command == "popAllAndGoToAutofillCiphers")
|
||||
else if (message.Command == POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE ||
|
||||
message.Command == POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE ||
|
||||
message.Command == POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE ||
|
||||
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)
|
||||
{
|
||||
@ -116,24 +127,29 @@ namespace Bit.App
|
||||
{
|
||||
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;
|
||||
tabsPage.ResetToVaultPage();
|
||||
}
|
||||
else if (message.Command == "popAllAndGoToTabGenerator")
|
||||
else if (message.Command == POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE)
|
||||
{
|
||||
Options.GeneratorTile = false;
|
||||
tabsPage.ResetToGeneratorPage();
|
||||
}
|
||||
else if (message.Command == "popAllAndGoToTabSend")
|
||||
else if (message.Command == POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE)
|
||||
{
|
||||
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));
|
||||
break;
|
||||
case NavigationTarget.AutofillCiphers:
|
||||
Current.MainPage = new NavigationPage(new AutofillCiphersPage(Options));
|
||||
case NavigationTarget.OtpCipherSelection:
|
||||
Current.MainPage = new NavigationPage(new CipherSelectionPage(Options));
|
||||
break;
|
||||
case NavigationTarget.SendAddEdit:
|
||||
Current.MainPage = new NavigationPage(new SendAddEditPage(Options));
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.App.Models
|
||||
{
|
||||
@ -23,6 +24,7 @@ namespace Bit.App.Models
|
||||
public Tuple<SendType, string, byte[], string> CreateSend { get; set; }
|
||||
public bool CopyInsteadOfShareAfterSaving { get; set; }
|
||||
public bool HideAccountSwitcher { get; set; }
|
||||
public OtpData? OtpData { get; set; }
|
||||
|
||||
public void SetAllFrom(AppOptions o)
|
||||
{
|
||||
@ -48,6 +50,7 @@ namespace Bit.App.Models
|
||||
CreateSend = o.CreateSend;
|
||||
CopyInsteadOfShareAfterSaving = o.CopyInsteadOfShareAfterSaving;
|
||||
HideAccountSwitcher = o.HideAccountSwitcher;
|
||||
OtpData = o.OtpData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,91 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
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.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
using Xamarin.CommunityToolkit.ObjectModel;
|
||||
using Xamarin.Forms;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public class AutofillCiphersPageViewModel : BaseViewModel
|
||||
public class AutofillCiphersPageViewModel : CipherSelectionPageViewModel
|
||||
{
|
||||
private readonly IPlatformUtilsService _platformUtilsService;
|
||||
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 CipherType? _fillType;
|
||||
|
||||
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 Command CipherOptionsCommand { get; set; }
|
||||
public bool LoadedOnce { get; set; }
|
||||
public ObservableRangeCollection<IGroupingsPageListItem> GroupedItems { get; set; }
|
||||
public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; }
|
||||
|
||||
public bool ShowNoData
|
||||
{
|
||||
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)
|
||||
public override void Init(AppOptions appOptions)
|
||||
{
|
||||
Uri = appOptions?.Uri;
|
||||
_fillType = appOptions.FillType;
|
||||
|
||||
string name = null;
|
||||
if (Uri?.StartsWith(Constants.AndroidAppProtocol) ?? false)
|
||||
{
|
||||
@ -104,14 +42,11 @@ namespace Bit.App.Pages
|
||||
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 ciphers = await _cipherService.GetAllDecryptedByUrlAsync(Uri, null);
|
||||
|
||||
var matching = ciphers.Item1?.Select(c => new GroupingsPageListItem { Cipher = c }).ToList();
|
||||
var hasMatching = matching?.Any() ?? false;
|
||||
if (matching?.Any() ?? false)
|
||||
@ -119,6 +54,7 @@ namespace Bit.App.Pages
|
||||
groupedItems.Add(
|
||||
new GroupingsPageListGroup(matching, AppResources.MatchingItems, matching.Count, false, true));
|
||||
}
|
||||
|
||||
var fuzzy = ciphers.Item2?.Select(c =>
|
||||
new GroupingsPageListItem { Cipher = c, FuzzyAutofill = true }).ToList();
|
||||
if (fuzzy?.Any() ?? false)
|
||||
@ -128,123 +64,88 @@ namespace Bit.App.Pages
|
||||
!hasMatching));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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;
|
||||
return groupedItems;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var cipher = listItem.Cipher;
|
||||
|
||||
if (_deviceActionService.SystemMajorVersion() < 21)
|
||||
{
|
||||
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;
|
||||
if (fuzzy)
|
||||
autofillResponse = await _deviceActionService.DisplayAlertAsync(null,
|
||||
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 };
|
||||
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());
|
||||
uris = new List<LoginUriView>();
|
||||
}
|
||||
if (autofillResponse == AppResources.YesAndSave && cipher.Type == CipherType.Login)
|
||||
uris.Add(new LoginUriView
|
||||
{
|
||||
var uris = cipher.Login?.Uris?.ToList();
|
||||
if (uris == null)
|
||||
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)
|
||||
{
|
||||
uris = new List<LoginUriView>();
|
||||
}
|
||||
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);
|
||||
}
|
||||
await _platformUtilsService.ShowDialogAsync(e.Error.GetSingleMessage(),
|
||||
AppResources.AnErrorHasOccurred);
|
||||
}
|
||||
}
|
||||
if (autofillResponse == AppResources.Yes || autofillResponse == AppResources.YesAndSave)
|
||||
{
|
||||
_autofillHandler.Autofill(cipher);
|
||||
}
|
||||
}
|
||||
if (autofillResponse == AppResources.Yes || autofillResponse == AppResources.YesAndSave)
|
||||
{
|
||||
_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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Lists.ItemViewModels.CustomFields;
|
||||
using Bit.App.Models;
|
||||
using Bit.App.Resources;
|
||||
@ -30,6 +31,7 @@ namespace Bit.App.Pages
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private readonly IAutofillHandler _autofillHandler;
|
||||
private readonly IWatchDeviceService _watchDeviceService;
|
||||
private readonly IAccountsManager _accountsManager;
|
||||
|
||||
private bool _showNotesSeparator;
|
||||
private bool _showPassword;
|
||||
@ -44,6 +46,8 @@ namespace Bit.App.Pages
|
||||
private bool _hasCollections;
|
||||
private string _previousCipherId;
|
||||
private List<Core.Models.View.CollectionView> _writeableCollections;
|
||||
private bool _fromOtp;
|
||||
|
||||
protected override string[] AdditionalPropertiesToRaiseOnCipherChanged => new string[]
|
||||
{
|
||||
nameof(IsLogin),
|
||||
@ -82,6 +86,8 @@ namespace Bit.App.Pages
|
||||
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
|
||||
_autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
|
||||
_watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>();
|
||||
_accountsManager = ServiceContainer.Resolve<IAccountsManager>();
|
||||
|
||||
|
||||
GeneratePasswordCommand = new Command(GeneratePassword);
|
||||
TogglePasswordCommand = new Command(TogglePassword);
|
||||
@ -302,6 +308,7 @@ namespace Bit.App.Pages
|
||||
public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow;
|
||||
public bool HasTotpValue => IsLogin && !string.IsNullOrEmpty(Cipher?.Login?.Totp);
|
||||
public string SetupTotpText => $"{BitwardenIcons.Camera} {AppResources.SetupTotp}";
|
||||
|
||||
public void Init()
|
||||
{
|
||||
PageTitle = EditMode && !CloneMode ? AppResources.EditItem : AppResources.AddItem;
|
||||
@ -309,6 +316,8 @@ namespace Bit.App.Pages
|
||||
|
||||
public async Task<bool> LoadAsync(AppOptions appOptions = null)
|
||||
{
|
||||
_fromOtp = appOptions?.OtpData != null;
|
||||
|
||||
var myEmail = await _stateService.GetEmailAsync();
|
||||
OwnershipOptions.Add(new KeyValuePair<string, string>(myEmail, null));
|
||||
var orgs = await _organizationService.GetAllAsync();
|
||||
@ -358,6 +367,10 @@ namespace Bit.App.Pages
|
||||
Cipher.OrganizationId = OrganizationId;
|
||||
}
|
||||
}
|
||||
if (appOptions?.OtpData != null && Cipher.Type == CipherType.Login)
|
||||
{
|
||||
Cipher.Login.Totp = appOptions.OtpData.Value.Uri;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -380,6 +393,7 @@ namespace Bit.App.Pages
|
||||
Cipher.Type = appOptions.SaveType.GetValueOrDefault(Cipher.Type);
|
||||
Cipher.Login.Username = appOptions.SaveUsername;
|
||||
Cipher.Login.Password = appOptions.SavePassword;
|
||||
Cipher.Login.Totp = appOptions.OtpData?.Uri;
|
||||
Cipher.Card.Code = appOptions.SaveCardCode;
|
||||
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)));
|
||||
}
|
||||
|
||||
if (appOptions?.OtpData != null)
|
||||
{
|
||||
_platformUtilsService.ShowToast(null, AppResources.AuthenticatorKey, AppResources.AuthenticatorKeyAdded);
|
||||
}
|
||||
}
|
||||
|
||||
if (EditMode && _previousCipherId != CipherId)
|
||||
@ -517,6 +536,10 @@ namespace Bit.App.Pages
|
||||
// Close and go back to app
|
||||
_autofillHandler.CloseAutofill();
|
||||
}
|
||||
else if (_fromOtp)
|
||||
{
|
||||
await _accountsManager.StartDefaultNavigationFlowAsync(op => op.OtpData = null);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (CloneMode)
|
||||
|
@ -1,30 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<pages:BaseContentPage xmlns="http://xamarin.com/schemas/2014/forms"
|
||||
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:u="clr-namespace:Bit.App.Utilities"
|
||||
xmlns:controls="clr-namespace:Bit.App.Controls"
|
||||
xmlns:effects="clr-namespace:Bit.App.Effects;assembly=BitwardenApp"
|
||||
x:DataType="pages:AutofillCiphersPageViewModel"
|
||||
x:DataType="pages:CipherSelectionPageViewModel"
|
||||
Title="{Binding PageTitle}"
|
||||
x:Name="_page">
|
||||
|
||||
<ContentPage.BindingContext>
|
||||
<pages:AutofillCiphersPageViewModel />
|
||||
</ContentPage.BindingContext>
|
||||
|
||||
<ContentPage.ToolbarItems>
|
||||
<controls:ExtendedToolbarItem
|
||||
x:Name="_accountAvatar"
|
||||
IconImageSource="{Binding AvatarImageSource}"
|
||||
Command="{Binding Source={x:Reference _accountListOverlay}, Path=ToggleVisibililtyCommand}"
|
||||
Order="Primary"
|
||||
Priority="-1"
|
||||
Priority="-2"
|
||||
UseOriginalImage="True"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
AutomationProperties.Name="{u:I18n Account}" />
|
||||
<ToolbarItem Icon="search.png" Clicked="Search_Clicked"
|
||||
<ToolbarItem IconImageSource="search.png" Clicked="Search_Clicked"
|
||||
AutomationProperties.IsInAccessibleTree="True"
|
||||
AutomationProperties.Name="{u:I18n Search}" />
|
||||
</ContentPage.ToolbarItems>
|
||||
@ -32,6 +28,21 @@
|
||||
<ContentPage.Resources>
|
||||
<ResourceDictionary>
|
||||
<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"
|
||||
x:DataType="pages:GroupingsPageListItem">
|
||||
@ -70,23 +81,44 @@
|
||||
Padding="20, 0"
|
||||
Spacing="20"
|
||||
IsVisible="{Binding ShowNoData}">
|
||||
<Image
|
||||
Source="empty_items_state" />
|
||||
<Label
|
||||
Text="{Binding NoDataText}"
|
||||
HorizontalTextAlignment="Center"></Label>
|
||||
<Button
|
||||
Text="{u:I18n AddAnItem}"
|
||||
Clicked="AddButton_Clicked"></Button>
|
||||
Command="{Binding AddCipherCommand}" />
|
||||
</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
|
||||
IsVisible="{Binding ShowList}"
|
||||
ItemsSource="{Binding GroupedItems}"
|
||||
VerticalOptions="FillAndExpand"
|
||||
ItemTemplate="{StaticResource listItemDataTemplateSelector}"
|
||||
SelectionMode="Single"
|
||||
SelectionChanged="RowSelected"
|
||||
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>
|
||||
</ResourceDictionary>
|
||||
</ContentPage.Resources>
|
||||
@ -104,9 +136,10 @@
|
||||
<!-- Android FAB -->
|
||||
<Button
|
||||
x:Name="_fab"
|
||||
Image="plus.png"
|
||||
Clicked="AddButton_Clicked"
|
||||
ImageSource="plus.png"
|
||||
Command="{Binding AddCipherCommand}"
|
||||
Style="{StaticResource btn-fab}"
|
||||
IsVisible="{OnPlatform iOS=false, Android=true}"
|
||||
AbsoluteLayout.LayoutFlags="PositionProportional"
|
||||
AbsoluteLayout.LayoutBounds="1, 1, AutoSize, AutoSize">
|
||||
<Button.Effects>
|
@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Controls;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Models;
|
||||
using Bit.App.Utilities;
|
||||
using Bit.Core.Abstractions;
|
||||
@ -12,27 +11,46 @@ using Xamarin.Forms;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
{
|
||||
public partial class AutofillCiphersPage : BaseContentPage
|
||||
public partial class CipherSelectionPage : BaseContentPage
|
||||
{
|
||||
private readonly AppOptions _appOptions;
|
||||
private readonly IBroadcasterService _broadcasterService;
|
||||
private readonly ISyncService _syncService;
|
||||
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;
|
||||
|
||||
if (appOptions?.OtpData is null)
|
||||
{
|
||||
BindingContext = new AutofillCiphersPageViewModel();
|
||||
}
|
||||
else
|
||||
{
|
||||
BindingContext = new OTPCipherSelectionPageViewModel();
|
||||
}
|
||||
|
||||
InitializeComponent();
|
||||
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
ToolbarItems.Add(_closeItem);
|
||||
ToolbarItems.Add(_addItem);
|
||||
}
|
||||
|
||||
SetActivityIndicator(_mainContent);
|
||||
_vm = BindingContext as AutofillCiphersPageViewModel;
|
||||
_vm = BindingContext as CipherSelectionPageViewModel;
|
||||
_vm.Page = this;
|
||||
_vm.Init(appOptions);
|
||||
|
||||
_broadcasterService = ServiceContainer.Resolve<IBroadcasterService>("broadcasterService");
|
||||
_syncService = ServiceContainer.Resolve<ISyncService>("syncService");
|
||||
_vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService");
|
||||
_accountsManager = ServiceContainer.Resolve<IAccountsManager>();
|
||||
}
|
||||
|
||||
protected async override void OnAppearing()
|
||||
@ -51,10 +69,16 @@ namespace Bit.App.Pages
|
||||
return;
|
||||
}
|
||||
|
||||
_accountAvatar?.OnAppearing();
|
||||
_vm.AvatarImageSource = await GetAvatarImageSourceAsync();
|
||||
// TODO: There's currently an issue on iOS where the toolbar item is not getting updated
|
||||
// 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
|
||||
{
|
||||
@ -116,40 +140,44 @@ namespace Bit.App.Pages
|
||||
_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())
|
||||
{
|
||||
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)
|
||||
{
|
||||
var pageForOther = new CipherAddEditPage(type: _appOptions.FillType, fromAutofill: true);
|
||||
await Navigation.PushModalAsync(new NavigationPage(pageForOther));
|
||||
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);
|
||||
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);
|
||||
Application.Current.MainPage = new NavigationPage(page);
|
||||
var page = new CiphersPage(null, appOptions: _appOptions);
|
||||
Navigation.PushModalAsync(new NavigationPage(page)).FireAndForget();
|
||||
}
|
||||
|
||||
void CloseItem_Clicked(object sender, EventArgs e)
|
||||
{
|
||||
if (DoOnce())
|
||||
{
|
||||
_accountsManager.StartDefaultNavigationFlowAsync(op => op.OtpData = null).FireAndForget();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
167
src/App/Pages/Vault/CipherSelectionPageViewModel.cs
Normal file
167
src/App/Pages/Vault/CipherSelectionPageViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
@ -81,12 +81,22 @@
|
||||
VerticalOptions="CenterAndExpand"
|
||||
HorizontalOptions="CenterAndExpand"
|
||||
HorizontalTextAlignment="Center" />
|
||||
<Label IsVisible="{Binding ShowNoData}"
|
||||
Text="{u:I18n NoItemsToList}"
|
||||
Margin="20, 0"
|
||||
VerticalOptions="CenterAndExpand"
|
||||
HorizontalOptions="CenterAndExpand"
|
||||
HorizontalTextAlignment="Center" />
|
||||
<StackLayout
|
||||
HorizontalOptions="Center"
|
||||
VerticalOptions="StartAndExpand"
|
||||
Margin="20, 80, 20, 0"
|
||||
Spacing="20"
|
||||
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
|
||||
IsVisible="{Binding ShowList}"
|
||||
ItemsSource="{Binding Ciphers}"
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Bit.App.Controls;
|
||||
using Bit.App.Models;
|
||||
using Bit.App.Resources;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Models.View;
|
||||
@ -17,15 +18,18 @@ namespace Bit.App.Pages
|
||||
private CiphersPageViewModel _vm;
|
||||
private bool _hasFocused;
|
||||
|
||||
public CiphersPage(Func<CipherView, bool> filter, string pageTitle = null, string vaultFilterSelection = null,
|
||||
string autofillUrl = null, bool deleted = false)
|
||||
public CiphersPage(Func<CipherView, bool> filter,
|
||||
string pageTitle = null,
|
||||
string vaultFilterSelection = null,
|
||||
bool deleted = false,
|
||||
AppOptions appOptions = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
_vm = BindingContext as CiphersPageViewModel;
|
||||
_vm.Page = this;
|
||||
_vm.Filter = filter;
|
||||
_vm.AutofillUrl = _autofillUrl = autofillUrl;
|
||||
_vm.Deleted = deleted;
|
||||
_autofillUrl = appOptions?.Uri;
|
||||
_vm.Prepare(filter, deleted, appOptions);
|
||||
|
||||
if (pageTitle != null)
|
||||
{
|
||||
_vm.PageTitle = string.Format(AppResources.SearchGroup, pageTitle);
|
||||
|
@ -3,13 +3,16 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Bit.App.Abstractions;
|
||||
using Bit.App.Models;
|
||||
using Bit.App.Resources;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Utilities;
|
||||
using Xamarin.CommunityToolkit.ObjectModel;
|
||||
using Xamarin.Forms;
|
||||
|
||||
namespace Bit.App.Pages
|
||||
@ -31,6 +34,7 @@ namespace Bit.App.Pages
|
||||
private bool _showNoData;
|
||||
private bool _showList;
|
||||
private bool _websiteIconsEnabled;
|
||||
private AppOptions _appOptions;
|
||||
|
||||
public CiphersPageViewModel()
|
||||
{
|
||||
@ -46,14 +50,21 @@ namespace Bit.App.Pages
|
||||
_logger = ServiceContainer.Resolve<ILogger>("logger");
|
||||
|
||||
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 Func<CipherView, bool> Filter { get; set; }
|
||||
public string AutofillUrl { get; set; }
|
||||
public bool Deleted { get; set; }
|
||||
public bool ShowAllIfSearchTextEmpty { get; set; }
|
||||
|
||||
protected override ICipherService cipherService => _cipherService;
|
||||
protected override IPolicyService policyService => _policyService;
|
||||
@ -65,7 +76,8 @@ namespace Bit.App.Pages
|
||||
get => _showNoData;
|
||||
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 ShowAddCipher => ShowNoData && _appOptions?.OtpData != null;
|
||||
|
||||
public bool WebsiteIconsEnabled
|
||||
{
|
||||
get => _websiteIconsEnabled;
|
||||
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()
|
||||
{
|
||||
await InitVaultFilterAsync(true);
|
||||
@ -101,25 +124,33 @@ namespace Bit.App.Pages
|
||||
{
|
||||
List<CipherView> ciphers = null;
|
||||
var searchable = !string.IsNullOrWhiteSpace(searchText) && searchText.Length > 1;
|
||||
if (searchable)
|
||||
var shouldShowAllWhenEmpty = ShowAllIfSearchTextEmpty && string.IsNullOrEmpty(searchText);
|
||||
if (searchable || shouldShowAllWhenEmpty)
|
||||
{
|
||||
if (timeout != null)
|
||||
{
|
||||
await Task.Delay(timeout.Value);
|
||||
}
|
||||
if (searchText != (Page as CiphersPage).SearchBar.Text)
|
||||
if (searchText != (Page as CiphersPage).SearchBar.Text
|
||||
&&
|
||||
!shouldShowAllWhenEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
previousCts?.Cancel();
|
||||
}
|
||||
|
||||
previousCts?.Cancel();
|
||||
try
|
||||
{
|
||||
var vaultFilteredCiphers = await GetAllCiphersAsync();
|
||||
ciphers = await _searchService.SearchCiphersAsync(searchText,
|
||||
Filter ?? (c => c.IsDeleted == Deleted), vaultFilteredCiphers, cts.Token);
|
||||
if (!shouldShowAllWhenEmpty)
|
||||
{
|
||||
ciphers = await _searchService.SearchCiphersAsync(searchText,
|
||||
Filter ?? (c => c.IsDeleted == Deleted), vaultFilteredCiphers, cts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
ciphers = vaultFilteredCiphers;
|
||||
}
|
||||
cts.Token.ThrowIfCancellationRequested();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@ -134,8 +165,8 @@ namespace Bit.App.Pages
|
||||
Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
Ciphers.ResetWithRange(ciphers);
|
||||
ShowNoData = searchable && Ciphers.Count == 0;
|
||||
ShowList = searchable && !ShowNoData;
|
||||
ShowNoData = !shouldShowAllWhenEmpty && searchable && Ciphers.Count == 0;
|
||||
ShowList = (searchable || shouldShowAllWhenEmpty) && !ShowNoData;
|
||||
});
|
||||
}, cts.Token);
|
||||
_searchCancellationTokenSource = cts;
|
||||
@ -144,6 +175,7 @@ namespace Bit.App.Pages
|
||||
public async Task SelectCipherAsync(CipherView cipher)
|
||||
{
|
||||
string selection = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AutofillUrl))
|
||||
{
|
||||
var options = new List<string> { AppResources.Autofill };
|
||||
@ -156,6 +188,19 @@ namespace Bit.App.Pages
|
||||
selection = await Page.DisplayActionSheet(AppResources.AutofillOrView, AppResources.Cancel, null,
|
||||
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))
|
||||
{
|
||||
var page = new CipherDetailsPage(cipher.Id);
|
||||
@ -205,7 +250,7 @@ namespace Bit.App.Pages
|
||||
|
||||
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);
|
||||
}
|
||||
@ -216,12 +261,10 @@ namespace Bit.App.Pages
|
||||
PerformSearchIfPopulated();
|
||||
}
|
||||
|
||||
private async void CipherOptionsAsync(CipherView cipher)
|
||||
private async Task AddCipherAsync()
|
||||
{
|
||||
if ((Page as BaseContentPage).DoOnce())
|
||||
{
|
||||
await Utilities.AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService);
|
||||
}
|
||||
var pageForLogin = new CipherAddEditPage(null, CipherType.Login, name: _appOptions?.OtpData?.Issuer ?? _appOptions?.OtpData?.AccountName, appOptions: _appOptions);
|
||||
await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
79
src/App/Pages/Vault/OTPCipherSelectionPageViewModel.cs
Normal file
79
src/App/Pages/Vault/OTPCipherSelectionPageViewModel.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
47
src/App/Resources/AppResources.Designer.cs
generated
47
src/App/Resources/AppResources.Designer.cs
generated
@ -1,7 +1,6 @@
|
||||
//------------------------------------------------------------------------------
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// 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
|
||||
// the code is regenerated.
|
||||
@ -14,12 +13,10 @@ namespace Bit.App.Resources {
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// 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.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Build.Tasks.StronglyTypedResourceBuilder", "15.1.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
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>
|
||||
/// Looks up a localized string similar to Add TOTP.
|
||||
/// </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>
|
||||
/// Looks up a localized string similar to Search {0}.
|
||||
/// </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 "{0}".
|
||||
/// </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>
|
||||
/// Looks up a localized string similar to 30 days.
|
||||
/// </summary>
|
||||
|
@ -2592,4 +2592,16 @@ Do you want to switch to this account?</value>
|
||||
<data name="OrganizationSsoIdentifierRequired" xml:space="preserve">
|
||||
<value>Organization SSO identifier required.</value>
|
||||
</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>
|
||||
|
30
src/App/Services/DeepLinkContext.cs
Normal file
30
src/App/Services/DeepLinkContext.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -7,7 +7,6 @@ using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Domain;
|
||||
using Bit.Core.Utilities;
|
||||
using Xamarin.Essentials;
|
||||
using Xamarin.Forms;
|
||||
|
||||
namespace Bit.App.Utilities.AccountManagement
|
||||
@ -58,6 +57,13 @@ namespace Bit.App.Utilities.AccountManagement
|
||||
_broadcasterService.Subscribe(nameof(AccountsManager), OnMessage);
|
||||
}
|
||||
|
||||
public async Task StartDefaultNavigationFlowAsync(Action<AppOptions> appOptionsAction)
|
||||
{
|
||||
appOptionsAction(Options);
|
||||
|
||||
await NavigateOnAccountChangeAsync();
|
||||
}
|
||||
|
||||
public async Task NavigateOnAccountChangeAsync(bool? isAuthed = null)
|
||||
{
|
||||
// TODO: this could be improved by doing chain of responsability pattern
|
||||
@ -89,6 +95,10 @@ namespace Bit.App.Utilities.AccountManagement
|
||||
{
|
||||
_accountsManagerHost.Navigate(NavigationTarget.AutofillCiphers);
|
||||
}
|
||||
else if (Options.OtpData != null)
|
||||
{
|
||||
_accountsManagerHost.Navigate(NavigationTarget.OtpCipherSelection);
|
||||
}
|
||||
else if (Options.CreateSend != null)
|
||||
{
|
||||
_accountsManagerHost.Navigate(NavigationTarget.SendAddEdit);
|
||||
|
@ -430,9 +430,11 @@ namespace Bit.App.Utilities
|
||||
Application.Current.MainPage = new NavigationPage(new CipherAddEditPage(appOptions: appOptions));
|
||||
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;
|
||||
}
|
||||
if (appOptions.CreateSend != null)
|
||||
|
@ -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.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
|
||||
ServiceContainer.Register<ICustomFieldItemFactory>("customFieldItemFactory", new CustomFieldItemFactory(i18nService, eventService));
|
||||
ServiceContainer.Register<IDeepLinkContext>(new DeepLinkContext(ServiceContainer.Resolve<IMessagingService>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -46,6 +46,7 @@
|
||||
/// which is used to handle Apple Watch state logic
|
||||
/// </summary>
|
||||
public const string LastUserShouldConnectToWatchKey = "lastUserShouldConnectToWatch";
|
||||
public const string OtpAuthScheme = "otpauth";
|
||||
public const string AppLocaleKey = "appLocale";
|
||||
public const string ClearSensitiveFields = "clearSensitiveFields";
|
||||
public const int SelectFileRequestCode = 42;
|
||||
|
@ -8,6 +8,7 @@
|
||||
Home,
|
||||
AddEditCipher,
|
||||
AutofillCiphers,
|
||||
SendAddEdit
|
||||
SendAddEdit,
|
||||
OtpCipherSelection
|
||||
}
|
||||
}
|
||||
|
@ -33,39 +33,22 @@ namespace Bit.Core.Services
|
||||
var isSteamAuth = key?.ToLowerInvariant().StartsWith("steam://") ?? false;
|
||||
if (isOtpAuth)
|
||||
{
|
||||
var qsParams = CoreHelpers.GetQueryParams(key);
|
||||
if (qsParams.ContainsKey("digits") && qsParams["digits"] != null &&
|
||||
int.TryParse(qsParams["digits"].Trim(), out var digitParam))
|
||||
var otpData = new OtpData(key.ToLowerInvariant());
|
||||
if (otpData.Digits > 0)
|
||||
{
|
||||
if (digitParam > 10)
|
||||
{
|
||||
digits = 10;
|
||||
}
|
||||
else if (digitParam > 0)
|
||||
{
|
||||
digits = digitParam;
|
||||
}
|
||||
digits = Math.Min(otpData.Digits.Value, 10);
|
||||
}
|
||||
if (qsParams.ContainsKey("period") && qsParams["period"] != null &&
|
||||
int.TryParse(qsParams["period"].Trim(), out var periodParam) && periodParam > 0)
|
||||
if (otpData.Period.HasValue)
|
||||
{
|
||||
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();
|
||||
if (algParam == "sha256")
|
||||
{
|
||||
alg = CryptoHashAlgorithm.Sha256;
|
||||
}
|
||||
else if (algParam == "sha512")
|
||||
{
|
||||
alg = CryptoHashAlgorithm.Sha512;
|
||||
}
|
||||
alg = otpData.Algorithm.Value;
|
||||
}
|
||||
}
|
||||
else if (isSteamAuth)
|
||||
|
@ -168,10 +168,27 @@ namespace Bit.Core.Utilities
|
||||
{
|
||||
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>();
|
||||
}
|
||||
|
||||
var queryStringNameValueCollection = HttpUtility.ParseQueryString(uri.Query);
|
||||
return queryStringNameValueCollection.AllKeys.Where(k => k != null).ToDictionary(k => k, k => queryStringNameValueCollection[k]);
|
||||
}
|
||||
|
94
src/Core/Utilities/OtpData.cs
Normal file
94
src/Core/Utilities/OtpData.cs
Normal 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; }
|
||||
}
|
||||
}
|
18
src/Core/Utilities/UriExtensions.cs
Normal file
18
src/Core/Utilities/UriExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -43,6 +43,8 @@ namespace Bit.iOS
|
||||
private IStateService _stateService;
|
||||
private IEventService _eventService;
|
||||
|
||||
private LazyResolve<IDeepLinkContext> _deepLinkContext = new LazyResolve<IDeepLinkContext>();
|
||||
|
||||
public override bool FinishedLaunching(UIApplication app, NSDictionary options)
|
||||
{
|
||||
Forms.Init();
|
||||
@ -239,7 +241,7 @@ namespace Bit.iOS
|
||||
|
||||
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,
|
||||
|
@ -29,6 +29,14 @@
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.8bit.bitwarden.url</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.8bit.bitwarden</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>otpauth</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
|
6
src/iOS/Resources/Assets.xcassets/Contents.json
Normal file
6
src/iOS/Resources/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
25
src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Contents.json
vendored
Normal file
25
src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state-dark.pdf
vendored
Normal file
BIN
src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state-dark.pdf
vendored
Normal file
Binary file not shown.
BIN
src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state.pdf
vendored
Normal file
BIN
src/iOS/Resources/Assets.xcassets/empty_items_state.imageset/Empty-items-state.pdf
vendored
Normal file
Binary file not shown.
@ -1,528 +1,608 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "ic_warning-1.pdf",
|
||||
"idiom": "universal"
|
||||
"filename" : "ic_warning-1.pdf",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"scale": "1x",
|
||||
"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": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"idiom": "universal"
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"scale": "1x",
|
||||
"idiom": "universal"
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "universal"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "3x",
|
||||
"idiom": "universal"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "iphone"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"scale": "1x",
|
||||
"idiom": "iphone"
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "iphone"
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"subtype": "retina4",
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "iphone"
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"scale": "3x",
|
||||
"idiom": "iphone"
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"idiom": "ipad"
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"scale": "1x",
|
||||
"idiom": "ipad"
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "ipad"
|
||||
"idiom" : "iphone"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"idiom": "watch"
|
||||
"idiom" : "iphone"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "watch"
|
||||
"idiom" : "iphone"
|
||||
},
|
||||
{
|
||||
"screenWidth": "{130,145}",
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "watch"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"screenWidth": "{146,165}",
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "watch"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "mac"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"scale": "1x",
|
||||
"idiom": "mac"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "mac"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "car"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "car"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "3x",
|
||||
"idiom": "car"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "universal"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
}
|
||||
],
|
||||
"scale": "1x",
|
||||
"idiom": "universal"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"subtype" : "retina4"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "universal"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"subtype" : "retina4"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"scale": "3x",
|
||||
"idiom": "universal"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"subtype" : "retina4"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
}
|
||||
],
|
||||
"idiom": "iphone"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"subtype" : "retina4"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "1x",
|
||||
"idiom": "iphone"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"subtype" : "retina4"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "iphone"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"subtype" : "retina4"
|
||||
},
|
||||
{
|
||||
"subtype": "retina4",
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "iphone"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"subtype" : "retina4"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "3x",
|
||||
"idiom": "iphone"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"subtype" : "retina4"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "ipad"
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"subtype" : "retina4"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
}
|
||||
],
|
||||
"scale": "1x",
|
||||
"idiom": "ipad"
|
||||
"idiom" : "ipad"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "ipad"
|
||||
"idiom" : "ipad"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "watch"
|
||||
"idiom" : "ipad"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "watch"
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"screenWidth": "{130,145}",
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "watch"
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"screenWidth": "{146,165}",
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "watch"
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
}
|
||||
],
|
||||
"idiom": "mac"
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "1x",
|
||||
"idiom": "mac"
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "mac"
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
}
|
||||
],
|
||||
"idiom": "car"
|
||||
"idiom" : "car"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"scale": "2x",
|
||||
"idiom": "car"
|
||||
"idiom" : "car"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "light"
|
||||
"appearance" : "luminosity",
|
||||
"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": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -179,6 +179,10 @@
|
||||
<BundleResource Include="Resources\generate.png" />
|
||||
<BundleResource Include="Resources\generate%402x.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>
|
||||
<InterfaceDefinition Include="LaunchScreen.storyboard" />
|
||||
|
Loading…
Reference in New Issue
Block a user