1
0
mirror of https://github.com/bitwarden/mobile synced 2025-12-15 15:53:44 +00:00

Compare commits

...

17 Commits

Author SHA1 Message Date
Federico Andrés Maccaroni
a8dec31a16 EC-295 Added custom renderer for CollectionView to attach ItemTouchHelper callback to handle swiping natively in Android 2022-10-24 21:01:19 -03:00
Federico Andrés Maccaroni
70ac2194cc Merged master into EC-295-swipe-to-copy-logins 2022-10-21 12:00:47 -03:00
Federico Andrés Maccaroni
54cbb1d683 Merge branch 'master' into EC-295-swipe-to-copy-logins 2022-09-23 12:19:36 -03:00
Federico Andrés Maccaroni
9703e16419 EC-295 Fix format 2022-09-13 12:00:08 -03:00
Federico Andrés Maccaroni
e13977788b EC-295 Added swipe actions for Card and Note and improve some things 2022-09-13 11:41:56 -03:00
Federico Andrés Maccaroni
1987adc453 Merge branch 'master' into EC-295-swipe-to-copy-logins 2022-09-09 15:58:50 -03:00
Federico Andrés Maccaroni
ef96692f94 Merge branch 'master' into EC-295-swipe-to-copy-logins 2022-09-09 15:11:11 -03:00
Federico Andrés Maccaroni
4e5619d5bb Merge branch 'master' into EC-295-swipe-to-copy-logins 2022-09-07 17:41:31 -03:00
Federico Andrés Maccaroni
8c4e3c33f9 Merge branch 'master' into EC-295-swipe-to-copy-logins 2022-08-26 11:53:33 -03:00
Federico Andrés Maccaroni
778d543e8e Merge branch 'master' into EC-295-swipe-to-copy-logins
# Conflicts:
#	src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml
#	src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs
#	src/App/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs
2022-08-26 11:49:14 -03:00
Federico Andrés Maccaroni
a5061df2a7 EC-295 Fix format 2022-07-29 12:40:28 -03:00
Federico Andrés Maccaroni
cd3415039d Merge branch 'master' into EC-295-swipe-to-copy-logins
# Conflicts:
#	src/Android/Properties/AndroidManifest.xml
#	src/App/Utilities/AppHelpers.cs
2022-07-29 12:38:49 -03:00
Federico Andrés Maccaroni
3878ea4e09 EC-295 Fixed Android swipe and added vibration when cipher username/password gets copied 2022-07-25 15:40:57 -03:00
Federico Andrés Maccaroni
9e7c462153 Merge branch 'master' into EC-295-swipe-to-copy-logins 2022-07-15 13:15:50 -03:00
Federico Andrés Maccaroni
52ab676b5f EC-295 merged master into swipe-to-copy-logins 2022-07-15 11:25:49 -03:00
Federico Andrés Maccaroni
602f3a7ea4 EC-295 format 2022-07-04 11:21:32 -03:00
Federico Andrés Maccaroni
2be5208b8f EC-295 Added swipe to copy on vault login items 2022-07-04 11:17:47 -03:00
24 changed files with 814 additions and 140 deletions

View File

@@ -156,6 +156,8 @@
<Compile Include="Services\FileService.cs" />
<Compile Include="Services\AutofillHandler.cs" />
<Compile Include="Constants.cs" />
<Compile Include="Renderers\CollectionView\ExtendedCollectionViewRenderer.cs" />
<Compile Include="Utilities\RecyclerSwipeItemTouchCallback.cs" />
</ItemGroup>
<ItemGroup>
<AndroidAsset Include="Assets\bwi-font.ttf" />
@@ -294,6 +296,7 @@
<Folder Include="Resources\values-v30\" />
<Folder Include="Resources\drawable-v26\" />
<Folder Include="Resources\drawable-night-v26\" />
<Folder Include="Renderers\CollectionView\" />
</ItemGroup>
<Import Project="$(MSBuildExtensionsPath)\Xamarin\Android\Xamarin.Android.CSharp.targets" />
</Project>

View File

@@ -20,6 +20,7 @@ using System.Net;
using Bit.App.Utilities;
using Bit.App.Pages;
using Bit.App.Utilities.AccountManagement;
using Bit.App.Utilities.Helpers;
using Bit.App.Controls;
#if !FDROID
using Android.Gms.Security;
@@ -74,7 +75,16 @@ namespace Bit.Droid
ServiceContainer.Resolve<IAuthService>("authService"),
ServiceContainer.Resolve<ILogger>("logger"),
ServiceContainer.Resolve<IMessagingService>("messagingService"));
ServiceContainer.Register<IAccountsManager>("accountsManager", accountsManager);
ServiceContainer.Register<IAccountsManager>("accountsManager", accountsManager);
var cipherHelper = new CipherHelper(
ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService"),
ServiceContainer.Resolve<IEventService>("eventService"),
ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService"),
ServiceContainer.Resolve<IClipboardService>("clipboardService"),
ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService")
);
ServiceContainer.Register<ICipherHelper>("cipherHelper", cipherHelper);
}
#if !FDROID
if (Build.VERSION.SdkInt <= BuildVersionCodes.Kitkat)

View File

@@ -11,6 +11,7 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="com.samsung.android.providers.context.permission.WRITE_USE_APP_FEATURE_SURVEY" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application android:label="Bitwarden" android:theme="@style/LaunchTheme" android:allowBackup="false" tools:replace="android:allowBackup" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:networkSecurityConfig="@xml/network_security_config">

View File

