EC-395 Apple Watch MVP (#2228)
* [EC-426] Add watchOS PoC app (#2054) * EC-426 Added watchOS app, configured iOS.csproj to bundle the output of XCode build into the Xamarin iOS app and added some custom logic to use WCSession to communicate between the iOS and the watchOS apps * EC-426 Removed Info.plist from iOS.Core project given that it's not needed * [EC-426] Added new encrypted watch app profiles * EC-426 added configuration for building watchApp and bundle it up on the iOS one * EC-426 Fix build for watchOS * EC-426 Fix build for watchOS applied shell bash * EC-426 Fix build for watchOS echo * EC-426 Fix build for watchOS simplify * EC-426 Fix build for watchOS added workspace path * EC-426 Changed code sign identity of watchOS project to Apple Distribution * EC-426 added manual code sign style and specified the provisioning profile for the targets on the watch xcode project * EC-426 updated path to watchOS on release on iOS.csproj and disabled android and f-.droid * EC-426 fix build * EC-426 fix path and check listing of directory of watchOS output just in case * EC-426 Fix Apple Watch build to list the folder recursively just in case we need to change the path for the watch bundle * EC-426 TEMP Change texts on input on login and lock to show that the app is for the Watch PoC testing * EC-426 Fix WatchApp build path * EC-426 Added WatchOS AppIcons * EC-426 added gitignore for XCode project removed files supposed to be ignored * EC-426 Cleaned the code a bit to avoid misbehavior * EC-426 Code cleanup Co-authored-by: Joseph Flinn <joseph.s.flinn@gmail.com> * [EC-585] Added data, encryption and some helpers and structure to the Watch app (#2164) * [EC-585] Added foundation classes on the watch to handle CoreData and some fixes on the communication of the ciphers, also some helper classes to store in keychain and encrypt data * EC-585 Added keychain helper, encryption helpers and added data storage using CoreData configuring it appropiately. View and ViewModel are here only to test that the fetching/saving works but it's not the actual UI of the watch app. Also removed all the places where the automatic file signature was added by XCode * EC-585 Fixed CipherServiceMock to implement protocol * EC-585 Fixed DeviceActionService duplicated services * [EC-614] Apple Watch MVP Cipher list UI (#2175) * [EC-585] Added foundation classes on the watch to handle CoreData and some fixes on the communication of the ciphers, also some helper classes to store in keychain and encrypt data * EC-585 Added keychain helper, encryption helpers and added data storage using CoreData configuring it appropiately. View and ViewModel are here only to test that the fetching/saving works but it's not the actual UI of the watch app. Also removed all the places where the automatic file signature was added by XCode * EC-585 Fixed CipherServiceMock to implement protocol * EC-585 Fixed DeviceActionService duplicated services * EC-614 Implemented watch ciphers list UI * [EC-615] Apple Watch MVP Cipher details UI (#2192) * [EC-585] Added foundation classes on the watch to handle CoreData and some fixes on the communication of the ciphers, also some helper classes to store in keychain and encrypt data * EC-585 Added keychain helper, encryption helpers and added data storage using CoreData configuring it appropiately. View and ViewModel are here only to test that the fetching/saving works but it's not the actual UI of the watch app. Also removed all the places where the automatic file signature was added by XCode * EC-585 Fixed CipherServiceMock to implement protocol * EC-585 Fixed DeviceActionService duplicated services * EC-614 Implemented watch ciphers list UI * EC-615 Added cipher details UI to watch and also implemented logic and helpers to generate the TOTPs * EC-615 Added value transformer to login uris on the cipher entity * EC-617 Added state view on watch app and some state helpers and wired it on the CipherListView. Also added some images (#2195) * [EC-581] Implement Apple Watch MVP Sync (#2206) * EC-581 Implemented sync iPhone -> watchOS, fix some issues with the watch database and sync flows for login/locks/multiple accounts * EC-581 Added watch sync on unlocking and need setup state when no user is synced and the session is not active * EC-581 Removed unused method * EC-581 Fix format * EC-759 Added avatar row on cipher list header to display avatar icon and email (#2213) * [EC-786] Apple Watch MVP Sync fixes (#2214) * EC-786 Commented things that are not going to be included on the MVP and fixed issue on the dictionary sent on the applicationContext to have a changing key based on time * EC-786 Commented need unlock state * EC-579 Added logic for Connect To Watch on iOS settings and moved it to the correct place. Also improved the synchronization and watch session activation logic (#2218) * EC-616 Added search header for ciphers and polished the code (#2226) Co-authored-by: Federico Maccaroni <fedemkr@gmail.com> Co-authored-by: Joseph Flinn <joseph.s.flinn@gmail.com>
|
@ -14,6 +14,10 @@
|
|||
<string>Dist: Extension 2021</string>
|
||||
<key>com.8bit.bitwarden.share-extension</key>
|
||||
<string>Dist: Share Extension 2021</string>
|
||||
<key>com.8bit.bitwarden.watchkitapp</key>
|
||||
<string>Dist: Bitwarden Watch App</string>
|
||||
<key>com.8bit.bitwarden.watchkitapp.watchkitextension</key>
|
||||
<string>Dist: Bitwarden Watch App Extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
BIN
.github/secrets/dist_watch_app.mobileprovision.gpg
vendored
Normal file
BIN
.github/secrets/dist_watch_app_extension.mobileprovision.gpg
vendored
Normal file
35
.github/workflows/build.yml
vendored
|
@ -58,6 +58,7 @@ jobs:
|
|||
android:
|
||||
name: Android
|
||||
runs-on: windows-2022
|
||||
if: github.repository == 'putting here a false condition so that this doesnt run while testing iOS CI'
|
||||
needs: setup
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
@ -258,6 +259,7 @@ jobs:
|
|||
f-droid:
|
||||
name: F-Droid Build
|
||||
runs-on: windows-2022
|
||||
if: github.repository == 'putting here a false condition so that this doesnt run while testing iOS CI'
|
||||
steps:
|
||||
- name: Setup NuGet
|
||||
uses: nuget/setup-nuget@b2bc17b761a1d88cab755a776c7922eb26eefbfa # v1.0.6
|
||||
|
@ -497,6 +499,12 @@ jobs:
|
|||
gpg --quiet --batch --yes --decrypt --passphrase="$DECRYPT_FILE_PASSWORD" \
|
||||
--output $HOME/secrets/dist_share_extension.mobileprovision \
|
||||
./.github/secrets/dist_share_extension.mobileprovision.gpg
|
||||
gpg --quiet --batch --yes --decrypt --passphrase="$DECRYPT_FILE_PASSWORD" \
|
||||
--output $HOME/secrets/dist_watch_app.mobileprovision \
|
||||
./.github/secrets/dist_watch_app.mobileprovision.gpg
|
||||
gpg --quiet --batch --yes --decrypt --passphrase="$DECRYPT_FILE_PASSWORD" \
|
||||
--output $HOME/secrets/dist_watch_app_extension.mobileprovision \
|
||||
./.github/secrets/dist_watch_app_extension.mobileprovision.gpg
|
||||
shell: bash
|
||||
|
||||
- name: Increment version
|
||||
|
@ -511,6 +519,9 @@ jobs:
|
|||
perl -0777 -pi.bak -e 's/<key>CFBundleVersion<\/key>\s*<string>1<\/string>/<key>CFBundleVersion<\/key>\n\t<string>'"$BUILD_NUMBER"'<\/string>/' ./src/iOS.Extension/Info.plist
|
||||
perl -0777 -pi.bak -e 's/<key>CFBundleVersion<\/key>\s*<string>1<\/string>/<key>CFBundleVersion<\/key>\n\t<string>'"$BUILD_NUMBER"'<\/string>/' ./src/iOS.Autofill/Info.plist
|
||||
perl -0777 -pi.bak -e 's/<key>CFBundleVersion<\/key>\s*<string>1<\/string>/<key>CFBundleVersion<\/key>\n\t<string>'"$BUILD_NUMBER"'<\/string>/' ./src/iOS.ShareExtension/Info.plist
|
||||
cd src/watchOS/bitwarden
|
||||
agvtool new-version -all $BUILD_NUMBER
|
||||
cd ../../..
|
||||
shell: bash
|
||||
|
||||
- name: Update Entitlements
|
||||
|
@ -545,6 +556,8 @@ jobs:
|
|||
BITWARDEN_PROFILE_PATH=$HOME/secrets/dist_bitwarden.mobileprovision
|
||||
EXTENSION_PROFILE_PATH=$HOME/secrets/dist_extension.mobileprovision
|
||||
SHARE_EXTENSION_PROFILE_PATH=$HOME/secrets/dist_share_extension.mobileprovision
|
||||
WATCH_APP_PROFILE_PATH=$HOME/secrets/dist_watch_app.mobileprovision
|
||||
WATCH_APP_EXTENSION_PROFILE_PATH=$HOME/secrets/dist_watch_app_extension.mobileprovision
|
||||
PROFILES_DIR_PATH=$HOME/Library/MobileDevice/Provisioning\ Profiles
|
||||
|
||||
mkdir -p "$PROFILES_DIR_PATH"
|
||||
|
@ -560,6 +573,28 @@ jobs:
|
|||
|
||||
SHARE_EXTENSION_UUID=$(grep UUID -A1 -a $SHARE_EXTENSION_PROFILE_PATH | grep -io "[-A-F0-9]\{36\}")
|
||||
cp $SHARE_EXTENSION_PROFILE_PATH "$PROFILES_DIR_PATH/$SHARE_EXTENSION_UUID.mobileprovision"
|
||||
|
||||
WATCH_APP_UUID=$(grep UUID -A1 -a $WATCH_APP_PROFILE_PATH | grep -io "[-A-F0-9]\{36\}")
|
||||
cp $WATCH_APP_PROFILE_PATH "$PROFILES_DIR_PATH/$WATCH_APP_UUID.mobileprovision"
|
||||
|
||||
WATCH_APP_EXTENSION_UUID=$(grep UUID -A1 -a $WATCH_APP_EXTENSION_PROFILE_PATH | grep -io "[-A-F0-9]\{36\}")
|
||||
cp $WATCH_APP_EXTENSION_PROFILE_PATH "$PROFILES_DIR_PATH/$WATCH_APP_EXTENSION_UUID.mobileprovision"
|
||||
shell: bash
|
||||
|
||||
- name: Bulid WatchApp
|
||||
run: |
|
||||
echo "########################################"
|
||||
echo "##### Build WatchApp with Release Configuration"
|
||||
echo "########################################"
|
||||
|
||||
xcodebuild archive -workspace ./src/watchOS/bitwarden/bitwarden.xcodeproj/project.xcworkspace -configuration Release -scheme bitwarden\ WatchKit\ App -archivePath ./src/watchOS/bitwarden
|
||||
|
||||
echo "########################################"
|
||||
echo "##### Done"
|
||||
echo "########################################"
|
||||
cd src/watchOS
|
||||
ls -R
|
||||
cd ../..
|
||||
shell: bash
|
||||
|
||||
- name: Restore packages
|
||||
|
|
125
.gitignore
vendored
|
@ -210,3 +210,128 @@ project.lock.json
|
|||
.DS_Store
|
||||
src/App/Css
|
||||
tools
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/swift,objective-c
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=swift,objective-c
|
||||
|
||||
### Objective-C ###
|
||||
# Xcode
|
||||
#
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
|
||||
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
||||
build/
|
||||
DerivedData/
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
|
||||
## Obj-C/Swift specific
|
||||
*.hmap
|
||||
|
||||
## App packaging
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
# CocoaPods
|
||||
# We recommend against adding the Pods directory to your .gitignore. However
|
||||
# you should judge for yourself, the pros and cons are mentioned at:
|
||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||
# Pods/
|
||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||
# *.xcworkspace
|
||||
|
||||
# Carthage
|
||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||
# Carthage/Checkouts
|
||||
|
||||
Carthage/Build/
|
||||
|
||||
# fastlane
|
||||
# It is recommended to not store the screenshots in the git repo.
|
||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
|
||||
# Code Injection
|
||||
# After new code Injection tools there's a generated folder /iOSInjectionProject
|
||||
# https://github.com/johnno1962/injectionforxcode
|
||||
|
||||
iOSInjectionProject/
|
||||
|
||||
### Objective-C Patch ###
|
||||
|
||||
### Swift ###
|
||||
# Xcode
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Swift Package Manager
|
||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
||||
# Packages/
|
||||
# Package.pins
|
||||
# Package.resolved
|
||||
# *.xcodeproj
|
||||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
|
||||
# hence it is not needed unless you have added a package configuration file to your project
|
||||
# .swiftpm
|
||||
|
||||
.build/
|
||||
|
||||
# CocoaPods
|
||||
# We recommend against adding the Pods directory to your .gitignore. However
|
||||
# you should judge for yourself, the pros and cons are mentioned at:
|
||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||
# Pods/
|
||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||
# *.xcworkspace
|
||||
|
||||
# Carthage
|
||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||
# Carthage/Checkouts
|
||||
|
||||
|
||||
# Accio dependency management
|
||||
Dependencies/
|
||||
.accio/
|
||||
|
||||
# fastlane
|
||||
# It is recommended to not store the screenshots in the git repo.
|
||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||
|
||||
|
||||
# Code Injection
|
||||
# After new code Injection tools there's a generated folder /iOSInjectionProject
|
||||
# https://github.com/johnno1962/injectionforxcode
|
||||
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/swift,objective-c
|
||||
|
|
|
@ -159,6 +159,7 @@
|
|||
<Compile Include="Services\AutofillHandler.cs" />
|
||||
<Compile Include="Constants.cs" />
|
||||
<Compile Include="Effects\RemoveFontPaddingEffect.cs" />
|
||||
<Compile Include="Services\WatchDeviceService.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AndroidAsset Include="Assets\bwi-font.ttf" />
|
||||
|
|
|
@ -45,9 +45,16 @@ namespace Bit.Droid
|
|||
if (ServiceContainer.RegisteredServices.Count == 0)
|
||||
{
|
||||
RegisterLocalServices();
|
||||
|
||||
var deviceActionService = ServiceContainer.Resolve<IDeviceActionService>("deviceActionService");
|
||||
ServiceContainer.Init(deviceActionService.DeviceUserAgent, Core.Constants.ClearCiphersCacheKey,
|
||||
Core.Constants.AndroidAllClearCipherCacheKeys);
|
||||
|
||||
ServiceContainer.Register<IWatchDeviceService>(new WatchDeviceService(ServiceContainer.Resolve<ICipherService>(),
|
||||
ServiceContainer.Resolve<IEnvironmentService>(),
|
||||
ServiceContainer.Resolve<IStateService>(),
|
||||
ServiceContainer.Resolve<IVaultTimeoutService>()));
|
||||
|
||||
InitializeAppSetup();
|
||||
|
||||
// TODO: Update when https://github.com/bitwarden/mobile/pull/1662 gets merged
|
||||
|
@ -73,8 +80,9 @@ namespace Bit.Droid
|
|||
ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService"),
|
||||
ServiceContainer.Resolve<IAuthService>("authService"),
|
||||
ServiceContainer.Resolve<ILogger>("logger"),
|
||||
ServiceContainer.Resolve<IMessagingService>("messagingService"));
|
||||
ServiceContainer.Register<IAccountsManager>("accountsManager", accountsManager);
|
||||
ServiceContainer.Resolve<IMessagingService>("messagingService"),
|
||||
ServiceContainer.Resolve<IWatchDeviceService>());
|
||||
ServiceContainer.Register<IAccountsManager>("accountsManager", accountsManager);
|
||||
}
|
||||
#if !FDROID
|
||||
if (Build.VERSION.SdkInt <= BuildVersionCodes.Kitkat)
|
||||
|
|
|
@ -18,6 +18,7 @@ using Bit.Core.Enums;
|
|||
using Bit.Core.Utilities;
|
||||
using Bit.Droid.Utilities;
|
||||
using Plugin.CurrentActivity;
|
||||
using static Bit.App.Pages.SettingsPageViewModel;
|
||||
|
||||
namespace Bit.Droid.Services
|
||||
{
|
||||
|
|
29
src/Android/Services/WatchDeviceService.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Services;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Models;
|
||||
|
||||
namespace Bit.Droid.Services
|
||||
{
|
||||
public class WatchDeviceService : BaseWatchDeviceService
|
||||
{
|
||||
public WatchDeviceService(ICipherService cipherService,
|
||||
IEnvironmentService environmentService,
|
||||
IStateService stateService,
|
||||
IVaultTimeoutService vaultTimeoutService)
|
||||
: base(cipherService, environmentService, stateService, vaultTimeoutService)
|
||||
{
|
||||
}
|
||||
|
||||
protected override bool IsSupported => false;
|
||||
|
||||
public override bool IsConnected => false;
|
||||
|
||||
protected override bool CanSendData => false;
|
||||
|
||||
protected override Task SendDataToWatchAsync(WatchDTO watchDto) => throw new NotImplementedException();
|
||||
|
||||
protected override void ConnectToWatch() => throw new NotImplementedException();
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
using System.Threading.Tasks;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models;
|
||||
|
||||
namespace Bit.App.Abstractions
|
||||
{
|
||||
|
|
|
@ -28,6 +28,7 @@ namespace Bit.App.Pages
|
|||
private readonly IBiometricService _biometricService;
|
||||
private readonly IKeyConnectorService _keyConnectorService;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IWatchDeviceService _watchDeviceService;
|
||||
private readonly WeakEventManager<int?> _secretEntryFocusWeakEventManager = new WeakEventManager<int?>();
|
||||
|
||||
private string _email;
|
||||
|
@ -56,6 +57,7 @@ namespace Bit.App.Pages
|
|||
_biometricService = ServiceContainer.Resolve<IBiometricService>("biometricService");
|
||||
_keyConnectorService = ServiceContainer.Resolve<IKeyConnectorService>("keyConnectorService");
|
||||
_logger = ServiceContainer.Resolve<ILogger>("logger");
|
||||
_watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>();
|
||||
|
||||
PageTitle = AppResources.VerifyMasterPassword;
|
||||
TogglePasswordCommand = new Command(TogglePassword);
|
||||
|
@ -387,6 +389,7 @@ namespace Bit.App.Pages
|
|||
private async Task DoContinueAsync()
|
||||
{
|
||||
await _stateService.SetBiometricLockedAsync(false);
|
||||
_watchDeviceService.SyncDataToWatchAsync().FireAndForget();
|
||||
_messagingService.Send("unlocked");
|
||||
UnlockedAction?.Invoke();
|
||||
}
|
||||
|
|
|
@ -7,7 +7,10 @@ using Bit.App.Pages.Accounts;
|
|||
using Bit.App.Resources;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models;
|
||||
using Bit.Core.Models.Domain;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Xamarin.CommunityToolkit.ObjectModel;
|
||||
using Xamarin.Forms;
|
||||
|
@ -32,6 +35,7 @@ namespace Bit.App.Pages
|
|||
private readonly IClipboardService _clipboardService;
|
||||
private readonly ILogger _loggerService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly IWatchDeviceService _watchDeviceService;
|
||||
private const int CustomVaultTimeoutValue = -100;
|
||||
|
||||
private bool _supportsBiometric;
|
||||
|
@ -44,6 +48,7 @@ namespace Bit.App.Pages
|
|||
private bool _showChangeMasterPassword;
|
||||
private bool _reportLoggingEnabled;
|
||||
private bool _approvePasswordlessLoginRequests;
|
||||
private bool _shouldConnectToWatch;
|
||||
|
||||
private List<KeyValuePair<string, int?>> _vaultTimeouts =
|
||||
new List<KeyValuePair<string, int?>>
|
||||
|
@ -87,6 +92,7 @@ namespace Bit.App.Pages
|
|||
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
|
||||
_loggerService = ServiceContainer.Resolve<ILogger>("logger");
|
||||
_pushNotificationService = ServiceContainer.Resolve<IPushNotificationService>();
|
||||
_watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>();
|
||||
|
||||
GroupedItems = new ObservableRangeCollection<ISettingsPageListItem>();
|
||||
PageTitle = AppResources.Settings;
|
||||
|
@ -138,6 +144,9 @@ namespace Bit.App.Pages
|
|||
!await _keyConnectorService.GetUsesKeyConnector();
|
||||
_reportLoggingEnabled = await _loggerService.IsEnabled();
|
||||
_approvePasswordlessLoginRequests = await _stateService.GetApprovePasswordlessLoginsAsync();
|
||||
|
||||
_shouldConnectToWatch = await _stateService.GetShouldConnectToWatchAsync();
|
||||
|
||||
BuildList();
|
||||
}
|
||||
|
||||
|
@ -601,19 +610,26 @@ namespace Bit.App.Pages
|
|||
ExecuteAsync = () => SetScreenCaptureAllowedAsync()
|
||||
});
|
||||
}
|
||||
var accountItems = new List<SettingsPageListItem>
|
||||
var accountItems = new List<SettingsPageListItem>();
|
||||
if (Device.RuntimePlatform == Device.iOS)
|
||||
{
|
||||
new SettingsPageListItem
|
||||
accountItems.Add(new SettingsPageListItem
|
||||
{
|
||||
Name = AppResources.FingerprintPhrase,
|
||||
ExecuteAsync = () => FingerprintAsync()
|
||||
},
|
||||
new SettingsPageListItem
|
||||
{
|
||||
Name = AppResources.LogOut,
|
||||
ExecuteAsync = () => LogOutAsync()
|
||||
}
|
||||
};
|
||||
Name = AppResources.ConnectToWatch,
|
||||
SubLabel = _shouldConnectToWatch ? AppResources.On : AppResources.Off,
|
||||
ExecuteAsync = () => ToggleWatchConnectionAsync()
|
||||
});
|
||||
}
|
||||
accountItems.Add(new SettingsPageListItem
|
||||
{
|
||||
Name = AppResources.FingerprintPhrase,
|
||||
ExecuteAsync = () => FingerprintAsync()
|
||||
});
|
||||
accountItems.Add(new SettingsPageListItem
|
||||
{
|
||||
Name = AppResources.LogOut,
|
||||
ExecuteAsync = () => LogOutAsync()
|
||||
});
|
||||
if (_showChangeMasterPassword)
|
||||
{
|
||||
accountItems.Insert(0, new SettingsPageListItem
|
||||
|
@ -791,5 +807,13 @@ namespace Bit.App.Pages
|
|||
await Page.DisplayAlert(AppResources.AnErrorHasOccurred, AppResources.GenericErrorMessage, AppResources.Ok);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleWatchConnectionAsync()
|
||||
{
|
||||
_shouldConnectToWatch = !_shouldConnectToWatch;
|
||||
|
||||
await _watchDeviceService.SetShouldConnectToWatchAsync(_shouldConnectToWatch);
|
||||
BuildList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ namespace Bit.App.Pages
|
|||
private readonly ICustomFieldItemFactory _customFieldItemFactory;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private readonly IAutofillHandler _autofillHandler;
|
||||
private readonly IWatchDeviceService _watchDeviceService;
|
||||
|
||||
private bool _showNotesSeparator;
|
||||
private bool _showPassword;
|
||||
|
@ -80,6 +81,7 @@ namespace Bit.App.Pages
|
|||
_customFieldItemFactory = ServiceContainer.Resolve<ICustomFieldItemFactory>("customFieldItemFactory");
|
||||
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
|
||||
_autofillHandler = ServiceContainer.Resolve<IAutofillHandler>();
|
||||
_watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>();
|
||||
|
||||
GeneratePasswordCommand = new Command(GeneratePassword);
|
||||
TogglePasswordCommand = new Command(TogglePassword);
|
||||
|
@ -507,6 +509,8 @@ namespace Bit.App.Pages
|
|||
EditMode && !CloneMode ? AppResources.ItemUpdated : AppResources.NewItemCreated);
|
||||
_messagingService.Send(EditMode && !CloneMode ? "editedCipher" : "addedCipher", Cipher.Id);
|
||||
|
||||
_watchDeviceService.SyncDataToWatchAsync().FireAndForget();
|
||||
|
||||
if (Page is CipherAddEditPage page && page.FromAutofillFramework)
|
||||
{
|
||||
// Close and go back to app
|
||||
|
|
|
@ -31,6 +31,7 @@ namespace Bit.App.Pages
|
|||
private readonly ILocalizeService _localizeService;
|
||||
private readonly ICustomFieldItemFactory _customFieldItemFactory;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private readonly IWatchDeviceService _watchDeviceService;
|
||||
|
||||
private List<ICustomFieldItemViewModel> _fields;
|
||||
private bool _canAccessPremium;
|
||||
|
@ -62,6 +63,7 @@ namespace Bit.App.Pages
|
|||
_localizeService = ServiceContainer.Resolve<ILocalizeService>("localizeService");
|
||||
_customFieldItemFactory = ServiceContainer.Resolve<ICustomFieldItemFactory>("customFieldItemFactory");
|
||||
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
|
||||
_watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>();
|
||||
|
||||
CopyCommand = new AsyncCommand<string>((id) => CopyAsync(id, null), onException: ex => _logger.Exception(ex), allowsMultipleExecutions: false);
|
||||
CopyUriCommand = new AsyncCommand<LoginUriView>(uriView => CopyAsync("LoginUri", uriView.Uri), onException: ex => _logger.Exception(ex), allowsMultipleExecutions: false);
|
||||
|
@ -371,6 +373,9 @@ namespace Bit.App.Pages
|
|||
await _cipherService.SoftDeleteWithServerAsync(Cipher.Id);
|
||||
}
|
||||
await _deviceActionService.HideLoadingAsync();
|
||||
|
||||
_watchDeviceService.SyncDataToWatchAsync().FireAndForget();
|
||||
|
||||
_platformUtilsService.ShowToast("success", null,
|
||||
Cipher.IsDeleted ? AppResources.ItemDeleted : AppResources.ItemSoftDeleted);
|
||||
_messagingService.Send(Cipher.IsDeleted ? "deletedCipher" : "softDeletedCipher", Cipher);
|
||||
|
|
9
src/App/Resources/AppResources.Designer.cs
generated
|
@ -1498,6 +1498,15 @@ namespace Bit.App.Resources {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Connect to Watch.
|
||||
/// </summary>
|
||||
public static string ConnectToWatch {
|
||||
get {
|
||||
return ResourceManager.GetString("ConnectToWatch", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Continue.
|
||||
/// </summary>
|
||||
|
|
|
@ -2453,6 +2453,9 @@ select Add TOTP to store the key safely</value>
|
|||
<data name="Random" xml:space="preserve">
|
||||
<value>Random</value>
|
||||
</data>
|
||||
<data name="ConnectToWatch" xml:space="preserve">
|
||||
<value>Connect to Watch</value>
|
||||
</data>
|
||||
<data name="AccessibilityServiceDisclosure" xml:space="preserve">
|
||||
<value>Accessibility Service Disclosure</value>
|
||||
</data>
|
||||
|
|
128
src/App/Services/BaseWatchDeviceService.cs
Normal file
|
@ -0,0 +1,128 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models;
|
||||
using Bit.Core.Models.View;
|
||||
using Xamarin.Forms;
|
||||
|
||||
namespace Bit.App.Services
|
||||
{
|
||||
public abstract class BaseWatchDeviceService : IWatchDeviceService
|
||||
{
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly IEnvironmentService _environmentService;
|
||||
private readonly IStateService _stateService;
|
||||
private readonly IVaultTimeoutService _vaultTimeoutService;
|
||||
|
||||
protected BaseWatchDeviceService(ICipherService cipherService,
|
||||
IEnvironmentService environmentService,
|
||||
IStateService stateService,
|
||||
IVaultTimeoutService vaultTimeoutService)
|
||||
{
|
||||
_cipherService = cipherService;
|
||||
_environmentService = environmentService;
|
||||
_stateService = stateService;
|
||||
_vaultTimeoutService = vaultTimeoutService;
|
||||
}
|
||||
|
||||
public abstract bool IsConnected { get; }
|
||||
|
||||
protected abstract bool CanSendData { get; }
|
||||
protected abstract bool IsSupported { get; }
|
||||
|
||||
public async Task SyncDataToWatchAsync()
|
||||
{
|
||||
if (!IsSupported)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldConnect = await _stateService.GetShouldConnectToWatchAsync();
|
||||
if (shouldConnect && !IsConnected)
|
||||
{
|
||||
ConnectToWatch();
|
||||
}
|
||||
|
||||
if (!CanSendData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userData = await _stateService.GetActiveUserCustomDataAsync(a => a?.Profile is null ? null : new WatchDTO.UserDataDto
|
||||
{
|
||||
Id = a.Profile.UserId,
|
||||
Name = a.Profile.Name,
|
||||
Email = a.Profile.Email
|
||||
});
|
||||
var state = await GetStateAsync(userData?.Id, shouldConnect);
|
||||
if (state != WatchState.Valid)
|
||||
{
|
||||
await SendDataToWatchAsync(new WatchDTO(state));
|
||||
return;
|
||||
}
|
||||
|
||||
var ciphersWithTotp = await _cipherService.GetAllDecryptedAsync(c => c.DeletedDate == null && c.Login?.Totp != null);
|
||||
|
||||
if (!ciphersWithTotp.Any())
|
||||
{
|
||||
await SendDataToWatchAsync(new WatchDTO(WatchState.Need2FAItem));
|
||||
return;
|
||||
}
|
||||
|
||||
var watchDto = new WatchDTO(state)
|
||||
{
|
||||
Ciphers = ciphersWithTotp.Select(c => new SimpleCipherView(c)).ToList(),
|
||||
UserData = userData,
|
||||
EnvironmentData = new WatchDTO.EnvironmentUrlDataDto
|
||||
{
|
||||
Base = _environmentService.BaseUrl,
|
||||
Icons = _environmentService.IconsUrl
|
||||
}
|
||||
//SettingsData = new WatchDTO.SettingsDataDto
|
||||
//{
|
||||
// VaultTimeoutInMinutes = await _vaultTimeoutService.GetVaultTimeout(userData?.Id),
|
||||
// VaultTimeoutAction = await _stateService.GetVaultTimeoutActionAsync(userData?.Id) ?? VaultTimeoutAction.Lock
|
||||
//}
|
||||
};
|
||||
await SendDataToWatchAsync(watchDto);
|
||||
}
|
||||
|
||||
private async Task<WatchState> GetStateAsync(string userId, bool shouldConnectToWatch)
|
||||
{
|
||||
if (!shouldConnectToWatch)
|
||||
{
|
||||
return WatchState.NeedSetup;
|
||||
}
|
||||
|
||||
if (!await _stateService.IsAuthenticatedAsync() || userId is null)
|
||||
{
|
||||
return WatchState.NeedLogin;
|
||||
}
|
||||
|
||||
//if (await _vaultTimeoutService.IsLockedAsync() ||
|
||||
// await _vaultTimeoutService.ShouldLockAsync())
|
||||
//{
|
||||
// return WatchState.NeedUnlock;
|
||||
//}
|
||||
|
||||
if (!await _stateService.CanAccessPremiumAsync(userId))
|
||||
{
|
||||
return WatchState.NeedPremium;
|
||||
}
|
||||
|
||||
return WatchState.Valid;
|
||||
}
|
||||
|
||||
public async Task SetShouldConnectToWatchAsync(bool shouldConnectToWatch)
|
||||
{
|
||||
await _stateService.SetShouldConnectToWatchAsync(shouldConnectToWatch);
|
||||
await SyncDataToWatchAsync();
|
||||
}
|
||||
|
||||
protected abstract Task SendDataToWatchAsync(WatchDTO watchDto);
|
||||
|
||||
protected abstract void ConnectToWatch();
|
||||
}
|
||||
}
|
|
@ -22,6 +22,8 @@ namespace Bit.App.Utilities.AccountManagement
|
|||
private readonly IAuthService _authService;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMessagingService _messagingService;
|
||||
private readonly IWatchDeviceService _watchDeviceService;
|
||||
|
||||
Func<AppOptions> _getOptionsFunc;
|
||||
private IAccountsManagerHost _accountsManagerHost;
|
||||
|
||||
|
@ -32,7 +34,8 @@ namespace Bit.App.Utilities.AccountManagement
|
|||
IPlatformUtilsService platformUtilsService,
|
||||
IAuthService authService,
|
||||
ILogger logger,
|
||||
IMessagingService messagingService)
|
||||
IMessagingService messagingService,
|
||||
IWatchDeviceService watchDeviceService)
|
||||
{
|
||||
_broadcasterService = broadcasterService;
|
||||
_vaultTimeoutService = vaultTimeoutService;
|
||||
|
@ -42,6 +45,7 @@ namespace Bit.App.Utilities.AccountManagement
|
|||
_authService = authService;
|
||||
_logger = logger;
|
||||
_messagingService = messagingService;
|
||||
_watchDeviceService = watchDeviceService;
|
||||
}
|
||||
|
||||
private AppOptions Options => _getOptionsFunc?.Invoke() ?? new AppOptions { IosExtension = true };
|
||||
|
@ -145,6 +149,7 @@ namespace Bit.App.Utilities.AccountManagement
|
|||
break;
|
||||
case AccountsManagerMessageCommands.SWITCHED_ACCOUNT:
|
||||
await SwitchedAccountAsync();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -217,6 +222,7 @@ namespace Bit.App.Utilities.AccountManagement
|
|||
}
|
||||
await Task.Delay(50);
|
||||
await _accountsManagerHost.UpdateThemeAsync();
|
||||
_watchDeviceService.SyncDataToWatchAsync().FireAndForget();
|
||||
_messagingService.Send(AccountsManagerMessageCommands.ACCOUNT_SWITCH_COMPLETED);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ namespace Bit.Core.Abstractions
|
|||
Task DeleteWithServerAsync(string id);
|
||||
Task<Cipher> EncryptAsync(CipherView model, SymmetricCryptoKey key = null, Cipher originalCipher = null);
|
||||
Task<List<Cipher>> GetAllAsync();
|
||||
Task<List<CipherView>> GetAllDecryptedAsync();
|
||||
Task<List<CipherView>> GetAllDecryptedAsync(Func<Cipher, bool> filter = null);
|
||||
Task<Tuple<List<CipherView>, List<CipherView>, List<CipherView>>> GetAllDecryptedByUrlAsync(string url,
|
||||
List<CipherType> includeOtherTypes = null);
|
||||
Task<List<CipherView>> GetAllDecryptedForGroupingAsync(string groupingId, bool folder = true);
|
||||
|
|
|
@ -14,6 +14,7 @@ namespace Bit.Core.Abstractions
|
|||
List<AccountView> AccountViews { get; }
|
||||
Task<string> GetActiveUserIdAsync();
|
||||
Task<string> GetActiveUserEmailAsync();
|
||||
Task<T> GetActiveUserCustomDataAsync<T>(Func<Account, T> dataMapper);
|
||||
Task<bool> IsActiveAccountAsync(string userId = null);
|
||||
Task SetActiveUserAsync(string userId);
|
||||
Task CheckExtensionActiveUserAndSwitchIfNeededAsync();
|
||||
|
@ -159,5 +160,7 @@ namespace Bit.Core.Abstractions
|
|||
Task SetPasswordlessLoginNotificationAsync(PasswordlessRequestNotification value);
|
||||
Task<UsernameGenerationOptions> GetUsernameGenerationOptionsAsync(string userId = null);
|
||||
Task SetUsernameGenerationOptionsAsync(UsernameGenerationOptions value, string userId = null);
|
||||
Task<bool> GetShouldConnectToWatchAsync(string userId = null);
|
||||
Task SetShouldConnectToWatchAsync(bool shouldConnect, string userId = null);
|
||||
}
|
||||
}
|
||||
|
|
12
src/Core/Abstractions/IWatchDeviceService.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
using System.Threading.Tasks;
|
||||
|
||||
namespace Bit.Core.Abstractions
|
||||
{
|
||||
public interface IWatchDeviceService
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
|
||||
Task SetShouldConnectToWatchAsync(bool shouldConnectToWatch);
|
||||
Task SyncDataToWatchAsync();
|
||||
}
|
||||
}
|
|
@ -96,5 +96,6 @@
|
|||
public static string BiometricUnlockKey(string userId) => $"biometricUnlock_{userId}";
|
||||
public static string ApprovePasswordlessLoginsKey(string userId) => $"approvePasswordlessLogins_{userId}";
|
||||
public static string UsernameGenOptionsKey(string userId) => $"usernameGenerationOptions_{userId}";
|
||||
public static string ShouldConnectToWatchKey(string userId) => $"shouldConnectToWatch_{userId}";
|
||||
}
|
||||
}
|
||||
|
|
13
src/Core/Enums/WatchState.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
namespace Bit.Core.Enums
|
||||
{
|
||||
public enum WatchState : byte
|
||||
{
|
||||
Valid = 0,
|
||||
NeedLogin,
|
||||
NeedPremium,
|
||||
NeedSetup,
|
||||
Need2FAItem,
|
||||
Syncing
|
||||
//NeedUnlock
|
||||
}
|
||||
}
|
48
src/Core/Models/View/SimpleCipherView.cs
Normal file
|
@ -0,0 +1,48 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Models.View
|
||||
{
|
||||
public class SimpleCipherView
|
||||
{
|
||||
public SimpleCipherView(CipherView c)
|
||||
{
|
||||
Id = c.Id;
|
||||
Name = c.Name;
|
||||
Type = c.Type;
|
||||
if (c.Login != null)
|
||||
{
|
||||
Login = new SimpleLoginView
|
||||
{
|
||||
Username = c.Login.Username,
|
||||
Totp = c.Login.Totp,
|
||||
Uris = c.Login.Uris?.Select(u => new SimpleLoginUriView(u.Uri)).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public CipherType Type { get; set; }
|
||||
public SimpleLoginView Login { get; set; }
|
||||
}
|
||||
|
||||
public class SimpleLoginView
|
||||
{
|
||||
public string Username { get; set; }
|
||||
public string Totp { get; set; }
|
||||
public List<SimpleLoginUriView> Uris { get; set; }
|
||||
}
|
||||
|
||||
public class SimpleLoginUriView
|
||||
{
|
||||
public SimpleLoginUriView(string uri)
|
||||
{
|
||||
Uri = uri;
|
||||
}
|
||||
|
||||
public string Uri { get; set; }
|
||||
}
|
||||
}
|
||||
|
44
src/Core/Models/View/WatchDTO.cs
Normal file
|
@ -0,0 +1,44 @@
|
|||
using System.Collections.Generic;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.View;
|
||||
|
||||
namespace Bit.Core.Models
|
||||
{
|
||||
public class WatchDTO
|
||||
{
|
||||
public WatchDTO(WatchState state)
|
||||
{
|
||||
State = state;
|
||||
}
|
||||
|
||||
public WatchState State { get; private set; }
|
||||
|
||||
public List<SimpleCipherView> Ciphers { get; set; }
|
||||
|
||||
public UserDataDto UserData { get; set; }
|
||||
|
||||
public EnvironmentUrlDataDto EnvironmentData { get; set; }
|
||||
|
||||
//public SettingsDataDto SettingsData { get; set; }
|
||||
|
||||
public class UserDataDto
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
public class EnvironmentUrlDataDto
|
||||
{
|
||||
public string Base { get; set; }
|
||||
public string Icons { get; set; }
|
||||
}
|
||||
|
||||
//public class SettingsDataDto
|
||||
//{
|
||||
// public int? VaultTimeoutInMinutes { get; set; }
|
||||
|
||||
// public VaultTimeoutAction VaultTimeoutAction { get; set; }
|
||||
//}
|
||||
}
|
||||
}
|
|
@ -26,6 +26,8 @@ namespace Bit.Core.Services
|
|||
private readonly IKeyConnectorService _keyConnectorService;
|
||||
private readonly IPasswordGenerationService _passwordGenerationService;
|
||||
private readonly bool _setCryptoKeys;
|
||||
|
||||
private readonly LazyResolve<IWatchDeviceService> _watchDeviceService = new LazyResolve<IWatchDeviceService>();
|
||||
private SymmetricCryptoKey _key;
|
||||
|
||||
public AuthService(
|
||||
|
@ -187,6 +189,7 @@ namespace Bit.Core.Services
|
|||
{
|
||||
callback.Invoke();
|
||||
_messagingService.Send(AccountsManagerMessageCommands.LOGGED_OUT);
|
||||
_watchDeviceService.Value.SyncDataToWatchAsync().FireAndForget();
|
||||
}
|
||||
|
||||
public List<TwoFactorProvider> GetSupportedTwoFactorProviders()
|
||||
|
|
|
@ -226,7 +226,7 @@ namespace Bit.Core.Services
|
|||
return response?.ToList() ?? new List<Cipher>();
|
||||
}
|
||||
|
||||
public async Task<List<CipherView>> GetAllDecryptedAsync()
|
||||
public async Task<List<CipherView>> GetAllDecryptedAsync(Func<Cipher, bool> filter = null)
|
||||
{
|
||||
if (_clearCipherCacheKey != null)
|
||||
{
|
||||
|
@ -237,7 +237,7 @@ namespace Bit.Core.Services
|
|||
await _storageService.RemoveAsync(_clearCipherCacheKey);
|
||||
}
|
||||
}
|
||||
if (DecryptedCipherCache != null)
|
||||
if (DecryptedCipherCache != null && filter is null)
|
||||
{
|
||||
return DecryptedCipherCache;
|
||||
}
|
||||
|
@ -261,13 +261,24 @@ namespace Bit.Core.Services
|
|||
decCiphers.Add(c);
|
||||
}
|
||||
var tasks = new List<Task>();
|
||||
var ciphers = await GetAllAsync();
|
||||
IEnumerable<Cipher> ciphers = await GetAllAsync();
|
||||
if (filter != null)
|
||||
{
|
||||
ciphers = ciphers.Where(filter);
|
||||
}
|
||||
|
||||
foreach (var cipher in ciphers)
|
||||
{
|
||||
tasks.Add(decryptAndAddCipherAsync(cipher));
|
||||
}
|
||||
await Task.WhenAll(tasks);
|
||||
decCiphers = decCiphers.OrderBy(c => c, new CipherLocaleComparer(_i18nService)).ToList();
|
||||
|
||||
if (filter != null)
|
||||
{
|
||||
return decCiphers;
|
||||
}
|
||||
|
||||
DecryptedCipherCache = decCiphers;
|
||||
return DecryptedCipherCache;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Abstractions;
|
||||
|
@ -52,6 +51,15 @@ namespace Bit.Core.Services
|
|||
return await GetEmailAsync(activeUserId);
|
||||
}
|
||||
|
||||
public async Task<T> GetActiveUserCustomDataAsync<T>(Func<Account, T> dataMapper)
|
||||
{
|
||||
var userId = await GetActiveUserIdAsync();
|
||||
var account = await GetAccountAsync(
|
||||
ReconcileOptions(new StorageOptions { UserId = userId }, await GetDefaultStorageOptionsAsync())
|
||||
);
|
||||
return dataMapper(account);
|
||||
}
|
||||
|
||||
public async Task<bool> IsActiveAccountAsync(string userId = null)
|
||||
{
|
||||
if (userId == null)
|
||||
|
@ -1685,5 +1693,21 @@ namespace Bit.Core.Services
|
|||
}
|
||||
throw new Exception("User does not exist in account list");
|
||||
}
|
||||
|
||||
public async Task<bool> GetShouldConnectToWatchAsync(string userId = null)
|
||||
{
|
||||
var reconciledOptions =
|
||||
ReconcileOptions(new StorageOptions { UserId = userId }, await GetDefaultStorageOptionsAsync());
|
||||
var key = Constants.ShouldConnectToWatchKey(reconciledOptions.UserId);
|
||||
return await GetValueAsync<bool?>(key, reconciledOptions) ?? false;
|
||||
}
|
||||
|
||||
public async Task SetShouldConnectToWatchAsync(bool shouldConnect, string userId = null)
|
||||
{
|
||||
var reconciledOptions =
|
||||
ReconcileOptions(new StorageOptions { UserId = userId }, await GetDefaultStorageOptionsAsync());
|
||||
var key = Constants.ShouldConnectToWatchKey(reconciledOptions.UserId);
|
||||
await SetValueAsync(key, shouldConnect, reconciledOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,8 @@ namespace Bit.Core.Services
|
|||
private readonly ILogger _logger;
|
||||
private readonly Func<Tuple<string, bool, bool>, Task> _logoutCallbackAsync;
|
||||
|
||||
private readonly LazyResolve<IWatchDeviceService> _watchDeviceService = new LazyResolve<IWatchDeviceService>();
|
||||
|
||||
public SyncService(
|
||||
IStateService stateService,
|
||||
IApiService apiService,
|
||||
|
@ -112,6 +114,8 @@ namespace Bit.Core.Services
|
|||
await SyncPoliciesAsync(response.Policies);
|
||||
await SyncSendsAsync(userId, response.Sends);
|
||||
await SetLastSyncAsync(now);
|
||||
_watchDeviceService.Value.SyncDataToWatchAsync().FireAndForget();
|
||||
|
||||
return SyncCompleted(true);
|
||||
}
|
||||
catch
|
||||
|
|
48
src/iOS.Core/Services/WatchDeviceService.cs
Normal file
|
@ -0,0 +1,48 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.App.Services;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Models;
|
||||
using Newtonsoft.Json;
|
||||
using WatchConnectivity;
|
||||
|
||||
namespace Bit.iOS.Core.Services
|
||||
{
|
||||
public class WatchDeviceService : BaseWatchDeviceService
|
||||
{
|
||||
public WatchDeviceService(ICipherService cipherService,
|
||||
IEnvironmentService environmentService,
|
||||
IStateService stateService,
|
||||
IVaultTimeoutService vaultTimeoutService)
|
||||
: base(cipherService, environmentService, stateService, vaultTimeoutService)
|
||||
{
|
||||
}
|
||||
|
||||
public override bool IsConnected => WCSessionManager.SharedManager.IsSessionActivated;
|
||||
|
||||
protected override bool CanSendData => WCSessionManager.SharedManager.IsValidSession;
|
||||
|
||||
protected override bool IsSupported => WCSession.IsSupported;
|
||||
|
||||
protected override Task SendDataToWatchAsync(WatchDTO watchDto)
|
||||
{
|
||||
var serializedData = JsonConvert.SerializeObject(watchDto);
|
||||
|
||||
// Add time to the key to make it change on every message sent so it's delivered faster.
|
||||
// If we use the same key then the OS may defer the delivery of the message because of
|
||||
// resources, reachability and other stuff
|
||||
WCSessionManager.SharedManager.SendBackgroundHighPriorityMessage(new Dictionary<string, object>
|
||||
{
|
||||
[$"watchDto-{DateTime.UtcNow.ToLongTimeString()}"] = serializedData
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override void ConnectToWatch()
|
||||
{
|
||||
WCSessionManager.SharedManager.StartSession();
|
||||
}
|
||||
}
|
||||
}
|
26
src/iOS.Core/Utilities/DictionaryExtensions.cs
Normal file
|
@ -0,0 +1,26 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Foundation;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Bit.iOS.Core.Utilities
|
||||
{
|
||||
public static class DictionaryExtensions
|
||||
{
|
||||
public static NSDictionary<NSString, NSObject> ToNSDictionary(this Dictionary<string, object> dict)
|
||||
{
|
||||
return dict.ToNSDictionary(k => new NSString(k), v => (NSObject)new NSString(JsonConvert.SerializeObject(v)));
|
||||
}
|
||||
|
||||
public static NSDictionary<KTo,VTo> ToNSDictionary<KFrom,VFrom,KTo,VTo>(this Dictionary<KFrom, VFrom> dict, Func<KFrom, KTo> keyConverter, Func<VFrom, VTo> valueConverter)
|
||||
where KTo : NSObject
|
||||
where VTo : NSObject
|
||||
{
|
||||
var NSValues = dict.Values.Select(x => valueConverter(x)).ToArray();
|
||||
var NSKeys = dict.Keys.Select(x => keyConverter(x)).ToArray();
|
||||
return NSDictionary<KTo, VTo>.FromObjectsAndKeys(NSValues, NSKeys, NSKeys.Count());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
185
src/iOS.Core/Utilities/WCSessionManager.cs
Normal file
|
@ -0,0 +1,185 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.iOS.Core.Utilities;
|
||||
using Foundation;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace WatchConnectivity
|
||||
{
|
||||
public sealed class WCSessionManager : WCSessionDelegate
|
||||
{
|
||||
// Setup is converted from https://www.natashatherobot.com/watchconnectivity-say-hello-to-wcsession/
|
||||
// with some extra bits
|
||||
private static readonly WCSessionManager sharedManager = new WCSessionManager();
|
||||
private static WCSession session = WCSession.IsSupported ? WCSession.DefaultSession : null;
|
||||
|
||||
public static string Device = "Phone";
|
||||
|
||||
public event WCSessionReceiveDataHandler ApplicationContextUpdated;
|
||||
public event WCSessionReceiveDataHandler MessagedReceived;
|
||||
public delegate void WCSessionReceiveDataHandler(WCSession session, Dictionary<string, object> applicationContext);
|
||||
|
||||
|
||||
private WCSession validSession
|
||||
{
|
||||
get
|
||||
{
|
||||
Console.WriteLine($"Paired status:{(session.Paired ? '✓' : '✗')}\n");
|
||||
Console.WriteLine($"Watch App Installed status:{(session.WatchAppInstalled ? '✓' : '✗')}\n");
|
||||
return (session.Paired && session.WatchAppInstalled) ? session : null;
|
||||
}
|
||||
}
|
||||
|
||||
private WCSession validReachableSession
|
||||
{
|
||||
get
|
||||
{
|
||||
return session.Reachable ? validSession : null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsValidSession => validSession != null;
|
||||
|
||||
public bool IsSessionReachable => session.Reachable;
|
||||
|
||||
public bool IsSessionActivated => validSession?.ActivationState == WCSessionActivationState.Activated;
|
||||
|
||||
private WCSessionManager() : base() { }
|
||||
|
||||
public static WCSessionManager SharedManager
|
||||
{
|
||||
get
|
||||
{
|
||||
return sharedManager;
|
||||
}
|
||||
}
|
||||
|
||||
public void StartSession()
|
||||
{
|
||||
if (session != null)
|
||||
{
|
||||
session.Delegate = this;
|
||||
session.ActivateSession();
|
||||
Console.WriteLine($"Started Watch Connectivity Session on {Device}");
|
||||
}
|
||||
}
|
||||
|
||||
public override void SessionReachabilityDidChange(WCSession session)
|
||||
{
|
||||
Console.WriteLine($"Watch connectivity Reachable:{(session.Reachable ? '✓' : '✗')} from {Device}");
|
||||
// handle session reachability change
|
||||
if (session.Reachable)
|
||||
{
|
||||
// great! continue on with Interactive Messaging
|
||||
}
|
||||
else
|
||||
{
|
||||
// 😥 prompt the user to unlock their iOS device
|
||||
}
|
||||
}
|
||||
|
||||
#region Application Context Methods
|
||||
|
||||
public void SendBackgroundHighPriorityMessage(Dictionary<string, object> applicationContext)
|
||||
{
|
||||
// Application context doesnt need the watch to be reachable, it will be received when opened
|
||||
if (validSession is null || validSession.ActivationState != WCSessionActivationState.Activated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Xamarin.Forms.Device.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var sendSuccessfully = validSession.UpdateApplicationContext(applicationContext.ToNSDictionary(), out var error);
|
||||
if (sendSuccessfully)
|
||||
{
|
||||
Console.WriteLine($"Sent App Context from {Device} \nPayLoad: {applicationContext.ToNSDictionary().ToString()} \n");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Error Updating Application Context: {error.LocalizedDescription}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Exception Updating Application Context: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
WCSessionUserInfoTransfer _transf;
|
||||
public void SendBackgroundFifoHighPriorityMessage(Dictionary<string, object> message)
|
||||
{
|
||||
if(validSession is null || validSession.ActivationState != WCSessionActivationState.Activated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_transf?.Cancel();
|
||||
|
||||
Console.WriteLine("Started transferring user info");
|
||||
|
||||
_transf = session.TransferUserInfo(message.ToNSDictionary());
|
||||
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (_transf.Transferring)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
Console.WriteLine("Finished transferring user info");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error transferring user info " + ex);
|
||||
}
|
||||
});
|
||||
|
||||
//session.SendMessage(dic,
|
||||
// (dd) =>
|
||||
// {
|
||||
// Console.WriteLine(dd?.ToString());
|
||||
// },
|
||||
// error =>
|
||||
// {
|
||||
// Console.WriteLine(error?.ToString());
|
||||
// }
|
||||
//);
|
||||
}
|
||||
|
||||
public override void DidReceiveApplicationContext(WCSession session, NSDictionary<NSString, NSObject> applicationContext)
|
||||
{
|
||||
Console.WriteLine($"Receiving Message on {Device}");
|
||||
if (ApplicationContextUpdated != null)
|
||||
{
|
||||
var keys = applicationContext.Keys.Select(k => k.ToString()).ToArray();
|
||||
var values = applicationContext.Values.Select(v => JsonConvert.DeserializeObject(v.ToString())).ToArray();
|
||||
var dictionary = keys.Zip(values, (k, v) => new { Key = k, Value = v })
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
ApplicationContextUpdated(session, dictionary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public override void DidReceiveMessage(WCSession session, NSDictionary<NSString, NSObject> message)
|
||||
{
|
||||
Console.WriteLine($"Receiving Message on {Device}");
|
||||
|
||||
var keys = message.Keys.Select(k => k.ToString()).ToArray();
|
||||
var values = message.Values.Select(v => v?.ToString() as object).ToArray();
|
||||
var dictionary = keys.Zip(values, (k, v) => new { Key = k, Value = v })
|
||||
.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
MessagedReceived?.Invoke(session, dictionary);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
|
@ -48,6 +48,12 @@ namespace Bit.iOS.Core.Utilities
|
|||
clearCipherCacheKey,
|
||||
Bit.Core.Constants.iOSAllClearCipherCacheKeys);
|
||||
InitLogger();
|
||||
|
||||
ServiceContainer.Register<IWatchDeviceService>(new WatchDeviceService(ServiceContainer.Resolve<ICipherService>(),
|
||||
ServiceContainer.Resolve<IEnvironmentService>(),
|
||||
ServiceContainer.Resolve<IStateService>(),
|
||||
ServiceContainer.Resolve<IVaultTimeoutService>()));
|
||||
|
||||
Bootstrap();
|
||||
|
||||
var appOptions = new AppOptions { IosExtension = true };
|
||||
|
@ -226,7 +232,8 @@ namespace Bit.iOS.Core.Utilities
|
|||
ServiceContainer.Resolve<IPlatformUtilsService>("platformUtilsService"),
|
||||
ServiceContainer.Resolve<IAuthService>("authService"),
|
||||
ServiceContainer.Resolve<ILogger>("logger"),
|
||||
ServiceContainer.Resolve<IMessagingService>("messagingService"));
|
||||
ServiceContainer.Resolve<IMessagingService>("messagingService"),
|
||||
ServiceContainer.Resolve<IWatchDeviceService>());
|
||||
ServiceContainer.Register<IAccountsManager>("accountsManager", accountsManager);
|
||||
|
||||
if (postBootstrapFunc != null)
|
||||
|
|
|
@ -204,9 +204,12 @@
|
|||
<Compile Include="Renderers\CollectionView\CollectionException.cs" />
|
||||
<Compile Include="Renderers\CollectionView\ExtendedGroupableItemsViewDelegator.cs" />
|
||||
<Compile Include="Effects\NoEmojiKeyboardEffect.cs" />
|
||||
<Compile Include="Utilities\WCSessionManager.cs" />
|
||||
<Compile Include="Services\FileService.cs" />
|
||||
<Compile Include="Utilities\UIViewControllerExtensions.cs" />
|
||||
<Compile Include="Services\AutofillHandler.cs" />
|
||||
<Compile Include="Utilities\DictionaryExtensions.cs" />
|
||||
<Compile Include="Services\WatchDeviceService.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\App\App.csproj">
|
||||
|
|
|
@ -11,12 +11,13 @@ using Bit.Core.Abstractions;
|
|||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.iOS.Core.Services;
|
||||
using Bit.iOS.Core.Utilities;
|
||||
using Bit.iOS.Services;
|
||||
using CoreNFC;
|
||||
using Foundation;
|
||||
using UIKit;
|
||||
using UserNotifications;
|
||||
using WatchConnectivity;
|
||||
using Xamarin.Forms;
|
||||
using Xamarin.Forms.Platform.iOS;
|
||||
|
||||
|
@ -57,6 +58,9 @@ namespace Bit.iOS
|
|||
LoadApplication(new App.App(null));
|
||||
iOSCoreHelpers.AppearanceAdjustments();
|
||||
ZXing.Net.Mobile.Forms.iOS.Platform.Init();
|
||||
|
||||
ConnectToWatchIfNeededAsync().FireAndForget();
|
||||
|
||||
_broadcasterService.Subscribe(nameof(AppDelegate), async (message) =>
|
||||
{
|
||||
try
|
||||
|
@ -302,6 +306,12 @@ namespace Bit.iOS
|
|||
ServiceContainer.Init(deviceActionService.DeviceUserAgent, Constants.ClearCiphersCacheKey,
|
||||
Constants.iOSAllClearCipherCacheKeys);
|
||||
iOSCoreHelpers.InitLogger();
|
||||
|
||||
ServiceContainer.Register<IWatchDeviceService>(new WatchDeviceService(ServiceContainer.Resolve<ICipherService>(),
|
||||
ServiceContainer.Resolve<IEnvironmentService>(),
|
||||
ServiceContainer.Resolve<IStateService>(),
|
||||
ServiceContainer.Resolve<IVaultTimeoutService>()));
|
||||
|
||||
_pushHandler = new iOSPushNotificationHandler(
|
||||
ServiceContainer.Resolve<IPushNotificationListenerService>("pushNotificationListenerService"));
|
||||
_nfcDelegate = new Core.NFCReaderDelegate((success, message) =>
|
||||
|
@ -393,5 +403,13 @@ namespace Bit.iOS
|
|||
await AppHelpers.SetPreconfiguredSettingsAsync(dict);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectToWatchIfNeededAsync()
|
||||
{
|
||||
if (_stateService != null && await _stateService.GetShouldConnectToWatchAsync())
|
||||
{
|
||||
WCSessionManager.SharedManager.StartSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -134,6 +134,15 @@
|
|||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Default' ">
|
||||
<AppExtensionDebugBundleId />
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<WatchAppBuildPath Condition=" '$(Configuration)' == 'Debug' ">$(Home)/Library/Developer/Xcode/DerivedData/bitwarden-cbtqsueryycvflfzbsoteofskiyr/Build/Products</WatchAppBuildPath>
|
||||
<WatchAppBuildPath Condition=" '$(Configuration)' != 'Debug' ">$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..'))/watchOS/bitwarden.xcarchive/Products/Applications/bitwarden.app/Watch</WatchAppBuildPath>
|
||||
<WatchAppBundle>Bitwarden.app</WatchAppBundle>
|
||||
<WatchAppConfiguration Condition=" '$(Platform)' == 'iPhoneSimulator' ">watchsimulator</WatchAppConfiguration>
|
||||
<WatchAppConfiguration Condition=" '$(Platform)' == 'iPhone' ">watchos</WatchAppConfiguration>
|
||||
<WatchAppBundleFullPath Condition=" '$(Configuration)' == 'Debug' ">$(WatchAppBuildPath)/$(Configuration)-$(WatchAppConfiguration)/$(WatchAppBundle)</WatchAppBundleFullPath>
|
||||
<WatchAppBundleFullPath Condition=" '$(Configuration)' != 'Debug' ">$(WatchAppBuildPath)/$(WatchAppBundle)</WatchAppBundleFullPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Main.cs" />
|
||||
<Compile Include="AppDelegate.cs" />
|
||||
|
@ -406,4 +415,17 @@
|
|||
<Folder Include="Resources\Assets.xcassets\LaunchScreen.imageset\" />
|
||||
<Folder Include="Resources\Assets.xcassets\ic_warning.imageset\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' AND Exists('$(WatchAppBundleFullPath)') ">
|
||||
<_ResolvedWatchAppReferences Include="$(WatchAppBundleFullPath)" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition=" '$(Configuration)' != 'Debug' ">
|
||||
<_ResolvedWatchAppReferences Include="$(WatchAppBundleFullPath)" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Condition=" '$(_ResolvedWatchAppReferences)' != '' ">
|
||||
<CodesignExtraArgs>--deep</CodesignExtraArgs>
|
||||
</PropertyGroup>
|
||||
<Target Name="PrintWatchAppBundleStatus" BeforeTargets="Build">
|
||||
<Message Text="WatchAppBundleFullPath: '$(WatchAppBundleFullPath)' exists" Condition=" Exists('$(WatchAppBundleFullPath)') " />
|
||||
<Message Text="WatchAppBundleFullPath: '$(WatchAppBundleFullPath)' does NOT exist" Condition=" !Exists('$(WatchAppBundleFullPath)') " />
|
||||
</Target>
|
||||
</Project>
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.349",
|
||||
"green" : "0.664",
|
||||
"red" : "0.279"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 3.9 KiB |
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 6.4 KiB |
After Width: | Height: | Size: 7.2 KiB |
After Width: | Height: | Size: 8.1 KiB |
After Width: | Height: | Size: 6.6 KiB |
After Width: | Height: | Size: 2 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 3.6 KiB |
After Width: | Height: | Size: 3.1 KiB |
|
@ -0,0 +1,138 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "48.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "24x24",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"filename" : "55.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "27.5x27.5",
|
||||
"subtype" : "42mm"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-59.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "companionSettings",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-87.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "companionSettings",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "66.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "33x33",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-80.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"filename" : "88.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "44x44",
|
||||
"subtype" : "40mm"
|
||||
},
|
||||
{
|
||||
"filename" : "92.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "46x46",
|
||||
"subtype" : "41mm"
|
||||
},
|
||||
{
|
||||
"filename" : "100.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "50x50",
|
||||
"subtype" : "44mm"
|
||||
},
|
||||
{
|
||||
"filename" : "102.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "51x51",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "54x54",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "172.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "86x86",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"filename" : "196.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "98x98",
|
||||
"subtype" : "42mm"
|
||||
},
|
||||
{
|
||||
"filename" : "216.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "108x108",
|
||||
"subtype" : "44mm"
|
||||
},
|
||||
{
|
||||
"filename" : "234.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "117x117",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "129x129",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-1024.png",
|
||||
"idiom" : "watch-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.5 KiB |
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo-horizontal-blue (2) 3.pdf",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "logo-horizontal-blue (2) 2.pdf",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "logo-horizontal-blue (2) 1.pdf",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : "<=145"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : ">183"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"auto-scaling" : "auto"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"assets" : [
|
||||
{
|
||||
"filename" : "Circular.imageset",
|
||||
"idiom" : "watch",
|
||||
"role" : "circular"
|
||||
},
|
||||
{
|
||||
"filename" : "Extra Large.imageset",
|
||||
"idiom" : "watch",
|
||||
"role" : "extra-large"
|
||||
},
|
||||
{
|
||||
"filename" : "Graphic Bezel.imageset",
|
||||
"idiom" : "watch",
|
||||
"role" : "graphic-bezel"
|
||||
},
|
||||
{
|
||||
"filename" : "Graphic Circular.imageset",
|
||||
"idiom" : "watch",
|
||||
"role" : "graphic-circular"
|
||||
},
|
||||
{
|
||||
"filename" : "Graphic Corner.imageset",
|
||||
"idiom" : "watch",
|
||||
"role" : "graphic-corner"
|
||||
},
|
||||
{
|
||||
"filename" : "Graphic Extra Large.imageset",
|
||||
"idiom" : "watch",
|
||||
"role" : "graphic-extra-large"
|
||||
},
|
||||
{
|
||||
"filename" : "Graphic Large Rectangular.imageset",
|
||||
"idiom" : "watch",
|
||||
"role" : "graphic-large-rectangular"
|
||||
},
|
||||
{
|
||||
"filename" : "Modular.imageset",
|
||||
"idiom" : "watch",
|
||||
"role" : "modular"
|
||||
},
|
||||
{
|
||||
"filename" : "Utilitarian.imageset",
|
||||
"idiom" : "watch",
|
||||
"role" : "utilitarian"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : "<=145"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : ">183"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"auto-scaling" : "auto"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : ">183"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"auto-scaling" : "auto"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : ">183"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"auto-scaling" : "auto"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : ">183"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"auto-scaling" : "auto"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : "<=145"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : ">183"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"auto-scaling" : "auto"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : ">183"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"auto-scaling" : "auto"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : "<=145"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : ">183"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"auto-scaling" : "auto"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : "<=145"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"scale" : "2x",
|
||||
"screen-width" : ">183"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"auto-scaling" : "auto"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xCE",
|
||||
"green" : "0xC0",
|
||||
"red" : "0xBA"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "globe 1.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "globe.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "globe 2.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 7.4 KiB |
After Width: | Height: | Size: 7.4 KiB |
After Width: | Height: | Size: 7.4 KiB |
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "emptystatedark.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "emptystatedark 1.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "emptystatedark 2.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<svg width="209" height="147" viewBox="0 0 209 147" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M91.5353 144.346H154.59" stroke="#89929F" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M111.803 126.331V144.346" stroke="#89929F" stroke-width="4" stroke-linejoin="round"/>
|
||||
<path d="M132.071 126.331V144.346" stroke="#89929F" stroke-width="4" stroke-linejoin="round"/>
|
||||
<rect x="51" y="34" width="143" height="92.3307" rx="4" fill="#1F242E" stroke="#89929F" stroke-width="4"/>
|
||||
<rect x="62.2598" y="44.1339" width="121.606" height="72.063" rx="2" fill="#2F343D" stroke="#89929F" stroke-width="2"/>
|
||||
<path d="M177 56H145" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 68H155" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M145 68H130" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 80H163" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M152 80H121" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 92H145" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M134 92H117" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 104H132" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M121 104H95" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M84 104H68" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M32.4069 50.6362C32.4069 77.4973 54.4612 99.2725 81.6667 99.2725C108.872 99.2725 130.927 77.4973 130.927 50.6362C130.927 23.7752 108.872 2 81.6667 2C54.4613 2 32.4069 23.7752 32.4069 50.6362Z" fill="#1F242E" stroke="#89929F" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.53554 127.318L5.91014 129.692C7.86276 131.645 11.0286 131.645 12.9812 129.692L42.7595 99.9139L50.5328 92.1407C51.2151 91.4515 51.063 90.3025 50.2643 89.7524C47.0256 87.5217 44.4776 84.7321 43.0358 82.8551C42.5613 82.2373 41.638 82.1441 41.0871 82.695L33.3139 90.4682L3.53553 120.247C1.58291 122.199 1.58291 125.365 3.53554 127.318Z" fill="#2F343D" stroke="#89929F" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect width="66" height="19" rx="4" transform="matrix(-1 0 0 1 115 24)" fill="#2F343D" stroke="#89929F" stroke-width="4"/>
|
||||
<path d="M85.2681 68V78.4644C85.2681 80.6735 83.4772 82.4644 81.2681 82.4644H46.2868C45.1497 82.4644 44.0595 81.9865 43.3321 81.1126C40.6843 77.9313 38.2669 74.1839 36.0797 69.2564C34.96 66.7339 36.8938 64 39.6536 64H81.2681C83.4772 64 85.2681 65.7909 85.2681 68Z" fill="#2F343D" stroke="#89929F" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M114 79.1012V68C114 65.7909 115.791 64 118 64H123.95C126.681 64 128.499 66.6372 127.261 69.0711C125.207 73.1122 122.318 77.9136 119.981 81.1559C119.376 81.994 118.396 82.4644 117.363 82.4644C115.506 82.4644 114 80.9586 114 79.1012Z" fill="#2F343D" stroke="#89929F" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.9 KiB |
|
@ -0,0 +1,22 @@
|
|||
<svg width="209" height="147" viewBox="0 0 209 147" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M91.5353 144.346H154.59" stroke="#89929F" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M111.803 126.331V144.346" stroke="#89929F" stroke-width="4" stroke-linejoin="round"/>
|
||||
<path d="M132.071 126.331V144.346" stroke="#89929F" stroke-width="4" stroke-linejoin="round"/>
|
||||
<rect x="51" y="34" width="143" height="92.3307" rx="4" fill="#1F242E" stroke="#89929F" stroke-width="4"/>
|
||||
<rect x="62.2598" y="44.1339" width="121.606" height="72.063" rx="2" fill="#2F343D" stroke="#89929F" stroke-width="2"/>
|
||||
<path d="M177 56H145" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 68H155" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M145 68H130" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 80H163" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M152 80H121" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 92H145" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M134 92H117" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 104H132" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M121 104H95" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M84 104H68" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M32.4069 50.6362C32.4069 77.4973 54.4612 99.2725 81.6667 99.2725C108.872 99.2725 130.927 77.4973 130.927 50.6362C130.927 23.7752 108.872 2 81.6667 2C54.4613 2 32.4069 23.7752 32.4069 50.6362Z" fill="#1F242E" stroke="#89929F" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.53554 127.318L5.91014 129.692C7.86276 131.645 11.0286 131.645 12.9812 129.692L42.7595 99.9139L50.5328 92.1407C51.2151 91.4515 51.063 90.3025 50.2643 89.7524C47.0256 87.5217 44.4776 84.7321 43.0358 82.8551C42.5613 82.2373 41.638 82.1441 41.0871 82.695L33.3139 90.4682L3.53553 120.247C1.58291 122.199 1.58291 125.365 3.53554 127.318Z" fill="#2F343D" stroke="#89929F" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect width="66" height="19" rx="4" transform="matrix(-1 0 0 1 115 24)" fill="#2F343D" stroke="#89929F" stroke-width="4"/>
|
||||
<path d="M85.2681 68V78.4644C85.2681 80.6735 83.4772 82.4644 81.2681 82.4644H46.2868C45.1497 82.4644 44.0595 81.9865 43.3321 81.1126C40.6843 77.9313 38.2669 74.1839 36.0797 69.2564C34.96 66.7339 36.8938 64 39.6536 64H81.2681C83.4772 64 85.2681 65.7909 85.2681 68Z" fill="#2F343D" stroke="#89929F" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M114 79.1012V68C114 65.7909 115.791 64 118 64H123.95C126.681 64 128.499 66.6372 127.261 69.0711C125.207 73.1122 122.318 77.9136 119.981 81.1559C119.376 81.994 118.396 82.4644 117.363 82.4644C115.506 82.4644 114 80.9586 114 79.1012Z" fill="#2F343D" stroke="#89929F" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.9 KiB |
|
@ -0,0 +1,22 @@
|
|||
<svg width="209" height="147" viewBox="0 0 209 147" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M91.5353 144.346H154.59" stroke="#89929F" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M111.803 126.331V144.346" stroke="#89929F" stroke-width="4" stroke-linejoin="round"/>
|
||||
<path d="M132.071 126.331V144.346" stroke="#89929F" stroke-width="4" stroke-linejoin="round"/>
|
||||
<rect x="51" y="34" width="143" height="92.3307" rx="4" fill="#1F242E" stroke="#89929F" stroke-width="4"/>
|
||||
<rect x="62.2598" y="44.1339" width="121.606" height="72.063" rx="2" fill="#2F343D" stroke="#89929F" stroke-width="2"/>
|
||||
<path d="M177 56H145" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 68H155" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M145 68H130" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 80H163" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M152 80H121" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 92H145" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M134 92H117" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M177 104H132" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M121 104H95" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M84 104H68" stroke="#89929F" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M32.4069 50.6362C32.4069 77.4973 54.4612 99.2725 81.6667 99.2725C108.872 99.2725 130.927 77.4973 130.927 50.6362C130.927 23.7752 108.872 2 81.6667 2C54.4613 2 32.4069 23.7752 32.4069 50.6362Z" fill="#1F242E" stroke="#89929F" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.53554 127.318L5.91014 129.692C7.86276 131.645 11.0286 131.645 12.9812 129.692L42.7595 99.9139L50.5328 92.1407C51.2151 91.4515 51.063 90.3025 50.2643 89.7524C47.0256 87.5217 44.4776 84.7321 43.0358 82.8551C42.5613 82.2373 41.638 82.1441 41.0871 82.695L33.3139 90.4682L3.53553 120.247C1.58291 122.199 1.58291 125.365 3.53554 127.318Z" fill="#2F343D" stroke="#89929F" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect width="66" height="19" rx="4" transform="matrix(-1 0 0 1 115 24)" fill="#2F343D" stroke="#89929F" stroke-width="4"/>
|
||||
<path d="M85.2681 68V78.4644C85.2681 80.6735 83.4772 82.4644 81.2681 82.4644H46.2868C45.1497 82.4644 44.0595 81.9865 43.3321 81.1126C40.6843 77.9313 38.2669 74.1839 36.0797 69.2564C34.96 66.7339 36.8938 64 39.6536 64H81.2681C83.4772 64 85.2681 65.7909 85.2681 68Z" fill="#2F343D" stroke="#89929F" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M114 79.1012V68C114 65.7909 115.791 64 118 64H123.95C126.681 64 128.499 66.6372 127.261 69.0711C125.207 73.1122 122.318 77.9136 119.981 81.1559C119.376 81.994 118.396 82.4644 117.363 82.4644C115.506 82.4644 114 80.9586 114 79.1012Z" fill="#2F343D" stroke="#89929F" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.9 KiB |
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "38",
|
||||
"green" : "28",
|
||||
"red" : "22"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "1.000",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import ClockKit
|
||||
|
||||
|
||||
class ComplicationController: NSObject, CLKComplicationDataSource {
|
||||
|
||||
// MARK: - Complication Configuration
|
||||
|
||||
func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) {
|
||||
let descriptors = [
|
||||
CLKComplicationDescriptor(identifier: "complication", displayName: "bitwarden", supportedFamilies: CLKComplicationFamily.allCases)
|
||||
// Multiple complication support can be added here with more descriptors
|
||||
]
|
||||
|
||||
// Call the handler with the currently supported complication descriptors
|
||||
handler(descriptors)
|
||||
}
|
||||
|
||||
func handleSharedComplicationDescriptors(_ complicationDescriptors: [CLKComplicationDescriptor]) {
|
||||
// Do any necessary work to support these newly shared complication descriptors
|
||||
}
|
||||
|
||||
// MARK: - Timeline Configuration
|
||||
|
||||
func getTimelineEndDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) {
|
||||
// Call the handler with the last entry date you can currently provide or nil if you can't support future timelines
|
||||
handler(nil)
|
||||
}
|
||||
|
||||
func getPrivacyBehavior(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void) {
|
||||
// Call the handler with your desired behavior when the device is locked
|
||||
handler(.showOnLockScreen)
|
||||
}
|
||||
|
||||
// MARK: - Timeline Population
|
||||
|
||||
func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) {
|
||||
// Call the handler with the current timeline entry
|
||||
handler(nil)
|
||||
}
|
||||
|
||||
func getTimelineEntries(for complication: CLKComplication, after date: Date, limit: Int, withHandler handler: @escaping ([CLKComplicationTimelineEntry]?) -> Void) {
|
||||
// Call the handler with the timeline entries after the given date
|
||||
handler(nil)
|
||||
}
|
||||
|
||||
// MARK: - Sample Templates
|
||||
|
||||
func getLocalizableSampleTemplate(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTemplate?) -> Void) {
|
||||
// This method will be called once per supported complication, and the results will be cached
|
||||
handler(nil)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import SwiftUI
|
||||
|
||||
struct AvatarView: View {
|
||||
var circleColor = Color.white
|
||||
var textColor = Color.black
|
||||
var initials = ""
|
||||
|
||||
init(_ user: User?) {
|
||||
let source = user?.name ?? user?.email
|
||||
var upperCaseText: String? = nil
|
||||
|
||||
if source == nil || source!.isEmpty {
|
||||
initials = ".."
|
||||
} else if source!.count > 1 {
|
||||
upperCaseText = source!.uppercased()
|
||||
initials = getFirstLetters(upperCaseText!, 2)
|
||||
} else {
|
||||
upperCaseText = source!.uppercased()
|
||||
initials = upperCaseText!
|
||||
}
|
||||
|
||||
circleColor = stringToColor(str: user?.id ?? upperCaseText, fallbackColor: Color(hex: "#FFFFFF33")!)
|
||||
textColor = textColorFromBgColor(circleColor)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(circleColor)
|
||||
.frame(width: 30, height: 30)
|
||||
Text(initials)
|
||||
.font(.footnote)
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
|
||||
func stringToColor(str: String?, fallbackColor: Color) -> Color {
|
||||
guard let str = str else {
|
||||
return fallbackColor
|
||||
}
|
||||
|
||||
var hash = 0
|
||||
for char in str {
|
||||
let uniSca = String(char).unicodeScalars
|
||||
let intCharValue = Int(uniSca[uniSca.startIndex].value)
|
||||
|
||||
hash = intCharValue + ((hash << 5) &- hash)
|
||||
}
|
||||
var color = "#"
|
||||
for i in 0..<3 {
|
||||
let value = (hash >> (i * 8)) & 0xff
|
||||
color += String(value, radix: 16).leftPadding(toLength: 2, withPad: "0")
|
||||
}
|
||||
return Color(hex: color) ?? fallbackColor
|
||||
}
|
||||
|
||||
func textColorFromBgColor(_ bgColor: Color, threshold: CGFloat = 0.65) -> Color {
|
||||
let (r, g, b, _) = bgColor.components
|
||||
let luminance = r * 0.299 + g * 0.587 + b * 0.114;
|
||||
return luminance > threshold ? Color.black : Color.white;
|
||||
}
|
||||
|
||||
func getFirstLetters(_ data: String, _ charCount: Int) -> String {
|
||||
let sanitizedData = data.trimmingCharacters(in: CharacterSet.whitespaces)
|
||||
let parts = sanitizedData.split(separator: " ")
|
||||
|
||||
if parts.count > 1 && charCount <= 2 {
|
||||
var text = "";
|
||||
for i in 0..<charCount {
|
||||
text += parts[i].prefix(1);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
if sanitizedData.count > 2 {
|
||||
return String(sanitizedData.prefix(2))
|
||||
}
|
||||
return sanitizedData;
|
||||
}
|
||||
}
|
||||
|
||||
struct AvatarView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AvatarView(User(id: "zxc", email: "asdfasdf@gmail.com", name: "John Snow"))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import SwiftUI
|
||||
|
||||
struct CircularProgressView: View {
|
||||
let progress: Double
|
||||
let strokeLineWidth:CGFloat
|
||||
let strokeColor:Color
|
||||
let endingStrokeColor:Color
|
||||
|
||||
var currentColor: Color{
|
||||
return progress > 0.2 ? strokeColor : endingStrokeColor
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(
|
||||
currentColor.opacity(0.5),
|
||||
lineWidth: strokeLineWidth
|
||||
)
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(
|
||||
currentColor,
|
||||
style: StrokeStyle(
|
||||
lineWidth: strokeLineWidth,
|
||||
lineCap: .round
|
||||
)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeOut, value: progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CircularProgressView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CircularProgressView(progress:0.5, strokeLineWidth:5, strokeColor: Color.blue, endingStrokeColor: Color.red)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Image view to be used on watchOS < 8
|
||||
///
|
||||
/// - Note: Based on: https://stackoverflow.com/questions/60710997/images-disappear-in-list-as-i-scroll-swiftui-swift
|
||||
///
|
||||
struct ImageView<PlaceholderView: View>: View {
|
||||
@ObservedObject var imageLoader:ImageLoader
|
||||
var imgMaxWidth:CGFloat
|
||||
var imgMaxHeight:CGFloat
|
||||
var placeholder: PlaceholderView
|
||||
|
||||
init(withURL url:String, maxWidth mw: CGFloat, maxHeight mh: CGFloat, @ViewBuilder _ placeholder: () -> PlaceholderView) {
|
||||
imageLoader = ImageLoader(urlString:url)
|
||||
self.imgMaxWidth = mw
|
||||
self.imgMaxHeight = mh
|
||||
self.placeholder = placeholder()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if imageLoader.image == nil {
|
||||
placeholder
|
||||
} else {
|
||||
Image(uiImage: imageLoader.image! )
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth:imgMaxWidth, maxHeight:imgMaxHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ImageLoader: ObservableObject {
|
||||
@Published var image: UIImage?
|
||||
var urlString: String?
|
||||
var imageCache = ImageCache.getImageCache()
|
||||
|
||||
init(urlString: String?) {
|
||||
self.urlString = urlString
|
||||
loadImage()
|
||||
}
|
||||
|
||||
func loadImage() {
|
||||
if loadImageFromCache() {
|
||||
return
|
||||
}
|
||||
|
||||
loadImageFromUrl()
|
||||
}
|
||||
|
||||
func loadImageFromCache() -> Bool {
|
||||
guard let urlString = urlString else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let cacheImage = imageCache.get(forKey: urlString) else {
|
||||
return false
|
||||
}
|
||||
|
||||
image = cacheImage
|
||||
return true
|
||||
}
|
||||
|
||||
func loadImageFromUrl() {
|
||||
guard let urlString = urlString else {
|
||||
return
|
||||
}
|
||||
|
||||
let url = URL(string: urlString)!
|
||||
let task = URLSession.shared.dataTask(with: url, completionHandler: getImageFromResponse(data:response:error:))
|
||||
task.resume()
|
||||
}
|
||||
|
||||
|
||||
func getImageFromResponse(data: Data?, response: URLResponse?, error: Error?) {
|
||||
guard error == nil else {
|
||||
return
|
||||
}
|
||||
guard let data = data else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard let loadedImage = UIImage(data: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
self.imageCache.set(forKey: self.urlString!, image: loadedImage)
|
||||
self.image = loadedImage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ImageCache {
|
||||
var cache = NSCache<NSString, UIImage>()
|
||||
|
||||
func get(forKey: String) -> UIImage? {
|
||||
return cache.object(forKey: NSString(string: forKey))
|
||||
}
|
||||
|
||||
func set(forKey: String, image: UIImage) {
|
||||
cache.setObject(image, forKey: NSString(string: forKey))
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageCache {
|
||||
private static var imageCache = ImageCache()
|
||||
static func getImageCache() -> ImageCache {
|
||||
return imageCache
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// List that has offset tracking and a header
|
||||
///
|
||||
/// - Note: Based on: https://stackoverflow.com/questions/74047146/tracking-scroll-position-in-a-list-swiftui
|
||||
///
|
||||
struct TrackableWithHeaderListView<HeaderContent:View, Content: View>: View {
|
||||
let offsetChanged: (CGPoint?) -> Void
|
||||
let headerContent: HeaderContent
|
||||
let content: Content
|
||||
|
||||
init(offsetChanged: @escaping (CGPoint?) -> Void = { _ in }, @ViewBuilder headerContent: () -> HeaderContent, @ViewBuilder content: () -> Content) {
|
||||
self.offsetChanged = offsetChanged
|
||||
self.headerContent = headerContent()
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
GeometryReader { geometry in
|
||||
headerContent
|
||||
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("ListView")).origin)
|
||||
}
|
||||
.frame(width: .infinity)
|
||||
content
|
||||
}
|
||||
.coordinateSpace(name: "ListView")
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: offsetChanged)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGPoint? = nil
|
||||
static func reduce(value: inout CGPoint?, nextValue: () -> CGPoint?) {
|
||||
if let nextValue = nextValue() {
|
||||
value = nextValue
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
// Based on https://medium.com/swlh/using-core-data-in-your-swiftui-app-with-combine-mvvm-and-protocols-4577f44d240d
|
||||
|
||||
class CoreDataHelper: DBHelperProtocol {
|
||||
static let shared = CoreDataHelper()
|
||||
|
||||
typealias ObjectType = NSManagedObject
|
||||
typealias PredicateType = NSPredicate
|
||||
|
||||
var context: NSManagedObjectContext { persistentContainer.viewContext }
|
||||
|
||||
// MARK: - Core Data
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
StringEncryptionTransformer.register()
|
||||
let container = NSPersistentContainer(name: "BitwardenDB")
|
||||
|
||||
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
|
||||
if let error = error as NSError? {
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
})
|
||||
return container
|
||||
}()
|
||||
|
||||
func saveContext () {
|
||||
let context = persistentContainer.viewContext
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
let nserror = error as NSError
|
||||
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DBHelper Protocol
|
||||
|
||||
|
||||
func create(_ object: NSManagedObject) {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
fatalError("error saving context while creating an object")
|
||||
}
|
||||
}
|
||||
|
||||
func fetch<T: NSManagedObject>(_ objectType: T.Type, _ entityName: String, predicate: NSPredicate? = nil, limit: Int? = nil) -> Result<[T], Error> {
|
||||
let request = NSFetchRequest<T>(entityName: entityName)
|
||||
request.predicate = predicate
|
||||
if let limit = limit {
|
||||
request.fetchLimit = limit
|
||||
}
|
||||
do {
|
||||
let result = try context.fetch(request)
|
||||
return .success(result as [T])
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchFirst<T: NSManagedObject>(_ objectType: T.Type, predicate: NSPredicate?) -> Result<T?, Error> {
|
||||
let result = fetch(objectType, predicate: predicate, limit: 1)
|
||||
switch result {
|
||||
case .success(let todos):
|
||||
return .success(todos.first as? T)
|
||||
case .failure(let error):
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
func update(_ object: NSManagedObject) {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
fatalError("error saving context while updating an object")
|
||||
}
|
||||
}
|
||||
|
||||
func delete(_ object: NSManagedObject) {
|
||||
context.delete(object)
|
||||
}
|
||||
|
||||
func insertBatch(_ entityName: String, items: [Any], itemMapper: @escaping (Any, NSManagedObjectContext) -> [String : Any], completionHandler: @escaping () -> Void) {
|
||||
self.persistentContainer.performBackgroundTask { context in
|
||||
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
let objects = items.map { item in
|
||||
itemMapper(item, context)
|
||||
}
|
||||
let batchInsert = NSBatchInsertRequest(entityName: entityName, objects: objects)
|
||||
batchInsert.resultType = NSBatchInsertRequestResultType.objectIDs
|
||||
do {
|
||||
let result = try context.execute(batchInsert) as! NSBatchInsertResult
|
||||
if let objectIDs = result.result as? [NSManagedObjectID], !objectIDs.isEmpty {
|
||||
let save = [NSInsertedObjectsKey: objectIDs]
|
||||
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: save, into: [self.context])
|
||||
}
|
||||
}
|
||||
catch let nsError as NSError {
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAll(_ entityName: String, predicate: NSPredicate? = nil, completionHandler: @escaping () -> Void) {
|
||||
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: entityName)
|
||||
fetchRequest.predicate = predicate
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
|
||||
deleteRequest.resultType = .resultTypeObjectIDs
|
||||
|
||||
self.persistentContainer.performBackgroundTask { context in
|
||||
do {
|
||||
try context.execute(deleteRequest)
|
||||
} catch let nsError as NSError {
|
||||
Log.e("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public protocol DBHelperProtocol {
|
||||
associatedtype ObjectType
|
||||
associatedtype PredicateType
|
||||
|
||||
func create(_ object: ObjectType)
|
||||
func fetchFirst(_ objectType: ObjectType.Type, predicate: PredicateType?) -> Result<ObjectType?, Error>
|
||||
func fetch(_ objectType: ObjectType.Type, predicate: PredicateType?, limit: Int?) -> Result<[ObjectType], Error>
|
||||
func update(_ object: ObjectType)
|
||||
func delete(_ object: ObjectType)
|
||||
func insertBatch(_ entityName: String, items: [Any], itemMapper: @escaping (Any, NSManagedObjectContext) -> [String : Any], completionHandler: @escaping () -> Void)
|
||||
}
|
||||
|
||||
public extension DBHelperProtocol {
|
||||
func fetch(_ objectType: ObjectType.Type, predicate: PredicateType? = nil, limit: Int? = nil) -> Result<[ObjectType], Error> {
|
||||
return fetch(objectType, predicate: predicate, limit: limit)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="22A380" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="CipherEntity" representedClassName="CipherEntity" syncable="YES">
|
||||
<attribute name="id" optional="YES" attributeType="String" valueTransformerName="StringEncryptionTransformer"/>
|
||||
<attribute name="loginUris" optional="YES" attributeType="Transformable" valueTransformerName="StringEncryptionTransformer"/>
|
||||
<attribute name="name" optional="YES" attributeType="Transformable" valueTransformerName="StringEncryptionTransformer"/>
|
||||
<attribute name="totp" optional="YES" attributeType="Transformable" valueTransformerName="StringEncryptionTransformer"/>
|
||||
<attribute name="type" optional="YES" attributeType="Transformable" valueTransformerName="StringEncryptionTransformer"/>
|
||||
<attribute name="userId" optional="YES" attributeType="String" valueTransformerName="StringEncryptionTransformer"/>
|
||||
<attribute name="username" optional="YES" attributeType="Transformable" valueTransformerName="StringEncryptionTransformer"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
</model>
|
|
@ -0,0 +1,43 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
enum DecoderConfigurationError: Error {
|
||||
case missingManagedObjectContext
|
||||
}
|
||||
|
||||
@objc(CipherEntity)
|
||||
public class CipherEntity: NSManagedObject, Codable {
|
||||
enum CodingKeys: CodingKey {
|
||||
case id, name, username, totp, loginUris, userId
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(userId, forKey: .userId)
|
||||
try container.encode(username, forKey: .username)
|
||||
try container.encode(totp, forKey: .totp)
|
||||
try container.encode(loginUris, forKey: .loginUris)
|
||||
}
|
||||
|
||||
public required convenience init(from decoder: Decoder) throws {
|
||||
guard let context = decoder.userInfo[CodingUserInfoKey.managedObjectContext] as? NSManagedObjectContext else {
|
||||
throw DecoderConfigurationError.missingManagedObjectContext
|
||||
}
|
||||
|
||||
self.init(context: context)
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self.name = try container.decode(String?.self, forKey: .name)
|
||||
self.userId = try container.decode(String.self, forKey: .userId)
|
||||
self.username = try container.decode(String?.self, forKey: .username)
|
||||
self.totp = try container.decode(String?.self, forKey: .totp)
|
||||
self.loginUris = try container.decode(String?.self, forKey: .loginUris)
|
||||
}
|
||||
}
|
||||
|
||||
extension CodingUserInfoKey {
|
||||
static let managedObjectContext = CodingUserInfoKey(rawValue: "managedObjectContext")!
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
|
||||
extension CipherEntity {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<CipherEntity> {
|
||||
return NSFetchRequest<CipherEntity>(entityName: "CipherEntity")
|
||||
}
|
||||
|
||||
@NSManaged public var id: String
|
||||
@NSManaged public var name: String?
|
||||
@NSManaged public var userId: String
|
||||
@NSManaged public var totp: String?
|
||||
@NSManaged public var type: NSObject?
|
||||
@NSManaged public var username: String?
|
||||
@NSManaged public var loginUris: String?
|
||||
|
||||
}
|
||||
|
||||
extension CipherEntity : Identifiable {
|
||||
func toCipher() -> Cipher{
|
||||
|
||||
var loginUrisArray: [LoginUri]?
|
||||
if loginUris != nil {
|
||||
loginUrisArray = try? JSONDecoder().decode([LoginUri].self, from: loginUris!.data(using: .utf8)!)
|
||||
}
|
||||
|
||||
return Cipher(id: id,
|
||||
name: name,
|
||||
userId: userId,
|
||||
login: Login(username: username, totp: totp, uris: loginUrisArray))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@objc(StringEncryptionTransformer)
|
||||
class StringEncryptionTransformer : ValueTransformer {
|
||||
var cryptoService: CryptoService = CryptoService()
|
||||
|
||||
override public class func allowsReverseTransformation() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func transformedValue(_ value: Any?) -> Any?{
|
||||
var toEncrypt: String
|
||||
|
||||
switch value {
|
||||
case let aString as String:
|
||||
toEncrypt = aString
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if let encryptedData = cryptoService.encrypt(toEncrypt) {
|
||||
return encryptedData
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
override func reverseTransformedValue(_ value: Any?) -> Any?{
|
||||
if let encryptedData = value as? Data {
|
||||
if let decryptedData = cryptoService.decrypt(encryptedData) {
|
||||
return String(decoding: decryptedData, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension StringEncryptionTransformer {
|
||||
static let name = NSValueTransformerName(rawValue: String(describing: StringEncryptionTransformer.self))
|
||||
|
||||
/// Registers the value transformer with `ValueTransformer`.
|
||||
public static func register() {
|
||||
let transformer = StringEncryptionTransformer()
|
||||
ValueTransformer.setValueTransformer(transformer, forName: name)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import Foundation
|
||||
|
||||
class IconImageHelper{
|
||||
static let shared: IconImageHelper = IconImageHelper()
|
||||
|
||||
private init(){}
|
||||
|
||||
func getLoginIconImage(_ cipher:Cipher) -> String? {
|
||||
guard let uris = cipher.login.uris, uris.count > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for u in uris {
|
||||
guard var hostname = u.uri, hostname.contains(".") else {
|
||||
continue
|
||||
}
|
||||
|
||||
if !hostname.contains("://") {
|
||||
hostname = "http://\(hostname)"
|
||||
}
|
||||
|
||||
if hostname.starts(with: "http") {
|
||||
return getIconUrl(hostname)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getIconUrl(_ uriString:String?) -> String? {
|
||||
guard let uriString = uriString else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let hostname = URL.createFullUri(from: uriString)?.host
|
||||
return hostname == nil ? "\(EnvironmentService.shared.iconsUrl)/icon.png" : "\(EnvironmentService.shared.iconsUrl)/\(hostname!)/icon.png"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
import Foundation
|
||||
|
||||
final class KeychainHelper {
|
||||
|
||||
static let standard = KeychainHelper()
|
||||
let genericService = "com.8bit.bitwarden.watch.kc"
|
||||
|
||||
private init() {}
|
||||
|
||||
func read<T>(_ key: String, _ type: T.Type) -> T? where T : Codable {
|
||||
guard let data = read(key) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let item = try JSONDecoder().decode(type, from: data)
|
||||
return item
|
||||
} catch {
|
||||
assertionFailure("Fail to decode item for keychain: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func save<T>(_ item: T, key: String) where T : Codable {
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(item)
|
||||
save(data, key)
|
||||
|
||||
} catch {
|
||||
assertionFailure("Fail to encode item for keychain: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: NON-GENERIC FUNC
|
||||
func read(_ key: String) -> Data? {
|
||||
let query = [
|
||||
kSecAttrService: genericService,
|
||||
kSecAttrAccount: key,
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecReturnData: true
|
||||
] as CFDictionary
|
||||
|
||||
var result: AnyObject?
|
||||
SecItemCopyMatching(query, &result)
|
||||
|
||||
return (result as? Data)
|
||||
}
|
||||
|
||||
func save(_ data: Data, _ key: String) {
|
||||
if let _ = read(key) {
|
||||
delete(key)
|
||||
}
|
||||
|
||||
let query = [
|
||||
kSecValueData: data,
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: genericService,
|
||||
kSecAttrAccount: key,
|
||||
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock
|
||||
] as CFDictionary
|
||||
|
||||
let status = SecItemAdd(query, nil)
|
||||
|
||||
if status == errSecDuplicateItem {
|
||||
// Item already exist, thus update it.
|
||||
let query = [
|
||||
kSecAttrService: genericService,
|
||||
kSecAttrAccount: key,
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
] as CFDictionary
|
||||
|
||||
let attributesToUpdate = [kSecValueData: data] as CFDictionary
|
||||
|
||||
SecItemUpdate(query, attributesToUpdate)
|
||||
}
|
||||
|
||||
|
||||
if status != errSecSuccess {
|
||||
Log.e("Error: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
func delete(_ key: String) {
|
||||
|
||||
let query = [
|
||||
kSecAttrService: genericService,
|
||||
kSecAttrAccount: key,
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
] as CFDictionary
|
||||
|
||||
SecItemDelete(query)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import Foundation
|
||||
|
||||
/// Wraps Swift.print() within DEBUG
|
||||
///
|
||||
/// - Note: *print()* might cause [security vulnerabilities](https://codifiedsecurity.com/mobile-app-security-testing-checklist-ios/)
|
||||
///
|
||||
/// - Parameter object: The object which is to be logged
|
||||
///
|
||||
func print(_ object: Any) {
|
||||
#if DEBUG
|
||||
Swift.print(object)
|
||||
#endif
|
||||
}
|
||||
|
||||
class Log{
|
||||
|
||||
static let shared = Log()
|
||||
|
||||
private init() {}
|
||||
|
||||
private static var isLoggingEnabled: Bool {
|
||||
#if DEBUG
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var dateFormat = "yyyy-MM-dd hh:mm:ssSSS"
|
||||
static var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = dateFormat
|
||||
formatter.locale = Locale.current
|
||||
formatter.timeZone = TimeZone.current
|
||||
return formatter
|
||||
}
|
||||
|
||||
class func e( _ object: Any, filename: String = #file, line: Int = #line, column: Int = #column, funcName: String = #function) {
|
||||
if isLoggingEnabled {
|
||||
print("\(Date().toString()) Error [\(sourceFileName(filePath: filename))]:\(line) \(column) \(funcName) -> \(object)")
|
||||
}
|
||||
}
|
||||
|
||||
private class func sourceFileName(filePath: String) -> String {
|
||||
let components = filePath.components(separatedBy: "/")
|
||||
return components.isEmpty ? "" : components.last!
|
||||
}
|
||||
}
|
||||
|
||||
internal extension Date {
|
||||
func toString() -> String {
|
||||
return Log.dateFormatter.string(from: self as Date)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>WKAppBundleIdentifier</key>
|
||||
<string>com.8bit.bitwarden.watchkitapp</string>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.watchkit</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,9 @@
|
|||
"ThereAreNoItemsToList"="There are no items to list";
|
||||
"ToViewVerificationCodesUpgradeToPremium"="To view verification codes, upgrade to premium";
|
||||
"Add2FactorAutenticationToAnItemToViewVerificationCodes"="Add 2 factor authentication to an item to view the verification codes";
|
||||
"LogInToBitwardenOnYourIPhoneToViewVerificationCodes"="Log in to Bitwarden on your iPhone to view verification codes";
|
||||
"SyncingItemsContainingVerificationCodes"="Syncing items containing verification codes";
|
||||
"UnlockBitwardenOnYourIPhoneToViewVerificationCodes"="Unlock Bitwarden on your iPhone to view verification codes";
|
||||
"SetUpBitwardenToViewItemsContainingVerificationCodes"="Set up Bitwarden to view items containing verification codes";
|
||||
"Search"="Search";
|
||||
"NoItemsFound"="No items found";
|
|
@ -0,0 +1,36 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
struct Cipher:Identifiable,Codable{
|
||||
var id:String
|
||||
var name:String?
|
||||
var userId:String?
|
||||
var login:Login
|
||||
}
|
||||
|
||||
struct Login:Codable{
|
||||
var username:String?
|
||||
var totp:String?
|
||||
var uris:[LoginUri]?
|
||||
}
|
||||
|
||||
struct LoginUri:Codable{
|
||||
var uri:String?
|
||||
}
|
||||
|
||||
extension Cipher{
|
||||
func toCipherEntity(moContext: NSManagedObjectContext) -> CipherEntity{
|
||||
let entity = CipherEntity(context: moContext)
|
||||
entity.id = id
|
||||
entity.name = name
|
||||
entity.userId = userId ?? "unknown"
|
||||
entity.username = login.username
|
||||
entity.totp = login.totp
|
||||
|
||||
if let uris = login.uris, let encodedData = try? JSONEncoder().encode(uris) {
|
||||
entity.loginUris = String(data: encodedData, encoding: .utf8)
|
||||
}
|
||||
|
||||
return entity
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import Foundation
|
||||
|
||||
struct CipherMock {
|
||||
static let ciphers:[Cipher] = [
|
||||
Cipher(id: "0", name: "1933", userId: "123123", login: Login(username: "thisisatest@testing.com", totp: "otpauth://account?period=10&secret=LLLLLLLLLLLLLLLL", uris: cipherLoginUris)),
|
||||
Cipher(id: "1", name: "GitHub", userId: "123123", login: Login(username: "thisisatest@testing.com", totp: "LLLLLLLLLLLLLLLL", uris: cipherLoginUris)),
|
||||
Cipher(id: "2", name: "No user", userId: "123123", login: Login(username: nil, totp: "otpauth://account?period=10&digits=8&algorithm=sha256&secret=LLLLLLLLLLLLLLLL", uris: cipherLoginUris)),
|
||||
Cipher(id: "3", name: "Site 2", userId: "123123", login: Login(username: "longtestemail000000@fastmailasdfasdf.com", totp: "otpauth://account?period=10&digits=7&algorithm=sha512&secret=LLLLLLLLLLLLLLLL", uris: cipherLoginUris)),
|
||||
Cipher(id: "4", name: "Really long name for a site that is used for a totp", userId: "123123", login: Login(username: "user3", totp: "steam://LLLLLLLLLLLLLLLL", uris: cipherLoginUris)),
|
||||
Cipher(id: "5", name: "Short", userId: "123123", login: Login(username: "u", totp: "steam://LLLLLLLLLLLLLLLL", uris: cipherLoginUris))
|
||||
]
|
||||
|
||||
static let cipherLoginUris:[LoginUri] = [
|
||||
LoginUri(uri: "github.com"),
|
||||
LoginUri(uri: "example2.com")
|
||||
]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
struct User : Codable {
|
||||
var id: String
|
||||
var email: String?
|
||||
var name: String?
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
//import Foundation
|
||||
//
|
||||
//enum VaultTimeoutAction : Int, Codable {
|
||||
// case lock = 0
|
||||
// case logout = 1
|
||||
//}
|
|
@ -0,0 +1,26 @@
|
|||
import Foundation
|
||||
|
||||
struct WatchDTO : Codable{
|
||||
var state: BWState
|
||||
var ciphers: [Cipher]?
|
||||
var userData: User?
|
||||
var environmentData: EnvironmentUrlDataDto?
|
||||
// var settingsData: SettingsDataDto?
|
||||
|
||||
init(state: BWState) {
|
||||
self.state = state
|
||||
self.ciphers = nil
|
||||
self.userData = nil
|
||||
self.environmentData = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvironmentUrlDataDto : Codable {
|
||||
var base: String?
|
||||
var icons: String?
|
||||
}
|
||||
|
||||
//struct SettingsDataDto : Codable {
|
||||
// var vaultTimeoutInMinutes: Int?
|
||||
// var vaultTimeoutAction: VaultTimeoutAction
|
||||
//}
|