1
0
mirror of https://github.com/bitwarden/mobile synced 2025-12-16 00:03:22 +00:00

[PM-3349] [PM-3350] MAUI Migration Initial (#2806)

* PM-3349 PM-3350 MAUI Migration Initial

* PM-3349 PM-3350 MAUI Migration fix nullable exception bindings and AsyncCommand canExecute null exception

* PM-3349 PM-3350 MAUI Migration fix nullable bindings and fallbacks

* PM-3349: Android
Added CustomTabbedPageHandler for Android to handle the tab "reselection" for PopToRoot.
Commented support for Windows in App.csproj
Disabled Interpreter on Android to avoid very slow app in Debug (during Login for example)
Added some null checks that were causing crashes (on GeneratorPageVM and PickerVM)
Minor TabsPage cleanup

* TabBarEffect removed and it's behavior is now taken care of by CustomTabbedPageHandler

* PM-3349 PM-3350 Add null checks on CipherDetailsPageVM to avoid crash opening Secure Notes.

* PM-3349 PM-3350 MAUI Migration Start iOS extensions

* Changes to solution to hopefully fix Config Mappings

* PM-3349 Removed Deploy from iOS.Autofill to allow running Android
Changed MainApplication SpecialFolder.Personal to SpecialFolder.LocalApplicationData

* PM-3350 MAUI Migration Fix iOS Autofill extension

* PM-3349 Changed UseMauiApp init so that Android Handlers still get added

* PM-3349 Implemented HybridWebViewHandler for Android which enables 2nd factor auth flows
Ensured CustomTabbedPageHandler had it's DisconnectHandler called
Some minor code upgrades of older obsolete Xamarin Forms code.

* PM-3349 Implemented HybridWebViewHandler for iOS

* hardcoded AccountViewCell Avatar image to 40x40 to avoid current iOS/Android bugs where they fill much larger space.

* PM-3349 PM-3350
Added (migrated) CustomNavigationHandler (which should partially fix the AvatarIcon in the NavBar in iOS)
Added (migrated) CustomContentPageHandler (which should mostly place the AvatarIcon in the navBar in the correct place for iOS)
Added Task.Delay (workaround) to allow the Avatar to load in iOS on the LoginPage
Added workaround for iOS bug with the toolbar size (more info in comment in AvatarImageSource.cs)
Went through the AccountViewCell MAUI-Migration comments. (and deleted/added more comments as needed)
Migrated some Device calls to DeviceInfo and MainThread
Added (migrated) CustomTabbedHandler (for managing the iOS TabBar)

* PM-3349 Replaced the FabShadowEffect with the new MAUI Shadow to fix the buggy shadows on the Android Fab Button.

* PM-3349 ToolbarHandler created for setting text on Android go back buttons.

* PM-3350 Migrated the CustomViewCellRenderer for iOS

* PM-3350 Removed ButtonHandlerMappings and some other code related with fonts as MAUI is taking care of Accessibility and no custom code should be needed
Migrated SelectableLabelRenderer to Handler
Cleaned LabelHandlerMappings and added logic to migrate the CustomLabelRenderer

* Enabled argon2Id for iOS

* PM-3349
Added Argon libraries for Android
minor change to gitignore so that the Argon x86 lib is not ignored on the Android platform

* PM-3350 Migrated some Device to DeviceInfo and added temporary workaround with some comments to be able to see the Generated Password on iOS

* PM-3350 Added some missing images in iOS

* PM-3349 PRM-3350 Replaced XZing with Camera.MAUI for QRCodes

* Checked some [MAUI-Migration] and deleted when it's working as intended.
SearchBarHandlerMapping: IME options working as intended
SliderHandlerMappings: The MAUI "replacement" for Color.Default seems to be White so the old use case doesn't seem to be needed anymore.

* PM-3350 Checked some [MAUI-Migration] and changed as needed.
TimePickerHandlerMappings: Remove old code for forcing the Wheel. After testing without it wheel picker is still used so this code shouldn't be needed anymore.
AppDelegate.ContinueUserActivity: Uncommented and changed the iOS ContinueUserActivity. It needs to call Platform.ContinueUserActivity according with Xamarin Essentials migration docs.

* PM-3349 Fixed white tint color not appearing on images added as MAU IImage SVG
PM-3349 PM-3350 Fix for Avatar text not adjusting to white/black color correctly

* PM-3350 Removed MAUI Splash Screen. Fixed iOS Privacy Screen logo (hardcoded image to avoid it getting cropped)

* PM-3350 Quick workaround to allow 2nd factor auth to not get stuck in iOS in modals.
Updated some older "Device" code to the newer MAUI code.

* PM-3350 Removed duplicate reference to LaunchScreen.storyboard

* PM-3349 PM-3350 Minor change to HomePage to set fixed Image height otherwise it takes more space than it did in the old Xamarin Forms app.
Added HIdeSoftInputOnTapped on several pages (the ones with Entry controls) to allow hiding the keyboard when tapping "outside" of it. (just like we did in Xamarin Forms app)

* PM-3350 Added Scrollview on HomePage so that the "Create account" button can be accessed in smaller devices like iPhone SE.

* PM-3349 Added Handler that enables the ExtendedDatePicker to get IsFocused events in Android. This is a workaround for fixing the current bug where it's not possible to select the "current day" in the expiration date of a Send.
Fix for TimePicker not displaying default Time Value
Updated some "Device" code to the new MAUI "DeviceInfo"

* PM-3349 PM-3350 Migrated IconLabelButton Frames to Borders to fix issue with TapGestureRecognizer in Android
Also fixed some minor "styles" for normal Button and IconLabelButton (both Android and iOS)

* PM-3349 Fix for TabGestureRecognizer not working inside the StackLayout area of IconLabelButton

* PM-3349 Fix for Android buttons having all letters in Caps

* PM-3349 PM-3350 Started using OnNavigatedTo/From instead of On(Dis)Appearing for LoginPage and LoginSSOPage to avoid the "Modal loading" issues in iOS
Also had to add IsInitialized logic to these pages because OnNavigatedTo can be called twice in some scenario.
Some minor migrations of Device to DeviceInfo was also done

* PM-3350 Fixed iOS extensions (iOS.Extension and iOS.ShareExtension)  to load and commented argon2id from debug configuration until we have the .a compiled again with the new platform/arch

* PM-3350 Added configurations for Release mode (no FDroid yet)

* PM-3349 PM-3350 Migrated remaining AutomationProperties to SemanticProperties.
All 'IsInAccessibleTree="True"' were deleted.
'IsInAccessibleTree="False"' were kept and stayed in code.

* PM-3349 PM-3350 Changed binding set for CipherViewCell so it updates accordingly

* PM-3349 PM-3350 Changed AccountViewCell and its binding to be directly against the ViewModel

* PM-3349 Fix for HTML Label on Android. Color hex doesn't need to be cropped anymore.

* PM-3350 Fix for colored html text on iOS

* PM-3349 PM-3350 Added the partial MAUI Community Toolkit implementation for TouchEffect. This is a temporary solution until they finalize this and add it to their nuget package.
This allows implementing the LongPressCommand in AccountSwitchingOverlay and also have the "Ripple effect" animation when touching an item in Android

* PM-3349 PM-3350 Changed SendViewCell and its binding to be directly against the ViewModel

* PM-3350 Fixed iOS Share extension lazy views loading and an issue with the avatar loading. Also discovered issue with TapGestureRecognizer not working on MAUI Embedding

* PM-3350 Fixed iOS Extensions navigation to several pages and improved avoiding duplicate calls to OnNavigatedTo

* PM-3350 Updated PCL Crypto to latest alpha version to fix "Dll not found NCrypt" issue

* PM-3350 Removed workaround for iOS issue with Avatar icon as it's now fixed in latest .Net8 release.

* PM-3349 PM-3350 Removed AsyncCommand "wrapper" and added AsyncRelayCommand directly in all ViewModels that were using the other one.

* PM-3350 Added watchOS app to main project and fixed some csproj conditions for runtime identifiers on iOS.

* PM-3350 Fixed/Updated all MAUI-Migration TODOs

* PM-3350 Fixed account toolbar item and TitleView on SendAddOnlyPage, also removed comments on AvatarImageSource given the workaround is not needed anymore to draw the image successfully.

* PM-3350 Updated AppCenter package to latest version 5.0.3 and updated some things into MAUI style

* PM-3350 Added workaround for iOS Avatar icon again.

* PM-3349 Added workaround for Android to avoid issues with setting MainPage when app is in background. They are now kept on a Queue to be executed after the app has resumed.
Updated some things on App.xaml.cs to the new MAUI style

* PM-3349 PM-3350 Fixed issue where creating an account with weak/exposed password would get stuck after the Captcha (if a captcha is shown)
Changed App.xaml.cs NavigateImpl to be private

* PM-3349 Started to configure build.yml for MAUI Android

* PM-3349 build.yml update paths for MAUI Android

* PM-3349 build.yml commented verify format and just set qa as variant on MAUI Android for faster checks on CI

* PM-3349 PM-3350 build.cake updated paths

* PM-3349 build.yml updated env helpers variables and set specific csproj to build on Android so not to build iOS extensions

* PM-3349 build.yml add Android "prod" variant

* PM-3350 build.yml updated iOS build and ignore Android build to try the CI faster

* PM-3350 build.yml changed nuget restore for dotnet restore on iOS build to fix issue on restoring due to msbuild

* PM-3350 build.yml Upgraded iOS build to run on macos-13 image which has XCode 15, and set the XCode 15 version as currently the default one is 14.x

* PM-3350 build.yml try to fix ILLINK warnings and changed image to be macos-13-arm64 to see if the build is faster

* PM-3350 build.yml changed image back to be macos-13 to see if the build is faster

* PM-3350 Added Document.Build.props to disable trimming on publish

* PM-3350 build.yml disable trimming on publish so it's faster

* PM-3350 added linkskip for iOS csprojs

* PM-3350 iOS projs disable linking and set Newstandkit as weak framework

* Update build.yml disabling iOS job to avoid long running process of publish until we can fix that

* PM-3349 PM-3350 Workaround to fix issues with text getting cropped/truncated when a Label has both Multiline and LinebreakMode set

* PM-3349 build.yml enabled android build workflow

* PM-3349 build.yml configured FDROID job for MAUI

* PM-3350 iOS extensions TapGestureRecognizer try Window workaround

* PM-3350 iOS applied workaround on the iOS Autofill and Share extension to maui embed the navigation page with its content page in the Window

* PM-3349 PM-3350 Added workaround for More Options to work on Search and Groupings Page
Updated some code to MAUI Style also

* PM-3349 PM-3350 Added the ability for users to press "Continue" button as a fallback when using the Yubikey if the "SubmitCommand" doesn't trigger automatically.

* PM-3349 PM-3350 Fix for text getting cut/truncated in both account switcher and ciphers/search lists
Issue is due to MAUI but can be avoided by using slightly different layout

* PM-3350 iOS updated CFBundlerShortVersionString to latest one 2023.10.1

* PM-3350 fix build.yml Bitwarden.ipa AppStore exported file

* PM-3350 build.yml added step to validate app for submitting into App Store and have better logs of it

* PM-3350 build.yml Added several fixes like not using MtouchUseLLVM on the iOS builds to fix they taking forever to build and some changes on the automation CI to do a debug build for the moment

* PM-3350 Improved MTouch linking and extra args on iOS related csprojs

* PM-3349 PM-3350 Added MAUI label on self-host settings and on about settings to differentiate from XF app

* PM-3349 PM-3350 build.yml uncommented jobs so we have a more complete workflow

* PM-3349 PM-3350 Minor change: removed unneeded HorizontalTextAlignment from Label.

* PM-3349 Replaced CrossCurrentActivity plugin with MAUI internal CurrentActivity

* PM-3350 Fix iOS extensions navigation and Window/RootViewController handling for TapGestureRecognizer to work

* PM-3350 Cleared left ClipLogger from the iOS extensions debug logging.

* PM-3349 PM-3350 Refactored cipher bindings to have a simpler approach reusing a new CipherItemViewModel to avoid unwanted issues in the app

* PM-3349 Added base structure for avoiding Android Autofill crash. This workaround works but it's not complete as it can't handle the entire workflow when showing CipherSelectionPAge (like checking if it should show LockPage)

* PM-3350 Bumped iOS version

* PM-3350 Changed linker to use default mode given that "Full" is presenting some problems as the linker is stripping things it shouldn't and we're trying to solve it. So for now we will use the mode "Link SDK assemblies only" so QA can test.

* PM-3349 Fix for app crashing on Android when Dark mode is enabled
Removed unused button style for android

* Proof of concept for having multiple window in Android for autofill support and navigating with the help of an Extended splash page.

* PM-3350 Fix crash on Release by adding Interpreter on iOS and also adding System.Security.Cryptography to be ignored by the linker

* PM-3350 Apply Cryptography TrimmerRootAssembly only to iOS

* PM-3350 Updated Plugin.Fingerprint so biometrics work

* Update .github/workflows/build.yml setup-xcode commit hash

Co-authored-by: Vince Grassia <593223+vgrassia@users.noreply.github.com>

* PM-3349 PM-3350 Enabled argon2id and fixed one issue with the Uris when getting the icon image

* PM-3349 Upgraded Android targetSdkVersion to 34

* minor change (public to private fields)

* minor improvemments on autofill-redirect

* PM-3349 Commented the Deploy step for Android job given that we're using the hotfix-rc branch for testing iOS on TestFlight

* PM-3349 Uncommented the Deploy step for Android job

* PM-3349 Ensure "_isResumed=true" is set on App.xaml.cs:Bootstrap

* Reusing App.xaml.cs Navigation for the Android RedirectPage
Some other cleanup and changes

* Improved autofill workaround to better handle switching between windows.

* PM-3349 minor fix to add space in HomePage between the region picker labels.

* Added some comments and improvemments.

* PM-3349 Added Window events unsubscription of events. Also changed code to avoid potentially having multiple autofillwindow

* PM-3349 Minor ui fix (space between buttons in delete account page)

* initial commit of android credential provider service (wip)

* Revert "initial commit of android credential provider service (wip)"

This reverts commit 6011b63958.

* PM-3350 Fix for Delete Account buttons on iOS

* PM-3349 PM-3350 Changed search icon used in app to avoid issue with icon size on iOS

* PM-3349 Added custom window so that we can always get the current Active Window. This is used to support the Android Autofil and multi-window scenarios.

* PM-3349 Fix for icon and text spacing in some list items

* PM-3349 Minor aligment improvemment for region selection in HomePage

* PM-3349 Changed the "track color" for the Android switch so that the color is different from the "thumb"

* PM-3350 Updated version to 2024.1.0 on iOS

* PM-3349 Fix Picker selection style by doing a custom PickerHandler for Android which uses SetSingleChoiceItems(...) to provide with the appropriate UI

* PM-3350 Updated MauiVersion to 8.0.4-nightly.* to have the TapGestureRecognizer fix applied. This is done on the Directory.Build.props so we don't have to change it on every csproj. Also removed the workaround of TapGestureHack and fix the Show environment picker to work on the extensions as well.

* PM-3350 Added nuget.config so we add the nuget package source for MAUI Nightly builds

* Bump main iOS version

* PM-3350 Removed "iOS" old folder project that has been moved into the MAUI Single app project.

* PM-3350 Improved code safety adding a lot of try...catch and logging throughout the app. Also made the invoking on main thread safer on several places of the app. Additionally, on the GroupingsPageViewModel changed the code removing the old Xamarin hack and just using Replace directly instead of Clearing first to see if that fixes the crash we're having sometimes on the app.

* PM-3350 PM-3349 Updated Unit Test projects to NET 8.0 and fixed it to work with Core project reference. Also fixed a test that was breaking due to CIpherKey creation being wrong. Added "UT" as a constant to add when building/running Core.Test project so we have something on the context that tells us that is for a UT. With this I had to remove FFImageLoading on UT context because it doesn't support NET 8.0

* PM-3350 PM-3349 Updated Readme with MAUI and main branch

* PM-3350 PM-3349 Enable running Core tests

* PM-3350 Fix build.yml format

* PM-3349 Fix navigation when coming from autofill with Accessibility Services enabled. The user was getting into Home page instead of where they were, with this workaround the app navigates as if the account has been switched, leaving the user as closely as possible to where they were, basically on the first screen for the current state of the user.

* PM-3350 PM-3349 Added property to Directory.Build.props to enable Unit Testing globally so Test runners work

* Improve TOTP scan performance on Android

* Move Android camera/scan changes to xaml

* PM-3350 Testing UseInterpreter false on CI build

* PM-3350 Enabled back UseInterpreter on iOS Release given that it crashes on startup without it.

* PM-3349 PM-3350 Improved code safety with try...catch, better invoke on main thread and better null handling.

* PM-3349 PM-3350 Updated XCode version on build.yml to 15.1

* PM-3350 Removed TapGesture Window MAUI hack from iOS.Extension and iOS.ShareExtension

* PM-3350 Fixed CancellationTokenSource proper disposal

* PM-3350 Fix Avatar toolbar icon on extensions to load properly and to take advantage of using directly SkiaSharp to do the native conversion to UIImage. Also improved the toolbar item so that size is set appropriately.

* PM-3349 PM-3350 Fix external link icon

* PM-3350 Added new style to prevent spell check and text prediction

* Fix merge from main

* PM-3350 Commented event collection upload on the timer and when sending the app to background to see if that prevents the app from crashing on release mode.

* PM-3350 Added check for state migration version before trying to migrate LiteDB values into Prefs when there's no need to and that may be inducing crashes on backgrounded iOS apps.

* PM-3350 Try to disable Interpreter to have better crash knowledge. This time testing if avoiding loading the argon2id lib we're able to not use the interpreter.

* PM-5928 Fix circle animation to be shown on verification codes list on each item

* PM-3350 Go back to use Interpreter and added some Directory.Build.props to easily change Codesign properties and also include/exclude iOS extensions / WatchOS from the build.

* PM-3350 Enabled iOS extensions and WatchOS app to be included based on the Directory.Build.props

* PM-3350 Go back to include argon2id and interpreter

* Removing error/loading placeholders of icons on the cells to see if that is causing the background crash on iOS; so we can test this in TestFlight

* [PM-5910] Workaround for for sliding elements in Duo 2FA flow (#2967)

* workaround for sliding elements in duo 2fa flow

* restrict workaround to Android

* restrict workaround to Android

* Revert "restrict workaround to Android"

This reverts commit c2753d5dc4.

* Revert "restrict workaround to Android"

This reverts commit 69688cfb98.

* PM-5902 fix for account switcher not dismissing when tapping outside (#2974)

* PM-3350 Fix iossimulator-x64 argon2id load so we can test on simulators and also made easier to maintain loading the argon2id library on the iOS projects by setting a general Directory.Build.props that is shared.

* PM-5903 Changed App.xaml.cs SetOption to only update the needed properties instead of replacing the existing Options object which would cause the AccountSwitcher button bug (#2973)

* [PM-5896] Fix MAUI iOS Background crash due to lock files on suspension (#2969)

* PM-5896 Fix background crash on iOS due to lock files when app gets suspended. Changed loading and error placeholders of the CachedImage to not be used and use default icon of IconLabel instead changing visibility.

* PM-5896 Changed methods to be protected so that they don't get removed by the linker.

* PM-5896 Added stub class and references to it so to have stronger references to Icon_Success and Icon_Error so the linker doesn't remove them.

* PM-3349 Removed commented code from build.yml regarding FDroid that is not needed anymore.

* PM-6077 Separated Android and iOS HybridWebViewHandler so that it can be used on iOS.Core (#2983)

* [PM-5907] Fix for incorrect TOTP white text color on label when using light theme on iOS (#2982)

* PM-5907 workaround for incorrect textcolor when programmatically changing text on Entry

* Update src/Core/Pages/Vault/CipherAddEditPage.xaml.cs

Co-authored-by: Federico Maccaroni <fedemkr@gmail.com>

---------

Co-authored-by: Federico Maccaroni <fedemkr@gmail.com>

* [PM-5906] Fix for incorrect Send MaxAccess white text color on label when using light theme on iOS (#2981)

* PM-5906 workaround for incorrect textcolor when programmatically changing text on Entry

* Update src/Core/Pages/Send/SendAddEditPage.xaml.cs

Co-authored-by: Federico Maccaroni <fedemkr@gmail.com>

---------

Co-authored-by: Federico Maccaroni <fedemkr@gmail.com>

* PM-3349 PM-3350 Fixed Unit tests because of referencing FFImageLoading when it's not possible

---------

Co-authored-by: Dinis Vieira <dinisvieira@outlook.com>
Co-authored-by: Vince Grassia <593223+vgrassia@users.noreply.github.com>
Co-authored-by: mpbw2 <59324545+mpbw2@users.noreply.github.com>
This commit is contained in:
Federico Maccaroni
2024-02-08 16:05:26 -03:00
committed by GitHub
parent f30158adf5
commit 39a34bd8c4
873 changed files with 12374 additions and 213060 deletions

View File

@@ -0,0 +1,15 @@
using System;
using System.Threading.Tasks;
using Bit.App.Models;
namespace Bit.App.Abstractions
{
public interface IAccountsManager
{
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);
}
}

View File

@@ -0,0 +1,14 @@
using System.Threading.Tasks;
using Bit.Core.Enums;
namespace Bit.App.Abstractions
{
public interface INavigationParams { }
public interface IAccountsManagerHost
{
Task SetPreviousPageInfoAsync();
void Navigate(NavigationTarget navTarget, INavigationParams navParams = null);
Task UpdateThemeAsync();
}
}

View File

@@ -7,6 +7,7 @@ using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Request;
using Bit.Core.Models.Response;
using DeviceType = Bit.Core.Enums.DeviceType;
namespace Bit.Core.Abstractions
{

View File

@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using Bit.Core.Models.Data;
using Bit.Core.Models.Domain;
using Bit.Core.Models.View;
using CollectionView = Bit.Core.Models.View.CollectionView;
namespace Bit.Core.Abstractions
{

View File

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

View File

@@ -0,0 +1,50 @@
using System.Threading.Tasks;
using Bit.App.Utilities.Prompts;
using Bit.Core.Enums;
using Bit.Core.Models;
namespace Bit.App.Abstractions
{
public interface IDeviceActionService
{
string DeviceUserAgent { get; }
Core.Enums.DeviceType DeviceType { get; }
int SystemMajorVersion();
string SystemModel();
string GetBuildNumber();
void Toast(string text, bool longDuration = false);
Task ShowLoadingAsync(string text);
Task HideLoadingAsync();
Task<string> DisplayPromptAync(string title = null, string description = null, string text = null,
string okButtonText = null, string cancelButtonText = null, bool numericKeyboard = false,
bool autofocus = true, bool password = false);
Task<ValidatablePromptResponse?> DisplayValidatablePromptAsync(ValidatablePromptConfig config);
Task<string> DisplayAlertAsync(string title, string message, string cancel, params string[] buttons);
Task<string> DisplayActionSheetAsync(string title, string cancel, string destruction, params string[] buttons);
bool SupportsFaceBiometric();
Task<bool> SupportsFaceBiometricAsync();
bool SupportsNfc();
bool SupportsCamera();
bool SupportsFido2();
bool SupportsAutofillServices();
bool SupportsInlineAutofill();
bool SupportsDrawOver();
bool LaunchApp(string appName);
void RateApp();
void OpenAccessibilitySettings();
void OpenAccessibilityOverlayPermissionSettings();
void OpenAutofillSettings();
long GetActiveTime();
void CloseMainApp();
float GetSystemFontSizeScale();
Task OnAccountSwitchCompleteAsync();
Task SetScreenCaptureAllowedAsync();
void OpenAppSettings();
void CloseExtensionPopUp();
string GetAutofillAccessibilityDescription();
string GetAutofillDrawOverDescription();
}
}

View File

@@ -1,6 +1,5 @@
using System.Threading.Tasks;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data;
using BwRegion = Bit.Core.Enums.Region;
namespace Bit.Core.Abstractions
{
@@ -13,12 +12,12 @@ namespace Bit.Core.Abstractions
string NotificationsUrl { get; set; }
string WebVaultUrl { get; set; }
string EventsUrl { get; set; }
Region SelectedRegion { get; set; }
BwRegion SelectedRegion { get; set; }
string GetWebVaultUrl(bool returnNullIfDefault = false);
string GetWebSendUrl();
string GetCurrentDomain();
Task SetUrlsFromStorageAsync();
Task<EnvironmentUrlData> SetRegionAsync(Region region, EnvironmentUrlData selfHostedUrls = null);
Task<EnvironmentUrlData> SetRegionAsync(BwRegion region, EnvironmentUrlData selfHostedUrls = null);
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Globalization;
namespace Bit.App.Abstractions
{
public interface ILocalizeService
{
CultureInfo GetCurrentCultureInfo();
/// <summary>
/// Format date using device locale.
/// Needed for iOS as it provides locales unsupported in .Net
/// </summary>
string GetLocaleShortDate(DateTime? date);
/// <summary>
/// Format time using device locale.
/// Needed for iOS as it provides locales unsupported in .Net
/// </summary>
string GetLocaleShortTime(DateTime? time);
}
}

View File

@@ -0,0 +1,14 @@
using System.Threading.Tasks;
using Bit.Core.Enums;
namespace Bit.App.Abstractions
{
public interface IPasswordRepromptService
{
string[] ProtectedFields { get; }
Task<bool> PromptAndCheckPasswordIfNeededAsync(CipherRepromptType repromptType = CipherRepromptType.Password);
Task<(string password, bool valid)> ShowPasswordPromptAndGetItAsync();
}
}

View File

@@ -11,7 +11,7 @@ namespace Bit.Core.Abstractions
/// <summary>
/// Gets the device type on the server enum
/// </summary>
DeviceType GetDevice();
Enums.DeviceType GetDevice();
string GetDeviceString();
ClientType GetClientType();
bool IsSelfHost();

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using Bit.App.Models;
using Newtonsoft.Json.Linq;
namespace Bit.App.Abstractions
{
public interface IPushNotificationListenerService
{
Task OnMessageAsync(JObject values, string device);
Task OnRegisteredAsync(string token, string device);
void OnUnregistered(string device);
void OnError(string message, string device);
Task OnNotificationTapped(BaseNotificationData data);
Task OnNotificationDismissed(BaseNotificationData data);
bool ShouldShowNotification();
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.App.Models;
namespace Bit.App.Abstractions
{
public interface IPushNotificationService
{
bool IsRegisteredForPush { get; }
Task<bool> AreNotificationsSettingsEnabledAsync();
Task<string> GetTokenAsync();
Task RegisterAsync();
Task UnregisterAsync();
void SendLocalNotification(string title, string message, BaseNotificationData data);
void DismissLocalNotification(string notificationId);
}
}

View File

@@ -1,12 +1,9 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Bit.Core.Enums;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Domain;
using Bit.Core.Models.Response;
using Bit.Core.Models.View;
using Bit.Core.Services;
using BwRegion = Bit.Core.Enums.Region;
namespace Bit.Core.Abstractions
{
@@ -186,9 +183,9 @@ namespace Bit.Core.Abstractions
Task<bool> GetShouldTrustDeviceAsync();
Task SetShouldTrustDeviceAsync(bool value);
Task SetUserHasMasterPasswordAsync(bool value, string userId = null);
Task<Region?> GetActiveUserRegionAsync();
Task<Region?> GetPreAuthRegionAsync();
Task SetPreAuthRegionAsync(Region value);
Task<BwRegion?> GetActiveUserRegionAsync();
Task<BwRegion?> GetPreAuthRegionAsync();
Task SetPreAuthRegionAsync(BwRegion value);
[Obsolete("Use GetPinKeyEncryptedUserKeyAsync instead, left for migration purposes")]
Task<string> GetPinProtectedAsync(string userId = null);
[Obsolete("Use SetPinKeyEncryptedUserKeyAsync instead, left for migration purposes")]

8
src/Core/App.xaml Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Bit.App.App">
<Application.Resources>
</Application.Resources>
</Application>

744
src/Core/App.xaml.cs Normal file
View File

@@ -0,0 +1,744 @@
using Bit.App.Abstractions;
using Bit.App.Models;
using Bit.App.Pages;
using Bit.Core.Resources.Localization;
using Bit.App.Services;
using Bit.App.Utilities;
using Bit.App.Utilities.AccountManagement;
using Bit.Core;
using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Response;
using Bit.Core.Pages;
using Bit.Core.Services;
using Bit.Core.Utilities;
[assembly: XamlCompilation(XamlCompilationOptions.Compile)]
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;
private readonly IVaultTimeoutService _vaultTimeoutService;
private readonly ISyncService _syncService;
private readonly IAuthService _authService;
private readonly IDeviceActionService _deviceActionService;
private readonly IFileService _fileService;
private readonly IAccountsManager _accountsManager;
private readonly IPushNotificationService _pushNotificationService;
private readonly IConfigService _configService;
private readonly ILogger _logger;
private static bool _isResumed;
// these variables are static because the app is launching new activities on notification click, creating new instances of App.
private static bool _pendingCheckPasswordlessLoginRequests;
private static object _processingLoginRequestLock = new object();
// [MAUI-Migration] Workaround to avoid issue on Android where trying to show the LockPage when the app is resuming or in background breaks the app.
// This queue keeps those actions so that when the app has resumed they can still be executed.
// Links: https://github.com/dotnet/maui/issues/11501 and https://bitwarden.atlassian.net/wiki/spaces/NMME/pages/664862722/MainPage+Assignments+not+working+on+Android+on+Background+or+App+resume
private readonly Queue<Action> _onResumeActions = new Queue<Action>();
private bool _hasNavigatedToAutofillWindow;
#if ANDROID
/*
* ** Workaround for our Android crashes when trying to use Autofill **
*
* This workaround works by managing the "Window Creation" ourselves.
* - If we get an AutofillExternalActivity we just create a "dummy" window/navigation page so that the activity can run without crashing. (no visible UI is needed)
* - If we get an FromAutofillFramework/Uri/Otp/CreateSend special Option request we create an Autofill Window
* - For everything else we use the default "mainWindow"
*/
public new static Page MainPage
{
get
{
return CurrentWindow?.Page;
}
set
{
if (CurrentWindow != null)
{
CurrentWindow.Page = value;
}
}
}
/// <summary>
/// Find the Current Active Window. There should only be one at any point in Android
/// </summary>
public static ResumeWindow CurrentWindow
{
get
{
return Application.Current?.Windows.OfType<ResumeWindow>().FirstOrDefault(w => w.IsActive);
}
}
/// <summary>
/// Allows setting Options from MainActivity before base.OnCreate
/// Note 1: This is only be used by Android due to way it's Lifecycle works
/// Note 2: This method does not replace existing Options in App.xaml.cs if it exists already.
/// It only updates properties in Options related with Autofill/CreateSend/etc..
/// </summary>
/// <param name="appOptions">Options created in Android MainActivity.cs</param>
public void SetAndroidOptions(AppOptions appOptions)
{
if (Options == null)
{
Options = appOptions ?? new AppOptions();
}
else if(appOptions != null)
{
Options.Uri = appOptions.Uri;
Options.MyVaultTile = appOptions.MyVaultTile;
Options.GeneratorTile = appOptions.GeneratorTile;
Options.FromAutofillFramework = appOptions.FromAutofillFramework;
Options.CreateSend = appOptions.CreateSend;
}
}
protected override Window CreateWindow(IActivationState activationState)
{
//When executing from AutofillExternalActivity we don't have "Options" so we need to filter "manually"
//In the AutofillExternalActivity we don't need to show any Page, so we just create a "dummy" Window with a NavigationPage to avoid crashing.
if (activationState != null
&& activationState.State.TryGetValue("autofillFramework", out string autofillFramework)
&& autofillFramework == "true"
&& activationState.State.ContainsKey("autofillFrameworkCipherId"))
{
return new Window(new NavigationPage()); //No actual page needed. Only used for auto-filling the fields directly (externally)
}
//"Internal" Autofill and Uri/Otp/CreateSend. This is where we create the autofill specific Window
if (Options != null && (Options.FromAutofillFramework || Options.Uri != null || Options.OtpData != null || Options.CreateSend != null))
{
_isResumed = true; //Specifically for the Autofill scenario we need to manually set the _isResumed here
_hasNavigatedToAutofillWindow = true;
return new AutoFillWindow(new NavigationPage(new AndroidNavigationRedirectPage()));
}
var homePage = new HomePage(Options);
// WORKAROUND: If the user autofills with Accessibility Services enabled and goes back to the application then there is currently an issue
// where this method is called again
// thus it goes through here and the user goes to HomePage as we see here.
// So to solve this, the next flag check has been added which then turns on a flag on the home page
// that will trigger a navigation on the accounts manager when it loads; workarounding this behavior and navigating the user
// to the proper page depending on its state.
// WARNING: this doens't navigate the user to where they were but it acts as if the user had changed their account.
if(_hasNavigatedToAutofillWindow)
{
homePage.PerformNavigationOnAccountChangedOnLoad = true;
// this is needed because when coming back from AutofillWindow OnResume won't be called and we need this flag
// so that void Navigate(NavigationTarget navTarget, INavigationParams navParams) doesn't enqueue the navigation
// and it performs it directly.
_isResumed = true;
_hasNavigatedToAutofillWindow = false;
}
//If we have an existing MainAppWindow we can use that one
var mainAppWindow = Windows.OfType<MainAppWindow>().FirstOrDefault();
if (mainAppWindow != null)
{
mainAppWindow.PendingPage = new NavigationPage(homePage);
}
//Create new main window
return new MainAppWindow(new NavigationPage(homePage));
}
#else
//iOS doesn't use the CreateWindow override used in Android so we just set the Application.Current.MainPage directly
public new static Page MainPage
{
get
{
return Application.Current?.MainPage;
}
set
{
if (Application.Current != null)
{
Application.Current.MainPage = value;
}
}
}
#endif
public App() : this(null)
{
}
public App(AppOptions appOptions)
{
Options = appOptions ?? new AppOptions();
if (Options.IosExtension)
{
Current = this;
return;
}
_broadcasterService = ServiceContainer.Resolve<IBroadcasterService>("broadcasterService");
_messagingService = ServiceContainer.Resolve<IMessagingService>("messagingService");
_stateService = ServiceContainer.Resolve<IStateService>("stateService");
_vaultTimeoutService = ServiceContainer.Resolve<IVaultTimeoutService>("vaultTimeoutService");
_syncService = ServiceContainer.Resolve<ISyncService>("syncService");
_authService = ServiceContainer.Resolve<IAuthService>("authService");
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
_fileService = ServiceContainer.Resolve<IFileService>();
_accountsManager = ServiceContainer.Resolve<IAccountsManager>("accountsManager");
_pushNotificationService = ServiceContainer.Resolve<IPushNotificationService>();
_configService = ServiceContainer.Resolve<IConfigService>();
_logger = ServiceContainer.Resolve<ILogger>();
_accountsManager.Init(() => Options, this);
Bootstrap();
_broadcasterService.Subscribe(nameof(App), async (message) =>
{
try
{
if (message.Command == "showDialog")
{
var details = message.Data as DialogDetails;
var confirmed = true;
var confirmText = string.IsNullOrWhiteSpace(details.ConfirmText) ?
AppResources.Ok : details.ConfirmText;
await MainThread.InvokeOnMainThreadAsync(async () =>
{
if (!string.IsNullOrWhiteSpace(details.CancelText))
{
confirmed = await MainPage.DisplayAlert(details.Title, details.Text, confirmText,
details.CancelText);
}
else
{
await MainPage.DisplayAlert(details.Title, details.Text, confirmText);
}
_messagingService.Send("showDialogResolve", new Tuple<int, bool>(details.DialogId, confirmed));
});
}
#if IOS
else if (message.Command == AppHelpers.RESUMED_MESSAGE_COMMAND)
{
ResumedAsync().FireAndForget();
}
else if (message.Command == "slept")
{
await SleptAsync();
}
#endif
else if (message.Command == "migrated")
{
await Task.Delay(1000);
await _accountsManager.NavigateOnAccountChangeAsync();
}
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)
{
if (message.Command == DeepLinkContext.NEW_OTP_MESSAGE)
{
Options.OtpData = new OtpData((string)message.Data);
}
await MainThread.InvokeOnMainThreadAsync(async () =>
{
if (MainPage is TabsPage tabsPage)
{
while (tabsPage.Navigation.ModalStack.Count > 0)
{
await tabsPage.Navigation.PopModalAsync(false);
}
if (message.Command == POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE)
{
MainPage = new NavigationPage(new CipherSelectionPage(Options));
}
else if (message.Command == POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE)
{
Options.MyVaultTile = false;
tabsPage.ResetToVaultPage();
}
else if (message.Command == POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE)
{
Options.GeneratorTile = false;
tabsPage.ResetToGeneratorPage();
}
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)));
}
}
});
}
else if (message.Command == "convertAccountToKeyConnector")
{
await MainThread.InvokeOnMainThreadAsync(async () =>
{
await MainPage.Navigation.PushModalAsync(
new NavigationPage(new RemoveMasterPasswordPage()));
});
}
else if (message.Command == Constants.ForceUpdatePassword)
{
await MainThread.InvokeOnMainThreadAsync(async () =>
{
await MainPage.Navigation.PushModalAsync(
new NavigationPage(new UpdateTempPasswordPage()));
});
}
else if (message.Command == Constants.ForceSetPassword)
{
await MainThread.InvokeOnMainThreadAsync(() => MainPage.Navigation.PushModalAsync(
new NavigationPage(new SetPasswordPage(orgIdentifier: (string)message.Data))));
}
else if (message.Command == "syncCompleted")
{
await _configService.GetAsync(true);
}
else if (message.Command == Constants.PasswordlessLoginRequestKey
|| message.Command == "unlocked"
|| message.Command == AccountsManagerMessageCommands.ACCOUNT_SWITCH_COMPLETED)
{
lock (_processingLoginRequestLock)
{
// lock doesn't allow for async execution
CheckPasswordlessLoginRequestsAsync().Wait();
}
}
}
catch (Exception ex)
{
LoggerHelper.LogEvenIfCantBeResolved(ex);
}
});
}
private async Task CheckPasswordlessLoginRequestsAsync()
{
if (!_isResumed)
{
_pendingCheckPasswordlessLoginRequests = true;
return;
}
_pendingCheckPasswordlessLoginRequests = false;
if (await _vaultTimeoutService.IsLockedAsync())
{
return;
}
var notification = await _stateService.GetPasswordlessLoginNotificationAsync();
if (notification == null)
{
return;
}
if (await CheckShouldSwitchActiveUserAsync(notification))
{
return;
}
// Delay to wait for the vault page to appear
await Task.Delay(2000);
// if there is a request modal opened ignore all incoming requests
if (MainPage.Navigation.ModalStack.Any(p => p is NavigationPage navPage && navPage.CurrentPage is LoginPasswordlessPage))
{
return;
}
var loginRequestData = await _authService.GetPasswordlessLoginRequestByIdAsync(notification.Id);
var page = new LoginPasswordlessPage(new LoginPasswordlessDetails()
{
PubKey = loginRequestData.PublicKey,
Id = loginRequestData.Id,
IpAddress = loginRequestData.RequestIpAddress,
Email = await _stateService.GetEmailAsync(),
FingerprintPhrase = loginRequestData.FingerprintPhrase,
RequestDate = loginRequestData.CreationDate,
DeviceType = loginRequestData.RequestDeviceType,
Origin = loginRequestData.Origin
});
await _stateService.SetPasswordlessLoginNotificationAsync(null);
_pushNotificationService.DismissLocalNotification(Constants.PasswordlessNotificationId);
if (!loginRequestData.IsExpired)
{
await MainThread.InvokeOnMainThreadAsync(() => MainPage.Navigation.PushModalAsync(new NavigationPage(page)));
}
}
private async Task<bool> CheckShouldSwitchActiveUserAsync(PasswordlessRequestNotification notification)
{
var activeUserId = await _stateService.GetActiveUserIdAsync();
if (notification.UserId == activeUserId)
{
return false;
}
var notificationUserEmail = await _stateService.GetEmailAsync(notification.UserId);
MainThread.BeginInvokeOnMainThread(async () =>
{
try
{
var result = await _deviceActionService.DisplayAlertAsync(AppResources.LogInRequested, string.Format(AppResources.LoginAttemptFromXDoYouWantToSwitchToThisAccount, notificationUserEmail), AppResources.Cancel, AppResources.Ok);
if (result == AppResources.Ok)
{
await _stateService.SetActiveUserAsync(notification.UserId);
_messagingService.Send(AccountsManagerMessageCommands.SWITCHED_ACCOUNT);
}
}
catch (Exception ex)
{
LoggerHelper.LogEvenIfCantBeResolved(ex);
}
});
return true;
}
public AppOptions Options { get; private set; }
protected override async void OnStart()
{
try
{
System.Diagnostics.Debug.WriteLine("XF App: OnStart");
_isResumed = true;
await ClearCacheIfNeededAsync();
Prime();
if (string.IsNullOrWhiteSpace(Options.Uri))
{
var updated = await AppHelpers.PerformUpdateTasksAsync(_syncService, _deviceActionService,
_stateService);
if (!updated)
{
SyncIfNeeded();
}
}
if (_pendingCheckPasswordlessLoginRequests)
{
_messagingService.Send(Constants.PasswordlessLoginRequestKey);
}
#if ANDROID
await _vaultTimeoutService.CheckVaultTimeoutAsync();
// Reset delay on every start
_vaultTimeoutService.DelayLockAndLogoutMs = null;
#endif
await _configService.GetAsync();
_messagingService.Send("startEventTimer");
}
catch (Exception ex)
{
_logger?.Exception(ex);
throw;
}
}
#if ANDROID
protected override async void OnSleep()
#else
protected override void OnSleep()
#endif
{
try
{
System.Diagnostics.Debug.WriteLine("XF App: OnSleep");
_isResumed = false;
#if ANDROID
var isLocked = await _vaultTimeoutService.IsLockedAsync();
if (!isLocked)
{
await _stateService.SetLastActiveTimeAsync(_deviceActionService.GetActiveTime());
}
if (!SetTabsPageFromAutofill(isLocked))
{
ClearAutofillUri();
}
await SleptAsync();
#endif
}
catch (Exception ex)
{
_logger?.Exception(ex);
throw;
}
}
protected override void OnResume()
{
try
{
System.Diagnostics.Debug.WriteLine("XF App: OnResume");
_isResumed = true;
if (_pendingCheckPasswordlessLoginRequests)
{
_messagingService.Send(Constants.PasswordlessLoginRequestKey);
}
#if ANDROID
ResumedAsync().FireAndForget();
#endif
}
catch (Exception ex)
{
_logger?.Exception(ex);
throw;
}
}
private async Task SleptAsync()
{
await _vaultTimeoutService.CheckVaultTimeoutAsync();
await ClearSensitiveFieldsAsync();
_messagingService.Send("stopEventTimer");
}
private async Task ResumedAsync()
{
await _stateService.CheckExtensionActiveUserAndSwitchIfNeededAsync();
await _vaultTimeoutService.CheckVaultTimeoutAsync();
await ClearSensitiveFieldsAsync();
_messagingService.Send("startEventTimer");
await UpdateThemeAsync();
await ClearCacheIfNeededAsync();
Prime();
SyncIfNeeded();
if (MainPage is NavigationPage navPage && navPage.CurrentPage is LockPage lockPage)
{
await lockPage.PromptBiometricAfterResumeAsync();
}
// [MAUI-Migration] Workaround to avoid issue on Android where trying to show the LockPage when the app is resuming or in background breaks the app.
// Currently we keep those actions in a queue until the app has resumed and execute them here.
// Links: https://github.com/dotnet/maui/issues/11501 and https://bitwarden.atlassian.net/wiki/spaces/NMME/pages/664862722/MainPage+Assignments+not+working+on+Android+on+Background+or+App+resume
await Task.Delay(50); //Small delay that is part of the workaround and ensures the app is ready to set "MainPage"
while (_onResumeActions.TryDequeue(out var action))
{
MainThread.BeginInvokeOnMainThread(action);
}
}
public async Task UpdateThemeAsync()
{
await MainThread.InvokeOnMainThreadAsync(() =>
{
ThemeManager.SetTheme(Resources);
_messagingService.Send(ThemeManager.UPDATED_THEME_MESSAGE_KEY);
});
}
private async Task ClearSensitiveFieldsAsync()
{
await MainThread.InvokeOnMainThreadAsync(() =>
{
_messagingService.Send(Constants.ClearSensitiveFields);
});
}
private void SetCulture()
{
// Calendars are removed by linker. ref https://bugzilla.xamarin.com/show_bug.cgi?id=59077
new System.Globalization.ThaiBuddhistCalendar();
new System.Globalization.HijriCalendar();
new System.Globalization.UmAlQuraCalendar();
}
private async Task ClearCacheIfNeededAsync()
{
var lastClear = await _stateService.GetLastFileCacheClearAsync();
if ((DateTime.UtcNow - lastClear.GetValueOrDefault(DateTime.MinValue)).TotalDays >= 1)
{
var task = Task.Run(() => _fileService.ClearCacheAsync());
}
}
private void ClearAutofillUri()
{
if (DeviceInfo.Platform == DevicePlatform.Android && !string.IsNullOrWhiteSpace(Options.Uri))
{
Options.Uri = null;
}
}
private bool SetTabsPageFromAutofill(bool isLocked)
{
if (DeviceInfo.Platform == DevicePlatform.Android && !string.IsNullOrWhiteSpace(Options.Uri) &&
!Options.FromAutofillFramework)
{
Task.Run(() =>
{
MainThread.BeginInvokeOnMainThread(() =>
{
try
{
Options.Uri = null;
if (isLocked)
{
App.MainPage = new NavigationPage(new LockPage());
}
else
{
App.MainPage = new TabsPage();
}
}
catch (Exception ex)
{
LoggerHelper.LogEvenIfCantBeResolved(ex);
throw;
}
});
});
return true;
}
return false;
}
private void Prime()
{
Task.Run(() =>
{
var word = EEFLongWordList.Instance.List[1];
var parsedDomain = DomainName.TryParse("https://bitwarden.com", out var domainName);
});
}
private void Bootstrap()
{
InitializeComponent();
SetCulture();
ThemeManager.SetTheme(Resources);
RequestedThemeChanged += (s, a) =>
{
UpdateThemeAsync().FireAndForget();
};
_isResumed = true;
#if IOS
//We only set the MainPage here for iOS. Android is using the CreateWindow override for the initial page.
App.MainPage = new NavigationPage(new HomePage(Options));
#endif
_accountsManager.NavigateOnAccountChangeAsync().FireAndForget();
ServiceContainer.Resolve<MobilePlatformUtilsService>("platformUtilsService").Init();
}
private void SyncIfNeeded()
{
if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None)
{
return;
}
Task.Run(async () =>
{
try
{
var lastSync = await _syncService.GetLastSyncAsync();
if (lastSync == null || ((DateTime.UtcNow - lastSync) > TimeSpan.FromMinutes(30)))
{
await Task.Delay(1000);
await _syncService.FullSyncAsync(false);
}
}
catch (Exception ex)
{
_logger.Exception(ex);
}
});
}
public async Task SetPreviousPageInfoAsync()
{
PreviousPageInfo lastPageBeforeLock = null;
if (MainPage is TabbedPage tabbedPage && tabbedPage.Navigation.ModalStack.Count > 0)
{
var topPage = tabbedPage.Navigation.ModalStack[tabbedPage.Navigation.ModalStack.Count - 1];
if (topPage is NavigationPage navPage)
{
if (navPage.CurrentPage is CipherDetailsPage cipherDetailsPage)
{
lastPageBeforeLock = new PreviousPageInfo
{
Page = "view",
CipherId = cipherDetailsPage.ViewModel.CipherId
};
}
else if (navPage.CurrentPage is CipherAddEditPage cipherAddEditPage && cipherAddEditPage.ViewModel.EditMode)
{
lastPageBeforeLock = new PreviousPageInfo
{
Page = "edit",
CipherId = cipherAddEditPage.ViewModel.CipherId
};
}
}
}
await _stateService.SetPreviousPageInfoAsync(lastPageBeforeLock);
}
public void Navigate(NavigationTarget navTarget, INavigationParams navParams)
{
// [MAUI-Migration] Workaround to avoid issue on Android where trying to show the LockPage when the app is resuming or in background breaks the app.
// If we are in background we add the Navigation Actions to a queue to execute when the app resumes.
// Links: https://github.com/dotnet/maui/issues/11501 and https://bitwarden.atlassian.net/wiki/spaces/NMME/pages/664862722/MainPage+Assignments+not+working+on+Android+on+Background+or+App+resume
#if ANDROID
if (!_isResumed)
{
_onResumeActions.Enqueue(() => NavigateImpl(navTarget, navParams));
return;
}
#endif
NavigateImpl(navTarget, navParams);
}
private void NavigateImpl(NavigationTarget navTarget, INavigationParams navParams)
{
switch (navTarget)
{
case NavigationTarget.HomeLogin:
App.MainPage = new NavigationPage(new HomePage(Options));
break;
case NavigationTarget.Login:
if (navParams is LoginNavigationParams loginParams)
{
App.MainPage = new NavigationPage(new LoginPage(loginParams.Email, Options));
}
break;
case NavigationTarget.Lock:
if (navParams is LockNavigationParams lockParams)
{
App.MainPage = new NavigationPage(new LockPage(Options, lockParams.AutoPromptBiometric));
}
else
{
App.MainPage = new NavigationPage(new LockPage(Options));
}
break;
case NavigationTarget.Home:
App.MainPage = new TabsPage(Options);
break;
case NavigationTarget.AddEditCipher:
App.MainPage = new NavigationPage(new CipherAddEditPage(appOptions: Options));
break;
case NavigationTarget.AutofillCiphers:
case NavigationTarget.OtpCipherSelection:
App.MainPage = new NavigationPage(new CipherSelectionPage(Options));
break;
case NavigationTarget.SendAddEdit:
App.MainPage = new NavigationPage(new SendAddEditPage(Options));
break;
}
}
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.Maui.Devices;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Behaviors
{
/// <summary>
/// This behavior prevents the Editor to be automatically scrolled to the bottom on focus.
/// This is needed due to this Xamarin Forms issue: https://github.com/xamarin/Xamarin.Forms/issues/2233
/// </summary>
public class EditorPreventAutoBottomScrollingOnFocusedBehavior : Behavior<Editor>
{
public static readonly BindableProperty ParentScrollViewProperty
= BindableProperty.Create(nameof(ParentScrollView), typeof(ScrollView), typeof(EditorPreventAutoBottomScrollingOnFocusedBehavior));
public ScrollView ParentScrollView
{
get => (ScrollView)GetValue(ParentScrollViewProperty);
set => SetValue(ParentScrollViewProperty, value);
}
protected override void OnAttachedTo(Editor bindable)
{
base.OnAttachedTo(bindable);
bindable.Focused += OnFocused;
}
private void OnFocused(object sender, FocusEventArgs e)
{
if (DeviceInfo.Platform.Equals(DevicePlatform.iOS) && ParentScrollView != null)
{
ParentScrollView.ScrollToAsync(ParentScrollView.ScrollX, ParentScrollView.ScrollY, true);
}
}
protected override void OnDetachingFrom(Editor bindable)
{
bindable.Focused -= OnFocused;
base.OnDetachingFrom(bindable);
}
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using ViewExtensions = Microsoft.Maui.Controls.ViewExtensions;
namespace CommunityToolkit.Maui.Extensions;
static class VisualElementExtensions
{
internal static bool TryFindParentElementWithParentOfType<T>(this VisualElement? element, out VisualElement? result, out T? parent) where T : VisualElement
{
result = null;
parent = null;
while (element?.Parent is not null)
{
if (element.Parent is not T parentElement)
{
element = element.Parent as VisualElement;
continue;
}
result = element;
parent = parentElement;
return true;
}
return false;
}
public static Task<bool> ColorTo(this VisualElement element, Color color, uint length = 250u, Easing? easing = null)
{
_ = element ?? throw new ArgumentNullException(nameof(element));
var animationCompletionSource = new TaskCompletionSource<bool>();
if (element.BackgroundColor is null)
{
return Task.FromResult(false);
}
new Animation
{
{ 0, 1, new Animation(v => element.BackgroundColor = new Color((float)v, element.BackgroundColor.Green, element.BackgroundColor.Blue, element.BackgroundColor.Alpha), element.BackgroundColor.Red, color.Red) },
{ 0, 1, new Animation(v => element.BackgroundColor = new Color(element.BackgroundColor.Red, (float)v, element.BackgroundColor.Blue, element.BackgroundColor.Alpha), element.BackgroundColor.Green, color.Green) },
{ 0, 1, new Animation(v => element.BackgroundColor = new Color(element.BackgroundColor.Red, element.BackgroundColor.Green, (float)v, element.BackgroundColor.Alpha), element.BackgroundColor.Blue, color.Blue) },
{ 0, 1, new Animation(v => element.BackgroundColor = new Color(element.BackgroundColor.Red, element.BackgroundColor.Green, element.BackgroundColor.Blue, (float)v), element.BackgroundColor.Alpha, color.Alpha) },
}.Commit(element, nameof(ColorTo), 16, length, easing, (d, b) => animationCompletionSource.SetResult(true));
return animationCompletionSource.Task;
}
public static void AbortAnimations(this VisualElement element, params string[] otherAnimationNames)
{
_ = element ?? throw new ArgumentNullException(nameof(element));
ViewExtensions.CancelAnimations(element);
element.AbortAnimation(nameof(ColorTo));
if (otherAnimationNames is null)
{
return;
}
foreach (var name in otherAnimationNames)
{
element.AbortAnimation(name);
}
}
}

View File

@@ -0,0 +1,789 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CommunityToolkit.Maui.Extensions;
using static System.Math;
namespace CommunityToolkit.Maui.Behaviors;
sealed class GestureManager : IDisposable
{
const int animationProgressDelay = 10;
Color? defaultBackgroundColor;
CancellationTokenSource? longPressTokenSource;
CancellationTokenSource? animationTokenSource;
Func<TouchBehavior, TouchState, HoverState, int, Easing?, CancellationToken, Task>? animationTaskFactory;
double? durationMultiplier;
double animationProgress;
TouchState animationState;
internal void HandleTouch(TouchBehavior sender, TouchStatus status)
{
if (sender.IsDisabled)
{
return;
}
var canExecuteAction = sender.CanExecute;
if (status != TouchStatus.Started || canExecuteAction)
{
if (!canExecuteAction)
{
status = TouchStatus.Canceled;
}
var state = status == TouchStatus.Started
? TouchState.Pressed
: TouchState.Normal;
if (status == TouchStatus.Started)
{
animationProgress = 0;
animationState = state;
}
var isToggled = sender.IsToggled;
if (isToggled.HasValue)
{
if (status != TouchStatus.Started)
{
durationMultiplier = (animationState == TouchState.Pressed && !isToggled.Value) ||
(animationState == TouchState.Normal && isToggled.Value)
? 1 - animationProgress
: animationProgress;
UpdateStatusAndState(sender, status, state);
if (status == TouchStatus.Canceled)
{
sender.ForceUpdateState(false);
return;
}
OnTapped(sender);
sender.IsToggled = !isToggled;
return;
}
state = isToggled.Value
? TouchState.Normal
: TouchState.Pressed;
}
UpdateStatusAndState(sender, status, state);
}
if (status == TouchStatus.Completed)
{
OnTapped(sender);
}
}
internal void HandleUserInteraction(TouchBehavior sender, TouchInteractionStatus interactionStatus)
{
if (sender.InteractionStatus != interactionStatus)
{
sender.InteractionStatus = interactionStatus;
sender.RaiseInteractionStatusChanged();
}
}
internal void HandleHover(TouchBehavior sender, HoverStatus status)
{
if (!sender.Element?.IsEnabled ?? true)
{
return;
}
var hoverState = status == HoverStatus.Entered
? HoverState.Hovered
: HoverState.Normal;
if (sender.HoverState != hoverState)
{
sender.HoverState = hoverState;
sender.RaiseHoverStateChanged();
}
if (sender.HoverStatus != status)
{
sender.HoverStatus = status;
sender.RaiseHoverStatusChanged();
}
}
internal async Task ChangeStateAsync(TouchBehavior sender, bool animated)
{
var status = sender.Status;
var state = sender.State;
var hoverState = sender.HoverState;
AbortAnimations(sender);
animationTokenSource = new CancellationTokenSource();
var token = animationTokenSource.Token;
var isToggled = sender.IsToggled;
if (sender.Element is not null)
{
UpdateVisualState(sender.Element, state, hoverState);
}
if (!animated)
{
if (isToggled.HasValue)
{
state = isToggled.Value
? TouchState.Pressed
: TouchState.Normal;
}
var durationMultiplier = this.durationMultiplier;
this.durationMultiplier = null;
await RunAnimationTask(sender, state, hoverState, animationTokenSource.Token, durationMultiplier.GetValueOrDefault()).ConfigureAwait(false);
return;
}
var pulseCount = sender.PulseCount;
if (pulseCount == 0 || (state == TouchState.Normal && !isToggled.HasValue))
{
if (isToggled.HasValue)
{
Console.WriteLine($"Touch state: {status}");
var r = (status == TouchStatus.Started && isToggled.Value) ||
(status != TouchStatus.Started && !isToggled.Value);
state = r
? TouchState.Normal
: TouchState.Pressed;
}
await RunAnimationTask(sender, state, hoverState, animationTokenSource.Token).ConfigureAwait(false);
return;
}
do
{
var rippleState = isToggled.HasValue && isToggled.Value
? TouchState.Normal
: TouchState.Pressed;
await RunAnimationTask(sender, rippleState, hoverState, animationTokenSource.Token);
if (token.IsCancellationRequested)
{
return;
}
rippleState = isToggled.HasValue && isToggled.Value
? TouchState.Pressed
: TouchState.Normal;
await RunAnimationTask(sender, rippleState, hoverState, animationTokenSource.Token);
if (token.IsCancellationRequested)
{
return;
}
}
while (--pulseCount != 0);
}
internal void HandleLongPress(TouchBehavior sender)
{
if (sender.State == TouchState.Normal)
{
longPressTokenSource?.Cancel();
longPressTokenSource?.Dispose();
longPressTokenSource = null;
return;
}
if (sender.LongPressCommand is null || sender.InteractionStatus == TouchInteractionStatus.Completed)
{
return;
}
longPressTokenSource = new CancellationTokenSource();
Task.Delay(sender.LongPressDuration, longPressTokenSource.Token).ContinueWith(t =>
{
if (t.IsFaulted && t.Exception != null)
{
throw t.Exception;
}
if (t.IsCanceled)
{
return;
}
var longPressAction = new Action(() =>
{
sender.HandleUserInteraction(TouchInteractionStatus.Completed);
sender.RaiseLongPressCompleted();
});
if (sender.Dispatcher.IsDispatchRequired)
{
sender.Dispatcher.Dispatch(longPressAction);
}
else
{
longPressAction.Invoke();
}
});
}
internal void SetCustomAnimationTask(Func<TouchBehavior, TouchState, HoverState, int, Easing?, CancellationToken, Task>? animationTaskFactory)
=> this.animationTaskFactory = animationTaskFactory;
internal void Reset()
{
SetCustomAnimationTask(null);
defaultBackgroundColor = default;
}
internal void OnTapped(TouchBehavior sender)
{
if (!sender.CanExecute || (sender.LongPressCommand != null && sender.InteractionStatus == TouchInteractionStatus.Completed))
{
return;
}
if (DeviceInfo.Platform == DevicePlatform.Android)
{
HandleCollectionViewSelection(sender);
}
if (sender.Element is IButtonController button)
{
button.SendClicked();
}
sender.RaiseCompleted();
}
void HandleCollectionViewSelection(TouchBehavior sender)
{
CollectionView? parent = null;
VisualElement? result = null;
if (!sender.Element?.TryFindParentElementWithParentOfType(out result, out parent) ?? true)
{
return;
}
var collectionView = parent ?? throw new NullReferenceException();
var item = result?.BindingContext ?? result ?? throw new NullReferenceException();
switch (collectionView.SelectionMode)
{
case SelectionMode.Single:
collectionView.SelectedItem = item;
break;
case SelectionMode.Multiple:
var selectedItems = collectionView.SelectedItems?.ToList() ?? new List<object>();
if (selectedItems.Contains(item))
{
selectedItems.Remove(item);
}
else
{
selectedItems.Add(item);
}
collectionView.UpdateSelectedItems(selectedItems);
break;
}
}
internal void AbortAnimations(TouchBehavior sender)
{
animationTokenSource?.Cancel();
animationTokenSource?.Dispose();
animationTokenSource = null;
var element = sender.Element;
if (element == null)
{
return;
}
element.AbortAnimations();
}
void UpdateStatusAndState(TouchBehavior sender, TouchStatus status, TouchState state)
{
sender.Status = status;
sender.RaiseStatusChanged();
if (sender.State != state || status != TouchStatus.Canceled)
{
sender.State = state;
sender.RaiseStateChanged();
}
}
void UpdateVisualState(VisualElement visualElement, TouchState touchState, HoverState hoverState)
{
var state = touchState == TouchState.Pressed
? TouchBehavior.PressedVisualState
: hoverState == HoverState.Hovered
? TouchBehavior.HoveredVisualState
: TouchBehavior.UnpressedVisualState;
VisualStateManager.GoToState(visualElement, state);
}
async Task SetBackgroundImageAsync(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, CancellationToken token)
{
var normalBackgroundImageSource = sender.NormalBackgroundImageSource;
var pressedBackgroundImageSource = sender.PressedBackgroundImageSource;
var hoveredBackgroundImageSource = sender.HoveredBackgroundImageSource;
if (normalBackgroundImageSource is null &&
pressedBackgroundImageSource is null &&
hoveredBackgroundImageSource is null)
{
return;
}
var aspect = sender.BackgroundImageAspect;
var source = normalBackgroundImageSource;
if (touchState == TouchState.Pressed)
{
if (sender.IsSet(TouchBehavior.PressedBackgroundImageAspectProperty))
{
aspect = sender.PressedBackgroundImageAspect;
}
source = pressedBackgroundImageSource;
}
else if (hoverState == HoverState.Hovered)
{
if (sender.IsSet(TouchBehavior.HoveredBackgroundImageAspectProperty))
{
aspect = sender.HoveredBackgroundImageAspect;
}
if (sender.IsSet(TouchBehavior.HoveredBackgroundImageSourceProperty))
{
source = hoveredBackgroundImageSource;
}
}
else
{
if (sender.IsSet(TouchBehavior.NormalBackgroundImageAspectProperty))
{
aspect = sender.NormalBackgroundImageAspect;
}
}
try
{
if (sender.ShouldSetImageOnAnimationEnd && duration > 0)
{
await Task.Delay(duration, token);
}
}
catch (TaskCanceledException)
{
return;
}
if (sender.Element is Image image)
{
using (image.Batch())
{
image.Aspect = aspect;
image.Source = source;
}
}
}
Task<bool> SetBackgroundColor(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalBackgroundColor = sender.NormalBackgroundColor;
var pressedBackgroundColor = sender.PressedBackgroundColor;
var hoveredBackgroundColor = sender.HoveredBackgroundColor;
if (sender.Element == null
|| (normalBackgroundColor is null
&& pressedBackgroundColor is null
&& hoveredBackgroundColor is null))
{
return Task.FromResult(false);
}
var element = sender.Element;
if (defaultBackgroundColor == default)
{
defaultBackgroundColor = element.BackgroundColor;
}
var color = GetBackgroundColor(normalBackgroundColor);
if (touchState == TouchState.Pressed)
{
color = GetBackgroundColor(pressedBackgroundColor);
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredBackgroundColorProperty))
{
color = GetBackgroundColor(hoveredBackgroundColor);
}
if (duration <= 0)
{
element.AbortAnimations();
element.BackgroundColor = color;
return Task.FromResult(true);
}
return element.ColorTo(color ?? Colors.Transparent, (uint)duration, easing);
}
Task<bool> SetOpacity(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalOpacity = sender.NormalOpacity;
var pressedOpacity = sender.PressedOpacity;
var hoveredOpacity = sender.HoveredOpacity;
if (Abs(normalOpacity - 1) <= double.Epsilon &&
Abs(pressedOpacity - 1) <= double.Epsilon &&
Abs(hoveredOpacity - 1) <= double.Epsilon)
{
return Task.FromResult(false);
}
var opacity = normalOpacity;
if (touchState == TouchState.Pressed)
{
opacity = pressedOpacity;
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredOpacityProperty))
{
opacity = hoveredOpacity;
}
var element = sender.Element;
if (duration <= 0 && element is not null)
{
element.AbortAnimations();
element.Opacity = opacity;
return Task.FromResult(true);
}
return element is null ?
Task.FromResult(false) :
element.FadeTo(opacity, (uint)Abs(duration), easing);
}
Task SetScale(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalScale = sender.NormalScale;
var pressedScale = sender.PressedScale;
var hoveredScale = sender.HoveredScale;
if (Abs(normalScale - 1) <= double.Epsilon &&
Abs(pressedScale - 1) <= double.Epsilon &&
Abs(hoveredScale - 1) <= double.Epsilon)
{
return Task.FromResult(false);
}
var scale = normalScale;
if (touchState == TouchState.Pressed)
{
scale = pressedScale;
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredScaleProperty))
{
scale = hoveredScale;
}
var element = sender.Element;
if (element is null)
{
return Task.FromResult(false);
}
if (duration <= 0)
{
element.AbortAnimations(nameof(SetScale));
element.Scale = scale;
return Task.FromResult(true);
}
var animationCompletionSource = new TaskCompletionSource<bool>();
element.Animate(nameof(SetScale), v =>
{
if (double.IsNaN(v))
{
return;
}
element.Scale = v;
}, element.Scale, scale, 16, (uint)Abs(duration), easing, (v, b) => animationCompletionSource.SetResult(b));
return animationCompletionSource.Task;
}
Task SetTranslation(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalTranslationX = sender.NormalTranslationX;
var pressedTranslationX = sender.PressedTranslationX;
var hoveredTranslationX = sender.HoveredTranslationX;
var normalTranslationY = sender.NormalTranslationY;
var pressedTranslationY = sender.PressedTranslationY;
var hoveredTranslationY = sender.HoveredTranslationY;
if (Abs(normalTranslationX) <= double.Epsilon
&& Abs(pressedTranslationX) <= double.Epsilon
&& Abs(hoveredTranslationX) <= double.Epsilon
&& Abs(normalTranslationY) <= double.Epsilon
&& Abs(pressedTranslationY) <= double.Epsilon
&& Abs(hoveredTranslationY) <= double.Epsilon)
{
return Task.FromResult(false);
}
var translationX = normalTranslationX;
var translationY = normalTranslationY;
if (touchState == TouchState.Pressed)
{
translationX = pressedTranslationX;
translationY = pressedTranslationY;
}
else if (hoverState == HoverState.Hovered)
{
if (sender.IsSet(TouchBehavior.HoveredTranslationXProperty))
{
translationX = hoveredTranslationX;
}
if (sender.IsSet(TouchBehavior.HoveredTranslationYProperty))
{
translationY = hoveredTranslationY;
}
}
var element = sender.Element;
if (duration <= 0 && element != null)
{
element.AbortAnimations();
element.TranslationX = translationX;
element.TranslationY = translationY;
return Task.FromResult(true);
}
return element?.TranslateTo(translationX, translationY, (uint)Abs(duration), easing) ?? Task.FromResult(false);
}
Task SetRotation(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalRotation = sender.NormalRotation;
var pressedRotation = sender.PressedRotation;
var hoveredRotation = sender.HoveredRotation;
if (Abs(normalRotation) <= double.Epsilon
&& Abs(pressedRotation) <= double.Epsilon
&& Abs(hoveredRotation) <= double.Epsilon)
{
return Task.FromResult(false);
}
var rotation = normalRotation;
if (touchState == TouchState.Pressed)
{
rotation = pressedRotation;
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredRotationProperty))
{
rotation = hoveredRotation;
}
var element = sender.Element;
if (duration <= 0 && element != null)
{
element.AbortAnimations();
element.Rotation = rotation;
return Task.FromResult(true);
}
return element?.RotateTo(rotation, (uint)Abs(duration), easing) ?? Task.FromResult(false);
}
Task SetRotationX(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalRotationX = sender.NormalRotationX;
var pressedRotationX = sender.PressedRotationX;
var hoveredRotationX = sender.HoveredRotationX;
if (Abs(normalRotationX) <= double.Epsilon &&
Abs(pressedRotationX) <= double.Epsilon &&
Abs(hoveredRotationX) <= double.Epsilon)
{
return Task.FromResult(false);
}
var rotationX = normalRotationX;
if (touchState == TouchState.Pressed)
{
rotationX = pressedRotationX;
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredRotationXProperty))
{
rotationX = hoveredRotationX;
}
var element = sender.Element;
if (duration <= 0 && element != null)
{
element.AbortAnimations();
element.RotationX = rotationX;
return Task.FromResult(true);
}
return element?.RotateXTo(rotationX, (uint)Abs(duration), easing) ?? Task.FromResult(false);
}
Task SetRotationY(TouchBehavior sender, TouchState touchState, HoverState hoverState, int duration, Easing? easing)
{
var normalRotationY = sender.NormalRotationY;
var pressedRotationY = sender.PressedRotationY;
var hoveredRotationY = sender.HoveredRotationY;
if (Abs(normalRotationY) <= double.Epsilon &&
Abs(pressedRotationY) <= double.Epsilon &&
Abs(hoveredRotationY) <= double.Epsilon)
{
return Task.FromResult(false);
}
var rotationY = normalRotationY;
if (touchState == TouchState.Pressed)
{
rotationY = pressedRotationY;
}
else if (hoverState == HoverState.Hovered && sender.IsSet(TouchBehavior.HoveredRotationYProperty))
{
rotationY = hoveredRotationY;
}
var element = sender.Element;
if (duration <= 0 && element != null)
{
element.AbortAnimations();
element.RotationY = rotationY;
return Task.FromResult(true);
}
return element?.RotateYTo(rotationY, (uint)Abs(duration), easing) ?? Task.FromResult(false);
}
Color? GetBackgroundColor(Color? color)
=> color is not null
? color
: defaultBackgroundColor;
Task RunAnimationTask(TouchBehavior sender, TouchState touchState, HoverState hoverState, CancellationToken token, double? durationMultiplier = null)
{
if (sender.Element == null)
{
return Task.FromResult(false);
}
var duration = sender.AnimationDuration;
var easing = sender.AnimationEasing;
if (touchState == TouchState.Pressed)
{
if (sender.IsSet(TouchBehavior.PressedAnimationDurationProperty))
{
duration = sender.PressedAnimationDuration;
}
if (sender.IsSet(TouchBehavior.PressedAnimationEasingProperty))
{
easing = sender.PressedAnimationEasing;
}
}
else if (hoverState == HoverState.Hovered)
{
if (sender.IsSet(TouchBehavior.HoveredAnimationDurationProperty))
{
duration = sender.HoveredAnimationDuration;
}
if (sender.IsSet(TouchBehavior.HoveredAnimationEasingProperty))
{
easing = sender.HoveredAnimationEasing;
}
}
else
{
if (sender.IsSet(TouchBehavior.NormalAnimationDurationProperty))
{
duration = sender.NormalAnimationDuration;
}
if (sender.IsSet(TouchBehavior.NormalAnimationEasingProperty))
{
easing = sender.NormalAnimationEasing;
}
}
if (durationMultiplier.HasValue)
{
duration = (int)durationMultiplier.Value * duration;
}
duration = Max(duration, 0);
return Task.WhenAll(
animationTaskFactory?.Invoke(sender, touchState, hoverState, duration, easing, token) ?? Task.FromResult(true),
SetBackgroundImageAsync(sender, touchState, hoverState, duration, token),
SetBackgroundColor(sender, touchState, hoverState, duration, easing),
SetOpacity(sender, touchState, hoverState, duration, easing),
SetScale(sender, touchState, hoverState, duration, easing),
SetTranslation(sender, touchState, hoverState, duration, easing),
SetRotation(sender, touchState, hoverState, duration, easing),
SetRotationX(sender, touchState, hoverState, duration, easing),
SetRotationY(sender, touchState, hoverState, duration, easing),
Task.Run(async () =>
{
animationProgress = 0;
animationState = touchState;
for (var progress = animationProgressDelay; progress < duration; progress += animationProgressDelay)
{
await Task.Delay(animationProgressDelay).ConfigureAwait(false);
if (token.IsCancellationRequested)
{
return;
}
animationProgress = (double)progress / duration;
}
animationProgress = 1;
}));
}
public void Dispose()
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,18 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.HoverStateChanged"/> event.
/// </summary>
///
public enum HoverState
{
/// <summary>
/// The pointer is not over the element.
/// </summary>
Normal,
/// <summary>
/// The pointer is over the element.
/// </summary>
Hovered
}

View File

@@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.HoverStateChanged"/> event.
/// </summary>
public class HoverStateChangedEventArgs : EventArgs
{
internal HoverStateChangedEventArgs(HoverState state)
=> State = state;
/// <summary>
/// Gets the new <see cref="HoverState"/> of the element.
/// </summary>
public HoverState State { get; }
}

View File

@@ -0,0 +1,16 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.HoverStatusChanged"/> event.
/// </summary>
public enum HoverStatus
{
/// <summary>
/// The pointer has entered the element.
/// </summary>
Entered,
/// <summary>
/// The pointer has exited the element.
/// </summary>
Exited
}

View File

@@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.HoverStatusChanged"/> event.
/// </summary>
public class HoverStatusChangedEventArgs : EventArgs
{
internal HoverStatusChangedEventArgs(HoverStatus status)
=> Status = status;
/// <summary>
/// Gets the new <see cref="HoverStatus"/> of the element.
/// </summary>
public HoverStatus Status { get; }
}

View File

@@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.LongPressCompleted"/> event.
/// </summary>
public class LongPressCompletedEventArgs : EventArgs
{
internal LongPressCompletedEventArgs(object? parameter)
=> Parameter = parameter;
/// <summary>
/// Gets the parameter of the <see cref="TouchBehavior.LongPressCompleted"/> event.
/// </summary>
public object? Parameter { get; }
}

View File

@@ -0,0 +1,19 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.Completed"/> event.
/// </summary>
public class TouchCompletedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="TouchCompletedEventArgs"/> class.
/// </summary>
internal TouchCompletedEventArgs(object? parameter)
=> Parameter = parameter;
/// <summary>
/// Gets the parameter associated with the touch event.
/// </summary>
public object? Parameter { get; }
}

View File

@@ -0,0 +1,17 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.Completed"/> event.
/// </summary>
public enum TouchInteractionStatus
{
/// <summary>
/// The touch interaction has started.
/// </summary>
Started,
/// <summary>
/// The touch interaction has completed.
/// </summary>
Completed
}

View File

@@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.InteractionStatusChanged"/> event.
/// </summary>
public class TouchInteractionStatusChangedEventArgs : EventArgs
{
internal TouchInteractionStatusChangedEventArgs(TouchInteractionStatus touchInteractionStatus)
=> TouchInteractionStatus = touchInteractionStatus;
/// <summary>
/// Gets the current touch interaction status.
/// </summary>
public TouchInteractionStatus TouchInteractionStatus { get; }
}

View File

@@ -0,0 +1,17 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.StatusChanged"/> event.
/// </summary>
public enum TouchState
{
/// <summary>
/// The pointer is not over the element.
/// </summary>
Normal,
/// <summary>
/// The pointer is over the element.
/// </summary>
Pressed
}

View File

@@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.StateChanged"/> event.
/// </summary>
public class TouchStateChangedEventArgs : EventArgs
{
internal TouchStateChangedEventArgs(TouchState state)
=> State = state;
/// <summary>
/// Gets the current state of the touch event.
/// </summary>
public TouchState State { get; }
}

View File

@@ -0,0 +1,22 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.StatusChanged"/> event.
/// </summary>
public enum TouchStatus
{
/// <summary>
/// The touch interaction has started.
/// </summary>
Started,
/// <summary>
/// The touch interaction has completed.
/// </summary>
Completed,
/// <summary>
/// The touch interaction has been canceled.
/// </summary>
Canceled
}

View File

@@ -0,0 +1,15 @@
namespace CommunityToolkit.Maui.Behaviors;
/// <summary>
/// Provides data for the <see cref="TouchBehavior.StatusChanged"/> event.
/// </summary>
public class TouchStatusChangedEventArgs : EventArgs
{
internal TouchStatusChangedEventArgs(TouchStatus status)
=> Status = status;
/// <summary>
/// Gets the current touch status.
/// </summary>
public TouchStatus Status { get; }
}

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
namespace CommunityToolkit.Maui.Behaviors;
public partial class TouchBehavior : IDisposable
{
readonly NullReferenceException nre = new(nameof(Element));
internal void RaiseInteractionStatusChanged()
=> weakEventManager.HandleEvent(Element ?? throw nre, new TouchInteractionStatusChangedEventArgs(InteractionStatus), nameof(InteractionStatusChanged));
internal void RaiseStatusChanged()
=> weakEventManager.HandleEvent(Element ?? throw nre, new TouchStatusChangedEventArgs(Status), nameof(StatusChanged));
internal void RaiseHoverStateChanged()
{
weakEventManager.HandleEvent(Element ?? throw nre, new HoverStateChangedEventArgs(HoverState), nameof(HoverStateChanged));
}
internal void RaiseHoverStatusChanged()
=> weakEventManager.HandleEvent(Element ?? throw nre, new HoverStatusChangedEventArgs(HoverStatus), nameof(HoverStatusChanged));
internal void RaiseCompleted()
{
var element = Element;
if (element is null)
{
return;
}
var parameter = CommandParameter;
Command?.Execute(parameter);
weakEventManager.HandleEvent(element, new TouchCompletedEventArgs(parameter), nameof(Completed));
}
internal void RaiseLongPressCompleted()
{
var element = Element;
if (element is null)
{
return;
}
var parameter = LongPressCommandParameter ?? CommandParameter;
LongPressCommand?.Execute(parameter);
weakEventManager.HandleEvent(element, new LongPressCompletedEventArgs(parameter), nameof(LongPressCompleted));
}
internal void ForceUpdateState(bool animated = true)
{
if (element is null)
{
return;
}
gestureManager.ChangeStateAsync(this, animated).ContinueWith(t =>
{
if (t.Exception is null)
{
return;
}
Console.WriteLine($"Failed to force update state, with the {t.Exception} exception and the {t.Exception.Message} message.");
}, TaskContinuationOptions.OnlyOnFaulted);
}
internal void HandleTouch(TouchStatus status)
=> gestureManager.HandleTouch(this, status);
internal void HandleUserInteraction(TouchInteractionStatus interactionStatus)
=> gestureManager.HandleUserInteraction(this, interactionStatus);
internal void HandleHover(HoverStatus status)
=> gestureManager.HandleHover(this, status);
internal void RaiseStateChanged()
{
ForceUpdateState();
HandleLongPress();
weakEventManager.HandleEvent(Element ?? throw nre, new TouchStateChangedEventArgs(State), nameof(StateChanged));
}
internal void HandleLongPress()
{
if (Element is null)
{
return;
}
gestureManager.HandleLongPress(this);
}
void SetChildrenInputTransparent(bool value)
{
if (Element is not Layout layout)
{
return;
}
layout.ChildAdded -= OnLayoutChildAdded;
if (!value)
{
return;
}
layout.InputTransparent = false;
foreach (var view in layout.Children)
{
OnLayoutChildAdded(layout, new ElementEventArgs((View)view));
}
layout.ChildAdded += OnLayoutChildAdded;
}
void OnLayoutChildAdded(object? sender, ElementEventArgs e)
{
if (e.Element is not View view)
{
return;
}
if (!ShouldMakeChildrenInputTransparent)
{
view.InputTransparent = false;
return;
}
view.InputTransparent = IsAvailable;
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
bool isDisposed;
/// <summary>
/// Dispose the object.
/// </summary>
protected virtual void Dispose(bool disposing)
{
if (isDisposed)
{
return;
}
if (disposing)
{
// free managed resources
gestureManager.Dispose();
}
isDisposed = true;
}
}

View File

@@ -0,0 +1,555 @@
#if ANDROID
using Android.Content;
using Android.Content.Res;
using Android.Graphics.Drawables;
using Android.OS;
using Android.Views;
using Android.Views.Accessibility;
using Android.Widget;
using Microsoft.Maui.Controls.Compatibility.Platform.Android;
using static System.OperatingSystem;
using AView = Android.Views.View;
using Color = Android.Graphics.Color;
using MColor = Microsoft.Maui.Graphics.Color;
using MView = Microsoft.Maui.Controls.View;
using PlatformView = Android.Views.View;
using ParentView = Android.Views.IViewParent;
namespace CommunityToolkit.Maui.Behaviors;
public partial class TouchBehavior
{
private static readonly MColor defaultNativeAnimationColor = MColor.FromRgba(128, 128, 128, 64);
private bool isHoverSupported;
private RippleDrawable? ripple;
private AView? rippleView;
private float startX;
private float startY;
private MColor? rippleColor;
private int rippleRadius = -1;
private AView? view = null;
private ViewGroup? viewGroup;
private AccessibilityManager? accessibilityManager;
private AccessibilityListener? accessibilityListener;
private bool IsAccessibilityMode => accessibilityManager is not null
&& accessibilityManager.IsEnabled
&& accessibilityManager.IsTouchExplorationEnabled;
private readonly bool isAtLeastM = IsAndroidVersionAtLeast((int) BuildVersionCodes.M);
internal bool IsCanceled { get; set; }
private bool IsForegroundRippleWithTapGestureRecognizer =>
ripple is not null &&
this.view is not null &&
/*
ripple.IsAlive() &&
this.view.IsAlive() &&
*/
(isAtLeastM ? this.view.Foreground : this.view.Background) == ripple &&
element is MView view &&
view.GestureRecognizers.Any(gesture => gesture is TapGestureRecognizer);
/// <summary>
/// Attaches the behavior to the platform view.
/// </summary>
/// <param name="bindable">Maui Visual Element</param>
/// <param name="platformView">Native View</param>
protected override void OnAttachedTo(VisualElement bindable, AView platformView)
{
Element = bindable;
view = platformView;
//viewGroup = Microsoft.Maui.Platform.ViewExtensions.GetParentOfType<ViewGroup>(platformView);
if (IsDisabled)
{
return;
}
platformView.Touch += OnTouch;
UpdateClickHandler();
accessibilityManager = platformView.Context?.GetSystemService(Context.AccessibilityService) as AccessibilityManager;
if (accessibilityManager is not null)
{
accessibilityListener = new AccessibilityListener(this);
accessibilityManager.AddAccessibilityStateChangeListener(accessibilityListener);
accessibilityManager.AddTouchExplorationStateChangeListener(accessibilityListener);
}
if (!IsAndroidVersionAtLeast((int) BuildVersionCodes.Lollipop) || !NativeAnimation)
{
return;
}
platformView.Clickable = true;
platformView.LongClickable = true;
CreateRipple();
ApplyRipple();
platformView.LayoutChange += OnLayoutChange;
}
/// <summary>
/// Detaches the behavior from the platform view.
/// </summary>
/// <param name="bindable">Maui Visual Element</param>
/// <param name="platformView">Native View</param>
protected override void OnDetachedFrom(VisualElement bindable, AView platformView)
{
element = bindable;
view = platformView;
if (element is null)
{
return;
}
try
{
if (accessibilityManager is not null && accessibilityListener is not null)
{
accessibilityManager.RemoveAccessibilityStateChangeListener(accessibilityListener);
accessibilityManager.RemoveTouchExplorationStateChangeListener(accessibilityListener);
accessibilityListener.Dispose();
accessibilityManager = null;
accessibilityListener = null;
}
RemoveRipple();
if (view is not null)
{
view.LayoutChange -= OnLayoutChange;
view.Touch -= OnTouch;
view.Click -= OnClick;
}
if (rippleView is not null)
{
rippleView.Pressed = false;
viewGroup?.RemoveView(rippleView);
rippleView.Dispose();
rippleView = null;
}
}
catch (ObjectDisposedException)
{
// Suppress exception
}
isHoverSupported = false;
}
private void OnLayoutChange(object? sender, AView.LayoutChangeEventArgs e)
{
if (sender is not AView view || rippleView is null)
{
return;
}
rippleView.Right = view.Width;
rippleView.Bottom = view.Height;
}
private void CreateRipple()
{
RemoveRipple();
var drawable = isAtLeastM && viewGroup is null
? view?.Foreground
: view?.Background;
var isBorderLess = NativeAnimationBorderless;
var isEmptyDrawable = Element is Layout || drawable is null;
var color = NativeAnimationColor;
if (drawable is RippleDrawable rippleDrawable && rippleDrawable.GetConstantState() is Drawable.ConstantState constantState)
{
ripple = (RippleDrawable) constantState.NewDrawable();
}
else
{
var content = isEmptyDrawable || isBorderLess ? null : drawable;
var mask = isEmptyDrawable && !isBorderLess ? new ColorDrawable(Color.White) : null;
ripple = new RippleDrawable(GetColorStateList(color), content, mask);
}
UpdateRipple(color);
}
private void RemoveRipple()
{
if (ripple is null)
{
return;
}
if (view is not null)
{
if (isAtLeastM && view.Foreground == ripple)
{
view.Foreground = null;
}
else if (view.Background == ripple)
{
view.Background = null;
}
}
if (rippleView is not null)
{
rippleView.Foreground = null;
rippleView.Background = null;
}
ripple.Dispose();
ripple = null;
}
private void UpdateRipple(MColor color)
{
if (IsDisabled || (color == rippleColor && NativeAnimationRadius == rippleRadius))
{
return;
}
rippleColor = color;
rippleRadius = NativeAnimationRadius;
ripple?.SetColor(GetColorStateList(color));
if (isAtLeastM && ripple is not null)
{
ripple.Radius = (int) (view?.Context?.Resources?.DisplayMetrics?.Density * NativeAnimationRadius ?? throw new NullReferenceException());
}
}
private ColorStateList GetColorStateList(MColor? color)
{
var animationColor = color;
animationColor ??= defaultNativeAnimationColor;
return new ColorStateList(
new[] {Array.Empty<int>()},
new[] {(int) animationColor.ToAndroid()});
}
private void UpdateClickHandler()
{
if (view is null /* || !view.IsAlive()*/)
{
return;
}
view.Click -= OnClick;
if (IsAccessibilityMode || (IsAvailable && (element?.IsEnabled ?? false)))
{
view.Click += OnClick;
return;
}
}
private void ApplyRipple()
{
if (ripple is null)
{
return;
}
var isBorderless = NativeAnimationBorderless;
if (viewGroup is null && view is not null)
{
if (IsAndroidVersionAtLeast((int) BuildVersionCodes.M))
{
view.Foreground = ripple;
}
else
{
view.Background = ripple;
}
return;
}
if (rippleView is null)
{
rippleView = new FrameLayout(viewGroup?.Context ?? view?.Context ?? throw new NullReferenceException())
{
LayoutParameters = new ViewGroup.LayoutParams(-1, -1),
Clickable = false,
Focusable = false,
Enabled = false
};
viewGroup?.AddView(rippleView);
rippleView.BringToFront();
}
viewGroup?.SetClipChildren(!isBorderless);
if (isBorderless)
{
rippleView.Background = null;
rippleView.Foreground = ripple;
}
else
{
rippleView.Foreground = null;
rippleView.Background = ripple;
}
}
private void OnClick(object? sender, EventArgs args)
{
if (IsDisabled)
{
return;
}
if (!IsAccessibilityMode)
{
return;
}
IsCanceled = false;
HandleEnd(TouchStatus.Completed);
}
private void HandleEnd(TouchStatus status)
{
if (IsCanceled)
{
return;
}
IsCanceled = true;
if (DisallowTouchThreshold > 0)
{
viewGroup?.Parent?.RequestDisallowInterceptTouchEvent(false);
}
HandleTouch(status);
HandleUserInteraction(TouchInteractionStatus.Completed);
EndRipple();
}
private void EndRipple()
{
if (IsDisabled)
{
return;
}
if (rippleView != null)
{
if (rippleView.Pressed)
{
rippleView.Pressed = false;
rippleView.Enabled = false;
}
}
else if (IsForegroundRippleWithTapGestureRecognizer)
{
if (view?.Pressed ?? false)
{
view.Pressed = false;
}
}
}
private void OnTouch(object? sender, AView.TouchEventArgs e)
{
e.Handled = false;
if (IsDisabled)
{
return;
}
if (IsAccessibilityMode)
{
return;
}
switch (e.Event?.ActionMasked)
{
case MotionEventActions.Down:
OnTouchDown(e);
break;
case MotionEventActions.Up:
OnTouchUp();
break;
case MotionEventActions.Cancel:
OnTouchCancel();
break;
case MotionEventActions.Move:
OnTouchMove(sender, e);
break;
case MotionEventActions.HoverEnter:
OnHoverEnter();
break;
case MotionEventActions.HoverExit:
OnHoverExit();
break;
}
}
private void OnTouchDown(AView.TouchEventArgs e)
{
_ = e.Event ?? throw new NullReferenceException();
IsCanceled = false;
startX = e.Event.GetX();
startY = e.Event.GetY();
HandleUserInteraction(TouchInteractionStatus.Started);
HandleTouch(TouchStatus.Started);
StartRipple(e.Event.GetX(), e.Event.GetY());
if (DisallowTouchThreshold > 0)
{
viewGroup?.Parent?.RequestDisallowInterceptTouchEvent(true);
}
}
private void OnTouchUp()
{
HandleEnd(Status == TouchStatus.Started ? TouchStatus.Completed : TouchStatus.Canceled);
}
private void OnTouchCancel()
{
HandleEnd(TouchStatus.Canceled);
}
private void OnTouchMove(object? sender, AView.TouchEventArgs e)
{
if (IsCanceled || e.Event == null)
{
return;
}
var diffX = Math.Abs(e.Event.GetX() - startX) / this.view?.Context?.Resources?.DisplayMetrics?.Density ?? throw new NullReferenceException();
var diffY = Math.Abs(e.Event.GetY() - startY) / this.view?.Context?.Resources?.DisplayMetrics?.Density ?? throw new NullReferenceException();
var maxDiff = Math.Max(diffX, diffY);
var disallowTouchThreshold = DisallowTouchThreshold;
if (disallowTouchThreshold > 0 && maxDiff > disallowTouchThreshold)
{
HandleEnd(TouchStatus.Canceled);
return;
}
if (sender is not AView view)
{
return;
}
var screenPointerCoords = new Point(view.Left + e.Event.GetX(), view.Top + e.Event.GetY());
var viewRect = new Rect(view.Left, view.Top, view.Right - view.Left, view.Bottom - view.Top);
var status = viewRect.Contains(screenPointerCoords) ? TouchStatus.Started : TouchStatus.Canceled;
if (isHoverSupported && ((status == TouchStatus.Canceled && HoverStatus == HoverStatus.Entered)
|| (status == TouchStatus.Started && HoverStatus == HoverStatus.Exited)))
{
HandleHover(status == TouchStatus.Started ? HoverStatus.Entered : HoverStatus.Exited);
}
if (Status != status)
{
HandleTouch(status);
if (status == TouchStatus.Started)
{
StartRipple(e.Event.GetX(), e.Event.GetY());
}
if (status == TouchStatus.Canceled)
{
EndRipple();
}
}
}
private void OnHoverEnter()
{
isHoverSupported = true;
HandleHover(HoverStatus.Entered);
}
private void OnHoverExit()
{
isHoverSupported = true;
HandleHover(HoverStatus.Exited);
}
private void StartRipple(float x, float y)
{
if (IsDisabled || !NativeAnimation)
{
return;
}
if (CanExecute)
{
UpdateRipple(NativeAnimationColor);
if (rippleView is not null)
{
rippleView.Enabled = true;
rippleView.BringToFront();
ripple?.SetHotspot(x, y);
rippleView.Pressed = true;
}
else if (IsForegroundRippleWithTapGestureRecognizer && view is not null)
{
ripple?.SetHotspot(x, y);
view.Pressed = true;
}
}
else if (rippleView is null)
{
UpdateRipple(Colors.Transparent);
}
}
private sealed class AccessibilityListener : Java.Lang.Object,
AccessibilityManager.IAccessibilityStateChangeListener,
AccessibilityManager.ITouchExplorationStateChangeListener
{
private TouchBehavior? platformTouchEffect;
internal AccessibilityListener(TouchBehavior platformTouchEffect)
{
this.platformTouchEffect = platformTouchEffect;
}
public void OnAccessibilityStateChanged(bool enabled)
{
platformTouchEffect?.UpdateClickHandler();
}
public void OnTouchExplorationStateChanged(bool enabled)
{
platformTouchEffect?.UpdateClickHandler();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
platformTouchEffect = null;
}
base.Dispose(disposing);
}
}
}
#endif

View File

@@ -0,0 +1,308 @@
#if IOS
using AsyncAwaitBestPractices;
using CoreGraphics;
using Foundation;
using Microsoft.Maui.Controls.Compatibility.Platform.iOS;
using Microsoft.Maui.Platform;
using UIKit;
namespace CommunityToolkit.Maui.Behaviors;
public partial class TouchBehavior
{
private UIGestureRecognizer? touchGesture;
private UIGestureRecognizer? hoverGesture;
/// <summary>
/// Attaches the behavior to the platform view.
/// </summary>
/// <param name="bindable">Maui Visual Element</param>
/// <param name="platformView">Native View</param>
protected override void OnAttachedTo(VisualElement bindable, UIView platformView)
{
Element = bindable;
touchGesture = new TouchUITapGestureRecognizer(this);
if (((platformView as IVisualNativeElementRenderer)?.Control ?? platformView) is UIButton button)
{
button.AllTouchEvents += PreventButtonHighlight;
((TouchUITapGestureRecognizer) touchGesture).IsButton = true;
}
platformView.AddGestureRecognizer(touchGesture);
if (UIDevice.CurrentDevice.CheckSystemVersion(13, 0))
{
hoverGesture = new UIHoverGestureRecognizer(OnHover);
platformView.AddGestureRecognizer(hoverGesture);
}
platformView.UserInteractionEnabled = true;
}
/// <summary>
/// Detaches the behavior from the platform view.
/// </summary>
/// <param name="bindable">Maui Visual Element</param>
/// <param name="platformView">Native View</param>
protected override void OnDetachedFrom(VisualElement bindable, UIView platformView)
{
if (((platformView as IVisualNativeElementRenderer)?.Control ?? platformView) is UIButton button)
{
button.AllTouchEvents -= PreventButtonHighlight;
}
if (touchGesture != null)
{
platformView?.RemoveGestureRecognizer(touchGesture);
touchGesture?.Dispose();
touchGesture = null;
}
if (hoverGesture != null)
{
platformView?.RemoveGestureRecognizer(hoverGesture);
hoverGesture?.Dispose();
hoverGesture = null;
}
Element = null;
}
private void OnHover()
{
if (IsDisabled)
{
return;
}
switch (hoverGesture?.State)
{
case UIGestureRecognizerState.Began:
case UIGestureRecognizerState.Changed:
HandleHover(HoverStatus.Entered);
break;
case UIGestureRecognizerState.Ended:
HandleHover(HoverStatus.Exited);
break;
}
}
private void PreventButtonHighlight(object? sender, EventArgs args)
{
if (sender is not UIButton button)
{
throw new ArgumentException($"{nameof(sender)} must be Type {nameof(UIButton)}", nameof(sender));
}
button.Highlighted = false;
}
}
internal sealed class TouchUITapGestureRecognizer : UIGestureRecognizer
{
private TouchBehavior behavior;
private float? defaultRadius;
private float? defaultShadowRadius;
private float? defaultShadowOpacity;
private CGPoint? startPoint;
public TouchUITapGestureRecognizer(TouchBehavior behavior)
{
this.behavior = behavior;
CancelsTouchesInView = false;
Delegate = new TouchUITapGestureRecognizerDelegate();
}
public bool IsCanceled { get; set; } = true;
public bool IsButton { get; set; }
public override void TouchesBegan(NSSet touches, UIEvent evt)
{
if (behavior?.IsDisabled ?? true)
{
return;
}
IsCanceled = false;
startPoint = GetTouchPoint(touches);
HandleTouch(TouchStatus.Started, TouchInteractionStatus.Started).SafeFireAndForget();
base.TouchesBegan(touches, evt);
}
public override void TouchesEnded(NSSet touches, UIEvent evt)
{
if (behavior?.IsDisabled ?? true)
{
return;
}
HandleTouch(behavior?.Status == TouchStatus.Started ? TouchStatus.Completed : TouchStatus.Canceled, TouchInteractionStatus.Completed).SafeFireAndForget();
IsCanceled = true;
base.TouchesEnded(touches, evt);
}
public override void TouchesCancelled(NSSet touches, UIEvent evt)
{
if (behavior?.IsDisabled ?? true)
{
return;
}
HandleTouch(TouchStatus.Canceled, TouchInteractionStatus.Completed).SafeFireAndForget();
IsCanceled = true;
base.TouchesCancelled(touches, evt);
}
public override void TouchesMoved(NSSet touches, UIEvent evt)
{
if (behavior?.IsDisabled ?? true)
{
return;
}
var disallowTouchThreshold = behavior.DisallowTouchThreshold;
var point = GetTouchPoint(touches);
if (point != null && startPoint != null && disallowTouchThreshold > 0)
{
var diffX = Math.Abs(point.Value.X - startPoint.Value.X);
var diffY = Math.Abs(point.Value.Y - startPoint.Value.Y);
var maxDiff = Math.Max(diffX, diffY);
if (maxDiff > disallowTouchThreshold)
{
HandleTouch(TouchStatus.Canceled, TouchInteractionStatus.Completed).SafeFireAndForget();
IsCanceled = true;
base.TouchesMoved(touches, evt);
return;
}
}
var status = point != null && View?.Bounds.Contains(point.Value) is true
? TouchStatus.Started
: TouchStatus.Canceled;
if (behavior?.Status != status)
{
HandleTouch(status).SafeFireAndForget();
}
if (status == TouchStatus.Canceled)
{
IsCanceled = true;
}
base.TouchesMoved(touches, evt);
}
public async Task HandleTouch(TouchStatus status, TouchInteractionStatus? interactionStatus = null)
{
if (IsCanceled || behavior == null)
{
return;
}
if (behavior?.IsDisabled ?? true)
{
return;
}
var canExecuteAction = behavior.CanExecute;
if (interactionStatus == TouchInteractionStatus.Started)
{
behavior?.HandleUserInteraction(TouchInteractionStatus.Started);
interactionStatus = null;
}
behavior?.HandleTouch(status);
if (interactionStatus.HasValue)
{
behavior?.HandleUserInteraction(interactionStatus.Value);
}
if (behavior == null || behavior.Element is null || (!behavior.NativeAnimation && !IsButton) || (!canExecuteAction && status == TouchStatus.Started))
{
return;
}
var color = behavior.NativeAnimationColor;
var radius = behavior.NativeAnimationRadius;
var shadowRadius = behavior.NativeAnimationShadowRadius;
var isStarted = status == TouchStatus.Started;
defaultRadius = (float?) (defaultRadius ?? View.Layer.CornerRadius);
defaultShadowRadius = (float?) (defaultShadowRadius ?? View.Layer.ShadowRadius);
defaultShadowOpacity ??= View.Layer.ShadowOpacity;
var tcs = new TaskCompletionSource<UIViewAnimatingPosition>();
UIViewPropertyAnimator.CreateRunningPropertyAnimator(.2, 0, UIViewAnimationOptions.AllowUserInteraction,
() =>
{
if (color == default(Color))
{
View.Layer.Opacity = isStarted ? 0.5f : (float) behavior.Element.Opacity;
}
else
{
View.Layer.BackgroundColor = (isStarted ? color : behavior.Element.BackgroundColor).ToCGColor();
}
View.Layer.CornerRadius = isStarted ? radius : defaultRadius.GetValueOrDefault();
if (shadowRadius >= 0)
{
View.Layer.ShadowRadius = isStarted ? shadowRadius : defaultShadowRadius.GetValueOrDefault();
View.Layer.ShadowOpacity = isStarted ? 0.7f : defaultShadowOpacity.GetValueOrDefault();
}
}, endPos => tcs.SetResult(endPos));
await tcs.Task;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
Delegate.Dispose();
}
base.Dispose(disposing);
}
private CGPoint? GetTouchPoint(NSSet touches)
{
return (touches?.AnyObject as UITouch)?.LocationInView(View);
}
private class TouchUITapGestureRecognizerDelegate : UIGestureRecognizerDelegate
{
public override bool ShouldRecognizeSimultaneously(UIGestureRecognizer gestureRecognizer, UIGestureRecognizer otherGestureRecognizer)
{
if (gestureRecognizer is TouchUITapGestureRecognizer touchGesture && otherGestureRecognizer is UIPanGestureRecognizer &&
otherGestureRecognizer.State == UIGestureRecognizerState.Began)
{
touchGesture.HandleTouch(TouchStatus.Canceled, TouchInteractionStatus.Completed).SafeFireAndForget();
touchGesture.IsCanceled = true;
}
return true;
}
public override bool ShouldReceiveTouch(UIGestureRecognizer recognizer, UITouch touch)
{
if (recognizer.View.IsDescendantOfView(touch.View))
{
return true;
}
return recognizer.View.Subviews.Any(view => view == touch.View);
}
}
}
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -74,6 +74,7 @@ namespace Bit.Core
public const string DefaultFido2CredentialType = "public-key";
public const string DefaultFido2CredentialAlgorithm = "ECDSA";
public const string DefaultFido2CredentialCurve = "P-256";
public const int LatestStateVersion = 7;
public static readonly string[] AndroidAllClearCipherCacheKeys =
{

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ContentView
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:xct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:effects="clr-namespace:Bit.App.Effects"
xmlns:view="clr-namespace:Bit.Core.Models.View"
x:Name="_mainOverlay"
x:DataType="controls:AccountSwitchingOverlayViewModel"
x:Class="Bit.App.Controls.AccountSwitchingOverlayView"
BackgroundColor="#22000000"
Padding="0"
IsVisible="False">
<Grid
x:Name="_accountListContainer"
VerticalOptions="Fill"
HorizontalOptions="Fill"
BackgroundColor="Transparent"
RowDefinitions="Auto, *">
<Frame
Grid.Row="0"
Padding="0"
HorizontalOptions="Fill"
VerticalOptions="Start">
<Frame.Shadow>
<Shadow
Brush="Black"
Radius="10"
Offset="0,3" />
</Frame.Shadow>
<ListView
x:Name="_accountListView"
ItemsSource="{Binding BindingContext.AccountViews, Source={x:Reference _mainOverlay}}"
BackgroundColor="{DynamicResource BackgroundColor}"
VerticalOptions="Start"
RowHeight="{Binding AccountListRowHeight, Source={x:Reference _mainOverlay}}"
effects:ScrollViewContentInsetAdjustmentBehaviorEffect.ContentInsetAdjustmentBehavior="Never"
AutomationId="AccountListView">
<ListView.ItemTemplate>
<DataTemplate x:DataType="controls:AccountViewCellViewModel">
<controls:AccountViewCell
SelectAccountCommand="{Binding SelectAccountCommand, Source={x:Reference _mainOverlay}}"
LongPressAccountCommand="{Binding LongPressAccountCommand, Source={x:Reference _mainOverlay}}"
AutomationId="AccountViewCell" />
</DataTemplate>
</ListView.ItemTemplate>
<ListView.Effects>
<effects:ScrollViewContentInsetAdjustmentBehaviorEffect />
</ListView.Effects>
</ListView>
</Frame>
<BoxView
Grid.Row="1"
BackgroundColor="Transparent"
HorizontalOptions="Fill"
VerticalOptions="Fill">
<BoxView.GestureRecognizers>
<TapGestureRecognizer Tapped="FreeSpaceOverlay_Tapped" />
</BoxView.GestureRecognizers>
</BoxView>
</Grid>
</ContentView>

View File

@@ -0,0 +1,198 @@
using System.Windows.Input;
using Bit.Core.Abstractions;
using Bit.Core.Utilities;
using CommunityToolkit.Mvvm.Input;
namespace Bit.App.Controls
{
public partial class AccountSwitchingOverlayView : ContentView
{
public static readonly BindableProperty MainPageProperty = BindableProperty.Create(
nameof(MainPage),
typeof(ContentPage),
typeof(AccountSwitchingOverlayView),
defaultBindingMode: BindingMode.OneWay);
public static readonly BindableProperty MainFabProperty = BindableProperty.Create(
nameof(MainFab),
typeof(View),
typeof(AccountSwitchingOverlayView),
defaultBindingMode: BindingMode.OneWay);
public ContentPage MainPage
{
get => (ContentPage)GetValue(MainPageProperty);
set => SetValue(MainPageProperty, value);
}
public View MainFab
{
get => (View)GetValue(MainFabProperty);
set => SetValue(MainFabProperty, value);
}
readonly LazyResolve<ILogger> _logger = new LazyResolve<ILogger>("logger");
public AccountSwitchingOverlayView()
{
InitializeComponent();
ToggleVisibililtyCommand = new AsyncRelayCommand(ToggleVisibilityAsync,
AsyncRelayCommandOptions.None);
SelectAccountCommand = new AsyncRelayCommand<AccountViewCellViewModel>(SelectAccountAsync,
AsyncRelayCommandOptions.None);
LongPressAccountCommand = new AsyncRelayCommand<AccountViewCellViewModel>(LongPressAccountAsync,
AsyncRelayCommandOptions.None);
}
public AccountSwitchingOverlayViewModel ViewModel => BindingContext as AccountSwitchingOverlayViewModel;
public ICommand ToggleVisibililtyCommand { get; }
public ICommand SelectAccountCommand { get; }
public ICommand LongPressAccountCommand { get; }
#if IOS
public int AccountListRowHeight => 70;
#else
public int AccountListRowHeight => 74;
#endif
public bool LongPressAccountEnabled { get; set; } = true;
public Action AfterHide { get; set; }
public async Task ToggleVisibilityAsync()
{
try
{
if (IsVisible)
{
await HideAsync();
}
else
{
await ShowAsync();
}
}
catch (Exception ex)
{
_logger.Value.Exception(ex);
}
}
public async Task ShowAsync()
{
if (ViewModel == null)
{
return;
}
await ViewModel.RefreshAccountViewsAsync();
await MainThread.InvokeOnMainThreadAsync(async () =>
{
// start listView in default (off-screen) position
await _accountListContainer.TranslateTo(0, _accountListContainer.Height * -1, 0);
// re-measure in case accounts have been removed without changing screens
if (ViewModel.AccountViews != null)
{
_accountListView.HeightRequest = AccountListRowHeight * ViewModel.AccountViews.Count;
}
// set overlay opacity to zero before making visible and start fade-in
Opacity = 0;
IsVisible = true;
this.FadeTo(1, 100);
#if ANDROID
// start fab fade-out
MainFab?.FadeTo(0, 200);
#endif
// slide account list into view
await _accountListContainer.TranslateTo(0, 0, 200, Easing.SinOut);
});
}
public async Task HideAsync()
{
if (!IsVisible)
{
// already hidden, don't animate again
return;
}
// Not all animations are awaited. This is intentional to allow multiple simultaneous animations.
await MainThread.InvokeOnMainThreadAsync(async () =>
{
// start overlay fade-out
this.FadeTo(0, 200);
#if ANDROID
// start fab fade-in
MainFab?.FadeTo(1, 200);
#endif
// slide account list out of view
await _accountListContainer.TranslateTo(0, _accountListContainer.Height * -1, 200, Easing.SinIn);
// remove overlay
IsVisible = false;
AfterHide?.Invoke();
});
}
private async void FreeSpaceOverlay_Tapped(object sender, EventArgs e)
{
try
{
await HideAsync();
}
catch (Exception ex)
{
_logger.Value.Exception(ex);
}
}
private async Task SelectAccountAsync(AccountViewCellViewModel item)
{
try
{
await Task.Delay(100);
await HideAsync();
ViewModel?.SelectAccountCommand?.Execute(item);
}
catch (Exception ex)
{
_logger.Value.Exception(ex);
}
}
private async Task LongPressAccountAsync(AccountViewCellViewModel item)
{
try
{
if (!LongPressAccountEnabled || item == null || !item.IsAccount)
{
return;
}
await Task.Delay(100);
await HideAsync();
ViewModel?.LongPressAccountCommand?.Execute(
new Tuple<ContentPage, AccountViewCellViewModel>(MainPage, item));
}
catch (Exception ex)
{
_logger.Value.Exception(ex);
}
}
}
}

View File

@@ -0,0 +1,85 @@
using System.Windows.Input;
using Bit.App.Utilities;
using Bit.Core.Abstractions;
using Bit.Core.Utilities;
namespace Bit.App.Controls
{
public class AccountSwitchingOverlayViewModel : ExtendedViewModel
{
private readonly IStateService _stateService;
private readonly IMessagingService _messagingService;
public AccountSwitchingOverlayViewModel(IStateService stateService,
IMessagingService messagingService,
ILogger logger)
{
_stateService = stateService;
_messagingService = messagingService;
SelectAccountCommand = CreateDefaultAsyncRelayCommand<AccountViewCellViewModel>(SelectAccountAsync,
onException: ex => logger.Exception(ex),
allowsMultipleExecutions: false);
LongPressAccountCommand = CreateDefaultAsyncRelayCommand<Tuple<ContentPage, AccountViewCellViewModel>>(LongPressAccountAsync,
onException: ex => logger.Exception(ex),
allowsMultipleExecutions: false);
}
// this needs to be a new list every time for the binding to get updated,
// XF doesn't currentlyl provide a direct way to update on same instance
// https://github.com/xamarin/Xamarin.Forms/issues/1950
public List<AccountViewCellViewModel> AccountViews => _stateService?.AccountViews is null
? null
: new List<AccountViewCellViewModel>(_stateService.AccountViews.Select(a => new AccountViewCellViewModel(a)).ToList());
public bool AllowActiveAccountSelection { get; set; }
public bool AllowAddAccountRow { get; set; }
public ICommand SelectAccountCommand { get; }
public ICommand LongPressAccountCommand { get; }
public bool FromIOSExtension { get; set; }
private async Task SelectAccountAsync(AccountViewCellViewModel item)
{
if (!item.AccountView.IsAccount)
{
_messagingService.Send(AccountsManagerMessageCommands.ADD_ACCOUNT);
return;
}
if (!item.AccountView.IsActive)
{
await _stateService.SetActiveUserAsync(item.AccountView.UserId);
_messagingService.Send(AccountsManagerMessageCommands.SWITCHED_ACCOUNT);
if (FromIOSExtension)
{
await _stateService.SaveExtensionActiveUserIdToStorageAsync(item.AccountView.UserId);
}
}
else if (AllowActiveAccountSelection)
{
_messagingService.Send(AccountsManagerMessageCommands.SWITCHED_ACCOUNT);
}
}
private async Task LongPressAccountAsync(Tuple<ContentPage, AccountViewCellViewModel> item)
{
var (page, account) = item;
if (account.AccountView.IsAccount)
{
await AppHelpers.AccountListOptions(page, account);
}
}
public async Task RefreshAccountViewsAsync()
{
await _stateService.RefreshAccountViewsAsync(AllowAddAccountRow);
MainThread.BeginInvokeOnMainThread(() => TriggerPropertyChanged(nameof(AccountViews)));
}
}
}

View File

@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="UTF-8"?>
<ViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Bit.App.Controls.AccountViewCell"
xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:u="clr-namespace:Bit.App.Utilities"
xmlns:core="clr-namespace:Bit.Core"
xmlns:behaviors="clr-namespace:CommunityToolkit.Maui.Behaviors"
x:Name="_accountView"
x:DataType="controls:AccountViewCellViewModel">
<Grid RowSpacing="0"
ColumnSpacing="0">
<Grid.Behaviors>
<!--TODO: [TouchEffect] Currently using a "copied" implementation from the github issue in the link until they add this to the Community Toolkit ( https://github.com/CommunityToolkit/Maui/issues/86 ) -->
<behaviors:TouchBehavior NativeAnimation="True"
LongPressCommand="{Binding LongPressAccountCommand, Source={x:Reference _accountView}}"
LongPressCommandParameter="{Binding BindingContext, Source={x:Reference _accountView}}" />
</Grid.Behaviors>
<Grid.GestureRecognizers>
<TapGestureRecognizer Command="{Binding SelectAccountCommand, Source={x:Reference _accountView}}" CommandParameter="{Binding .}" />
</Grid.GestureRecognizers>
<Grid.Resources>
<u:InverseBoolConverter x:Key="inverseBool" />
</Grid.Resources>
<Grid
IsVisible="{Binding IsAccount}"
VerticalOptions="CenterAndExpand">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Image
Grid.Column="0"
Source="{Binding AvatarImageSource}"
HorizontalOptions="Center"
HeightRequest="40"
WidthRequest="40"
Margin="10,0"
VerticalOptions="Center" />
<Grid
Grid.Column="1"
RowSpacing="1"
VerticalOptions="Center">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Label
Grid.Row="0"
Text="{Binding AccountView.Email}"
IsVisible="{Binding IsActive}"
StyleClass="accountlist-title, accountlist-title-platform"
LineBreakMode="TailTruncation"
AutomationId="AccountEmailLabel" />
<Label
Grid.Row="0"
Text="{Binding AccountView.Email}"
IsVisible="{Binding IsActive, Converter={StaticResource inverseBool}}"
StyleClass="accountlist-title, accountlist-title-platform"
TextColor="{DynamicResource MutedColor}"
LineBreakMode="TailTruncation"
AutomationId="AccountEmailLabel" />
<Label
Grid.Row="1"
IsVisible="{Binding ShowHostname}"
Text="{Binding AccountView.Hostname}"
StyleClass="accountlist-sub, accountlist-sub-platform"
LineBreakMode="TailTruncation"
AutomationId="AccountHostUrlLabel" />
<Label
Grid.Row="2"
Text="{u:I18n AccountUnlocked}"
IsVisible="{Binding IsUnlockedAndNotActive}"
StyleClass="accountlist-sub, accountlist-sub-platform"
FontAttributes="Italic"
TextTransform="Lowercase"
LineBreakMode="TailTruncation"
AutomationId="AccountStatusLabel" />
<Label
Grid.Row="2"
Text="{u:I18n AccountLocked}"
IsVisible="{Binding IsLockedAndNotActive}"
StyleClass="accountlist-sub, accountlist-sub-platform"
FontAttributes="Italic"
TextTransform="Lowercase"
LineBreakMode="TailTruncation"
AutomationId="AccountStatusLabel" />
<Label
Grid.Row="2"
Text="{u:I18n AccountLoggedOut}"
IsVisible="{Binding IsLoggedOutAndNotActive}"
StyleClass="accountlist-sub, accountlist-sub-platform"
FontAttributes="Italic"
TextTransform="Lowercase"
LineBreakMode="TailTruncation"
AutomationId="AccountStatusLabel" />
</Grid>
<controls:IconLabel
Grid.Column="2"
Text="{Binding AuthStatusIconNotActive}"
IsVisible="{Binding IsActive, Converter={StaticResource inverseBool}}"
Margin="12,0"
HorizontalOptions="Center"
VerticalOptions="Center"
StyleClass="list-icon, list-icon-platform"
AutomationId="InactiveVaultIcon" />
<controls:IconLabel
Grid.Column="2"
Text="{Binding AuthStatusIconActive}"
IsVisible="{Binding IsActive}"
Margin="12,0"
HorizontalOptions="Center"
VerticalOptions="Center"
StyleClass="list-icon, list-icon-platform"
TextColor="{DynamicResource TextColor}"
AutomationId="ActiveVaultIcon" />
</Grid>
<Grid
IsVisible="{Binding IsAccount, Converter={StaticResource inverseBool}}"
VerticalOptions="CenterAndExpand">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<controls:IconLabel
Grid.Column="0"
VerticalOptions="Center"
HorizontalOptions="Center"
Margin="14,0"
TextColor="{DynamicResource TextColor}"
Text="{Binding Source={x:Static core:BitwardenIcons.Plus}}"
AutomationProperties.IsInAccessibleTree="False" />
<Label
Text="{u:I18n AddAccount}"
StyleClass="accountlist-title, accountlist-title-platform"
LineBreakMode="TailTruncation"
VerticalOptions="Center"
Grid.Column="1"
AutomationId="AddAccountButton" />
</Grid>
</Grid>
</ViewCell>

View File

@@ -0,0 +1,30 @@
using System.Windows.Input;
namespace Bit.App.Controls
{
public partial class AccountViewCell : ViewCell
{
public static readonly BindableProperty SelectAccountCommandProperty = BindableProperty.Create(
nameof(SelectAccountCommand), typeof(ICommand), typeof(AccountViewCell));
public static readonly BindableProperty LongPressAccountCommandProperty = BindableProperty.Create(
nameof(LongPressAccountCommand), typeof(ICommand), typeof(AccountViewCell));
public AccountViewCell()
{
InitializeComponent();
}
public ICommand SelectAccountCommand
{
get => GetValue(SelectAccountCommandProperty) as ICommand;
set => SetValue(SelectAccountCommandProperty, value);
}
public ICommand LongPressAccountCommand
{
get => GetValue(LongPressAccountCommandProperty) as ICommand;
set => SetValue(LongPressAccountCommandProperty, value);
}
}
}

View File

@@ -0,0 +1,95 @@
using System.Globalization;
using Bit.Core;
using Bit.Core.Enums;
using Bit.Core.Models.View;
using Bit.Core.Utilities;
namespace Bit.App.Controls
{
public class AccountViewCellViewModel : ExtendedViewModel
{
private AccountView _accountView;
private AvatarImageSource _avatar;
public AccountViewCellViewModel(AccountView accountView)
{
AccountView = accountView;
AvatarImageSource = ServiceContainer.Resolve<IAvatarImageSourcePool>("avatarImageSourcePool")
?.GetOrCreateAvatar(AccountView.UserId, AccountView.Name, AccountView.Email, AccountView.AvatarColor);
}
public AccountView AccountView
{
get => _accountView;
set => SetProperty(ref _accountView, value);
}
public AvatarImageSource AvatarImageSource
{
get => _avatar;
set => SetProperty(ref _avatar, value);
}
public bool IsAccount
{
get => AccountView.IsAccount;
}
public bool ShowHostname
{
get => !string.IsNullOrWhiteSpace(AccountView.Hostname);
}
public bool IsActive
{
get => AccountView.IsActive;
}
public bool IsUnlocked
{
get => AccountView.AuthStatus == AuthenticationStatus.Unlocked;
}
public bool IsUnlockedAndNotActive
{
get => IsUnlocked && !IsActive;
}
public bool IsLocked
{
get => AccountView.AuthStatus == AuthenticationStatus.Locked;
}
public bool IsLockedAndNotActive
{
get => IsLocked && !IsActive;
}
public bool IsLoggedOut
{
get => AccountView.AuthStatus == AuthenticationStatus.LoggedOut;
}
public bool IsLoggedOutAndNotActive
{
get => IsLoggedOut && !IsActive;
}
public string AuthStatusIconActive
{
get => BitwardenIcons.CheckCircle;
}
public string AuthStatusIconNotActive
{
get
{
if (IsUnlocked)
{
return BitwardenIcons.Unlock;
}
return BitwardenIcons.Lock;
}
}
}
}

View File

@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<controls:BaseCipherViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Bit.App.Controls.AuthenticatorViewCell"
xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:pages="clr-namespace:Bit.App.Pages"
xmlns:u="clr-namespace:Bit.App.Utilities"
xmlns:core="clr-namespace:Bit.Core"
StyleClass="list-row, list-row-platform"
HorizontalOptions="FillAndExpand"
x:DataType="pages:GroupingsPageTOTPListItem"
ColumnDefinitions="40,*,40,Auto,40"
RowSpacing="0"
Padding="0,10,0,0"
RowDefinitions="*,*">
<controls:BaseCipherViewCell.Resources>
<u:IconGlyphConverter x:Key="iconGlyphConverter" />
<u:InverseBoolConverter x:Key="inverseBool" />
</controls:BaseCipherViewCell.Resources>
<controls:CachedImage
x:Name="_iconImage"
Grid.Column="0"
Grid.RowSpan="2"
BitmapOptimizations="True"
HorizontalOptions="Center"
VerticalOptions="Center"
WidthRequest="22"
HeightRequest="22"
Success="Icon_Success"
Error="Icon_Error"
AutomationProperties.IsInAccessibleTree="False" />
<controls:IconLabel
x:Name="_iconPlaceholderImage"
Grid.Column="0"
Grid.RowSpan="2"
HorizontalOptions="Center"
VerticalOptions="Center"
StyleClass="list-icon, list-icon-platform"
Text="{Binding Cipher, Converter={StaticResource iconGlyphConverter}}"
AutomationProperties.IsInAccessibleTree="False" />
<Label
LineBreakMode="TailTruncation"
Grid.Column="1"
Grid.Row="0"
VerticalTextAlignment="Center"
VerticalOptions="End"
StyleClass="list-title, list-title-platform"
Text="{Binding Cipher.Name}" />
<Label
LineBreakMode="TailTruncation"
Grid.Column="1"
Grid.Row="1"
VerticalTextAlignment="Center"
VerticalOptions="Start"
StyleClass="list-subtitle, list-subtitle-platform"
Text="{Binding Cipher.SubTitle}" />
<controls:CircularProgressbarView
Progress="{Binding Progress}"
Grid.Row="0"
Grid.Column="2"
Grid.RowSpan="2"
HorizontalOptions="Fill"
WidthRequest="50"
HeightRequest="50"
VerticalOptions="CenterAndExpand" />
<Label
Text="{Binding TotpSec, Mode=OneWay}"
Style="{DynamicResource textTotp}"
BackgroundColor="Transparent"
Grid.Row="0"
Grid.Column="2"
Grid.RowSpan="2"
StyleClass="text-sm"
HorizontalTextAlignment="Center"
HorizontalOptions="Fill"
VerticalTextAlignment="Center"
VerticalOptions="Fill" />
<StackLayout
Grid.Row="0"
Grid.Column="3"
Margin="3,0,2,0"
Spacing="5"
Grid.RowSpan="2"
Orientation="Horizontal"
HorizontalOptions="Fill"
VerticalOptions="Fill">
<controls:MonoLabel
Text="{Binding TotpCodeFormattedStart, Mode=OneWay}"
Style="{DynamicResource textTotp}"
StyleClass="text-lg"
HorizontalTextAlignment="Center"
VerticalTextAlignment="Center"
HorizontalOptions="Center"
VerticalOptions="FillAndExpand" />
<controls:MonoLabel
Text="{Binding TotpCodeFormattedEnd, Mode=OneWay}"
Style="{DynamicResource textTotp}"
StyleClass="text-lg"
HorizontalTextAlignment="Center"
VerticalTextAlignment="Center"
HorizontalOptions="Center"
VerticalOptions="FillAndExpand" />
</StackLayout>
<controls:IconButton
StyleClass="box-row-button, box-row-button-platform"
Text="{Binding Source={x:Static core:BitwardenIcons.Clone}}"
Command="{Binding CopyCommand}"
CommandParameter="LoginTotp"
Grid.Row="0"
Grid.Column="4"
Grid.RowSpan="2"
Padding="0,0,1,0"
HorizontalOptions="Center"
VerticalOptions="Center"
SemanticProperties.Description="{u:I18n CopyTotp}" />
</controls:BaseCipherViewCell>

View File

@@ -0,0 +1,14 @@
namespace Bit.App.Controls
{
public partial class AuthenticatorViewCell : BaseCipherViewCell
{
public AuthenticatorViewCell()
{
InitializeComponent();
}
protected override CachedImage Icon => _iconImage;
protected override IconLabel IconPlaceholder => _iconPlaceholderImage;
}
}

View File

@@ -0,0 +1,62 @@
using SkiaSharp;
namespace Bit.App.Controls
{
public class AvatarImageSource : StreamImageSource
{
private readonly string _text;
private readonly string _id;
private readonly string _color;
private readonly AvatarInfo _avatarInfo;
public override bool Equals(object obj)
{
if (obj is null)
{
return false;
}
if (obj is AvatarImageSource avatar)
{
return avatar._id == _id && avatar._text == _text && avatar._color == _color;
}
return base.Equals(obj);
}
public override int GetHashCode() => _id?.GetHashCode() ?? _text?.GetHashCode() ?? -1;
public AvatarImageSource(string userId = null, string name = null, string email = null, string color = null)
{
_id = userId;
_text = name;
if (string.IsNullOrWhiteSpace(_text))
{
_text = email;
}
_color = color;
//Workaround: [MAUI-Migration] There is currently a bug in MAUI where the actual size of the image is used instead of the size it should occupy in the Toolbar.
//This causes some issues with the position of the icon. As a workaround we make the icon smaller until this is fixed.
//Github issues: https://github.com/dotnet/maui/issues/12359 and https://github.com/dotnet/maui/pull/17120
_avatarInfo = new AvatarInfo(userId, name, email, color, DeviceInfo.Platform == DevicePlatform.iOS ? 20 : 50);
}
public override Func<CancellationToken, Task<Stream>> Stream => GetStreamAsync;
private Task<Stream> GetStreamAsync(CancellationToken userToken = new CancellationToken())
{
var result = Draw();
return Task.FromResult(result);
}
private Stream Draw()
{
using (var img = SKAvatarImageHelper.Draw(_avatarInfo))
{
var data = img.Encode(SKEncodedImageFormat.Png, 100);
return data?.AsStream(true);
}
}
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Concurrent;
namespace Bit.App.Controls
{
public interface IAvatarImageSourcePool
{
AvatarImageSource GetOrCreateAvatar(string userId, string name, string email, string color);
}
public class AvatarImageSourcePool : IAvatarImageSourcePool
{
private readonly ConcurrentDictionary<string, AvatarImageSource> _cache = new ConcurrentDictionary<string, AvatarImageSource>();
public AvatarImageSource GetOrCreateAvatar(string userId, string name, string email, string color)
{
var key = $"{userId}{name}{email}{color}";
if (!_cache.TryGetValue(key, out var avatar))
{
avatar = new AvatarImageSource(userId, name, email, color);
if (!_cache.TryAdd(key, avatar)
&&
!_cache.TryGetValue(key, out avatar)) // If add fails another thread created the avatar in between the first try get and the try add.
{
// if add and get after fails, then something wrong is going on with this method.
throw new InvalidOperationException("Something is wrong creating the avatar image");
}
}
return avatar;
}
}
}

View File

@@ -0,0 +1,63 @@
using Bit.Core.Utilities;
#nullable enable
namespace Bit.App.Controls
{
public struct AvatarInfo
{
private const string DEFAULT_BACKGROUND_COLOR = "#33ffffff";
public AvatarInfo(string? userId = null, string? name = null, string? email = null, string? color = null, int size = 50)
{
Size = size;
var text = string.IsNullOrWhiteSpace(name) ? email : name;
string? upperCaseText = null;
if (string.IsNullOrEmpty(text))
{
CharsToDraw = "..";
}
else if (text.Length > 1)
{
upperCaseText = text.ToUpper();
CharsToDraw = GetFirstLetters(upperCaseText, 2);
}
else
{
CharsToDraw = upperCaseText = text.ToUpper();
}
BackgroundColor = color ?? CoreHelpers.StringToColor(userId ?? upperCaseText, DEFAULT_BACKGROUND_COLOR);
TextColor = CoreHelpers.TextColorFromBgColor(BackgroundColor);
}
public string CharsToDraw { get; }
public string BackgroundColor { get; }
public string TextColor { get; }
public int Size { get; }
private static string GetFirstLetters(string data, int charCount)
{
var sanitizedData = data.Trim();
var parts = sanitizedData.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 1 && charCount <= 2)
{
var text = string.Empty;
for (var i = 0; i < charCount; i++)
{
text += parts[i][0];
}
return text;
}
if (sanitizedData.Length > 2)
{
return sanitizedData.Substring(0, 2);
}
return sanitizedData;
}
}
}

View File

@@ -0,0 +1,63 @@
using SkiaSharp;
namespace Bit.App.Controls
{
public static class SKAvatarImageHelper
{
public static SKImage Draw(AvatarInfo avatarInfo)
{
using (var bitmap = new SKBitmap(avatarInfo.Size * 2,
avatarInfo.Size * 2,
SKImageInfo.PlatformColorType,
SKAlphaType.Premul))
{
using (var canvas = new SKCanvas(bitmap))
{
canvas.Clear(SKColors.Transparent);
using (var paint = new SKPaint
{
IsAntialias = true,
Style = SKPaintStyle.Fill,
StrokeJoin = SKStrokeJoin.Miter,
Color = SKColor.Parse(avatarInfo.BackgroundColor)
})
{
var midX = canvas.LocalClipBounds.Size.ToSizeI().Width / 2;
var midY = canvas.LocalClipBounds.Size.ToSizeI().Height / 2;
var radius = midX - midX / 5;
using (var circlePaint = new SKPaint
{
IsAntialias = true,
Style = SKPaintStyle.Fill,
StrokeJoin = SKStrokeJoin.Miter,
Color = SKColor.Parse(avatarInfo.BackgroundColor)
})
{
canvas.DrawCircle(midX, midY, radius, circlePaint);
var typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Normal);
var textSize = midX / 1.3f;
using (var textPaint = new SKPaint
{
IsAntialias = true,
Style = SKPaintStyle.Fill,
Color = SKColor.Parse(avatarInfo.TextColor),
TextSize = textSize,
TextAlign = SKTextAlign.Center,
Typeface = typeface
})
{
var rect = new SKRect();
textPaint.MeasureText(avatarInfo.CharsToDraw, ref rect);
canvas.DrawText(avatarInfo.CharsToDraw, midX, midY + rect.Height / 2, textPaint);
return SKImage.FromBitmap(bitmap);
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
namespace Bit.App.Controls
{
#if !UT
public class CachedImage : FFImageLoading.Maui.CachedImage
{
}
#else
/// <summary>
/// Given that FFImageLoading package doesn't support net8.0 then for Unit tests projects to build and run correctly
/// we need to not include the reference to FFImageLoading and therefore wrap this class
/// to provide a stub one that does nothing so this project doesn't break and we can run the tests.
/// </summary>
public class CachedImage : View
{
public static readonly BindableProperty SourceProperty = BindableProperty.Create(
nameof(Source), typeof(ImageSource), typeof(CachedImage));
public static readonly BindableProperty AspectProperty = BindableProperty.Create(
nameof(Aspect), typeof(Aspect), typeof(CachedImage));
public bool BitmapOptimizations { get; set; }
public string ErrorPlaceholder { get; set; }
public string LoadingPlaceholder { get; set; }
public ImageSource Source
{
get { return (ImageSource)GetValue(SourceProperty); }
set { SetValue(SourceProperty, value); }
}
public Aspect Aspect
{
get { return (Aspect)GetValue(AspectProperty); }
set { SetValue(AspectProperty, value); }
}
public bool IsLoading { get; set; }
public event EventHandler Success;
public event EventHandler Error;
}
#endif
}

View File

@@ -0,0 +1,110 @@
using Bit.App.Pages;
namespace Bit.App.Controls
{
public abstract class BaseCipherViewCell : ExtendedGrid
{
protected virtual CachedImage Icon { get; }
protected virtual IconLabel IconPlaceholder { get; }
// HACK: PM-5896 Fix for Background Crash on iOS
// While loading the cipher icon and the user sent the app to background
// the app was crashing sometimes when the "LoadingPlaceholder" or "ErrorPlaceholder"
// were being accessed, thus locked, and as soon the app got suspended by the OS
// the app would crash because there can't be any lock files by the app when it gets suspended.
// So, the approach has changed to reuse the IconLabel default icon to use it for these placeholders
// as well. In order to do that both icon controls change their visibility dynamically here reacting to
// CachedImage events and binding context changes.
protected override void OnBindingContextChanged()
{
Icon.Source = null;
if (BindingContext is CipherItemViewModel cipherItemVM)
{
Icon.Source = cipherItemVM.IconImageSource;
if (!cipherItemVM.IconImageSuccesfullyLoaded)
{
UpdateIconImages(cipherItemVM.ShowIconImage);
}
}
base.OnBindingContextChanged();
}
private void UpdateIconImages(bool showIcon)
{
MainThread.BeginInvokeOnMainThread(() =>
{
if (!showIcon)
{
Icon.IsVisible = false;
IconPlaceholder.IsVisible = true;
return;
}
IconPlaceholder.IsVisible = Icon.IsLoading;
});
}
#if !UT
public void Icon_Success(object sender, FFImageLoading.Maui.CachedImageEvents.SuccessEventArgs e)
{
if (BindingContext is CipherItemViewModel cipherItemVM)
{
cipherItemVM.IconImageSuccesfullyLoaded = true;
}
MainThread.BeginInvokeOnMainThread(() =>
{
Icon.IsVisible = true;
IconPlaceholder.IsVisible = false;
});
}
public void Icon_Error(object sender, FFImageLoading.Maui.CachedImageEvents.ErrorEventArgs e)
{
if (BindingContext is CipherItemViewModel cipherItemVM)
{
cipherItemVM.IconImageSuccesfullyLoaded = false;
}
MainThread.BeginInvokeOnMainThread(() =>
{
Icon.IsVisible = false;
IconPlaceholder.IsVisible = true;
});
}
#else
private void Icon_Success(object sender, EventArgs e) {}
private void Icon_Error(object sender, EventArgs e) {}
#endif
}
public class StubBaseCipherViewCellSoLinkerDoesntRemoveMethods : BaseCipherViewCell
{
protected override CachedImage Icon => new CachedImage();
protected override IconLabel IconPlaceholder => new IconLabel();
public static void CallThisSoLinkerDoesntRemoveMethods()
{
#if !UT
var stub = new StubBaseCipherViewCellSoLinkerDoesntRemoveMethods();
try
{
stub.Icon_Success(stub, new FFImageLoading.Maui.CachedImageEvents.SuccessEventArgs(new FFImageLoading.Work.ImageInformation(), FFImageLoading.Work.LoadingResult.Disk));
}
catch (Exception)
{
}
try
{
stub.Icon_Error(stub, new FFImageLoading.Maui.CachedImageEvents.ErrorEventArgs(new InvalidOperationException("stub")));
}
catch (Exception)
{
}
#endif
}
}
}

View File

@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<controls:BaseCipherViewCell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Bit.App.Controls.CipherViewCell"
xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:pages="clr-namespace:Bit.App.Pages"
xmlns:u="clr-namespace:Bit.App.Utilities"
xmlns:core="clr-namespace:Bit.Core"
StyleClass="list-row, list-row-platform"
RowSpacing="0"
ColumnSpacing="0"
x:DataType="pages:CipherItemViewModel"
AutomationId="CipherCell">
<Grid.Resources>
<u:IconGlyphConverter x:Key="iconGlyphConverter"/>
<u:IconImageConverter x:Key="iconImageConverter"/>
<u:InverseBoolConverter x:Key="inverseBool" />
<u:StringHasValueConverter x:Key="stringHasValueConverter" />
</Grid.Resources>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" x:Name="_iconColumn" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="60" />
</Grid.ColumnDefinitions>
<controls:CachedImage
x:Name="_iconImage"
Grid.Column="0"
BitmapOptimizations="True"
HorizontalOptions="CenterAndExpand"
VerticalOptions="CenterAndExpand"
Margin="9"
WidthRequest="22"
HeightRequest="22"
Aspect="AspectFit"
Success="Icon_Success"
Error="Icon_Error"
AutomationProperties.IsInAccessibleTree="False"
AutomationId="CipherWebsiteIcon" />
<controls:IconLabel
x:Name="_iconPlaceholderImage"
Grid.Column="0"
HorizontalOptions="Center"
VerticalOptions="Center"
StyleClass="list-icon, list-icon-platform"
Text="{Binding Cipher, Converter={StaticResource iconGlyphConverter}}"
ShouldUpdateFontSizeDynamicallyForAccesibility="True"
AutomationProperties.IsInAccessibleTree="False"
AutomationId="CipherTypeIcon" />
<Grid RowSpacing="0" ColumnSpacing="0" Grid.Row="0" Grid.Column="1" VerticalOptions="Center" Padding="0, 7">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Label
Grid.Column="0"
Grid.Row="0"
LineBreakMode="TailTruncation"
StyleClass="list-title, list-title-platform"
Text="{Binding Cipher.Name}"
AutomationId="CipherNameLabel" />
<Label
Grid.Column="0"
Grid.Row="1"
Grid.ColumnSpan="3"
LineBreakMode="TailTruncation"
StyleClass="list-subtitle, list-subtitle-platform"
Text="{Binding Cipher.SubTitle}"
IsVisible="{Binding Source={RelativeSource Self}, Path=Text,
Converter={StaticResource stringHasValueConverter}}"
AutomationId="CipherSubTitleLabel" />
<controls:IconLabel
Grid.Column="1"
Grid.Row="0"
HorizontalOptions="Start"
VerticalOptions="Center"
StyleClass="list-title-icon"
Margin="5, 0, 0, 0"
Text="{Binding Source={x:Static core:BitwardenIcons.Collection}}"
IsVisible="{Binding Cipher.Shared, Mode=OneTime}"
SemanticProperties.Description="{u:I18n Shared}"
AutomationId="CipherInCollectionIcon" />
<controls:IconLabel
Grid.Column="2"
Grid.Row="0"
HorizontalOptions="Start"
VerticalOptions="Center"
StyleClass="list-title-icon"
Margin="5, 0, 0, 0"
Text="{Binding Source={x:Static core:BitwardenIcons.Paperclip}}"
IsVisible="{Binding Cipher.HasAttachments, Mode=OneTime}"
SemanticProperties.Description="{u:I18n Attachments}"
AutomationId="CipherWithAttachmentsIcon" />
</Grid>
<controls:MiButton
Grid.Row="0"
Grid.Column="2"
Text="{Binding Source={x:Static core:BitwardenIcons.ViewCellMenu}}"
StyleClass="list-row-button, list-row-button-platform, btn-disabled"
Clicked="MoreButton_Clicked"
VerticalOptions="CenterAndExpand"
HorizontalOptions="EndAndExpand"
SemanticProperties.Description="{u:I18n Options}"
AutomationId="CipherOptionsButton" />
</controls:BaseCipherViewCell>

View File

@@ -0,0 +1,44 @@
using System.Windows.Input;
using Bit.App.Abstractions;
using Bit.App.Pages;
using Bit.Core.Utilities;
namespace Bit.App.Controls
{
public partial class CipherViewCell : BaseCipherViewCell
{
private const int ICON_COLUMN_DEFAULT_WIDTH = 40;
private const int ICON_IMAGE_DEFAULT_WIDTH = 22;
public static readonly BindableProperty ButtonCommandProperty = BindableProperty.Create(
nameof(ButtonCommand), typeof(ICommand), typeof(CipherViewCell));
public CipherViewCell()
{
InitializeComponent();
var fontScale = ServiceContainer.Resolve<IDeviceActionService>().GetSystemFontSizeScale();
_iconColumn.Width = new GridLength(ICON_COLUMN_DEFAULT_WIDTH * fontScale, GridUnitType.Absolute);
_iconImage.WidthRequest = ICON_IMAGE_DEFAULT_WIDTH * fontScale;
_iconImage.HeightRequest = ICON_IMAGE_DEFAULT_WIDTH * fontScale;
}
protected override CachedImage Icon => _iconImage;
protected override IconLabel IconPlaceholder => _iconPlaceholderImage;
public ICommand ButtonCommand
{
get => GetValue(ButtonCommandProperty) as ICommand;
set => SetValue(ButtonCommandProperty, value);
}
private void MoreButton_Clicked(object sender, EventArgs e)
{
if (BindingContext is CipherItemViewModel cipherItem)
{
ButtonCommand?.Execute(cipherItem.Cipher);
}
}
}
}

View File

@@ -0,0 +1,142 @@
using System;
using System.Runtime.CompilerServices;
using SkiaSharp;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Devices;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
using SkiaSharp.Views.Maui.Controls;
using SkiaSharp.Views.Maui;
namespace Bit.App.Controls
{
public class CircularProgressbarView : SKCanvasView
{
private Circle _circle;
public static readonly BindableProperty ProgressProperty = BindableProperty.Create(
nameof(Progress), typeof(double), typeof(CircularProgressbarView), propertyChanged: OnProgressChanged);
public static readonly BindableProperty RadiusProperty = BindableProperty.Create(
nameof(Radius), typeof(float), typeof(CircularProgressbarView), 15f);
public static readonly BindableProperty StrokeWidthProperty = BindableProperty.Create(
nameof(StrokeWidth), typeof(float), typeof(CircularProgressbarView), 3f);
public static readonly BindableProperty ProgressColorProperty = BindableProperty.Create(
nameof(ProgressColor), typeof(Color), typeof(CircularProgressbarView), Color.FromArgb("175DDC"));
public static readonly BindableProperty EndingProgressColorProperty = BindableProperty.Create(
nameof(EndingProgressColor), typeof(Color), typeof(CircularProgressbarView), Color.FromArgb("dd4b39"));
public static readonly BindableProperty BackgroundProgressColorProperty = BindableProperty.Create(
nameof(BackgroundProgressColor), typeof(Color), typeof(CircularProgressbarView), Colors.White);
public double Progress
{
get { return (double)GetValue(ProgressProperty); }
set { SetValue(ProgressProperty, value); }
}
public float Radius
{
get => (float)GetValue(RadiusProperty);
set => SetValue(RadiusProperty, value);
}
public float StrokeWidth
{
get => (float)GetValue(StrokeWidthProperty);
set => SetValue(StrokeWidthProperty, value);
}
public Color ProgressColor
{
get => (Color)GetValue(ProgressColorProperty);
set => SetValue(ProgressColorProperty, value);
}
public Color EndingProgressColor
{
get => (Color)GetValue(EndingProgressColorProperty);
set => SetValue(EndingProgressColorProperty, value);
}
public Color BackgroundProgressColor
{
get => (Color)GetValue(BackgroundProgressColorProperty);
set => SetValue(BackgroundProgressColorProperty, value);
}
private static void OnProgressChanged(BindableObject bindable, object oldvalue, object newvalue)
{
var context = bindable as CircularProgressbarView;
context.InvalidateSurface();
}
protected override void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
base.OnPropertyChanged(propertyName);
if (propertyName == nameof(Progress))
{
_circle = new Circle(Radius * (float)DeviceDisplay.MainDisplayInfo.Density, (info) => new SKPoint((float)info.Width / 2, (float)info.Height / 2));
}
}
protected override void OnPaintSurface(SKPaintSurfaceEventArgs e)
{
base.OnPaintSurface(e);
if (_circle != null)
{
_circle.CalculateCenter(e.Info);
e.Surface.Canvas.Clear();
DrawCircle(e.Surface.Canvas, _circle, StrokeWidth * (float)DeviceDisplay.MainDisplayInfo.Density, BackgroundProgressColor.ToSKColor());
DrawArc(e.Surface.Canvas, _circle, () => (float)Progress, StrokeWidth * (float)DeviceDisplay.MainDisplayInfo.Density, ProgressColor.ToSKColor(), EndingProgressColor.ToSKColor());
}
}
private void DrawCircle(SKCanvas canvas, Circle circle, float strokewidth, SKColor color)
{
canvas.DrawCircle(circle.Center, circle.Redius,
new SKPaint()
{
StrokeWidth = strokewidth,
Color = color,
IsStroke = true,
IsAntialias = true
});
}
private void DrawArc(SKCanvas canvas, Circle circle, Func<float> progress, float strokewidth, SKColor color, SKColor progressEndColor)
{
var progressValue = progress();
var angle = progressValue * 3.6f;
canvas.DrawArc(circle.Rect, 270, angle, false,
new SKPaint()
{
StrokeWidth = strokewidth,
Color = progressValue < 20f ? progressEndColor : color,
IsStroke = true,
IsAntialias = true
});
}
}
public class Circle
{
private readonly Func<SKImageInfo, SKPoint> _centerFunc;
public Circle(float redius, Func<SKImageInfo, SKPoint> centerFunc)
{
_centerFunc = centerFunc;
Redius = redius;
}
public SKPoint Center { get; set; }
public float Redius { get; set; }
public SKRect Rect => new SKRect(Center.X - Redius, Center.Y - Redius, Center.X + Redius, Center.Y + Redius);
public void CalculateCenter(SKImageInfo argsInfo)
{
Center = _centerFunc(argsInfo);
}
}
}

View File

@@ -0,0 +1,14 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class CustomLabel : Label
{
public CustomLabel()
{
}
public int? FontWeight { get; set; }
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Grid
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:Bit.App.Controls"
x:Class="Bit.App.Controls.DateTimePicker"
Unloaded="DateTimePicker_OnUnloaded"
ColumnDefinitions="*,*">
<controls:ExtendedDatePicker
x:Name="_datePicker"
Grid.Column="0"
NullableDate="{Binding Date, Mode=TwoWay}"
Format="d" />
<controls:ExtendedTimePicker
x:Name="_timePicker"
Grid.Column="1"
NullableTime="{Binding Time, Mode=TwoWay}"
Format="t" />
</Grid>

View File

@@ -0,0 +1,38 @@
using System.Runtime.CompilerServices;
using CommunityToolkit.Maui.Views;
namespace Bit.App.Controls
{
public partial class DateTimePicker : Grid
{
public DateTimePicker()
{
InitializeComponent();
}
protected override void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
base.OnPropertyChanged(propertyName);
if (propertyName == nameof(BindingContext)
&&
BindingContext is DateTimeViewModel dateTimeViewModel)
{
SemanticProperties.SetDescription(_datePicker, dateTimeViewModel.DateName);
SemanticProperties.SetDescription(_timePicker, dateTimeViewModel.TimeName);
_datePicker.PlaceHolder = dateTimeViewModel.DatePlaceholder;
_timePicker.PlaceHolder = dateTimeViewModel.TimePlaceholder;
}
}
private void DateTimePicker_OnUnloaded(object sender, EventArgs e)
{
_datePicker?.DisconnectHandler();
}
}
public class LazyDateTimePicker : LazyView<DateTimePicker>
{
}
}

View File

@@ -0,0 +1,69 @@
using Bit.Core.Utilities;
namespace Bit.App.Controls
{
public class DateTimeViewModel : ExtendedViewModel
{
DateTime? _date;
TimeSpan? _time;
public DateTimeViewModel(string dateName, string timeName)
{
DateName = dateName;
TimeName = timeName;
}
public Action<DateTime?> OnDateChanged { get; set; }
public Action<TimeSpan?> OnTimeChanged { get; set; }
public DateTime? Date
{
get => _date;
set
{
if (SetProperty(ref _date, value))
{
OnDateChanged?.Invoke(value);
}
}
}
public TimeSpan? Time
{
get => _time;
set
{
if (SetProperty(ref _time, value))
{
OnTimeChanged?.Invoke(value);
}
}
}
public string DateName { get; }
public string TimeName { get; }
public string DatePlaceholder { get; set; }
public string TimePlaceholder { get; set; }
public DateTime? DateTime
{
get
{
if (Date.HasValue)
{
if (Time.HasValue)
{
return Date.Value.Add(Time.Value);
}
return Date;
}
return null;
}
set
{
Date = value?.Date;
Time = value?.Date.TimeOfDay;
}
}
}
}

View File

@@ -0,0 +1,20 @@
using System.Globalization;
using CommunityToolkit.Maui.Converters;
namespace Bit.App.Controls
{
public class ExtendedCollectionView : CollectionView
{
public string ExtraDataForLogging { get; set; }
}
public class SelectionChangedEventArgsConverter : BaseConverterOneWay<SelectionChangedEventArgs, object>
{
public override object DefaultConvertReturnValue { get; set; } = null;
public override object ConvertFrom(SelectionChangedEventArgs value, CultureInfo culture)
{
return value?.CurrentSelection.FirstOrDefault();
}
}
}

View File

@@ -0,0 +1,89 @@
namespace Bit.App.Controls
{
public class ExtendedDatePicker : DatePicker
{
private string _format;
public static readonly BindableProperty PlaceHolderProperty = BindableProperty.Create(
nameof(PlaceHolder), typeof(string), typeof(ExtendedDatePicker));
public string PlaceHolder
{
get { return (string)GetValue(PlaceHolderProperty); }
set { SetValue(PlaceHolderProperty, value); }
}
public static readonly BindableProperty NullableDateProperty = BindableProperty.Create(
nameof(NullableDate), typeof(DateTime?), typeof(ExtendedDatePicker));
public DateTime? NullableDate
{
get { return (DateTime?)GetValue(NullableDateProperty); }
set
{
SetValue(NullableDateProperty, value);
UpdateDate();
}
}
private void UpdateDate()
{
if (NullableDate.HasValue)
{
if (_format != null)
{
Format = _format;
}
}
else
{
Format = PlaceHolder;
}
}
protected override void OnBindingContextChanged()
{
base.OnBindingContextChanged();
if (BindingContext != null)
{
_format = Format;
UpdateDate();
}
}
protected override void OnPropertyChanged(string propertyName = null)
{
base.OnPropertyChanged(propertyName);
if (propertyName == DateProperty.PropertyName || (propertyName == IsFocusedProperty.PropertyName &&
!IsFocused && (Date.ToString("d") ==
DateTime.Now.ToString("d"))))
{
NullableDate = Date;
UpdateDate();
}
if (propertyName == NullableDateProperty.PropertyName)
{
if (NullableDate.HasValue)
{
Date = NullableDate.Value;
if (Date.ToString(_format) == DateTime.Now.ToString(_format))
{
UpdateDate();
}
}
else
{
UpdateDate();
}
}
}
//Currently the Disconnect Handler needs to be manually called from the App: https://github.com/dotnet/maui/issues/3604
public void DisconnectHandler()
{
Handler?.DisconnectHandler();
}
}
}

View File

@@ -0,0 +1,21 @@
using CommunityToolkit.Maui.Behaviors;
namespace Bit.App.Controls
{
public class ExtendedGrid : Grid
{
public ExtendedGrid()
{
#if ANDROID
// Add Android Ripple effect. Eventually we should be able to replace this with the Maui Community Toolkit implementation. (https://github.com/CommunityToolkit/Maui/issues/86)
// TODO: [TouchEffect] When this TouchBehavior is replaced we can delete the existing TouchBehavior support files (which is all the files and folders inside "Core.Behaviors.PlatformBehaviors.MCTTouch.*")
var touchBehavior = new TouchBehavior()
{
NativeAnimation = true,
ShouldMakeChildrenInputTransparent = false
};
Behaviors.Add(touchBehavior);
#endif
}
}
}

View File

@@ -0,0 +1,22 @@
using Bit.App.Utilities;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class ExtendedSearchBar : SearchBar
{
public ExtendedSearchBar()
{
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
if (Device.RuntimePlatform == Device.iOS)
{
if (ThemeManager.UsingLightTheme)
{
TextColor = Colors.Black;
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class ExtendedSlider : Slider
{
public static readonly BindableProperty ThumbBorderColorProperty = BindableProperty.Create(
nameof(ThumbBorderColor), typeof(Color), typeof(ExtendedSlider), Color.FromArgb("b5b5b5"));
public Color ThumbBorderColor
{
get => (Color)GetValue(ThumbBorderColorProperty);
set => SetValue(ThumbBorderColorProperty, value);
}
}
}

View File

@@ -0,0 +1,21 @@
using CommunityToolkit.Maui.Behaviors;
namespace Bit.App.Controls
{
public class ExtendedStackLayout : StackLayout
{
public ExtendedStackLayout()
{
#if ANDROID
// Add Android Ripple effect. Eventually we should be able to replace this with the Maui Community Toolkit implementation. (https://github.com/CommunityToolkit/Maui/issues/86)
// TODO: [TouchEffect] When this TouchBehavior is replaced we can delete the existing TouchBehavior support files (which is all the files and folders inside "Core.Behaviors.PlatformBehaviors.MCTTouch.*")
var touchBehavior = new TouchBehavior()
{
NativeAnimation = true,
ShouldMakeChildrenInputTransparent = false
};
Behaviors.Add(touchBehavior);
#endif
}
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class ExtendedStepper : Stepper
{
public static readonly BindableProperty StepperBackgroundColorProperty = BindableProperty.Create(
nameof(StepperBackgroundColor), typeof(Color), typeof(ExtendedStepper), Colors.White);
public static readonly BindableProperty StepperForegroundColorProperty = BindableProperty.Create(
nameof(StepperForegroundColor), typeof(Color), typeof(ExtendedStepper), Colors.Black);
public Color StepperBackgroundColor
{
get => (Color)GetValue(StepperBackgroundColorProperty);
set => SetValue(StepperBackgroundColorProperty, value);
}
public Color StepperForegroundColor
{
get => (Color)GetValue(StepperForegroundColorProperty);
set => SetValue(StepperForegroundColorProperty, value);
}
}
}

View File

@@ -0,0 +1,83 @@
namespace Bit.App.Controls
{
public class ExtendedTimePicker : TimePicker
{
private string _format;
public static readonly BindableProperty PlaceHolderProperty = BindableProperty.Create(
nameof(PlaceHolder), typeof(string), typeof(ExtendedTimePicker));
public string PlaceHolder
{
get { return (string)GetValue(PlaceHolderProperty); }
set { SetValue(PlaceHolderProperty, value); }
}
public static readonly BindableProperty NullableTimeProperty = BindableProperty.Create(
nameof(NullableTime), typeof(TimeSpan?), typeof(ExtendedTimePicker));
public TimeSpan? NullableTime
{
get { return (TimeSpan?)GetValue(NullableTimeProperty); }
set
{
SetValue(NullableTimeProperty, value);
UpdateTime();
}
}
private void UpdateTime()
{
if (NullableTime.HasValue)
{
if (_format != null)
{
Format = _format;
}
}
else
{
Format = PlaceHolder;
}
}
protected override void OnBindingContextChanged()
{
base.OnBindingContextChanged();
if (BindingContext != null)
{
_format = Format;
UpdateTime();
}
}
protected override void OnPropertyChanged(string propertyName = null)
{
base.OnPropertyChanged(propertyName);
if (propertyName == TimeProperty.PropertyName || (propertyName == IsFocusedProperty.PropertyName &&
!IsFocused && (Time.ToString("t") ==
DateTime.Now.TimeOfDay.ToString("t"))))
{
NullableTime = Time;
UpdateTime();
}
if (propertyName == NullableTimeProperty.PropertyName)
{
if (NullableTime.HasValue)
{
Time = NullableTime.Value;
if (Time.ToString(_format) == DateTime.Now.TimeOfDay.ToString(_format))
{
UpdateTime();
}
}
else
{
UpdateTime();
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
using System;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class ExtendedToolbarItem : ToolbarItem
{
public bool UseOriginalImage { get; set; }
// HACK: For the issue of correctly updating the avatar toolbar item color on iOS
// we need to subscribe to the PropertyChanged event of the ToolbarItem on the CustomNavigationRenderer
// The problem is that there are a lot of private places where the navigation renderer disposes objects
// that we don't have access to, and that we should in order to properly prevent memory leaks
// So as a hack solution we have this OnAppearing/OnDisappearing actions and methods to be called on page lifecycle
// to subscribe/unsubscribe indirectly on the CustomNavigationRenderer
public Action OnAppearingAction { get; set; }
public Action OnDisappearingAction { get; set; }
public void OnAppearing()
{
OnAppearingAction?.Invoke();
}
public void OnDisappearing()
{
OnDisappearingAction?.Invoke();
}
}
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" ?>
<ContentView
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:Bit.App.Controls"
x:Class="Bit.App.Controls.ExternalLinkItemView"
xmlns:core="clr-namespace:Bit.Core"
x:Name="_contentView">
<ContentView.GestureRecognizers>
<TapGestureRecognizer Command="{Binding GoToLinkCommand, Mode=OneWay, Source={x:Reference _contentView}}" />
</ContentView.GestureRecognizers>
<StackLayout
Orientation="Horizontal">
<controls:CustomLabel
Text="{Binding Title, Mode=OneWay, Source={x:Reference _contentView}}"
HorizontalOptions="StartAndExpand"
LineBreakMode="TailTruncation" />
<controls:IconLabel
Text="{Binding Source={x:Static core:BitwardenIcons.ExternalLink}}"
TextColor="{DynamicResource TextColor}"
HorizontalOptions="End"
VerticalOptions="Center"
SemanticProperties.Description="{Binding Title, Mode=OneWay, Source={x:Reference _contentView}}" />
</StackLayout>
</ContentView>

View File

@@ -0,0 +1,32 @@
using System.Windows.Input;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public partial class ExternalLinkItemView : ContentView
{
public static readonly BindableProperty TitleProperty = BindableProperty.Create(
nameof(Title), typeof(string), typeof(ExternalLinkItemView), null, BindingMode.OneWay);
public static readonly BindableProperty GoToLinkCommandProperty = BindableProperty.Create(
nameof(GoToLinkCommand), typeof(ICommand), typeof(ExternalLinkItemView));
public ExternalLinkItemView()
{
InitializeComponent();
}
public string Title
{
get { return (string)GetValue(TitleProperty); }
set { SetValue(TitleProperty, value); }
}
public ICommand GoToLinkCommand
{
get => GetValue(GoToLinkCommandProperty) as ICommand;
set => SetValue(GoToLinkCommandProperty, value);
}
}
}

View File

@@ -0,0 +1,31 @@
namespace Bit.App.Controls
{
public class HybridWebView : View
{
private Action<string> _func;
public static readonly BindableProperty UriProperty = BindableProperty.Create(propertyName: nameof(Uri),
returnType: typeof(string), declaringType: typeof(HybridWebView), defaultValue: default(string));
public string Uri
{
get { return (string)GetValue(UriProperty); }
set { SetValue(UriProperty, value); }
}
public void RegisterAction(Action<string> callback)
{
_func = callback;
}
public void Cleanup()
{
_func = null;
}
public void InvokeAction(string data)
{
_func?.Invoke(data);
}
}
}

View File

@@ -0,0 +1,26 @@
using Bit.App.Effects;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class IconButton : Button
{
public IconButton()
{
Padding = 0;
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
switch (Device.RuntimePlatform)
{
case Device.iOS:
FontFamily = "bwi-font";
break;
case Device.Android:
FontFamily = "bwi-font.ttf";
break;
}
Effects.Add(new RemoveFontPaddingEffect());
}
}
}

View File

@@ -0,0 +1,27 @@
using Bit.App.Effects;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class IconLabel : Label
{
public bool ShouldUpdateFontSizeDynamicallyForAccesibility { get; set; }
public IconLabel()
{
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
switch (Device.RuntimePlatform)
{
case Device.iOS:
FontFamily = "bwi-font";
break;
case Device.Android:
FontFamily = "bwi-font.ttf";
break;
}
Effects.Add(new RemoveFontPaddingEffect());
}
}
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Bit.App.Controls.IconLabelButton"
xmlns:controls="clr-namespace:Bit.App.Controls"
x:Name="_iconLabelButton"
HeightRequest="45"
StyleClass="btn-icon-secondary">
<Border StrokeThickness="2" BackgroundColor="{Binding IconLabelBackgroundColor, Source={x:Reference _iconLabelButton}}" Stroke="{Binding IconLabelBorderColor, Source={x:Reference _iconLabelButton}}">
<Border.StrokeShape>
<RoundRectangle CornerRadius="{Binding CornerRadius, Source={x:Reference _iconLabelButton}}" />
</Border.StrokeShape>
<Grid>
<Grid.GestureRecognizers>
<TapGestureRecognizer Tapped="TapGestureRecognizer_OnTapped" Command="{Binding ButtonCommand, Source={x:Reference _iconLabelButton}}" />
</Grid.GestureRecognizers>
<StackLayout
Spacing="6"
Orientation="Horizontal"
HorizontalOptions="Center">
<controls:IconLabel
VerticalOptions="Center"
HorizontalTextAlignment="Center"
FontSize="Large"
TextColor="{Binding IconLabelColor, Source={x:Reference _iconLabelButton}}"
Text="{Binding Icon, Source={x:Reference _iconLabelButton}}">
</controls:IconLabel>
<Label
VerticalOptions="Center"
HorizontalTextAlignment="Center"
TextColor="{Binding IconLabelColor, Source={x:Reference _iconLabelButton}}"
FontSize="Medium"
Text="{Binding Label, Source={x:Reference _iconLabelButton}}" />
</StackLayout>
</Grid>
</Border>
</ContentView>

View File

@@ -0,0 +1,83 @@
using System.Windows.Input;
namespace Bit.App.Controls
{
public partial class IconLabelButton : ContentView
{
public static readonly BindableProperty IconProperty = BindableProperty.Create(
nameof(Icon), typeof(string), typeof(IconLabelButton));
public static readonly BindableProperty LabelProperty = BindableProperty.Create(
nameof(Label), typeof(string), typeof(IconLabelButton));
public static readonly BindableProperty ButtonCommandProperty = BindableProperty.Create(
nameof(ButtonCommand), typeof(ICommand), typeof(IconLabelButton));
public static readonly BindableProperty IconLabelColorProperty = BindableProperty.Create(
nameof(IconLabelColor), typeof(Color), typeof(IconLabelButton), Colors.White);
public static readonly BindableProperty IconLabelBackgroundColorProperty = BindableProperty.Create(
nameof(IconLabelBackgroundColor), typeof(Color), typeof(IconLabelButton), Colors.White);
public static readonly BindableProperty IconLabelBorderColorProperty = BindableProperty.Create(
nameof(IconLabelBorderColor), typeof(Color), typeof(IconLabelButton), Colors.White);
public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create(
nameof(CornerRadius), typeof(CornerRadius), typeof(IconLabelButton));
public event EventHandler<TappedEventArgs> Tapped;
public IconLabelButton()
{
InitializeComponent();
}
public string Icon
{
get => GetValue(IconProperty) as string;
set => SetValue(IconProperty, value);
}
public string Label
{
get => GetValue(LabelProperty) as string;
set => SetValue(LabelProperty, value);
}
public ICommand ButtonCommand
{
get => GetValue(ButtonCommandProperty) as ICommand;
set => SetValue(ButtonCommandProperty, value);
}
public Color IconLabelColor
{
get { return (Color)GetValue(IconLabelColorProperty); }
set { SetValue(IconLabelColorProperty, value); }
}
public Color IconLabelBackgroundColor
{
get { return (Color)GetValue(IconLabelBackgroundColorProperty); }
set { SetValue(IconLabelBackgroundColorProperty, value); }
}
public Color IconLabelBorderColor
{
get { return (Color)GetValue(IconLabelBorderColorProperty); }
set { SetValue(IconLabelBorderColorProperty, value); }
}
public CornerRadius CornerRadius
{
get { return (CornerRadius)GetValue(CornerRadiusProperty); }
set { SetValue(CornerRadiusProperty, value); }
}
private void TapGestureRecognizer_OnTapped(object sender, TappedEventArgs e)
{
Tapped?.Invoke(sender, e);
}
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class MiButton : Button
{
public MiButton()
{
Padding = 0;
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
switch (Device.RuntimePlatform)
{
case Device.iOS:
FontFamily = "Material Icons";
break;
case Device.Android:
FontFamily = "MaterialIcons_Regular.ttf";
break;
}
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class MiLabel : Label
{
public MiLabel()
{
// TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes
switch (Device.RuntimePlatform)
{
case Device.iOS:
FontFamily = "Material Icons";
break;
case Device.Android:
FontFamily = "MaterialIcons_Regular.ttf";
break;
}
}
}
}

View File

@@ -0,0 +1,14 @@
namespace Bit.App.Controls
{
public class MonoEntry : Entry
{
public MonoEntry()
{
#if ANDROID
FontFamily = "RobotoMono_Regular";
#elif IOS
FontFamily = "Menlo-Regular";
#endif
}
}
}

View File

@@ -0,0 +1,17 @@
namespace Bit.App.Controls
{
public class MonoLabel : Label
{
public MonoLabel()
{
if (DeviceInfo.Platform == DevicePlatform.iOS)
{
FontFamily = "Menlo-Regular";
}
else if (DeviceInfo.Platform == DevicePlatform.Android)
{
FontFamily = "RobotoMono_Regular.ttf";
}
}
}
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace Bit.App.Controls
{
public interface IPasswordStrengthable
{
string Password { get; }
List<string> UserInputs { get; }
}
}

View File

@@ -0,0 +1,17 @@
using Bit.Core.Attributes;
namespace Bit.App.Controls
{
public enum PasswordStrengthLevel
{
[LocalizableEnum("Weak")]
VeryWeak,
[LocalizableEnum("Weak")]
Weak,
[LocalizableEnum("Good")]
Good,
[LocalizableEnum("Strong")]
Strong
}
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" ?>
<StackLayout
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:u="clr-namespace:Bit.App.Utilities"
x:DataType="controls:PasswordStrengthViewModel"
x:Class="Bit.App.Controls.PasswordStrengthProgressBar"
StyleClass="box">
<StackLayout.Resources>
<ResourceDictionary>
<u:LocalizableEnumConverter x:Key="localizableEnum" />
</ResourceDictionary>
</StackLayout.Resources>
<ProgressBar
x:Name="_progressBar"
u:ProgressBarExtensions.AnimatedProgress="{Binding PasswordStrength}"
ScaleY="2" />
<Label
x:Name="_progressLabel"
Text="{Binding PasswordStrengthLevel, Converter={StaticResource localizableEnum}, TargetNullValue=' ' }"
StyleClass="box-footer-label" />
</StackLayout>

View File

@@ -0,0 +1,110 @@
using System;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public partial class PasswordStrengthProgressBar : StackLayout
{
public static readonly BindableProperty PasswordStrengthLevelProperty = BindableProperty.Create(
nameof(PasswordStrengthLevel),
typeof(PasswordStrengthLevel),
typeof(PasswordStrengthProgressBar),
propertyChanged: OnControlPropertyChanged);
public static readonly BindableProperty VeryWeakColorProperty = BindableProperty.Create(
nameof(VeryWeakColor),
typeof(Color),
typeof(PasswordStrengthProgressBar),
propertyChanged: OnControlPropertyChanged);
public static readonly BindableProperty WeakColorProperty = BindableProperty.Create(
nameof(WeakColor),
typeof(Color),
typeof(PasswordStrengthProgressBar),
propertyChanged: OnControlPropertyChanged);
public static readonly BindableProperty GoodColorProperty = BindableProperty.Create(
nameof(GoodColor),
typeof(Color),
typeof(PasswordStrengthProgressBar),
propertyChanged: OnControlPropertyChanged);
public static readonly BindableProperty StrongColorProperty = BindableProperty.Create(
nameof(StrongColor),
typeof(Color),
typeof(PasswordStrengthProgressBar),
propertyChanged: OnControlPropertyChanged);
public PasswordStrengthLevel? PasswordStrengthLevel
{
get { return (PasswordStrengthLevel?)GetValue(PasswordStrengthLevelProperty); }
set { SetValue(PasswordStrengthLevelProperty, value); }
}
public Color VeryWeakColor
{
get { return (Color)GetValue(VeryWeakColorProperty); }
set { SetValue(VeryWeakColorProperty, value); }
}
public Color WeakColor
{
get { return (Color)GetValue(WeakColorProperty); }
set { SetValue(WeakColorProperty, value); }
}
public Color GoodColor
{
get { return (Color)GetValue(GoodColorProperty); }
set { SetValue(GoodColorProperty, value); }
}
public Color StrongColor
{
get { return (Color)GetValue(StrongColorProperty); }
set { SetValue(StrongColorProperty, value); }
}
public PasswordStrengthProgressBar()
{
InitializeComponent();
SetBinding(PasswordStrengthProgressBar.PasswordStrengthLevelProperty, new Binding() { Path = nameof(PasswordStrengthViewModel.PasswordStrengthLevel) });
UpdateColors();
}
private static void OnControlPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
(bindable as PasswordStrengthProgressBar)?.UpdateColors();
}
public void UpdateColors()
{
if (_progressBar == null || _progressLabel == null)
{
return;
}
_progressBar.ProgressColor = GetColorForStrength();
_progressLabel.TextColor = _progressBar.ProgressColor;
}
private Color GetColorForStrength()
{
switch (PasswordStrengthLevel)
{
case Controls.PasswordStrengthLevel.VeryWeak:
return VeryWeakColor;
case Controls.PasswordStrengthLevel.Weak:
return WeakColor;
case Controls.PasswordStrengthLevel.Good:
return GoodColor;
case Controls.PasswordStrengthLevel.Strong:
return StrongColor;
default:
return Colors.Transparent;
}
}
}
}

View File

@@ -0,0 +1,68 @@
using System.Collections.Generic;
using Bit.Core.Abstractions;
using Bit.Core.Utilities;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class PasswordStrengthViewModel : ExtendedViewModel
{
private readonly IPasswordGenerationService _passwordGenerationService;
private readonly IPasswordStrengthable _passwordStrengthable;
private double _passwordStrength;
private Color _passwordColor;
private PasswordStrengthLevel? _passwordStrengthLevel;
public PasswordStrengthViewModel(IPasswordStrengthable passwordStrengthable)
{
_passwordGenerationService = ServiceContainer.Resolve<IPasswordGenerationService>();
_passwordStrengthable = passwordStrengthable;
}
public double PasswordStrength
{
get => _passwordStrength;
set => SetProperty(ref _passwordStrength, value);
}
public PasswordStrengthLevel? PasswordStrengthLevel
{
get => _passwordStrengthLevel;
set => SetProperty(ref _passwordStrengthLevel, value);
}
public List<string> GetPasswordStrengthUserInput(string email) => _passwordGenerationService.GetPasswordStrengthUserInput(email);
public void CalculatePasswordStrength()
{
if (string.IsNullOrEmpty(_passwordStrengthable.Password))
{
PasswordStrength = 0;
PasswordStrengthLevel = null;
return;
}
var passwordStrength = _passwordGenerationService.PasswordStrength(_passwordStrengthable.Password, _passwordStrengthable.UserInputs);
// The passwordStrength.Score is 0..4, convertion was made to be used as a progress directly by the control 0..1
PasswordStrength = (passwordStrength.Score + 1f) / 5f;
if (PasswordStrength <= 0.4f)
{
PasswordStrengthLevel = Controls.PasswordStrengthLevel.VeryWeak;
}
else if (PasswordStrength <= 0.6f)
{
PasswordStrengthLevel = Controls.PasswordStrengthLevel.Weak;
}
else if (PasswordStrength <= 0.8f)
{
PasswordStrengthLevel = Controls.PasswordStrengthLevel.Good;
}
else
{
PasswordStrengthLevel = Controls.PasswordStrengthLevel.Strong;
}
}
}
}

View File

@@ -0,0 +1,299 @@
#if ANDROID
using System;
using System.Collections.Specialized;
using Android.App;
using Android.Content.PM;
using Android.Content.Res;
using Android.Graphics.Drawables;
using Android.Text;
using Android.Text.Style;
using Android.Widget;
using Microsoft.Maui;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
using AGravityFlags = Android.Views.GravityFlags;
using ALayoutDirection = Android.Views.LayoutDirection;
using AppCompatAlertDialog = AndroidX.AppCompat.App.AlertDialog;
using AResource = Android.Resource;
using ATextAlignment = Android.Views.TextAlignment;
using ATextDirection = Android.Views.TextDirection;
namespace Bit.Core.Controls.Picker
{
// HACK: Due to https://github.com/dotnet/maui/issues/19681 and not willing to use reflection to access
// the alert dialog, we need to redefine the PickerHandler implementation for a custom one of ours
// which handles showing the current selected item. Remove this workaround when MAUI releases a fix for this.
// This is an adapted copy from https://github.com/dotnet/maui/blob/main/src/Core/src/Handlers/Picker/PickerHandler.Android.cs
public partial class PickerHandler : ViewHandler<IPicker, MauiPicker>
{
AppCompatAlertDialog? _dialog;
protected override MauiPicker CreatePlatformView() =>
new MauiPicker(Context);
protected override void ConnectHandler(MauiPicker platformView)
{
platformView.FocusChange += OnFocusChange;
platformView.Click += OnClick;
base.ConnectHandler(platformView);
}
protected override void DisconnectHandler(MauiPicker platformView)
{
platformView.FocusChange -= OnFocusChange;
platformView.Click -= OnClick;
base.DisconnectHandler(platformView);
}
// This is a Android-specific mapping
public static void MapBackground(IPickerHandler handler, IPicker picker)
{
handler.PlatformView?.UpdateBackground(picker);
}
// TODO Uncomment me on NET8 [Obsolete]
public static void MapReload(IPickerHandler handler, IPicker picker, object? args) => Reload(handler);
internal static void MapItems(IPickerHandler handler, IPicker picker) => Reload(handler);
public static void MapTitle(IPickerHandler handler, IPicker picker)
{
handler.PlatformView?.UpdateTitle(picker);
}
public static void MapTitleColor(IPickerHandler handler, IPicker picker)
{
handler.PlatformView?.UpdateTitleColor(picker);
}
public static void MapSelectedIndex(IPickerHandler handler, IPicker picker)
{
handler.PlatformView?.UpdateSelectedIndex(picker);
}
public static void MapCharacterSpacing(IPickerHandler handler, IPicker picker)
{
handler.PlatformView?.UpdateCharacterSpacing(picker);
}
public static void MapFont(IPickerHandler handler, IPicker picker)
{
var fontManager = handler.GetRequiredService<IFontManager>();
handler.PlatformView?.UpdateFont(picker, fontManager);
}
public static void MapHorizontalTextAlignment(IPickerHandler handler, IPicker picker)
{
handler.PlatformView?.UpdateHorizontalAlignment(picker.HorizontalTextAlignment);
}
public static void MapTextColor(IPickerHandler handler, IPicker picker)
{
handler.PlatformView.UpdateTextColor(picker);
}
public static void MapVerticalTextAlignment(IPickerHandler handler, IPicker picker)
{
handler.PlatformView?.UpdateVerticalAlignment(picker.VerticalTextAlignment);
}
void OnFocusChange(object? sender, global::Android.Views.View.FocusChangeEventArgs e)
{
if (PlatformView == null)
return;
if (e.HasFocus)
{
if (PlatformView.Clickable)
PlatformView.CallOnClick();
else
OnClick(PlatformView, EventArgs.Empty);
}
else if (_dialog != null)
{
_dialog.Hide();
_dialog = null;
}
}
void OnClick(object? sender, EventArgs e)
{
if (_dialog == null && VirtualView != null)
{
using (var builder = new AppCompatAlertDialog.Builder(Context))
{
if (VirtualView.TitleColor == null)
{
builder.SetTitle(VirtualView.Title ?? string.Empty);
}
else
{
var title = new SpannableString(VirtualView.Title ?? string.Empty);
#pragma warning disable CA1416 // https://github.com/xamarin/xamarin-android/issues/6962
title.SetSpan(new ForegroundColorSpan(VirtualView.TitleColor.ToPlatform()), 0, title.Length(), SpanTypes.ExclusiveExclusive);
#pragma warning restore CA1416
builder.SetTitle(title);
}
string[] items = VirtualView.GetItemsAsArray();
for (var i = 0; i < items.Length; i++)
{
var item = items[i];
if (item == null)
items[i] = String.Empty;
}
builder.SetSingleChoiceItems(items, VirtualView.SelectedIndex, (s, e) =>
{
var selectedIndex = e.Which;
VirtualView.SelectedIndex = selectedIndex;
base.PlatformView?.UpdatePicker(VirtualView);
_dialog.Dismiss();
});
builder.SetNegativeButton(AResource.String.Cancel, (o, args) => { });
_dialog = builder.Create();
}
if (_dialog == null)
return;
_dialog.UpdateFlowDirection(PlatformView);
_dialog.SetCanceledOnTouchOutside(true);
_dialog.DismissEvent += (sender, args) =>
{
_dialog = null;
};
_dialog.Show();
}
}
static void Reload(IPickerHandler handler)
{
handler.PlatformView.UpdatePicker(handler.VirtualView);
}
}
public static class PickerExtensions
{
const AGravityFlags HorizontalGravityMask = AGravityFlags.CenterHorizontal | AGravityFlags.End | AGravityFlags.Start;
internal static void UpdatePicker(this MauiPicker platformPicker, IPicker picker)
{
platformPicker.Hint = picker.Title;
if (picker.SelectedIndex == -1 || picker.SelectedIndex >= picker.GetCount())
platformPicker.Text = null;
else
platformPicker.Text = picker.GetItem(picker.SelectedIndex);
}
internal static void UpdateHorizontalAlignment(this EditText view, TextAlignment alignment, AGravityFlags orMask = AGravityFlags.NoGravity)
{
if (!Rtl.IsSupported)
{
view.Gravity = (view.Gravity & ~HorizontalGravityMask) | alignment.ToHorizontalGravityFlags() | orMask;
}
else
view.TextAlignment = alignment.ToTextAlignment();
}
internal static AGravityFlags ToHorizontalGravityFlags(this TextAlignment alignment)
{
switch (alignment)
{
case TextAlignment.Center:
return AGravityFlags.CenterHorizontal;
case TextAlignment.End:
return AGravityFlags.End;
default:
return AGravityFlags.Start;
}
}
internal static ATextAlignment ToTextAlignment(this TextAlignment alignment)
{
switch (alignment)
{
case TextAlignment.Center:
return ATextAlignment.Center;
case TextAlignment.End:
return ATextAlignment.ViewEnd;
default:
return ATextAlignment.ViewStart;
}
}
internal static void UpdateFlowDirection(this AndroidX.AppCompat.App.AlertDialog alertDialog, MauiPicker platformPicker)
{
var platformLayoutDirection = platformPicker.LayoutDirection;
// Propagate the MauiPicker LayoutDirection to the AlertDialog
var dv = alertDialog.Window?.DecorView;
if (dv is not null)
dv.LayoutDirection = platformLayoutDirection;
var lv = alertDialog?.ListView;
if (lv is not null)
{
lv.LayoutDirection = platformLayoutDirection;
lv.TextDirection = platformLayoutDirection.ToTextDirection();
}
}
internal static ATextDirection ToTextDirection(this ALayoutDirection direction)
{
switch (direction)
{
case ALayoutDirection.Ltr:
return ATextDirection.Ltr;
case ALayoutDirection.Rtl:
return ATextDirection.Rtl;
default:
return ATextDirection.Inherit;
}
}
public static T GetRequiredService<T>(this IElementHandler handler)
where T : notnull
{
var services = handler.GetServiceProvider();
var service = services.GetRequiredService<T>();
return service;
}
public static IServiceProvider GetServiceProvider(this IElementHandler handler)
{
var context = handler.MauiContext ??
throw new InvalidOperationException($"Unable to find the context. The {nameof(ElementHandler.MauiContext)} property should have been set by the host.");
var services = context?.Services ??
throw new InvalidOperationException($"Unable to find the service provider. The {nameof(ElementHandler.MauiContext)} property should have been set by the host.");
return services;
}
}
static class Rtl
{
/// <summary>
/// True if /manifest/application@android:supportsRtl="true"
/// </summary>
public static readonly bool IsSupported =
(Android.App.Application.Context?.ApplicationInfo?.Flags & ApplicationInfoFlags.SupportsRtl) != 0;
}
}
#endif

View File

@@ -0,0 +1,53 @@
#if ANDROID
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
namespace Bit.Core.Controls.Picker
{
// HACK: Due to https://github.com/dotnet/maui/issues/19681 and not willing to use reflection to access
// the alert dialog, we need to redefine the PickerHandler implementation for a custom one of ours
// which handles showing the current selected item. Remove this workaround when MAUI releases a fix for this.
// This is a copy from https://github.com/dotnet/maui/blob/main/src/Core/src/Handlers/Picker/PickerHandler.cs
public partial class PickerHandler : ViewHandler<IPicker, MauiPicker>, IPickerHandler
{
public static IPropertyMapper<IPicker, IPickerHandler> Mapper = new PropertyMapper<IPicker, PickerHandler>(ViewMapper)
{
#if __ANDROID__ || WINDOWS
[nameof(IPicker.Background)] = MapBackground,
#endif
[nameof(IPicker.CharacterSpacing)] = MapCharacterSpacing,
[nameof(IPicker.Font)] = MapFont,
[nameof(IPicker.SelectedIndex)] = MapSelectedIndex,
[nameof(IPicker.TextColor)] = MapTextColor,
[nameof(IPicker.Title)] = MapTitle,
[nameof(IPicker.TitleColor)] = MapTitleColor,
[nameof(ITextAlignment.HorizontalTextAlignment)] = MapHorizontalTextAlignment,
[nameof(ITextAlignment.VerticalTextAlignment)] = MapVerticalTextAlignment,
[nameof(IPicker.Items)] = MapItems,
};
public static CommandMapper<IPicker, IPickerHandler> CommandMapper = new(ViewCommandMapper)
{
};
public PickerHandler() : base(Mapper, CommandMapper)
{
}
public PickerHandler(IPropertyMapper? mapper)
: base(mapper ?? Mapper, CommandMapper)
{
}
public PickerHandler(IPropertyMapper? mapper, CommandMapper? commandMapper)
: base(mapper ?? Mapper, commandMapper ?? CommandMapper)
{
}
IPicker IPickerHandler.VirtualView => VirtualView;
Microsoft.Maui.Platform.MauiPicker IPickerHandler.PlatformView => PlatformView;
}
}
#endif

View File

@@ -0,0 +1,98 @@
using System;
using System.Collections;
using System.Collections.Specialized;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
[Obsolete]
public class RepeaterView : StackLayout
{
public static readonly BindableProperty ItemTemplateProperty = BindableProperty.Create(
nameof(ItemTemplate), typeof(DataTemplate), typeof(RepeaterView), default(DataTemplate));
public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create(
nameof(ItemsSource), typeof(ICollection), typeof(RepeaterView), null, BindingMode.OneWay,
propertyChanged: ItemsSourceChanging);
public RepeaterView()
{
Spacing = 0;
}
public ICollection ItemsSource
{
get => GetValue(ItemsSourceProperty) as ICollection;
set => SetValue(ItemsSourceProperty, value);
}
public DataTemplate ItemTemplate
{
get => GetValue(ItemTemplateProperty) as DataTemplate;
set => SetValue(ItemTemplateProperty, value);
}
private void OnCollectionChanged(object sender,
NotifyCollectionChangedEventArgs notifyCollectionChangedEventArgs)
{
Populate();
}
protected override void OnPropertyChanged(string propertyName = null)
{
base.OnPropertyChanged(propertyName);
if (propertyName == ItemTemplateProperty.PropertyName || propertyName == ItemsSourceProperty.PropertyName)
{
Populate();
}
}
protected override void OnBindingContextChanged()
{
base.OnBindingContextChanged();
Populate();
}
protected virtual View ViewFor(object item)
{
View view = null;
var template = ItemTemplate;
if (template != null)
{
if (template is DataTemplateSelector selector)
{
template = selector.SelectTemplate(item, this);
}
var content = template.CreateContent();
view = content is View ? content as View : ((ViewCell)content).View;
view.BindingContext = item;
}
return view;
}
private void Populate()
{
if (ItemsSource != null)
{
Children.Clear();
foreach (var item in ItemsSource)
{
Children.Add(ViewFor(item));
}
}
}
private static void ItemsSourceChanging(BindableObject bindable, object oldValue, object newValue)
{
if (oldValue != null && oldValue is INotifyCollectionChanged ov)
{
ov.CollectionChanged -= (bindable as RepeaterView).OnCollectionChanged;
}
if (newValue != null && newValue is INotifyCollectionChanged nv)
{
nv.CollectionChanged += (bindable as RepeaterView).OnCollectionChanged;
}
}
}
}

View File

@@ -0,0 +1,11 @@
using System;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class SelectableLabel : Label
{
}
}

View File

@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="UTF-8"?>
<controls:ExtendedGrid xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Bit.App.Controls.SendViewCell"
xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:u="clr-namespace:Bit.App.Utilities"
xmlns:core="clr-namespace:Bit.Core"
StyleClass="list-row, list-row-platform"
RowSpacing="0"
ColumnSpacing="0"
x:DataType="controls:SendViewCellViewModel">
<Grid.Resources>
<u:SendIconGlyphConverter x:Key="sendIconGlyphConverter"/>
</Grid.Resources>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" x:Name="_iconColumn" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="60" />
</Grid.ColumnDefinitions>
<controls:IconLabel
Grid.Row="0"
Grid.Column="0"
HorizontalOptions="Center"
VerticalOptions="Center"
StyleClass="list-icon, list-icon-platform"
Text="{Binding Send, Converter={StaticResource sendIconGlyphConverter}}"
ShouldUpdateFontSizeDynamicallyForAccesibility="True"
AutomationProperties.IsInAccessibleTree="False" />
<Grid RowSpacing="0" ColumnSpacing="0" Grid.Row="0" Grid.Column="1" VerticalOptions="Center" Padding="0, 7">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label
LineBreakMode="TailTruncation"
Grid.Column="0"
Grid.Row="0"
StyleClass="list-title, list-title-platform"
Text="{Binding Send.Name}"
AutomationId="SendNameLabel" />
<Label
LineBreakMode="TailTruncation"
Grid.Column="0"
Grid.Row="1"
Grid.ColumnSpan="6"
StyleClass="list-subtitle, list-subtitle-platform"
Text="{Binding Send.DisplayDate}"
AutomationId="SendDateLabel" />
<controls:IconLabel
Grid.Column="1"
Grid.Row="0"
HorizontalOptions="Start"
VerticalOptions="Center"
StyleClass="list-title-icon"
Margin="5, 0, 0, 0"
Text="{Binding Source={x:Static core:BitwardenIcons.ExclamationTriangle}}"
IsVisible="{Binding Send.Disabled, Mode=OneTime}"
SemanticProperties.Description="{u:I18n Disabled}"
AutomationId="DisabledSendLabel" />
<controls:IconLabel
Grid.Column="2"
Grid.Row="0"
HorizontalOptions="Start"
VerticalOptions="Center"
StyleClass="list-title-icon"
Margin="5, 0, 0, 0"
Text="{Binding Source={x:Static core:BitwardenIcons.Key}}"
IsVisible="{Binding Send.HasPassword, Mode=OneTime}"
SemanticProperties.Description="{u:I18n Password}"
AutomationId="PasswordProtectedSendLabel" />
<controls:IconLabel
Grid.Column="3"
Grid.Row="0"
HorizontalOptions="Start"
VerticalOptions="Center"
StyleClass="list-title-icon"
Margin="5, 0, 0, 0"
Text="{Binding Source={x:Static core:BitwardenIcons.Ban}}"
IsVisible="{Binding Send.MaxAccessCountReached, Mode=OneTime}"
SemanticProperties.Description="{u:I18n MaxAccessCountReached}"
AutomationId="SendMaxAccessCountReachedLabel" />
<controls:IconLabel
Grid.Column="4"
Grid.Row="0"
HorizontalOptions="Start"
VerticalOptions="Center"
StyleClass="list-title-icon"
Margin="5, 0, 0, 0"
Text="{Binding Source={x:Static core:BitwardenIcons.Clock}}"
IsVisible="{Binding Send.Expired, Mode=OneTime}"
SemanticProperties.Description="{u:I18n Expired}"
AutomationId="ExpiredSendLabel" />
<controls:IconLabel
Grid.Column="5"
Grid.Row="0"
HorizontalOptions="Start"
VerticalOptions="Center"
StyleClass="list-title-icon"
Margin="5, 0, 0, 0"
Text="{Binding Source={x:Static core:BitwardenIcons.Trash}}"
IsVisible="{Binding Send.PendingDelete, Mode=OneTime}"
SemanticProperties.Description="{u:I18n PendingDelete}"
AutomationId="SendWithPendingDeletionLabel" />
</Grid>
<controls:MiButton
Grid.Row="0"
Grid.Column="2"
Text="{Binding Source={x:Static core:BitwardenIcons.ViewCellMenu}}"
IsVisible="{Binding ShowOptions, Mode=OneWay}"
StyleClass="list-row-button, list-row-button-platform, btn-disabled"
Clicked="MoreButton_Clicked"
VerticalOptions="CenterAndExpand"
HorizontalOptions="EndAndExpand"
SemanticProperties.Description="{u:I18n Options}"
AutomationId="SendOptionsButton" />
</controls:ExtendedGrid>

View File

@@ -0,0 +1,35 @@
using Bit.App.Abstractions;
using Bit.Core.Models.View;
using Bit.Core.Utilities;
namespace Bit.App.Controls
{
public partial class SendViewCell : ExtendedGrid
{
public static readonly BindableProperty ButtonCommandProperty = BindableProperty.Create(
nameof(ButtonCommand), typeof(Command<SendView>), typeof(SendViewCell));
public SendViewCell()
{
InitializeComponent();
var deviceActionService = ServiceContainer.Resolve<IDeviceActionService>();
_iconColumn.Width = new GridLength(40 * deviceActionService.GetSystemFontSizeScale(), GridUnitType.Absolute);
}
public Command<SendView> ButtonCommand
{
get => GetValue(ButtonCommandProperty) as Command<SendView>;
set => SetValue(ButtonCommandProperty, value);
}
private void MoreButton_Clicked(object sender, EventArgs e)
{
var send = ((sender as MiButton)?.BindingContext as SendViewCellViewModel)?.Send;
if (send != null)
{
ButtonCommand?.Execute(send);
}
}
}
}

View File

@@ -0,0 +1,30 @@
using System.Globalization;
using Bit.Core.Models.View;
using Bit.Core.Utilities;
namespace Bit.App.Controls
{
public class SendViewCellViewModel : ExtendedViewModel
{
private SendView _send;
private bool _showOptions;
public SendViewCellViewModel(SendView sendView, bool showOptions)
{
Send = sendView;
ShowOptions = showOptions;
}
public SendView Send
{
get => _send;
set => SetProperty(ref _send, value);
}
public bool ShowOptions
{
get => _showOptions;
set => SetProperty(ref _showOptions, value);
}
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public class BaseSettingItemView : ContentView
{
public static readonly BindableProperty TitleProperty = BindableProperty.Create(
nameof(Title), typeof(string), typeof(SwitchItemView), null);
public static readonly BindableProperty SubtitleProperty = BindableProperty.Create(
nameof(Subtitle), typeof(string), typeof(SwitchItemView), null);
public string Title
{
get { return (string)GetValue(TitleProperty); }
set { SetValue(TitleProperty, value); }
}
public string Subtitle
{
get { return (string)GetValue(SubtitleProperty); }
set { SetValue(SubtitleProperty, value); }
}
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<controls:BaseSettingItemView
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:Bit.App.Controls"
x:Class="Bit.App.Controls.SettingChooserItemView"
x:Name="_contentView"
ControlTemplate="{StaticResource SettingControlTemplate}">
<controls:BaseSettingItemView.GestureRecognizers>
<TapGestureRecognizer Command="{Binding ChooseCommand, Mode=OneWay, Source={x:Reference _contentView}}" />
</controls:BaseSettingItemView.GestureRecognizers>
<controls:CustomLabel
Text="{Binding DisplayValue, Source={x:Reference _contentView}}"
HorizontalTextAlignment="End"
TextColor="{DynamicResource MutedColor}"
StyleClass="list-sub" />
</controls:BaseSettingItemView>

View File

@@ -0,0 +1,32 @@
using System.Windows.Input;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public partial class SettingChooserItemView : BaseSettingItemView
{
public static readonly BindableProperty DisplayValueProperty = BindableProperty.Create(
nameof(DisplayValue), typeof(string), typeof(SettingChooserItemView), null);
public static readonly BindableProperty ChooseCommandProperty = BindableProperty.Create(
nameof(ChooseCommand), typeof(ICommand), typeof(ExternalLinkItemView));
public string DisplayValue
{
get { return (string)GetValue(DisplayValueProperty); }
set { SetValue(DisplayValueProperty, value); }
}
public SettingChooserItemView()
{
InitializeComponent();
}
public ICommand ChooseCommand
{
get => GetValue(ChooseCommandProperty) as ICommand;
set => SetValue(ChooseCommandProperty, value);
}
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<controls:BaseSettingItemView
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:Bit.App.Controls"
x:Class="Bit.App.Controls.SwitchItemView"
x:Name="_contentView"
ControlTemplate="{StaticResource SettingControlTemplate}">
<controls:BaseSettingItemView.GestureRecognizers>
<TapGestureRecognizer Tapped="ContentView_Tapped" />
</controls:BaseSettingItemView.GestureRecognizers>
<Switch
x:Name="_switch"
HeightRequest="20"
Scale="{OnPlatform iOS=0.8, Android=1}"
IsToggled="{Binding IsToggled, Mode=TwoWay, Source={x:Reference _contentView}}"
AutomationId="{Binding SwitchAutomationId, Mode=OneWay, Source={x:Reference _contentView}}"/>
</controls:BaseSettingItemView>

View File

@@ -0,0 +1,46 @@
using System.Windows.Input;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
namespace Bit.App.Controls
{
public partial class SwitchItemView : BaseSettingItemView
{
public static readonly BindableProperty IsToggledProperty = BindableProperty.Create(
nameof(IsToggled), typeof(bool), typeof(SwitchItemView), null, BindingMode.TwoWay);
public static readonly BindableProperty SwitchAutomationIdProperty = BindableProperty.Create(
nameof(SwitchAutomationId), typeof(string), typeof(SwitchItemView), null, BindingMode.OneWay);
public static readonly BindableProperty ToggleSwitchCommandProperty = BindableProperty.Create(
nameof(ToggleSwitchCommand), typeof(ICommand), typeof(ExternalLinkItemView));
public SwitchItemView()
{
InitializeComponent();
}
public bool IsToggled
{
get { return (bool)GetValue(IsToggledProperty); }
set { SetValue(IsToggledProperty, value); }
}
public string SwitchAutomationId
{
get { return (string)GetValue(SwitchAutomationIdProperty); }
set { SetValue(SwitchAutomationIdProperty, value); }
}
public ICommand ToggleSwitchCommand
{
get => GetValue(ToggleSwitchCommandProperty) as ICommand;
set => SetValue(ToggleSwitchCommandProperty, value);
}
void ContentView_Tapped(System.Object sender, System.EventArgs e)
{
_switch.IsToggled = !_switch.IsToggled;
}
}
}

View File

@@ -0,0 +1,12 @@
using Bit.App.Controls;
namespace Bit.App.Controls
{
// WORKAROUND: There is an issue causing Multiline Labels that also have a LineBreakMode to not display text properly. (it truncates text on first line even with space available)
// MAUI Github Issue: https://github.com/dotnet/maui/issues/14125 and https://github.com/dotnet/maui/pull/14918
// This class is used to be able to only add the workaround to this specific Label avoiding potential issues on other "normal" Label
// When this gets fixed by MAUI we can remove this class and some of the Mapping in LabelHandlerMappings
public class TruncatedMultilineCustomLabel : CustomLabel
{
}
}

View File

@@ -1,50 +1,111 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<!--When running Unit tests we'll have the custom constant "UT" added, so in this manner we can add the net8.0 target we need for UT -->
<TargetFrameworks Condition="$(CustomConstants.Contains(UT))">net8.0;net8.0-android;net8.0-ios</TargetFrameworks>
<TargetFrameworks Condition="!$(CustomConstants.Contains(UT))">net8.0-android;net8.0-ios</TargetFrameworks>
<RootNamespace>Bit.Core</RootNamespace>
<AssemblyName>BitwardenCore</AssemblyName>
<Configurations>Debug;Release;FDroid</Configurations>
<UseMaui>true</UseMaui>
<ImplicitUsings>enable</ImplicitUsings>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">11.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>pdbonly</DebugType>
<DebugSymbols>true</DebugSymbols>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net8.0-ios|AnyCPU'">
<CreatePackage>false</CreatePackage>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net8.0-ios|AnyCPU'">
<CreatePackage>false</CreatePackage>
</PropertyGroup>
<PropertyGroup>
<DefineConstants Condition=" '$(CustomConstants)' != '' ">$(DefineConstants);$(CustomConstants)</DefineConstants>
</PropertyGroup>
<ItemGroup>
<None Remove="Resources\eff_long_word_list.txt" />
<None Remove="Resources\public_suffix_list.dat" />
<None Remove="Microsoft.AppCenter.Crashes" />
<None Remove="Services\Logging\" />
<None Remove="Attributes\" />
<None Remove="MessagePack" />
<None Remove="MessagePack.MSBuild.Tasks" />
<None Remove="Services\EmailForwarders\" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\eff_long_word_list.txt" />
<EmbeddedResource Include="Resources\public_suffix_list.dat" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="LiteDB" Version="5.0.17" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="PCLCrypto" Version="2.0.147" />
<PackageReference Include="zxcvbn-core" Version="7.0.92" />
<PackageReference Include="Microsoft.AppCenter.Crashes" Version="5.0.2" />
<PackageReference Include="MessagePack" Version="2.4.59" />
<PackageReference Include="MessagePack.MSBuild.Tasks" Version="2.4.59">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<PackageReference Include="Camera.MAUI" Version="1.4.4" />
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="MessagePack" Version="2.5.124" />
<PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="LiteDB" Version="5.0.17" />
<PackageReference Include="PCLCrypto" Version="2.1.40-alpha" />
<PackageReference Include="zxcvbn-core" Version="7.0.92" />
<PackageReference Include="MessagePack.MSBuild.Tasks" Version="2.5.124">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Maui" Version="5.2.0" />
<PackageReference Include="Plugin.Fingerprint" Version="3.0.0-beta.1" />
<PackageReference Include="SkiaSharp.Views.Maui.Controls" Version="2.88.4-preview.84" />
<PackageReference Include="SkiaSharp.Views.Maui.Controls.Compatibility" Version="2.88.4-preview.84" />
<PackageReference Include="AsyncAwaitBestPractices.MVVM" Version="6.0.6" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
<!-- HACK: When running Unit Tests we cannot load FFImageLoading because it doesn't support "raw" net8.0 -->
<PackageReference Condition="!$(CustomConstants.Contains(UT))" Include="FFImageLoadingCompat.Maui" Version="0.1.1" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
<PackageReference Include="Xamarin.AndroidX.AutoFill" Version="1.1.0.18" />
<PackageReference Include="Xamarin.AndroidX.Activity.Ktx" Version="1.7.2.1" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android' AND !$(DefineConstants.Contains(FDROID))">
<PackageReference Include="Xamarin.GooglePlayServices.SafetyNet" Version="118.0.1.5" />
<PackageReference Include="Xamarin.Firebase.Messaging" Version="123.1.2.2" />
</ItemGroup>
<ItemGroup Condition="!$(DefineConstants.Contains(FDROID))">
<PackageReference Include="Microsoft.AppCenter.Crashes" Version="5.0.3" />
</ItemGroup>
<ItemGroup>
<Folder Include="Services\Logging\" />
<Folder Include="Attributes\" />
<Folder Include="Services\EmailForwarders\" />
<Folder Include="Resources\Fonts\" />
<Folder Include="Effects\" />
<Folder Include="Resources\Raw\" />
<Folder Include="Behaviors\" />
<Folder Include="Controls\" />
<Folder Include="Lists\" />
<Folder Include="Resources\Styles\" />
<Folder Include="Utilities\AccountManagement\" />
<Folder Include="Utilities\Automation\" />
<Folder Include="Utilities\Prompts\" />
<Folder Include="Resources\Localization\" />
<Folder Include="Controls\Picker\" />
<Folder Include="Controls\Avatar\" />
</ItemGroup>
</Project>
<ItemGroup>
<MauiImage Include="Resources\Images\dotnet_bot.svg">
<BaseSize>168,208</BaseSize>
</MauiImage>
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
<MauiFont Include="Resources\Fonts\*" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Resources\Localization\AppResources.resx">
<LastGenOutput>AppResources.Designer.cs</LastGenOutput>
<Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>
<Compile Update="Pages\AndroidNavigationRedirectPage.xaml.cs">
<DependentUpon>AndroidNavigationRedirectPage.xaml</DependentUpon>
</Compile>
<Compile Update="Resources\Localization\AppResources.Designer.cs">
<DependentUpon>AppResources.resx</DependentUpon>
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
</Compile>
</ItemGroup>
<ItemGroup>
<MauiXaml Update="Pages\AndroidNavigationRedirectPage.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
</ItemGroup>
<ItemGroup>
<None Remove="Controls\Picker\" />
<None Remove="Controls\Avatar\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,31 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui;
using Microsoft.Maui.Controls.Platform;
#if ANDROID
using Android.Widget;
#endif
namespace Bit.App.Effects
{
public class FixedSizeEffect : RoutingEffect
{
}
#if ANDROID
public class FixedSizePlatformEffect : PlatformEffect
{
protected override void OnAttached()
{
if (Element is Label label && Control is TextView textView)
{
textView.SetTextSize(Android.Util.ComplexUnitType.Sp, (float)label.FontSize);
}
}
protected override void OnDetached()
{
}
}
#endif
}

View File

@@ -0,0 +1,32 @@
using System;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
using Microsoft.Maui.Controls.Platform;
#if ANDROID
using Android.Widget;
#endif
namespace Bit.App.Effects
{
public class NoEmojiKeyboardEffect : RoutingEffect
{
}
#if ANDROID
public class NoEmojiKeyboardPlatformEffect : PlatformEffect
{
protected override void OnAttached()
{
if (Control is EditText editText)
{
editText.InputType = Android.Text.InputTypes.ClassText | Android.Text.InputTypes.TextVariationVisiblePassword | Android.Text.InputTypes.TextFlagMultiLine;
}
}
protected override void OnDetached()
{
}
}
#endif
}

View File

@@ -0,0 +1,32 @@
using System;
using Microsoft.Maui.Controls;
using Microsoft.Maui;
using Microsoft.Maui.Controls.Platform;
#if ANDROID
using Android.Widget;
#endif
namespace Bit.App.Effects
{
public class RemoveFontPaddingEffect : RoutingEffect
{
}
#if ANDROID
public class RemoveFontPaddingPlatformEffect : PlatformEffect
{
protected override void OnAttached()
{
if (Control is TextView textView)
{
textView.SetIncludeFontPadding(false);
}
}
protected override void OnDetached()
{
}
}
#endif
}

View File

@@ -0,0 +1,49 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui;
using Microsoft.Maui.Controls.Platform;
#if IOS
using UIKit;
#endif
namespace Bit.App.Effects
{
public class ScrollEnabledEffect : RoutingEffect
{
public static readonly BindableProperty IsScrollEnabledProperty =
BindableProperty.CreateAttached("IsScrollEnabled", typeof(bool), typeof(ScrollEnabledEffect), true);
public static bool GetIsScrollEnabled(BindableObject view)
{
return (bool)view.GetValue(IsScrollEnabledProperty);
}
public static void SetIsScrollEnabled(BindableObject view, bool value)
{
view.SetValue(IsScrollEnabledProperty, value);
}
public ScrollEnabledEffect()
: base("Bitwarden.ScrollEnabledEffect")
{
}
}
#if IOS
public class ScrollEnabledPlatformEffect : PlatformEffect
{
protected override void OnAttached()
{
// this can be for any view that inherits from UIScrollView like UITextView.
if (Element != null && Control is UIScrollView scrollView)
{
scrollView.ScrollEnabled = Bit.App.Effects.ScrollEnabledEffect.GetIsScrollEnabled(Element);
}
}
protected override void OnDetached()
{
}
}
#endif
}

View File

@@ -0,0 +1,70 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui;
using Microsoft.Maui.Controls.Platform;
#if IOS
using UIKit;
#endif
namespace Bit.App.Effects
{
public enum ScrollContentInsetAdjustmentBehavior
{
Automatic,
ScrollableAxes,
Never,
Always
}
public class ScrollViewContentInsetAdjustmentBehaviorEffect : RoutingEffect
{
public static readonly BindableProperty ContentInsetAdjustmentBehaviorProperty =
BindableProperty.CreateAttached("ContentInsetAdjustmentBehavior", typeof(ScrollContentInsetAdjustmentBehavior), typeof(ScrollViewContentInsetAdjustmentBehaviorEffect), ScrollContentInsetAdjustmentBehavior.Automatic);
public static ScrollContentInsetAdjustmentBehavior GetContentInsetAdjustmentBehavior(BindableObject view)
{
return (ScrollContentInsetAdjustmentBehavior)view.GetValue(ContentInsetAdjustmentBehaviorProperty);
}
public static void SetContentInsetAdjustmentBehavior(BindableObject view, ScrollContentInsetAdjustmentBehavior value)
{
view.SetValue(ContentInsetAdjustmentBehaviorProperty, value);
}
public ScrollViewContentInsetAdjustmentBehaviorEffect()
: base($"Bitwarden.{nameof(ScrollViewContentInsetAdjustmentBehaviorEffect)}")
{
}
}
#if IOS
public class ScrollViewContentInsetAdjustmentBehaviorPlatformEffect : PlatformEffect
{
protected override void OnAttached()
{
if (Element != null && Control is UIScrollView scrollView)
{
switch (Bit.App.Effects.ScrollViewContentInsetAdjustmentBehaviorEffect.GetContentInsetAdjustmentBehavior(Element))
{
case Bit.App.Effects.ScrollContentInsetAdjustmentBehavior.Automatic:
scrollView.ContentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentBehavior.Automatic;
break;
case Bit.App.Effects.ScrollContentInsetAdjustmentBehavior.ScrollableAxes:
scrollView.ContentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentBehavior.ScrollableAxes;
break;
case Bit.App.Effects.ScrollContentInsetAdjustmentBehavior.Never:
scrollView.ContentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentBehavior.Never;
break;
case Bit.App.Effects.ScrollContentInsetAdjustmentBehavior.Always:
scrollView.ContentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentBehavior.Always;
break;
}
}
}
protected override void OnDetached()
{
}
}
#endif
}

Some files were not shown because too many files have changed in this diff Show More