@@ -0,0 +1,87 @@
using System;
using Android.Content;
using AndroidX.RecyclerView.Widget;
using Bit.App.Controls;
using Bit.App.Utilities;
using Bit.Core;
using Bit.Droid.Renderers.CollectionView;
using Bit.Droid.Utilities;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;
using static Android.Content.ClipData;
using static AndroidX.RecyclerView.Widget.RecyclerView;
[assembly: ExportRenderer(typeof(ExtendedCollectionView), typeof(ExtendedCollectionViewRenderer))]
namespace Bit.Droid.Renderers.CollectionView
{
public class CustomGroupableItemsViewAdapter<TItemsView, TItemsViewSource> : GroupableItemsViewAdapter<TItemsView, TItemsViewSource>
where TItemsView : GroupableItemsView
where TItemsViewSource : IGroupableItemsViewSource
{
protected internal CustomGroupableItemsViewAdapter(TItemsView groupableItemsView, Func<View, Context, ItemContentView> createView = null)
: base(groupableItemsView, createView)
{
}
public object GetItemAt(int position)
{
return ItemsSource.GetItem(position);
}
}
public class ExtendedCollectionViewRenderer : GroupableItemsViewRenderer<ExtendedCollectionView, CustomGroupableItemsViewAdapter<ExtendedCollectionView, IGroupableItemsViewSource>, IGroupableItemsViewSource>
{
ItemTouchHelper _itemTouchHelper;
public ExtendedCollectionViewRenderer(Context context) : base(context)
{
}
protected override CustomGroupableItemsViewAdapter<ExtendedCollectionView, IGroupableItemsViewSource> CreateAdapter()
{
return new CustomGroupableItemsViewAdapter<ExtendedCollectionView, IGroupableItemsViewSource>(ItemsView);
}
protected override void SetUpNewElement(ExtendedCollectionView newElement)
{
base.SetUpNewElement(newElement);
if (newElement is null)
{
return;
}
var itemTouchCallback = new RecyclerSwipeItemTouchCallback<CipherViewCellViewModel>(ItemTouchHelper.Right, this.Context, new CipherViewModelSwipeableItem(),
viewHolder =>
{
if (viewHolder is TemplatedItemViewHolder templatedViewHolder
&&
templatedViewHolder.View?.BindingContext is CipherViewCellViewModel vm)
{
return vm;
}
return null;
});
itemTouchCallback.OnSwipedCommand = new Command<ViewHolder>(viewHolder =>
{
ItemsViewAdapter.NotifyItemChanged(viewHolder.LayoutPosition);
ItemsView.OnSwipeCommand?.Execute(ItemsViewAdapter.GetItemAt(viewHolder.BindingAdapterPosition));
});
_itemTouchHelper = new ItemTouchHelper(itemTouchCallback);
_itemTouchHelper.AttachToRecyclerView(this);
}
protected override void TearDownOldElement(ItemsView oldElement)
{
base.TearDownOldElement(oldElement);
if (oldElement is null)
{
return;
}
_itemTouchHelper.AttachToRecyclerView(null);
}
}
}

View File

@@ -14,7 +14,7 @@ namespace Bit.Droid.Renderers
protected override void OnElementChanged(ElementChangedEventArgs<View> elementChangedEvent)
{
base.OnElementChanged(elementChangedEvent);
if (elementChangedEvent.NewElement != null)
if (elementChangedEvent.NewElement is ExtendedGrid extGrid && extGrid.ApplyRipple)
{
SetBackgroundResource(Resource.Drawable.list_item_bg);
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Windows.Input;
using Android.Graphics;
using Android.Graphics.Drawables;
using Android.Util;
using Android.Views;
using AndroidX.RecyclerView.Widget;
using Bit.App.Abstractions;
using Xamarin.Forms.Platform.Android;
using FontImageSource = Xamarin.Forms.FontImageSource;
namespace Bit.Droid.Utilities
{
public class RecyclerSwipeItemTouchCallback<TItem> : ItemTouchHelper.SimpleCallback
{
private Paint _clearPaint;
private readonly ColorDrawable _background = new ColorDrawable();
private readonly Android.Content.Context _context;
private readonly ISwipeableItem<TItem> _swipeableItem;
private readonly Func<RecyclerView.ViewHolder, TItem> _viewHolderToTItem;
private Dictionary<string, Typeface> _fontFamilyTypefaceCache = new Dictionary<string, Typeface>();
public RecyclerSwipeItemTouchCallback(int swipeDir, Android.Content.Context context, ISwipeableItem<TItem> swipeableItem, Func<RecyclerView.ViewHolder, TItem> viewHolderToTItem)
: base(0, swipeDir)
{
_context = context;
_swipeableItem = swipeableItem;
_viewHolderToTItem = viewHolderToTItem;
_clearPaint = new Paint();
_clearPaint.SetXfermode(new PorterDuffXfermode(PorterDuff.Mode.Clear));
}
public ICommand OnSwipedCommand { get; set; }
public override bool OnMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target)
{
return false;
}
public override void OnChildDrawOver(Canvas c, RecyclerView recyclerView,
RecyclerView.ViewHolder viewHolder, float dX, float dY,
int actionState, bool isCurrentlyActive)
{
var itemView = viewHolder.ItemView;
int itemHeight = itemView.Bottom - itemView.Top;
var isCanceled = (dX == 0f) && !isCurrentlyActive;
if (isCanceled)
{
ClearCanvas(c, itemView.Right + dX, itemView.Top, itemView.Right, itemView.Bottom);
base.OnChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, false);
return;
}
if (!(_swipeableItem.GetSwipeIcon(_viewHolderToTItem(viewHolder)) is FontImageSource fontSource))
{
return;
}
using var paint = GetIconPaint(itemView, fontSource);
var width = (int)(paint.MeasureText(fontSource.Glyph) + .5f);
var baseline = (int)(-paint.Ascent() + .5f);
var height = (int)(baseline + paint.Descent() + .5f);
int itemTop = itemView.Top + (itemHeight - height) / 2;
int itemMargin = (itemHeight - height) / 2;
int itemBottom = itemTop + height;
_background.Color = _swipeableItem.GetBackgroundColor(_viewHolderToTItem(viewHolder)).ToAndroid();
if (dX < 0)
{
_background.SetBounds((int)(itemView.Right + dX), itemView.Top, itemView.Right, itemView.Bottom);
_background.Draw(c);
int itemLeft = itemView.Right - itemMargin - width;
int itemRight = itemView.Right - itemMargin;
c.DrawText(fontSource.Glyph, itemLeft, itemBottom, paint);
}
else
{
_background.SetBounds((int)(itemView.Left + dX), itemView.Top, itemView.Left, itemView.Bottom);
_background.Draw(c);
int itemLeft = itemView.Left + itemMargin;
int itemRight = itemView.Left + itemMargin + width;
c.DrawText(fontSource.Glyph, itemLeft, itemBottom, paint);
}
base.OnChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
private Paint GetIconPaint(View itemView, FontImageSource fontSource)
{
var paint = new Paint
{
TextSize = TypedValue.ApplyDimension(ComplexUnitType.Dip, (float)fontSource.Size, _context.Resources.DisplayMetrics),
Color = fontSource.Color.ToAndroid(),
TextAlign = Paint.Align.Left,
AntiAlias = true,
};
if (fontSource.FontFamily != null)
{
if (!_fontFamilyTypefaceCache.TryGetValue(fontSource.FontFamily, out var typeface))
{
var font = new Xamarin.Forms.Font();
// HACK: there is no way to set the font family of Font
// and the only public extension method to get the typeface is thorugh a Xamarin.Forms.Font
// so we use reflection here to set the font family and take advantage of ToTypeface method
// Also, we need to box the font in order to use reflection to set the property because it's a struct
object fontBoxed = font;
var pinfo = typeof(Xamarin.Forms.Font)
.GetProperty(nameof(Xamarin.Forms.Font.FontFamily));
pinfo.SetValue(fontBoxed, fontSource.FontFamily, null);
typeface = ((Xamarin.Forms.Font)fontBoxed).ToTypeface();
_fontFamilyTypefaceCache.Add(fontSource.FontFamily, typeface);
}
paint.SetTypeface(typeface);
}
int alpha = Math.Abs(((int)((itemView.TranslationX / itemView.Width) * 510)));
paint.Alpha = Math.Min(alpha, 255);
return paint;
}
public override int GetSwipeDirs(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder)
{
if (viewHolder is TemplatedItemViewHolder templatedViewHolder
&&
_swipeableItem.CanSwipe(_viewHolderToTItem(viewHolder)))
{
return base.GetSwipeDirs(recyclerView, viewHolder);
}
return 0;
}
private void ClearCanvas(Canvas c, float left, float top, float right, float bottom)
{
if (c != null)
c.DrawRect(left, top, right, bottom, _clearPaint);
}
public override void OnSwiped(RecyclerView.ViewHolder viewHolder, int direction)
{
OnSwipedCommand?.Execute(viewHolder);
}
}
}

View File

@@ -0,0 +1,11 @@
using Xamarin.Forms;
namespace Bit.App.Abstractions
{
public interface ISwipeableItem<TItem>
{
bool CanSwipe(TItem item);
FontImageSource GetSwipeIcon(TItem item);
Color GetBackgroundColor(TItem item);
}
}

View File

@@ -138,6 +138,7 @@
<Folder Include="Lists\ItemViewModels\CustomFields\" />
<Folder Include="Controls\AccountSwitchingOverlay\" />
<Folder Include="Utilities\AccountManagement\" />
<Folder Include="Utilities\Helpers\" />
<Folder Include="Controls\DateTime\" />
</ItemGroup>
@@ -429,6 +430,7 @@
<None Remove="Lists\ItemViewModels\CustomFields\" />
<None Remove="Controls\AccountSwitchingOverlay\" />
<None Remove="Utilities\AccountManagement\" />
<None Remove="Utilities\Helpers\" />
<None Remove="Controls\DateTime\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,89 @@
using System.Collections.Generic;
using Bit.App.Abstractions;
using Bit.App.Utilities;
using Bit.Core;
using Bit.Core.Enums;
using Xamarin.Forms;
namespace Bit.App.Controls
{
public class CipherViewModelSwipeableItem : ISwipeableItem<CipherViewCellViewModel>
{
readonly Dictionary<CipherType, FontImageSource> _imageCache = new Dictionary<CipherType, FontImageSource>();
public bool CanSwipe(CipherViewCellViewModel item)
{
if (item?.Cipher is null)
{
return false;
}
return item.Cipher.Type == CipherType.Login
||
item.Cipher.Type == CipherType.Card
||
item.Cipher.Type == CipherType.SecureNote;
}
public Xamarin.Forms.Color GetBackgroundColor(CipherViewCellViewModel item)
{
if (item?.Cipher is null)
{
return ThemeManager.GetResourceColor("PrimaryColor");
}
if (item.Cipher.Type == CipherType.Login
&&
string.IsNullOrEmpty(item.Cipher.Login?.Password))
{
return ThemeManager.GetResourceColor("SeparatorColor");
}
if (item.Cipher.Type == CipherType.Card
&&
string.IsNullOrEmpty(item.Cipher.Card?.Number))
{
return ThemeManager.GetResourceColor("SeparatorColor");
}
if (item.Cipher.Type == CipherType.SecureNote
&&
string.IsNullOrEmpty(item.Cipher.Notes))
{
return ThemeManager.GetResourceColor("SeparatorColor");
}
return ThemeManager.GetResourceColor("PrimaryColor");
}
public FontImageSource GetSwipeIcon(CipherViewCellViewModel item)
{
if (item?.Cipher is null)
{
return null;
}
if (!_imageCache.TryGetValue(item.Cipher.Type, out var image))
{
image = new IconFontImageSource { Color = ThemeManager.GetResourceColor("BackgroundColor") };
switch (item.Cipher.Type)
{
case CipherType.Login:
image.Glyph = BitwardenIcons.Key;
break;
case CipherType.Card:
image.Glyph = BitwardenIcons.Hashtag;
break;
case CipherType.SecureNote:
image.Glyph = BitwardenIcons.Clone;
break;
default:
return null;
}
_imageCache.Add(item.Cipher.Type, image);
}
return image;
}
}
}

View File

@@ -1,9 +1,19 @@
using Xamarin.Forms;
using System.Windows.Input;
using Xamarin.Forms;
namespace Bit.App.Controls
{
public class ExtendedCollectionView : CollectionView
{
public static BindableProperty OnSwipeCommandProperty =
BindableProperty.Create(nameof(OnSwipeCommand), typeof(ICommand), typeof(ExtendedCollectionView));
public ICommand OnSwipeCommand
{
get => (ICommand)GetValue(OnSwipeCommandProperty);
set => SetValue(OnSwipeCommandProperty, value);
}
public string ExtraDataForLogging { get; set; }
}
}

View File

@@ -4,5 +4,6 @@ namespace Bit.App.Controls
{
public class ExtendedGrid : Grid
{
public bool ApplyRipple { get; set; } = true;
}
}

View File

@@ -0,0 +1,20 @@
using Xamarin.Forms;
namespace Bit.App.Controls
{
public class IconFontImageSource : FontImageSource
{
public IconFontImageSource()
{
switch (Device.RuntimePlatform)
{
case Device.iOS:
FontFamily = "bwi-font";
break;
case Device.Android:
FontFamily = "bwi-font.ttf#bwi-font";
break;
}
}
}
}

View File

@@ -6,6 +6,7 @@ using Bit.App.Controls;
using Bit.App.Models;
using Bit.App.Resources;
using Bit.App.Utilities;
using Bit.App.Utilities.Helpers;
using Bit.Core;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
@@ -26,6 +27,7 @@ namespace Bit.App.Pages
private readonly IStateService _stateService;
private readonly IPasswordRepromptService _passwordRepromptService;
private readonly IMessagingService _messagingService;
private readonly ICipherHelper _cipherHelper;
private readonly ILogger _logger;
private bool _showNoData;
@@ -42,6 +44,7 @@ namespace Bit.App.Pages
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
_passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService");
_messagingService = ServiceContainer.Resolve<IMessagingService>("messagingService");
_cipherHelper = ServiceContainer.Resolve<ICipherHelper>("cipherHelper");
_logger = ServiceContainer.Resolve<ILogger>("logger");
GroupedItems = new ObservableRangeCollection<IGroupingsPageListItem>();
@@ -182,7 +185,7 @@ namespace Bit.App.Pages
}
if (_deviceActionService.SystemMajorVersion() < 21)
{
await AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService);
await _cipherHelper.ShowCipherOptionsAsync(Page, cipher);
}
else
{
@@ -243,7 +246,7 @@ namespace Bit.App.Pages
{
if ((Page as BaseContentPage).DoOnce())
{
await AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService);
await _cipherHelper.ShowCipherOptionsAsync(Page, cipher);
}
}
}

View File

@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using Bit.App.Abstractions;
using Bit.App.Resources;
using Bit.App.Utilities.Helpers;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -25,6 +26,7 @@ namespace Bit.App.Pages
private readonly IPasswordRepromptService _passwordRepromptService;
private readonly IOrganizationService _organizationService;
private readonly IPolicyService _policyService;
private readonly ICipherHelper _cipherHelper;
private CancellationTokenSource _searchCancellationTokenSource;
private readonly ILogger _logger;
@@ -43,6 +45,7 @@ namespace Bit.App.Pages
_passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService");
_organizationService = ServiceContainer.Resolve<IOrganizationService>("organizationService");
_policyService = ServiceContainer.Resolve<IPolicyService>("policyService");
_cipherHelper = ServiceContainer.Resolve<ICipherHelper>("cipherHelper");
_logger = ServiceContainer.Resolve<ILogger>("logger");
Ciphers = new ExtendedObservableCollection<CipherView>();
@@ -194,7 +197,7 @@ namespace Bit.App.Pages
}
if (_deviceActionService.SystemMajorVersion() < 21)
{
await Utilities.AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService);
await _cipherHelper.ShowCipherOptionsAsync(Page, cipher);
}
else
{
@@ -220,7 +223,7 @@ namespace Bit.App.Pages
{
if ((Page as BaseContentPage).DoOnce())
{
await Utilities.AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService);
await _cipherHelper.ShowCipherOptionsAsync(Page, cipher);
}
}
}

View File

@@ -33,7 +33,9 @@
<ContentPage.Resources>
<ResourceDictionary>
<u:InverseBoolConverter x:Key="inverseBool" />
<xct:IsNotNullOrEmptyConverter x:Key="isNotNullOrEmptyConverter" />
<u:CipherTypeToSwipeActionGlyphConverter x:Key="cipherTypeToSwipeActionGlyphConverter" />
<u:CipherToSwipeBackgroundColor x:Key="cipherToSwipeBackgroundColor" />
<ToolbarItem x:Name="_syncItem" x:Key="syncItem" Text="{u:I18n Sync}"
Clicked="Sync_Clicked" Order="Secondary" />
@@ -53,6 +55,27 @@
ButtonCommand="{Binding BindingContext.CipherOptionsCommand, Source={x:Reference _page}}"
WebsiteIconsEnabled="{Binding BindingContext.WebsiteIconsEnabled, Source={x:Reference _page}}" />
</DataTemplate>
<DataTemplate x:Key="swipeableCipherTemplate"
x:DataType="pages:GroupingsPageListItem">
<SwipeView Threshold="{Binding Source={x:Reference _page}, Path=SwipeThreshold}">
<SwipeView.LeftItems>
<SwipeItems Mode="Execute">
<SwipeItem
BackgroundColor="{Binding Cipher, Converter={StaticResource cipherToSwipeBackgroundColor}}"
Command="{Binding Source={x:Reference _page}, Path=BindingContext.SwipeItemActionCommand}"
CommandParameter="{Binding .}"
IconImageSource="{Binding Cipher.Type, Converter={StaticResource cipherTypeToSwipeActionGlyphConverter}}">
</SwipeItem>
</SwipeItems>
</SwipeView.LeftItems>
<controls:CipherViewCell
Cipher="{Binding Cipher}"
ButtonCommand="{Binding BindingContext.CipherOptionsCommand, Source={x:Reference _page}}"
WebsiteIconsEnabled="{Binding BindingContext.WebsiteIconsEnabled, Source={x:Reference _page}}"
ApplyRipple="False"
BackgroundColor="{DynamicResource BackgroundColor}"/>
</SwipeView>
</DataTemplate>
<DataTemplate x:Key="authenticatorTemplate"
x:DataType="pages:GroupingsPageTOTPListItem">
@@ -113,8 +136,9 @@
<pages:GroupingsPageListItemSelector x:Key="listItemDataTemplateSelector"
HeaderTemplate="{StaticResource headerTemplate}"
CipherTemplate="{StaticResource cipherTemplate}"
GroupTemplate="{StaticResource groupTemplate}"
AuthenticatorTemplate="{StaticResource authenticatorTemplate}"
GroupTemplate="{StaticResource groupTemplate}" />
SwipeableCipherTemplate="{StaticResource swipeableCipherTemplate}" />
<StackLayout x:Key="mainLayout" x:Name="_mainLayout">
<StackLayout
@@ -165,7 +189,8 @@
SelectionMode="Single"
SelectionChanged="RowSelected"
StyleClass="list, list-platform"
ExtraDataForLogging="Groupings Page" />
ExtraDataForLogging="Groupings Page"
OnSwipeCommand="{Binding SwipeItemActionCommand}"/>
</RefreshView>
</StackLayout>
</ResourceDictionary>

View File

@@ -28,6 +28,19 @@ namespace Bit.App.Pages
private PreviousPageInfo _previousPage;
double _swipeThreshold;
public double SwipeThreshold
{
get
{
if (_swipeThreshold == default)
{
_swipeThreshold = (Content?.Width ?? 500) / 2;
}
return _swipeThreshold;
}
}
public GroupingsPage(bool mainPage, CipherType? type = null, string folderId = null,
string collectionId = null, string pageTitle = null, string vaultFilterSelection = null,
PreviousPageInfo previousPage = null, bool deleted = false, bool showTotp = false)

View File

@@ -7,6 +7,7 @@ namespace Bit.App.Pages
public DataTemplate HeaderTemplate { get; set; }
public DataTemplate CipherTemplate { get; set; }
public DataTemplate GroupTemplate { get; set; }
public DataTemplate SwipeableCipherTemplate { get; set; }
public DataTemplate AuthenticatorTemplate { get; set; }
protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
@@ -23,7 +24,16 @@ namespace Bit.App.Pages
if (item is GroupingsPageListItem listItem)
{
return listItem.Cipher != null ? CipherTemplate : GroupTemplate;
if (listItem.Cipher is null)
{
return GroupTemplate;
}
return CipherTemplate;
//return listItem.Cipher.Type != Core.Enums.CipherType.Identity && SwipeableCipherTemplate != null
// ? SwipeableCipherTemplate
// : CipherTemplate;
}
return null;

View File

@@ -8,6 +8,7 @@ using Bit.App.Abstractions;
using Bit.App.Controls;
using Bit.App.Resources;
using Bit.App.Utilities;
using Bit.App.Utilities.Helpers;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Models.Domain;
@@ -50,9 +51,9 @@ namespace Bit.App.Pages
private readonly IPlatformUtilsService _platformUtilsService;
private readonly IMessagingService _messagingService;
private readonly IStateService _stateService;
private readonly IPasswordRepromptService _passwordRepromptService;
private readonly IOrganizationService _organizationService;
private readonly IPolicyService _policyService;
private readonly ICipherHelper _cipherHelper;
private readonly ILogger _logger;
public GroupingsPageViewModel()
@@ -66,9 +67,9 @@ namespace Bit.App.Pages
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
_messagingService = ServiceContainer.Resolve<IMessagingService>("messagingService");
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
_passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService");
_organizationService = ServiceContainer.Resolve<IOrganizationService>("organizationService");
_policyService = ServiceContainer.Resolve<IPolicyService>("policyService");
_cipherHelper = ServiceContainer.Resolve<ICipherHelper>("cipherHelper");
_logger = ServiceContainer.Resolve<ILogger>("logger");
Loading = true;
@@ -79,6 +80,7 @@ namespace Bit.App.Pages
await LoadAsync();
});
CipherOptionsCommand = new Command<CipherView>(CipherOptionsAsync);
SwipeItemActionCommand = new AsyncCommand<IGroupingsPageListItem>(SwipeItemActionAsync, onException: ex => _logger.Exception(ex), allowsMultipleExecutions: false);
VaultFilterCommand = new AsyncCommand(VaultFilterOptionsAsync,
onException: ex => _logger.Exception(ex),
allowsMultipleExecutions: false);
@@ -169,6 +171,8 @@ namespace Bit.App.Pages
public ObservableRangeCollection<IGroupingsPageListItem> GroupedItems { get; set; }
public Command RefreshCommand { get; set; }
public Command<CipherView> CipherOptionsCommand { get; set; }
public IAsyncCommand<IGroupingsPageListItem> SwipeItemActionCommand { get; }
public bool LoadedOnce { get; set; }
public async Task LoadAsync()
@@ -713,7 +717,43 @@ namespace Bit.App.Pages
{
if ((Page as BaseContentPage).DoOnce())
{
await AppHelpers.CipherListOptions(Page, cipher, _passwordRepromptService);
await _cipherHelper.ShowCipherOptionsAsync(Page, cipher);
}
}
private async Task SwipeItemActionAsync(IGroupingsPageListItem listItem)
{
if (listItem is GroupingsPageListItem groupPageListItem && groupPageListItem.Cipher is CipherView cipher)
{
switch (cipher.Type)
{
case CipherType.Login:
if (string.IsNullOrEmpty(cipher.Login?.Password) || !await _cipherHelper.CopyPasswordAsync(cipher))
{
return;
}
break;
case CipherType.Card:
if (!await _cipherHelper.CopyCardNumberAsync(cipher))
{
return;
}
break;
case CipherType.SecureNote:
await _cipherHelper.CopyNotesAsync(cipher);
break;
default:
_logger.Error($"The cipher type {cipher.Type} does not have any swipe action associated");
return;
}
try
{
Xamarin.Essentials.Vibration.Vibrate();
}
catch (Xamarin.Essentials.FeatureNotSupportedException)
{
}
}
}
}

View File

@@ -24,132 +24,6 @@ namespace Bit.App.Utilities
{
public static class AppHelpers
{
public static async Task<string> CipherListOptions(ContentPage page, CipherView cipher, IPasswordRepromptService passwordRepromptService)
{
var platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");
var eventService = ServiceContainer.Resolve<IEventService>("eventService");
var vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService");
var clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
var options = new List<string> { AppResources.View };
if (!cipher.IsDeleted)
{
options.Add(AppResources.Edit);
}
if (cipher.Type == Core.Enums.CipherType.Login)
{
if (!string.IsNullOrWhiteSpace(cipher.Login.Username))
{
options.Add(AppResources.CopyUsername);
}
if (!string.IsNullOrWhiteSpace(cipher.Login.Password) && cipher.ViewPassword)
{
options.Add(AppResources.CopyPassword);
}
if (!string.IsNullOrWhiteSpace(cipher.Login.Totp))
{
var stateService = ServiceContainer.Resolve<IStateService>("stateService");
var canAccessPremium = await stateService.CanAccessPremiumAsync();
if (canAccessPremium || cipher.OrganizationUseTotp)
{
options.Add(AppResources.CopyTotp);
}
}
if (cipher.Login.CanLaunch)
{
options.Add(AppResources.Launch);
}
}
else if (cipher.Type == Core.Enums.CipherType.Card)
{
if (!string.IsNullOrWhiteSpace(cipher.Card.Number))
{
options.Add(AppResources.CopyNumber);
}
if (!string.IsNullOrWhiteSpace(cipher.Card.Code))
{
options.Add(AppResources.CopySecurityCode);
}
}
else if (cipher.Type == Core.Enums.CipherType.SecureNote)
{
if (!string.IsNullOrWhiteSpace(cipher.Notes))
{
options.Add(AppResources.CopyNotes);
}
}
var selection = await page.DisplayActionSheet(cipher.Name, AppResources.Cancel, null, options.ToArray());
if (await vaultTimeoutService.IsLockedAsync())
{
platformUtilsService.ShowToast("info", null, AppResources.VaultIsLocked);
}
else if (selection == AppResources.View)
{
await page.Navigation.PushModalAsync(new NavigationPage(new CipherDetailsPage(cipher.Id)));
}
else if (selection == AppResources.Edit)
{
if (cipher.Reprompt == CipherRepromptType.None || await passwordRepromptService.ShowPasswordPromptAsync())
{
await page.Navigation.PushModalAsync(new NavigationPage(new CipherAddEditPage(cipher.Id)));
}
}
else if (selection == AppResources.CopyUsername)
{
await clipboardService.CopyTextAsync(cipher.Login.Username);
platformUtilsService.ShowToastForCopiedValue(AppResources.Username);
}
else if (selection == AppResources.CopyPassword)
{
if (cipher.Reprompt == CipherRepromptType.None || await passwordRepromptService.ShowPasswordPromptAsync())
{
await clipboardService.CopyTextAsync(cipher.Login.Password);
platformUtilsService.ShowToastForCopiedValue(AppResources.Password);
var task = eventService.CollectAsync(Core.Enums.EventType.Cipher_ClientCopiedPassword, cipher.Id);
}
}
else if (selection == AppResources.CopyTotp)
{
if (cipher.Reprompt == CipherRepromptType.None || await passwordRepromptService.ShowPasswordPromptAsync())
{
var totpService = ServiceContainer.Resolve<ITotpService>("totpService");
var totp = await totpService.GetCodeAsync(cipher.Login.Totp);
if (!string.IsNullOrWhiteSpace(totp))
{
await clipboardService.CopyTextAsync(totp);
platformUtilsService.ShowToastForCopiedValue(AppResources.VerificationCodeTotp);
}
}
}
else if (selection == AppResources.Launch)
{
platformUtilsService.LaunchUri(cipher.Login.LaunchUri);
}
else if (selection == AppResources.CopyNumber)
{
if (cipher.Reprompt == CipherRepromptType.None || await passwordRepromptService.ShowPasswordPromptAsync())
{
await clipboardService.CopyTextAsync(cipher.Card.Number);
platformUtilsService.ShowToastForCopiedValue(AppResources.Number);
}
}
else if (selection == AppResources.CopySecurityCode)
{
if (cipher.Reprompt == CipherRepromptType.None || await passwordRepromptService.ShowPasswordPromptAsync())
{
await clipboardService.CopyTextAsync(cipher.Card.Code);
platformUtilsService.ShowToastForCopiedValue(AppResources.SecurityCode);
var task = eventService.CollectAsync(Core.Enums.EventType.Cipher_ClientCopiedCardCode, cipher.Id);
}
}
else if (selection == AppResources.CopyNotes)
{
await clipboardService.CopyTextAsync(cipher.Notes);
platformUtilsService.ShowToastForCopiedValue(AppResources.Notes);
}
return selection;
}
public static async Task<string> SendListOptions(ContentPage page, SendView send)
{
var platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService");

View File

@@ -0,0 +1,56 @@
using Bit.Core.Models.View;
using Xamarin.CommunityToolkit.Converters;
using Xamarin.Forms;
namespace Bit.App.Utilities
{
public class CipherToSwipeBackgroundColor : BaseConverter<CipherView, Color>
{
public override Color ConvertFrom(CipherView value)
{
if (value is null)
{
return Color.Default;
}
Color GetDisabledColor()
{
if (App.Current.Resources.TryGetValue("ButtonBackgroundColorDisabled", out var disabledColor))
{
return (Color)disabledColor;
}
return Color.LightGray;
}
if (value.Type == Core.Enums.CipherType.Login
&&
value.Login?.Password is null)
{
return GetDisabledColor();
}
if (value.Type == Core.Enums.CipherType.Card
&&
value.Card?.Number is null)
{
return GetDisabledColor();
}
if (value.Type == Core.Enums.CipherType.SecureNote
&&
value.Notes is null)
{
return GetDisabledColor();
}
if (App.Current.Resources.TryGetValue("PrimaryColor", out var enabledColor))
{
return (Color)enabledColor;
}
return Color.Default;
}
public override CipherView ConvertBackTo(Color value) => throw new System.NotImplementedException();
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Globalization;
using Bit.App.Controls;
using Bit.Core;
using Bit.Core.Enums;
using Xamarin.Forms;
namespace Bit.App.Utilities
{
public class CipherTypeToSwipeActionGlyphConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var fontImageSource = new IconFontImageSource();
if (value is CipherType cipherType)
{
if (Application.Current.Resources.TryGetValue("TextColor", out var textColor))
{
fontImageSource.Color = (Color)textColor;
}
switch (cipherType)
{
case CipherType.Login:
fontImageSource.Glyph = BitwardenIcons.Key;
break;
case CipherType.Card:
fontImageSource.Glyph = BitwardenIcons.Hashtag;
break;
case CipherType.SecureNote:
fontImageSource.Glyph = BitwardenIcons.Clone;
break;
}
}
return fontImageSource;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.App.Abstractions;
using Bit.App.Pages;
using Bit.App.Resources;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Models.View;
using Bit.Core.Utilities;
using Xamarin.Forms;
namespace Bit.App.Utilities.Helpers
{
public class CipherHelper : ICipherHelper
{
private readonly IPlatformUtilsService _platformUtilsService;
private readonly IEventService _eventService;
private readonly IVaultTimeoutService _vaultTimeoutService;
private readonly IClipboardService _clipboardService;
private readonly IPasswordRepromptService _passwordRepromptService;
public CipherHelper(IPlatformUtilsService platformUtilsService,
IEventService eventService,
IVaultTimeoutService vaultTimeoutService,
IClipboardService clipboardService,
IPasswordRepromptService passwordRepromptService)
{
_platformUtilsService = platformUtilsService;
_eventService = eventService;
_vaultTimeoutService = vaultTimeoutService;
_clipboardService = clipboardService;
_passwordRepromptService = passwordRepromptService;
}
public async Task<string> ShowCipherOptionsAsync(Page page, CipherView cipher)
{
var selection = await page.DisplayActionSheet(cipher.Name, AppResources.Cancel, null, await GetCipherOptionsAsync(cipher));
if (await _vaultTimeoutService.IsLockedAsync())
{
_platformUtilsService.ShowToast("info", null, AppResources.VaultIsLocked);
}
else if (selection == AppResources.View)
{
await page.Navigation.PushModalAsync(new NavigationPage(new CipherDetailsPage(cipher.Id)));
}
else if (selection == AppResources.Edit)
{
if (await RepromptPasswordIfNeededAsync(cipher))
{
await page.Navigation.PushModalAsync(new NavigationPage(new CipherAddEditPage(cipher.Id)));
}
}
else if (selection == AppResources.CopyUsername)
{
await CopyUsernameAsync(cipher);
}
else if (selection == AppResources.CopyPassword)
{
await CopyPasswordAsync(cipher);
}
else if (selection == AppResources.CopyTotp)
{
if (await RepromptPasswordIfNeededAsync(cipher))
{
var totpService = ServiceContainer.Resolve<ITotpService>("totpService");
var totp = await totpService.GetCodeAsync(cipher.Login.Totp);
if (!string.IsNullOrWhiteSpace(totp))
{
await _clipboardService.CopyTextAsync(totp);
_platformUtilsService.ShowToastForCopiedValue(AppResources.VerificationCodeTotp);
}
}
}
else if (selection == AppResources.Launch)
{
_platformUtilsService.LaunchUri(cipher.Login.LaunchUri);
}
else if (selection == AppResources.CopyNumber)
{
await CopyCardNumberAsync(cipher);
}
else if (selection == AppResources.CopySecurityCode)
{
if (await RepromptPasswordIfNeededAsync(cipher))
{
await _clipboardService.CopyTextAsync(cipher.Card.Code);
_platformUtilsService.ShowToastForCopiedValue(AppResources.SecurityCode);
_eventService.CollectAsync(EventType.Cipher_ClientCopiedCardCode, cipher.Id).FireAndForget();
}
}
else if (selection == AppResources.CopyNotes)
{
await CopyNotesAsync(cipher);
}
return selection;
}
public async Task CopyUsernameAsync(CipherView cipher)
{
await _clipboardService.CopyTextAsync(cipher.Login.Username);
_platformUtilsService.ShowToastForCopiedValue(AppResources.Username);
}
public async Task<bool> CopyPasswordAsync(CipherView cipher)
{
if (await RepromptPasswordIfNeededAsync(cipher))
{
await _clipboardService.CopyTextAsync(cipher.Login.Password);
_platformUtilsService.ShowToastForCopiedValue(AppResources.Password);
_eventService.CollectAsync(EventType.Cipher_ClientCopiedPassword, cipher.Id).FireAndForget();
return true;
}
return false;
}
public async Task<bool> CopyCardNumberAsync(CipherView cipher)
{
if (await RepromptPasswordIfNeededAsync(cipher))
{
await _clipboardService.CopyTextAsync(cipher.Card.Number);
_platformUtilsService.ShowToastForCopiedValue(AppResources.Number);
return true;
}
return false;
}
public async Task CopyNotesAsync(CipherView cipher)
{
await _clipboardService.CopyTextAsync(cipher.Notes);
_platformUtilsService.ShowToastForCopiedValue(AppResources.Notes);
}
private async Task<string[]> GetCipherOptionsAsync(CipherView cipher)
{
var options = new List<string> { AppResources.View };
if (!cipher.IsDeleted)
{
options.Add(AppResources.Edit);
}
if (cipher.Type == CipherType.Login)
{
if (!string.IsNullOrWhiteSpace(cipher.Login.Username))
{
options.Add(AppResources.CopyUsername);
}
if (!string.IsNullOrWhiteSpace(cipher.Login.Password) && cipher.ViewPassword)
{
options.Add(AppResources.CopyPassword);
}
if (!string.IsNullOrWhiteSpace(cipher.Login.Totp))
{
var stateService = ServiceContainer.Resolve<IStateService>("stateService");
var canAccessPremium = await stateService.CanAccessPremiumAsync();
if (canAccessPremium || cipher.OrganizationUseTotp)
{
options.Add(AppResources.CopyTotp);
}
}
if (cipher.Login.CanLaunch)
{
options.Add(AppResources.Launch);
}
}
else if (cipher.Type == CipherType.Card)
{
if (!string.IsNullOrWhiteSpace(cipher.Card.Number))
{
options.Add(AppResources.CopyNumber);
}
if (!string.IsNullOrWhiteSpace(cipher.Card.Code))
{
options.Add(AppResources.CopySecurityCode);
}
}
else if (cipher.Type == CipherType.SecureNote)
{
if (!string.IsNullOrWhiteSpace(cipher.Notes))
{
options.Add(AppResources.CopyNotes);
}
}
return options.ToArray();
}
private async Task<bool> RepromptPasswordIfNeededAsync(CipherView cipher)
{
return cipher.Reprompt == CipherRepromptType.None || await _passwordRepromptService.ShowPasswordPromptAsync();
}
}
}

View File

@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using Bit.Core.Models.View;
using Xamarin.Forms;
namespace Bit.App.Utilities.Helpers
{
public interface ICipherHelper
{
Task<string> ShowCipherOptionsAsync(Page page, CipherView cipher);
Task CopyUsernameAsync(CipherView cipher);
Task<bool> CopyPasswordAsync(CipherView cipher);
Task<bool> CopyCardNumberAsync(CipherView cipher);
Task CopyNotesAsync(CipherView cipher);
}
}

View File

@@ -9,6 +9,7 @@ using Bit.App.Resources;
using Bit.App.Services;
using Bit.App.Utilities;
using Bit.App.Utilities.AccountManagement;
using Bit.App.Utilities.Helpers;
using Bit.Core.Abstractions;
using Bit.Core.Services;
using Bit.Core.Utilities;
@@ -229,6 +230,15 @@ namespace Bit.iOS.Core.Utilities
ServiceContainer.Resolve<IMessagingService>("messagingService"));
ServiceContainer.Register<IAccountsManager>("accountsManager", accountsManager);
var cipherHelper = new CipherHelper(
ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService"),
ServiceContainer.Resolve<IEventService>("eventService"),
ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService"),
ServiceContainer.Resolve<IClipboardService>("clipboardService"),
ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService")
);
ServiceContainer.Register<ICipherHelper>("cipherHelper", cipherHelper);
if (postBootstrapFunc != null)
{
await postBootstrapFunc.Invoke();