From 4533d9e63ea5491a72a0b35b5dccdf96d72876bd Mon Sep 17 00:00:00 2001 From: Krzysztof Rodak Date: Thu, 7 Mar 2024 20:20:25 +0500 Subject: [PATCH] feat: ios: Banana Spit backup flow (#2355) * feat: ios: Assets, localizable, models, rename and moving seeds related classes * feat: ios: UI elements fixes and cleanup * feat: ios: Banana split main modal * feat: ios: Banana split action modal * feat: ios: banana split / passphrase keychain classes * feat: ios: QR code modal, passphrase modal * feat: ios: initial key details integration * feat: ios: Unit tests for BS keychain * feat: ios: banana split unit tests --------- Co-authored-by: Pavel Rybalko --- ios/PolkadotVault.xcodeproj/project.pbxproj | 164 ++++++++-- .../Backend/Services/BananaSplitService.swift | 11 +- .../Components/Buttons/CircleButton.swift | 2 +- .../Components/Buttons/IconButton.swift | 58 ++++ .../Text/ActionableInfoBoxView.swift | 7 +- .../Text/AttributedInfoBoxView.swift | 5 +- .../Text/AttributedTintInfoBox.swift | 5 +- .../Components/Text/InfoBoxView.swift | 4 +- .../TextFields/PrimaryTextField.swift | 11 +- .../KeychainBananaSplitQueryProvider.swift | 102 ++++++ .../Seeds/KeychainBananaSplitMediator.swift | 135 ++++++++ .../KeychainSeedsAccessAdapter.swift} | 14 +- .../KeychainSeedsQueryProvider.swift} | 12 +- .../Keychain/{ => Seeds}/SeedsMediator.swift | 9 +- .../Runtime/RuntimePropertiesProvider.swift | 15 +- ios/PolkadotVault/Design/Heights.swift | 2 + .../Extensions/Localizable+Formatted.swift | 17 + .../Alerts/HorizontalActionsBottomModal.swift | 7 + .../KeySet/KeyDetailsActionsModal.swift | 64 ---- ios/PolkadotVault/PolkadotVaultApp.swift | 4 +- .../Contents.json | 16 + .../banana_split_backup.svg | 13 + .../show_passphrase.imageset/Contents.json | 15 + .../show_passphrase.svg | 3 + .../refresh_passphrase.imageset/Contents.json | 16 + .../refreshPassphrase.svg | 3 + .../Resources/en.lproj/Localizable.strings | 28 +- .../BananaSplit/BananaSplitActionModal.swift | 89 +++++ .../BananaSplit/BananaSplitModal.swift | 309 ++++++++++++++++++ .../BananaSplitPassphraseModal.swift | 84 +++++ .../BananaSplit/BananaSplitQRCodeModal.swift | 173 ++++++++++ .../ExportKeysSelectionModal.swift | 0 .../ExportMultipleKeysModal+ViewModel.swift | 0 .../ExportMultipleKeysModal.swift | 0 .../Modals/KeyDetailsActionsModal.swift | 117 +++++++ .../Views/KeyDetails+ViewModel.swift | 79 ++++- .../KeyDetails/Views/KeyDetailsView.swift | 21 +- .../PublicKey}/PublicKeyActionsModal.swift | 0 ...eychainBananaSplitQueryProviderTests.swift | 161 +++++++++ .../KeychainSeedsAccessAdapterTests.swift} | 12 +- .../KeychainSeedsQueryProviderTests.swift} | 20 +- .../{ => Seeds}/SeedsMediatorTests.swift | 2 +- ...BananaSplitActionModalViewModelTests.swift | 101 ++++++ ...naSplitPassphraseModalViewModelTests.swift | 93 ++++++ ...BananaSplitQRCodeModalViewModelTests.swift | 106 ++++++ 45 files changed, 1956 insertions(+), 153 deletions(-) create mode 100644 ios/PolkadotVault/Components/Buttons/IconButton.swift create mode 100644 ios/PolkadotVault/Core/Keychain/BananaSplit/KeychainBananaSplitQueryProvider.swift create mode 100644 ios/PolkadotVault/Core/Keychain/Seeds/KeychainBananaSplitMediator.swift rename ios/PolkadotVault/Core/Keychain/{KeychainAccessAdapter.swift => Seeds/KeychainSeedsAccessAdapter.swift} (93%) rename ios/PolkadotVault/Core/Keychain/{KeychainQueryProvider.swift => Seeds/KeychainSeedsQueryProvider.swift} (87%) rename ios/PolkadotVault/Core/Keychain/{ => Seeds}/SeedsMediator.swift (95%) delete mode 100644 ios/PolkadotVault/Modals/KeySet/KeyDetailsActionsModal.swift create mode 100644 ios/PolkadotVault/Resources/Assets.xcassets/key_set/banana_split_backup.imageset/Contents.json create mode 100644 ios/PolkadotVault/Resources/Assets.xcassets/key_set/banana_split_backup.imageset/banana_split_backup.svg create mode 100644 ios/PolkadotVault/Resources/Assets.xcassets/key_set/show_passphrase.imageset/Contents.json create mode 100644 ios/PolkadotVault/Resources/Assets.xcassets/key_set/show_passphrase.imageset/show_passphrase.svg create mode 100644 ios/PolkadotVault/Resources/Assets.xcassets/refresh_passphrase.imageset/Contents.json create mode 100644 ios/PolkadotVault/Resources/Assets.xcassets/refresh_passphrase.imageset/refreshPassphrase.svg create mode 100644 ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitActionModal.swift create mode 100644 ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitModal.swift create mode 100644 ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitPassphraseModal.swift create mode 100644 ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitQRCodeModal.swift rename ios/PolkadotVault/Screens/KeyDetails/{ExportKeys => Modals}/ExportKeysSelectionModal.swift (100%) rename ios/PolkadotVault/Screens/KeyDetails/{ExportKeys => Modals}/ExportMultipleKeysModal+ViewModel.swift (100%) rename ios/PolkadotVault/Screens/KeyDetails/{ExportKeys => Modals}/ExportMultipleKeysModal.swift (100%) create mode 100644 ios/PolkadotVault/Screens/KeyDetails/Modals/KeyDetailsActionsModal.swift rename ios/PolkadotVault/{Modals/KeySet => Screens/PublicKey}/PublicKeyActionsModal.swift (100%) create mode 100644 ios/PolkadotVaultTests/Core/Keychain/BananaSplit/KeychainBananaSplitQueryProviderTests.swift rename ios/PolkadotVaultTests/Core/Keychain/{KeychainAccessAdapterTests.swift => Seeds/KeychainSeedsAccessAdapterTests.swift} (97%) rename ios/PolkadotVaultTests/Core/Keychain/{KeychainQueryProviderTests.swift => Seeds/KeychainSeedsQueryProviderTests.swift} (87%) rename ios/PolkadotVaultTests/Core/Keychain/{ => Seeds}/SeedsMediatorTests.swift (99%) create mode 100644 ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitActionModalViewModelTests.swift create mode 100644 ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitPassphraseModalViewModelTests.swift create mode 100644 ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitQRCodeModalViewModelTests.swift diff --git a/ios/PolkadotVault.xcodeproj/project.pbxproj b/ios/PolkadotVault.xcodeproj/project.pbxproj index ce997efebc..09aa07db70 100644 --- a/ios/PolkadotVault.xcodeproj/project.pbxproj +++ b/ios/PolkadotVault.xcodeproj/project.pbxproj @@ -114,6 +114,11 @@ 6D52E3AA2946F58200AD72F0 /* VerifierCertificateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D52E3A92946F58200AD72F0 /* VerifierCertificateView.swift */; }; 6D52E3AF2946FF5400AD72F0 /* NetworkSelectionModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D52E3AE2946FF5400AD72F0 /* NetworkSelectionModal.swift */; }; 6D52E3B12946FF5E00AD72F0 /* NetworkLogoIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D52E3B02946FF5E00AD72F0 /* NetworkLogoIcon.swift */; }; + 6D5338762B7D306700F37EB1 /* BananaSplitModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5338752B7D306700F37EB1 /* BananaSplitModal.swift */; }; + 6D5338782B7D7E9300F37EB1 /* IconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5338772B7D7E9300F37EB1 /* IconButton.swift */; }; + 6D53387A2B7E4CC400F37EB1 /* BananaSplitQRCodeModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5338792B7E4CC400F37EB1 /* BananaSplitQRCodeModal.swift */; }; + 6D53387D2B7E512C00F37EB1 /* BananaSplitActionModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D53387C2B7E512C00F37EB1 /* BananaSplitActionModal.swift */; }; + 6D5338852B865F4500F37EB1 /* BananaSplitPassphraseModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5338842B865F4500F37EB1 /* BananaSplitPassphraseModal.swift */; }; 6D55F286292CF2F800871896 /* StrokeContainerBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D55F285292CF2F800871896 /* StrokeContainerBackground.swift */; }; 6D57DC4B289D614F00005C63 /* BackendNavigationAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D57DC4A289D614F00005C63 /* BackendNavigationAdapter.swift */; }; 6D57DC4D289D652400005C63 /* Dispatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D57DC4C289D652400005C63 /* Dispatching.swift */; }; @@ -195,7 +200,7 @@ 6D8973A52A08E18C0046A2F3 /* ScanTabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8973A42A08E18C0046A2F3 /* ScanTabService.swift */; }; 6D8973A72A08E6550046A2F3 /* GeneralVerifierService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8973A62A08E6550046A2F3 /* GeneralVerifierService.swift */; }; 6D8AF88228BCC4D100CF0AB2 /* AccessControlProvidingAssemblerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8AF88128BCC4D100CF0AB2 /* AccessControlProvidingAssemblerTests.swift */; }; - 6D8AF88A28BCC60600CF0AB2 /* KeychainQueryProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8AF88928BCC60600CF0AB2 /* KeychainQueryProviderTests.swift */; }; + 6D8AF88A28BCC60600CF0AB2 /* KeychainSeedsQueryProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8AF88928BCC60600CF0AB2 /* KeychainSeedsQueryProviderTests.swift */; }; 6D8F813C2994C64E000ED0BA /* ErrorDisplayed+TransactionSigning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8F813B2994C64E000ED0BA /* ErrorDisplayed+TransactionSigning.swift */; }; 6D91F3EE28C16163007560F5 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D91F3ED28C16163007560F5 /* CircularProgressView.swift */; }; 6D932CDF292E05CB008AD883 /* InlineButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D932CDE292E05CB008AD883 /* InlineButton.swift */; }; @@ -252,7 +257,7 @@ 6DAB52EF2B5E81BB005FDBA8 /* LogNoteModalViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DAB52EE2B5E81BB005FDBA8 /* LogNoteModalViewModelTests.swift */; }; 6DAB52F22B5E832F005FDBA8 /* NoKeySetsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DAB52F12B5E832F005FDBA8 /* NoKeySetsViewModelTests.swift */; }; 6DAFCAF82B0A360600DDD165 /* CameraPermissionHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DAFCAF72B0A360600DDD165 /* CameraPermissionHandlerTests.swift */; }; - 6DAFCAFA2B0AE5C000DDD165 /* KeychainAccessAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DAFCAF92B0AE5C000DDD165 /* KeychainAccessAdapterTests.swift */; }; + 6DAFCAFA2B0AE5C000DDD165 /* KeychainSeedsAccessAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DAFCAF92B0AE5C000DDD165 /* KeychainSeedsAccessAdapterTests.swift */; }; 6DAFCAFD2B0AE87300DDD165 /* RuntimePropertiesProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DAFCAFC2B0AE87300DDD165 /* RuntimePropertiesProviderTests.swift */; }; 6DAFCB002B0AEB7E00DDD165 /* ConnectivityMediatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DAFCAFF2B0AEB7E00DDD165 /* ConnectivityMediatorTests.swift */; }; 6DAFCB022B0AEE4900DDD165 /* ApplicationStatePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DAFCB012B0AEE4900DDD165 /* ApplicationStatePublisherTests.swift */; }; @@ -293,8 +298,8 @@ 6DC5643328B68FC5003D540B /* AccessControlProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC5643228B68FC5003D540B /* AccessControlProvider.swift */; }; 6DC5643528B69355003D540B /* AccessControlProvidingAssembler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC5643428B69355003D540B /* AccessControlProvidingAssembler.swift */; }; 6DC5643728B79EC6003D540B /* SeedsMediator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC5643628B79EC6003D540B /* SeedsMediator.swift */; }; - 6DC5643928B7DED8003D540B /* KeychainQueryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC5643828B7DED8003D540B /* KeychainQueryProvider.swift */; }; - 6DC5643B28B8D189003D540B /* KeychainAccessAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC5643A28B8D189003D540B /* KeychainAccessAdapter.swift */; }; + 6DC5643928B7DED8003D540B /* KeychainSeedsQueryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC5643828B7DED8003D540B /* KeychainSeedsQueryProvider.swift */; }; + 6DC5643B28B8D189003D540B /* KeychainSeedsAccessAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC5643A28B8D189003D540B /* KeychainSeedsAccessAdapter.swift */; }; 6DC5643D28B91EFE003D540B /* NavbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC5643C28B91EFE003D540B /* NavbarButton.swift */; }; 6DC5644028B929EA003D540B /* KeyDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC5643F28B929EA003D540B /* KeyDetailsView.swift */; }; 6DC909F629C87C8C00AE6BAD /* LogsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC909F529C87C8C00AE6BAD /* LogsService.swift */; }; @@ -307,6 +312,10 @@ 6DD860EA299CED3F0000D81E /* AirgapMediator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD860E9299CED3F0000D81E /* AirgapMediator.swift */; }; 6DD9FF1628C8B85300FB6195 /* HorizontalActionsBottomModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD9FF1528C8B85300FB6195 /* HorizontalActionsBottomModal.swift */; }; 6DD9FF1928C8C9B000FB6195 /* Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD9FF1828C8C9AF00FB6195 /* Animations.swift */; }; + 6DDD01D32B9504D1000F53B3 /* KeychainBananaSplitQueryProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD01D22B9504D1000F53B3 /* KeychainBananaSplitQueryProviderTests.swift */; }; + 6DDD01D72B958372000F53B3 /* BananaSplitPassphraseModalViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD01D62B958372000F53B3 /* BananaSplitPassphraseModalViewModelTests.swift */; }; + 6DDD01D92B958845000F53B3 /* BananaSplitActionModalViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD01D82B958845000F53B3 /* BananaSplitActionModalViewModelTests.swift */; }; + 6DDD01DB2B9589B5000F53B3 /* BananaSplitQRCodeModalViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD01DA2B9589B5000F53B3 /* BananaSplitQRCodeModalViewModelTests.swift */; }; 6DDD38B22B11C3C2000D2B62 /* SeedsMediatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD38B12B11C3C2000D2B62 /* SeedsMediatorTests.swift */; }; 6DDD38B72B1346C8000D2B62 /* KeyDetailsPublicKeyViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD38B62B1346C8000D2B62 /* KeyDetailsPublicKeyViewModelTests.swift */; }; 6DDD38B92B134ADF000D2B62 /* MKeyDetails+Generate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD38B82B134ADF000D2B62 /* MKeyDetails+Generate.swift */; }; @@ -369,6 +378,8 @@ 6DF07ADE29C1062300C01DE8 /* SetUpNetworksIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF07ADD29C1062300C01DE8 /* SetUpNetworksIntroView.swift */; }; 6DF07AE229C1140500C01DE8 /* SetUpNetworksStepOneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF07AE129C1140500C01DE8 /* SetUpNetworksStepOneView.swift */; }; 6DF07AE429C1141000C01DE8 /* SetUpNetworksStepTwoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF07AE329C1141000C01DE8 /* SetUpNetworksStepTwoView.swift */; }; + 6DF310A72B8CBC1A00A38205 /* KeychainBananaSplitQueryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF310A62B8CBC1A00A38205 /* KeychainBananaSplitQueryProvider.swift */; }; + 6DF310AB2B8E150100A38205 /* KeychainBananaSplitMediator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF310AA2B8E150100A38205 /* KeychainBananaSplitMediator.swift */; }; 6DF3CFEC29936F41002DF203 /* EnterKeySetNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF3CFEB29936F41002DF203 /* EnterKeySetNameView.swift */; }; 6DF3CFEE299370F7002DF203 /* CreateKeySetSeedPhraseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF3CFED299370F7002DF203 /* CreateKeySetSeedPhraseView.swift */; }; 6DF3CFF029937E48002DF203 /* AttributedTintInfoBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF3CFEF29937E48002DF203 /* AttributedTintInfoBox.swift */; }; @@ -539,6 +550,11 @@ 6D52E3A92946F58200AD72F0 /* VerifierCertificateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifierCertificateView.swift; sourceTree = ""; }; 6D52E3AE2946FF5400AD72F0 /* NetworkSelectionModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSelectionModal.swift; sourceTree = ""; }; 6D52E3B02946FF5E00AD72F0 /* NetworkLogoIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkLogoIcon.swift; sourceTree = ""; }; + 6D5338752B7D306700F37EB1 /* BananaSplitModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BananaSplitModal.swift; sourceTree = ""; }; + 6D5338772B7D7E9300F37EB1 /* IconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconButton.swift; sourceTree = ""; }; + 6D5338792B7E4CC400F37EB1 /* BananaSplitQRCodeModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BananaSplitQRCodeModal.swift; sourceTree = ""; }; + 6D53387C2B7E512C00F37EB1 /* BananaSplitActionModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BananaSplitActionModal.swift; sourceTree = ""; }; + 6D5338842B865F4500F37EB1 /* BananaSplitPassphraseModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BananaSplitPassphraseModal.swift; sourceTree = ""; }; 6D55F285292CF2F800871896 /* StrokeContainerBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrokeContainerBackground.swift; sourceTree = ""; }; 6D57DC4A289D614F00005C63 /* BackendNavigationAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendNavigationAdapter.swift; sourceTree = ""; }; 6D57DC4C289D652400005C63 /* Dispatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dispatching.swift; sourceTree = ""; }; @@ -619,7 +635,7 @@ 6D8973A42A08E18C0046A2F3 /* ScanTabService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanTabService.swift; sourceTree = ""; }; 6D8973A62A08E6550046A2F3 /* GeneralVerifierService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralVerifierService.swift; sourceTree = ""; }; 6D8AF88128BCC4D100CF0AB2 /* AccessControlProvidingAssemblerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessControlProvidingAssemblerTests.swift; sourceTree = ""; }; - 6D8AF88928BCC60600CF0AB2 /* KeychainQueryProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainQueryProviderTests.swift; sourceTree = ""; }; + 6D8AF88928BCC60600CF0AB2 /* KeychainSeedsQueryProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainSeedsQueryProviderTests.swift; sourceTree = ""; }; 6D8F813B2994C64E000ED0BA /* ErrorDisplayed+TransactionSigning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ErrorDisplayed+TransactionSigning.swift"; sourceTree = ""; }; 6D91F3EB28C114D1007560F5 /* ExportPrivateKeyModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportPrivateKeyModal.swift; sourceTree = ""; }; 6D91F3ED28C16163007560F5 /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; @@ -675,7 +691,7 @@ 6DAB52EE2B5E81BB005FDBA8 /* LogNoteModalViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogNoteModalViewModelTests.swift; sourceTree = ""; }; 6DAB52F12B5E832F005FDBA8 /* NoKeySetsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoKeySetsViewModelTests.swift; sourceTree = ""; }; 6DAFCAF72B0A360600DDD165 /* CameraPermissionHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPermissionHandlerTests.swift; sourceTree = ""; }; - 6DAFCAF92B0AE5C000DDD165 /* KeychainAccessAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainAccessAdapterTests.swift; sourceTree = ""; }; + 6DAFCAF92B0AE5C000DDD165 /* KeychainSeedsAccessAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainSeedsAccessAdapterTests.swift; sourceTree = ""; }; 6DAFCAFC2B0AE87300DDD165 /* RuntimePropertiesProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimePropertiesProviderTests.swift; sourceTree = ""; }; 6DAFCAFF2B0AEB7E00DDD165 /* ConnectivityMediatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityMediatorTests.swift; sourceTree = ""; }; 6DAFCB012B0AEE4900DDD165 /* ApplicationStatePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationStatePublisherTests.swift; sourceTree = ""; }; @@ -716,8 +732,8 @@ 6DC5643228B68FC5003D540B /* AccessControlProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessControlProvider.swift; sourceTree = ""; }; 6DC5643428B69355003D540B /* AccessControlProvidingAssembler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessControlProvidingAssembler.swift; sourceTree = ""; }; 6DC5643628B79EC6003D540B /* SeedsMediator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedsMediator.swift; sourceTree = ""; }; - 6DC5643828B7DED8003D540B /* KeychainQueryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainQueryProvider.swift; sourceTree = ""; }; - 6DC5643A28B8D189003D540B /* KeychainAccessAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainAccessAdapter.swift; sourceTree = ""; }; + 6DC5643828B7DED8003D540B /* KeychainSeedsQueryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainSeedsQueryProvider.swift; sourceTree = ""; }; + 6DC5643A28B8D189003D540B /* KeychainSeedsAccessAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainSeedsAccessAdapter.swift; sourceTree = ""; }; 6DC5643C28B91EFE003D540B /* NavbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavbarButton.swift; sourceTree = ""; }; 6DC5643F28B929EA003D540B /* KeyDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyDetailsView.swift; sourceTree = ""; }; 6DC909F529C87C8C00AE6BAD /* LogsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsService.swift; sourceTree = ""; }; @@ -731,6 +747,10 @@ 6DD860E9299CED3F0000D81E /* AirgapMediator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirgapMediator.swift; sourceTree = ""; }; 6DD9FF1528C8B85300FB6195 /* HorizontalActionsBottomModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalActionsBottomModal.swift; sourceTree = ""; }; 6DD9FF1828C8C9AF00FB6195 /* Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animations.swift; sourceTree = ""; }; + 6DDD01D22B9504D1000F53B3 /* KeychainBananaSplitQueryProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainBananaSplitQueryProviderTests.swift; sourceTree = ""; }; + 6DDD01D62B958372000F53B3 /* BananaSplitPassphraseModalViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BananaSplitPassphraseModalViewModelTests.swift; sourceTree = ""; }; + 6DDD01D82B958845000F53B3 /* BananaSplitActionModalViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BananaSplitActionModalViewModelTests.swift; sourceTree = ""; }; + 6DDD01DA2B9589B5000F53B3 /* BananaSplitQRCodeModalViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BananaSplitQRCodeModalViewModelTests.swift; sourceTree = ""; }; 6DDD38B12B11C3C2000D2B62 /* SeedsMediatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedsMediatorTests.swift; sourceTree = ""; }; 6DDD38B62B1346C8000D2B62 /* KeyDetailsPublicKeyViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyDetailsPublicKeyViewModelTests.swift; sourceTree = ""; }; 6DDD38B82B134ADF000D2B62 /* MKeyDetails+Generate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MKeyDetails+Generate.swift"; sourceTree = ""; }; @@ -797,6 +817,8 @@ 6DF07ADD29C1062300C01DE8 /* SetUpNetworksIntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetUpNetworksIntroView.swift; sourceTree = ""; }; 6DF07AE129C1140500C01DE8 /* SetUpNetworksStepOneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetUpNetworksStepOneView.swift; sourceTree = ""; }; 6DF07AE329C1141000C01DE8 /* SetUpNetworksStepTwoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetUpNetworksStepTwoView.swift; sourceTree = ""; }; + 6DF310A62B8CBC1A00A38205 /* KeychainBananaSplitQueryProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainBananaSplitQueryProvider.swift; sourceTree = ""; }; + 6DF310AA2B8E150100A38205 /* KeychainBananaSplitMediator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = KeychainBananaSplitMediator.swift; path = ../Seeds/KeychainBananaSplitMediator.swift; sourceTree = ""; }; 6DF3CFEB29936F41002DF203 /* EnterKeySetNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterKeySetNameView.swift; sourceTree = ""; }; 6DF3CFED299370F7002DF203 /* CreateKeySetSeedPhraseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateKeySetSeedPhraseView.swift; sourceTree = ""; }; 6DF3CFEF29937E48002DF203 /* AttributedTintInfoBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedTintInfoBox.swift; sourceTree = ""; }; @@ -891,7 +913,6 @@ 6DBF6E2528E44DBA00CC959F /* Errors */, 6DCC572528D8C0410014278A /* Backup */, 6DD9FF1428C8B83C00FB6195 /* Alerts */, - 6D88CFF628C634BC001FB0A1 /* KeySet */, 6DE8466F28C0FA090051346A /* ExportPrivateKey */, ); path = Modals; @@ -1229,6 +1250,7 @@ 6D2D245028CE5F0E00862726 /* PublicKey */ = { isa = PBXGroup; children = ( + 6D2D245628CF5C5200862726 /* PublicKeyActionsModal.swift */, 6D2D245128CE5F2D00862726 /* KeyDetailsPublicKeyView.swift */, 6D4F711829F913FC00729610 /* KeyDetailsPublicKeyViewRenderable.swift */, ); @@ -1320,6 +1342,17 @@ path = NetworkSelection; sourceTree = ""; }; + 6D53387B2B7E512000F37EB1 /* BananaSplit */ = { + isa = PBXGroup; + children = ( + 6D5338752B7D306700F37EB1 /* BananaSplitModal.swift */, + 6D5338792B7E4CC400F37EB1 /* BananaSplitQRCodeModal.swift */, + 6D53387C2B7E512C00F37EB1 /* BananaSplitActionModal.swift */, + 6D5338842B865F4500F37EB1 /* BananaSplitPassphraseModal.swift */, + ); + path = BananaSplit; + sourceTree = ""; + }; 6D57DC4E289D667000005C63 /* Navigation */ = { isa = PBXGroup; children = ( @@ -1451,16 +1484,6 @@ path = Authentication; sourceTree = ""; }; - 6D5DCA012A7CFA1E0050B101 /* ExportKeys */ = { - isa = PBXGroup; - children = ( - 6D042AF32901B40400B3F4F7 /* ExportMultipleKeysModal.swift */, - 6D0FA73A2907010E00E45BA6 /* ExportMultipleKeysModal+ViewModel.swift */, - 6D5DCA022A7CFA2E0050B101 /* ExportKeysSelectionModal.swift */, - ); - path = ExportKeys; - sourceTree = ""; - }; 6D6430EA28CB2FD300342E37 /* Animators */ = { isa = PBXGroup; children = ( @@ -1668,22 +1691,23 @@ path = Modifiers; sourceTree = ""; }; - 6D88CFF628C634BC001FB0A1 /* KeySet */ = { + 6D88CFF628C634BC001FB0A1 /* Modals */ = { isa = PBXGroup; children = ( + 6D042AF32901B40400B3F4F7 /* ExportMultipleKeysModal.swift */, + 6D0FA73A2907010E00E45BA6 /* ExportMultipleKeysModal+ViewModel.swift */, + 6D5DCA022A7CFA2E0050B101 /* ExportKeysSelectionModal.swift */, 6D88CFF728C634CA001FB0A1 /* KeyDetailsActionsModal.swift */, - 6D2D245628CF5C5200862726 /* PublicKeyActionsModal.swift */, ); - path = KeySet; + path = Modals; sourceTree = ""; }; 6D8AF88028BCC4BF00CF0AB2 /* Keychain */ = { isa = PBXGroup; children = ( + 6DDD01D12B9504C0000F53B3 /* BananaSplit */, + 6DDD01D02B95049D000F53B3 /* Seeds */, 6D8AF88128BCC4D100CF0AB2 /* AccessControlProvidingAssemblerTests.swift */, - 6D8AF88928BCC60600CF0AB2 /* KeychainQueryProviderTests.swift */, - 6DAFCAF92B0AE5C000DDD165 /* KeychainAccessAdapterTests.swift */, - 6DDD38B12B11C3C2000D2B62 /* SeedsMediatorTests.swift */, ); path = Keychain; sourceTree = ""; @@ -2033,11 +2057,10 @@ 6DC5643128B68FB2003D540B /* Keychain */ = { isa = PBXGroup; children = ( + 6DF310A52B8CBC0800A38205 /* BananaSplit */, + 6DF310A42B8CBAB900A38205 /* Seeds */, 6DC5643228B68FC5003D540B /* AccessControlProvider.swift */, 6DC5643428B69355003D540B /* AccessControlProvidingAssembler.swift */, - 6DC5643628B79EC6003D540B /* SeedsMediator.swift */, - 6DC5643828B7DED8003D540B /* KeychainQueryProvider.swift */, - 6DC5643A28B8D189003D540B /* KeychainAccessAdapter.swift */, 6DC2EDFD2B1198FC00298F00 /* KeychainService.swift */, ); path = Keychain; @@ -2046,7 +2069,8 @@ 6DC5643E28B929E0003D540B /* KeyDetails */ = { isa = PBXGroup; children = ( - 6D5DCA012A7CFA1E0050B101 /* ExportKeys */, + 6D53387B2B7E512000F37EB1 /* BananaSplit */, + 6D88CFF628C634BC001FB0A1 /* Modals */, 6DA501CA290A47930096DA4E /* Views */, 6DE8466828BF6E9B0051346A /* Models */, ); @@ -2081,9 +2105,46 @@ path = Alerts; sourceTree = ""; }; + 6DDD01D02B95049D000F53B3 /* Seeds */ = { + isa = PBXGroup; + children = ( + 6DAFCAF92B0AE5C000DDD165 /* KeychainSeedsAccessAdapterTests.swift */, + 6DDD38B12B11C3C2000D2B62 /* SeedsMediatorTests.swift */, + 6D8AF88928BCC60600CF0AB2 /* KeychainSeedsQueryProviderTests.swift */, + ); + path = Seeds; + sourceTree = ""; + }; + 6DDD01D12B9504C0000F53B3 /* BananaSplit */ = { + isa = PBXGroup; + children = ( + 6DDD01D22B9504D1000F53B3 /* KeychainBananaSplitQueryProviderTests.swift */, + ); + path = BananaSplit; + sourceTree = ""; + }; + 6DDD01D42B958361000F53B3 /* KeyDetails */ = { + isa = PBXGroup; + children = ( + 6DDD01D52B958365000F53B3 /* BananaSplit */, + ); + path = KeyDetails; + sourceTree = ""; + }; + 6DDD01D52B958365000F53B3 /* BananaSplit */ = { + isa = PBXGroup; + children = ( + 6DDD01D62B958372000F53B3 /* BananaSplitPassphraseModalViewModelTests.swift */, + 6DDD01D82B958845000F53B3 /* BananaSplitActionModalViewModelTests.swift */, + 6DDD01DA2B9589B5000F53B3 /* BananaSplitQRCodeModalViewModelTests.swift */, + ); + path = BananaSplit; + sourceTree = ""; + }; 6DDD38B42B1346BB000D2B62 /* Screens */ = { isa = PBXGroup; children = ( + 6DDD01D42B958361000F53B3 /* KeyDetails */, 6D9856622B715632002358D3 /* DerivedKey */, 6D98564E2B6A6A61002358D3 /* Onboarding */, 6D9856452B6A6227002358D3 /* Errors */, @@ -2129,6 +2190,7 @@ 6DF8316628F9BA4A00CB2BCE /* CapsuleButton.swift */, 6D2A5D102AA607C7009E0C3A /* ActionSheetCircleButton.swift */, 6D699FB42AA9CC7A0043B23A /* QRCodeButton.swift */, + 6D5338772B7D7E9300F37EB1 /* IconButton.swift */, ); path = Buttons; sourceTree = ""; @@ -2273,6 +2335,25 @@ path = SetUpNetworks; sourceTree = ""; }; + 6DF310A42B8CBAB900A38205 /* Seeds */ = { + isa = PBXGroup; + children = ( + 6DC5643828B7DED8003D540B /* KeychainSeedsQueryProvider.swift */, + 6DC5643628B79EC6003D540B /* SeedsMediator.swift */, + 6DC5643A28B8D189003D540B /* KeychainSeedsAccessAdapter.swift */, + ); + path = Seeds; + sourceTree = ""; + }; + 6DF310A52B8CBC0800A38205 /* BananaSplit */ = { + isa = PBXGroup; + children = ( + 6DF310A62B8CBC1A00A38205 /* KeychainBananaSplitQueryProvider.swift */, + 6DF310AA2B8E150100A38205 /* KeychainBananaSplitMediator.swift */, + ); + path = BananaSplit; + sourceTree = ""; + }; 6DF3CFEA29936D33002DF203 /* CreateKey */ = { isa = PBXGroup; children = ( @@ -2610,6 +2691,7 @@ 6D88CFF228C60AED001FB0A1 /* FullScreenRoundedModal.swift in Sources */, 6DD9FF1928C8C9B000FB6195 /* Animations.swift in Sources */, 2DA5F85E27566C3600D8DD29 /* TCFieldName.swift in Sources */, + 6D5338762B7D306700F37EB1 /* BananaSplitModal.swift in Sources */, 6DF9A13B2983B0ED00B31B6D /* DevicePincodeRequiredView.swift in Sources */, 6D57DC4D289D652400005C63 /* Dispatching.swift in Sources */, 2D48F35127609CDE004B27BE /* HistoryCard.swift in Sources */, @@ -2630,7 +2712,7 @@ 6D31E7C22A404B4900BF9D9B /* AddKeysForNetworkModal.swift in Sources */, 6DA2ACAA2939E85700AAEADC /* Event+EventTitle.swift in Sources */, 6D01997E289D238700F4C317 /* Localizable.swift in Sources */, - 6DC5643B28B8D189003D540B /* KeychainAccessAdapter.swift in Sources */, + 6DC5643B28B8D189003D540B /* KeychainSeedsAccessAdapter.swift in Sources */, 6D10EACD297114550063FB71 /* DerivationPathComponents.swift in Sources */, 6D33EE332A9F4825005F3827 /* KeyDetailsView+MainList.swift in Sources */, 6DEFB53228FEE42D00762219 /* ExportKeySetService.swift in Sources */, @@ -2720,6 +2802,7 @@ 6D019968289C937600F4C317 /* AuthenticatedScreenContainer.swift in Sources */, 6D16685728F530F4008C664A /* CaptureDeviceConfigurator.swift in Sources */, 6D5DCA032A7CFA2E0050B101 /* ExportKeysSelectionModal.swift in Sources */, + 6D53387D2B7E512C00F37EB1 /* BananaSplitActionModal.swift in Sources */, 6D8045D928D0761E00237F8C /* QRCodeAddressFooterView.swift in Sources */, 2DA5F8332756653B00D8DD29 /* CameraPreview.swift in Sources */, 6DDD737A29404E5000F04CE7 /* Event+EntryType.swift in Sources */, @@ -2747,6 +2830,7 @@ 6D16687D28F84953008C664A /* EnvironmentValues+SafeAreaInsets.swift in Sources */, 6D6DF33429F65A0B00FC06AD /* KeyDetailsActionService.swift in Sources */, 6DA08B8629AC88D50027CFCB /* WrappingHStack.swift in Sources */, + 6D5338852B865F4500F37EB1 /* BananaSplitPassphraseModal.swift in Sources */, 6D6430F728CB662500342E37 /* ExportPrivateKeyWarningModal.swift in Sources */, 6DAA6CB329BF7155002329A8 /* OnboardingMediator.swift in Sources */, 6D52E3AA2946F58200AD72F0 /* VerifierCertificateView.swift in Sources */, @@ -2763,6 +2847,7 @@ 6DF714712A55652900F6A527 /* DynamicDerivationsService.swift in Sources */, 6D7B44FC2979263200111D0E /* TermsOfServiceView.swift in Sources */, 6D52E3A32946C1B500AD72F0 /* SettingsRowView.swift in Sources */, + 6D53387A2B7E4CC400F37EB1 /* BananaSplitQRCodeModal.swift in Sources */, 6D2A5D112AA607C7009E0C3A /* ActionSheetCircleButton.swift in Sources */, 6D16687F28F86DE0008C664A /* CameraButton.swift in Sources */, 6DBBC1F2298CB09C00368638 /* NetworkIdenticon.swift in Sources */, @@ -2778,6 +2863,7 @@ 6D0677AC29BB0C6000D76D90 /* AppLaunchMediator.swift in Sources */, 6D88CFF028C60815001FB0A1 /* CircleButton.swift in Sources */, 6DA501CC290A48190096DA4E /* KeyDetails+ViewModel.swift in Sources */, + 6D5338782B7D7E9300F37EB1 /* IconButton.swift in Sources */, 6DA2ACAE2939EAC300AAEADC /* Event+isImportant.swift in Sources */, 6D8973A52A08E18C0046A2F3 /* ScanTabService.swift in Sources */, 2DA5F84327566BE300D8DD29 /* TransactionCardSelector.swift in Sources */, @@ -2832,6 +2918,7 @@ 6D10EAC9296FA8910063FB71 /* Localizable+Formatted.swift in Sources */, 6D17EF8628EEEDDA008626E9 /* CameraPreviewUIView.swift in Sources */, 6DA501D4290BC55A0096DA4E /* KeyDetailsService.swift in Sources */, + 6DF310A72B8CBC1A00A38205 /* KeychainBananaSplitQueryProvider.swift in Sources */, 6D0BF95229F2BBF500F5B569 /* NetworkIconCapsuleView.swift in Sources */, 6D6430F128CB32CC00342E37 /* Snackbar.swift in Sources */, 6DB9903D2962A619001101DC /* MTransaction+ImportDerivedKeys.swift in Sources */, @@ -2840,7 +2927,7 @@ 6DBF6E4328EACB7B00CC959F /* ConnectivityMediator.swift in Sources */, 6D36CBBA2A55834B0001BB31 /* CreateDerivedKeyNameService.swift in Sources */, 6D7B44FE2979298700111D0E /* PrivacyPolicyView.swift in Sources */, - 6DC5643928B7DED8003D540B /* KeychainQueryProvider.swift in Sources */, + 6DC5643928B7DED8003D540B /* KeychainSeedsQueryProvider.swift in Sources */, 6D52E3AF2946FF5400AD72F0 /* NetworkSelectionModal.swift in Sources */, 6D5FDDCC2977D08E0076C1C4 /* LogNoteModal.swift in Sources */, 6DF91F3E29C06B5F000A6BB2 /* Verifier+Show.swift in Sources */, @@ -2848,6 +2935,7 @@ 6D77F31F296D0C5600044C7C /* CreateKeyNetworkSelectionView.swift in Sources */, 6DC909F629C87C8C00AE6BAD /* LogsService.swift in Sources */, 6DC5643728B79EC6003D540B /* SeedsMediator.swift in Sources */, + 6DF310AB2B8E150100A38205 /* KeychainBananaSplitMediator.swift in Sources */, 6D042AF22901B3FB00B3F4F7 /* QRCodeImageGenerator.swift in Sources */, 6D95E97528B500EE00E28A11 /* Heights.swift in Sources */, 6D8045DE28D087D400237F8C /* QRCodeRootFooterView.swift in Sources */, @@ -2904,7 +2992,8 @@ 6DE48E932B1F0B96003094D5 /* AutoMockable+W.generated.swift in Sources */, 6DE48E2E2B1EB97C003094D5 /* AutoMockableHeader.swift in Sources */, 6DE48E902B1F0B96003094D5 /* AutoMockable+A.generated.swift in Sources */, - 6D8AF88A28BCC60600CF0AB2 /* KeychainQueryProviderTests.swift in Sources */, + 6DDD01DB2B9589B5000F53B3 /* BananaSplitQRCodeModalViewModelTests.swift in Sources */, + 6D8AF88A28BCC60600CF0AB2 /* KeychainSeedsQueryProviderTests.swift in Sources */, 6DB2E7C12B4BBAF7002387DE /* SettingsViewModelTests.swift in Sources */, 6D9856542B6A6B03002358D3 /* AirgapComponentTests.swift in Sources */, 6DDD38B22B11C3C2000D2B62 /* SeedsMediatorTests.swift in Sources */, @@ -2930,11 +3019,13 @@ 6D9856642B715643002358D3 /* CreateKeyNetworkSelectionViewModelTests.swift in Sources */, 6DE48E7F2B1F0B96003094D5 /* AutoMockable+Z.generated.swift in Sources */, 6D80EB572B4EB117009C544B /* SignSpecsListViewModelTests.swift in Sources */, + 6DDD01D32B9504D1000F53B3 /* KeychainBananaSplitQueryProviderTests.swift in Sources */, 6DAFCAF82B0A360600DDD165 /* CameraPermissionHandlerTests.swift in Sources */, 6DE48E952B1F0B96003094D5 /* AutoMockable+Q.generated.swift in Sources */, 6DE48E802B1F0B96003094D5 /* AutoMockable+P.generated.swift in Sources */, 6D9856732B716564002358D3 /* CreateDerivedKeyConfirmationViewModelTests.swift in Sources */, 6D80EB502B4EAD3E009C544B /* VerifierCertificateViewModelTests.swift in Sources */, + 6DDD01D72B958372000F53B3 /* BananaSplitPassphraseModalViewModelTests.swift in Sources */, 6D2C78B02B56EF55006431E3 /* SettingsBackupModalViewModelTests.swift in Sources */, 6D80EB522B4EB0B8009C544B /* MSufficientCryptoReady+Generate.swift in Sources */, 6DE48E8C2B1F0B96003094D5 /* AutoMockable+R.generated.swift in Sources */, @@ -2955,7 +3046,8 @@ 6DE48E972B1F0B96003094D5 /* AutoMockable+L.generated.swift in Sources */, 6D98566D2B716112002358D3 /* DerivationPathNameViewModelTests.swift in Sources */, 6DE48E852B1F0B96003094D5 /* AutoMockable+U.generated.swift in Sources */, - 6DAFCAFA2B0AE5C000DDD165 /* KeychainAccessAdapterTests.swift in Sources */, + 6DDD01D92B958845000F53B3 /* BananaSplitActionModalViewModelTests.swift in Sources */, + 6DAFCAFA2B0AE5C000DDD165 /* KeychainSeedsAccessAdapterTests.swift in Sources */, 6DB2E7CE2B4BC7F6002387DE /* NetworkSettingDetailsViewModelTests.swift in Sources */, 6DAB52E92B5E718D005FDBA8 /* LogsListViewModelTests.swift in Sources */, ); @@ -3041,6 +3133,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.8.1; + MACOSX_DEPLOYMENT_TARGET = ""; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -3099,6 +3192,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.8.1; + MACOSX_DEPLOYMENT_TARGET = ""; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -3126,6 +3220,7 @@ INFOPLIST_FILE = PolkadotVault/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.8.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3161,6 +3256,7 @@ INFOPLIST_FILE = PolkadotVault/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.8.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3276,6 +3372,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.8.1; + MACOSX_DEPLOYMENT_TARGET = ""; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -3302,6 +3399,7 @@ INFOPLIST_FILE = PolkadotVault/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.8.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/PolkadotVault/Backend/Services/BananaSplitService.swift b/ios/PolkadotVault/Backend/Services/BananaSplitService.swift index f6c0855fa3..c8fb6f6dac 100644 --- a/ios/PolkadotVault/Backend/Services/BananaSplitService.swift +++ b/ios/PolkadotVault/Backend/Services/BananaSplitService.swift @@ -7,6 +7,10 @@ import Foundation +struct BananaSplitBackup: Equatable, Codable { + let qrCodes: [[UInt8]] +} + // sourcery: AutoMockable protocol BananaSplitServicing: AnyObject { func encrypt( @@ -15,7 +19,7 @@ protocol BananaSplitServicing: AnyObject { passphrase: String, totalShards: UInt32, requiredShards: UInt32, - _ completion: @escaping (Result<[QrData], ServiceError>) -> Void + _ completion: @escaping (Result) -> Void ) func generatePassphrase( with words: UInt32, @@ -40,16 +44,17 @@ final class BananaSplitService { passphrase: String, totalShards: UInt32, requiredShards: UInt32, - _ completion: @escaping (Result<[QrData], ServiceError>) -> Void + _ completion: @escaping (Result) -> Void ) { backendService.performCall({ - try bsEncrypt( + let qrCodes = try bsEncrypt( secret: secret, title: title, passphrase: passphrase, totalShards: totalShards, requiredShards: requiredShards ) + return BananaSplitBackup(qrCodes: qrCodes.map(\.payload)) }, completion: completion) } diff --git a/ios/PolkadotVault/Components/Buttons/CircleButton.swift b/ios/PolkadotVault/Components/Buttons/CircleButton.swift index 4b8c92d58e..5238930b95 100644 --- a/ios/PolkadotVault/Components/Buttons/CircleButton.swift +++ b/ios/PolkadotVault/Components/Buttons/CircleButton.swift @@ -23,7 +23,7 @@ struct CloseModalButton: View { ZStack { Circle() .frame(width: Sizes.xmarkButtonDiameter, height: Sizes.xmarkButtonDiameter, alignment: .center) - .foregroundColor(.fill6) + .foregroundColor(.fill18) Image(.xmarkButton) .foregroundColor(.textAndIconsPrimary) } diff --git a/ios/PolkadotVault/Components/Buttons/IconButton.swift b/ios/PolkadotVault/Components/Buttons/IconButton.swift new file mode 100644 index 0000000000..20991f765e --- /dev/null +++ b/ios/PolkadotVault/Components/Buttons/IconButton.swift @@ -0,0 +1,58 @@ +// +// IconButton.swift +// PolkadotVault +// +// Created by Krzysztof Rodak on 15/02/2024. +// + +import SwiftUI + +struct IconButtonStyle: ButtonStyle { + func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .padding(Spacing.medium) + .foregroundColor(.textAndIconsSecondary) + .frame( + height: Heights.iconButton, + alignment: .center + ) + } +} + +struct IconButton: View { + private let action: () -> Void + private let icon: ImageResource + + init( + action: @escaping () -> Void, + icon: ImageResource + ) { + self.action = action + self.icon = icon + } + + var body: some View { + Button(action: action) { + HStack { + Image(icon) + } + } + .buttonStyle(IconButtonStyle()) + } +} + +#if DEBUG + struct IconButton_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading, spacing: 10) { + IconButton( + action: {}, + icon: .refreshPassphrase + ) + } + .padding() + .preferredColorScheme(.dark) + .previewLayout(.sizeThatFits) + } + } +#endif diff --git a/ios/PolkadotVault/Components/Text/ActionableInfoBoxView.swift b/ios/PolkadotVault/Components/Text/ActionableInfoBoxView.swift index 81df528f7b..c7024ac588 100644 --- a/ios/PolkadotVault/Components/Text/ActionableInfoBoxView.swift +++ b/ios/PolkadotVault/Components/Text/ActionableInfoBoxView.swift @@ -24,10 +24,11 @@ struct ActionableInfoBoxView: View { VStack(alignment: .leading, spacing: Spacing.medium) { HStack { Text(renderable.text) - .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) .foregroundColor(.textAndIconsPrimary) .font(PrimaryFont.bodyM.font) - .fixedSize(horizontal: false, vertical: true) Spacer().frame(maxWidth: Spacing.medium) Image(.infoIconBold) .foregroundColor(.accentPink300) @@ -44,8 +45,8 @@ struct ActionableInfoBoxView: View { .onTapGesture { action.action() } } } - .padding(Spacing.medium) + .frame(maxWidth: .infinity) .containerBackground(CornerRadius.small, state: .actionableInfo) } } diff --git a/ios/PolkadotVault/Components/Text/AttributedInfoBoxView.swift b/ios/PolkadotVault/Components/Text/AttributedInfoBoxView.swift index 5cf8a916ca..ee26e44686 100644 --- a/ios/PolkadotVault/Components/Text/AttributedInfoBoxView.swift +++ b/ios/PolkadotVault/Components/Text/AttributedInfoBoxView.swift @@ -13,12 +13,15 @@ struct AttributedInfoBoxView: View { var body: some View { HStack { Text(text) - .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) Spacer().frame(maxWidth: Spacing.medium) Image(.helpOutline) .foregroundColor(.accentPink300) } .padding() + .frame(maxWidth: .infinity) .font(PrimaryFont.bodyM.font) .background( RoundedRectangle(cornerRadius: CornerRadius.small) diff --git a/ios/PolkadotVault/Components/Text/AttributedTintInfoBox.swift b/ios/PolkadotVault/Components/Text/AttributedTintInfoBox.swift index a506cd7b49..679a311650 100644 --- a/ios/PolkadotVault/Components/Text/AttributedTintInfoBox.swift +++ b/ios/PolkadotVault/Components/Text/AttributedTintInfoBox.swift @@ -13,12 +13,15 @@ struct AttributedTintInfoBox: View { var body: some View { HStack { Text(text) - .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) Spacer().frame(width: Spacing.large) Image(.helpOutline) .foregroundColor(.accentPink300) } .padding(Spacing.medium) + .frame(maxWidth: .infinity) .font(PrimaryFont.bodyM.font) .background( RoundedRectangle(cornerRadius: CornerRadius.medium) diff --git a/ios/PolkadotVault/Components/Text/InfoBoxView.swift b/ios/PolkadotVault/Components/Text/InfoBoxView.swift index 7513655944..9e5c72ed86 100644 --- a/ios/PolkadotVault/Components/Text/InfoBoxView.swift +++ b/ios/PolkadotVault/Components/Text/InfoBoxView.swift @@ -13,7 +13,8 @@ struct InfoBoxView: View { var body: some View { HStack { Text(text) - .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(nil) + .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) .foregroundColor(.textAndIconsTertiary) Spacer().frame(maxWidth: Spacing.medium) @@ -21,6 +22,7 @@ struct InfoBoxView: View { .foregroundColor(.accentPink300) } .padding() + .frame(maxWidth: .infinity) .font(PrimaryFont.bodyM.font) .strokeContainerBackground(CornerRadius.small) } diff --git a/ios/PolkadotVault/Components/TextFields/PrimaryTextField.swift b/ios/PolkadotVault/Components/TextFields/PrimaryTextField.swift index 2c2011e263..e9b545362c 100644 --- a/ios/PolkadotVault/Components/TextFields/PrimaryTextField.swift +++ b/ios/PolkadotVault/Components/TextFields/PrimaryTextField.swift @@ -9,6 +9,7 @@ import SwiftUI struct PrimaryTextFieldStyle: ViewModifier { let placeholder: String + let keyboardType: UIKeyboardType @Binding var text: String @Binding var isValid: Bool @@ -19,7 +20,7 @@ struct PrimaryTextFieldStyle: ViewModifier { .font(PrimaryFont.bodyL.font) .autocapitalization(.none) .disableAutocorrection(true) - .keyboardType(.asciiCapable) + .keyboardType(keyboardType) .submitLabel(.return) .frame(height: Heights.textFieldHeight) .padding(.horizontal, Spacing.medium) @@ -35,9 +36,15 @@ struct PrimaryTextFieldStyle: ViewModifier { extension View { func primaryTextFieldStyle( _ placeholder: String, + keyboardType: UIKeyboardType = .asciiCapable, text: Binding, isValid: Binding = Binding.constant(true) ) -> some View { - modifier(PrimaryTextFieldStyle(placeholder: placeholder, text: text, isValid: isValid)) + modifier(PrimaryTextFieldStyle( + placeholder: placeholder, + keyboardType: keyboardType, + text: text, + isValid: isValid + )) } } diff --git a/ios/PolkadotVault/Core/Keychain/BananaSplit/KeychainBananaSplitQueryProvider.swift b/ios/PolkadotVault/Core/Keychain/BananaSplit/KeychainBananaSplitQueryProvider.swift new file mode 100644 index 0000000000..e6bc330c92 --- /dev/null +++ b/ios/PolkadotVault/Core/Keychain/BananaSplit/KeychainBananaSplitQueryProvider.swift @@ -0,0 +1,102 @@ +// +// KeychainBananaSplitQueryProvider.swift +// Polkadot Vault +// +// Created by Krzysztof Rodak on 26/02/2024. +// + +import Foundation + +struct BananaSplitPassphrase: Codable, Equatable { + let passphrase: String +} + +enum KeychainBananaSplitQuery { + case fetch(seedName: String) + case check(seedName: String) + case delete(seedName: String) + case save(seedName: String, bananaSplit: BananaSplitBackup) +} + +enum KeychainBananaSplitPassphraseQuery { + case fetch(seedName: String) + case delete(seedName: String) + case save(seedName: String, passphrase: BananaSplitPassphrase, accessControl: SecAccessControl) +} + +// sourcery: AutoMockable +protocol KeychainBananaSplitQueryProviding: AnyObject { + func query(for queryType: KeychainBananaSplitQuery) -> CFDictionary + func passhpraseQuery(for queryType: KeychainBananaSplitPassphraseQuery) -> CFDictionary +} + +final class KeychainBananaSplitQueryProvider: KeychainBananaSplitQueryProviding { + enum Constants { + static let bananaSplitSuffix = "_bananaSplit" + static let passphraseSuffix = "_passphrase" + } + + private let jsonEncoder: JSONEncoder + + init(jsonEncoder: JSONEncoder = JSONEncoder()) { + self.jsonEncoder = jsonEncoder + } + + func query(for queryType: KeychainBananaSplitQuery) -> CFDictionary { + var dictionary: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword + ] + switch queryType { + case let .fetch(seedName): + dictionary[kSecMatchLimit] = kSecMatchLimitOne + dictionary[kSecAttrAccount] = backupName(seedName) + dictionary[kSecReturnAttributes] = false + dictionary[kSecReturnData] = true + case let .check(seedName): + dictionary[kSecMatchLimit] = kSecMatchLimitOne + dictionary[kSecAttrAccount] = backupName(seedName) + dictionary[kSecReturnData] = false + case let .delete(seedName): + dictionary[kSecAttrAccount] = backupName(seedName) + case let .save(seedName, bananaSplit): + dictionary[kSecAttrAccount] = backupName(seedName) + if let data = try? jsonEncoder.encode(bananaSplit) { + dictionary[kSecValueData] = data + } + dictionary[kSecReturnData] = false + } + return dictionary as CFDictionary + } + + func passhpraseQuery(for queryType: KeychainBananaSplitPassphraseQuery) -> CFDictionary { + var dictionary: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword + ] + switch queryType { + case let .fetch(seedName): + dictionary[kSecMatchLimit] = kSecMatchLimitOne + dictionary[kSecAttrAccount] = passphraseName(seedName) + dictionary[kSecReturnAttributes] = false + dictionary[kSecReturnData] = true + case let .delete(seedName): + dictionary[kSecAttrAccount] = passphraseName(seedName) + case let .save(seedName, passphrase, accessControl): + dictionary[kSecAttrAccessControl] = accessControl + dictionary[kSecAttrAccount] = passphraseName(seedName) + if let data = try? jsonEncoder.encode(passphrase) { + dictionary[kSecValueData] = data + } + + dictionary[kSecReturnData] = false + } + return dictionary as CFDictionary + } + + private func backupName(_ seedName: String) -> String { + seedName + Constants.bananaSplitSuffix + } + + private func passphraseName(_ seedName: String) -> String { + seedName + Constants.passphraseSuffix + } +} diff --git a/ios/PolkadotVault/Core/Keychain/Seeds/KeychainBananaSplitMediator.swift b/ios/PolkadotVault/Core/Keychain/Seeds/KeychainBananaSplitMediator.swift new file mode 100644 index 0000000000..1c3d223304 --- /dev/null +++ b/ios/PolkadotVault/Core/Keychain/Seeds/KeychainBananaSplitMediator.swift @@ -0,0 +1,135 @@ +// +// KeychainBananaSplitMediator.swift +// Polkadot Vault +// +// Created by Krzysztof Rodak on 27/02/2024. +// + +import Foundation + +// sourcery: AutoMockable +protocol KeychainBananaSplitAccessMediating: AnyObject { + func saveBananaSplit( + with seedName: String, + bananaSplitBackup: BananaSplitBackup, + passphrase: BananaSplitPassphrase + ) -> Result + func retrieveBananaSplit(with seedName: String) -> Result + func retrieveBananaSplitPassphrase(with seedName: String) -> Result + func removeBananaSplitBackup(seedName: String) -> Result + func checkIfBananaSplitAlreadyExists(seedName: String) -> Result +} + +final class KeychainBananaSplitMediator: KeychainBananaSplitAccessMediating { + private let keychainService: KeychainServicing + private let queryProvider: KeychainBananaSplitQueryProviding + private let acccessControlProvider: AccessControlProviding + private let jsonDecoder: JSONDecoder + + init( + keychainService: KeychainServicing = KeychainService(), + acccessControlProvider: AccessControlProviding = AccessControlProvidingAssembler().assemble(), + queryProvider: KeychainBananaSplitQueryProviding = KeychainBananaSplitQueryProvider(), + jsonDecoder: JSONDecoder = JSONDecoder() + ) { + self.keychainService = keychainService + self.acccessControlProvider = acccessControlProvider + self.queryProvider = queryProvider + self.jsonDecoder = jsonDecoder + } + + func saveBananaSplit( + with seedName: String, + bananaSplitBackup: BananaSplitBackup, + passphrase: BananaSplitPassphrase + ) -> Result { + do { + let accessControl = try acccessControlProvider.accessControl() + var query = queryProvider.query( + for: .save(seedName: seedName, bananaSplit: bananaSplitBackup) + ) + var osStatus = keychainService.add(query, nil) + if osStatus != errSecSuccess { + let message = SecCopyErrorMessageString(osStatus, nil) as? String ?? "" + return .failure(.saveError(message: message)) + } + + query = queryProvider.passhpraseQuery( + for: KeychainBananaSplitPassphraseQuery.save( + seedName: seedName, + passphrase: passphrase, + accessControl: accessControl + ) + ) + osStatus = keychainService.add(query, nil) + if osStatus != errSecSuccess { + let message = SecCopyErrorMessageString(osStatus, nil) as? String ?? "" + return .failure(.saveError(message: message)) + } + return .success(()) + } catch { + return .failure(.accessControlNotAvailable) + } + } + + func retrieveBananaSplit(with seedName: String) -> Result { + var item: CFTypeRef? + let query = queryProvider.query(for: KeychainBananaSplitQuery.fetch(seedName: seedName)) + let osStatus = keychainService.copyMatching(query, &item) + if osStatus == errSecSuccess, let itemAsData = item as? Data { + do { + let result = try jsonDecoder.decode(BananaSplitBackup.self, from: itemAsData) + return .success(result) + } catch { + return .failure(.dataDecodingError) + } + } + return .failure(.fetchError) + } + + func retrieveBananaSplitPassphrase(with seedName: String) -> Result { + var item: CFTypeRef? + let query = queryProvider.passhpraseQuery(for: KeychainBananaSplitPassphraseQuery.fetch(seedName: seedName)) + let osStatus = keychainService.copyMatching(query, &item) + if osStatus == errSecSuccess, let itemAsData = item as? Data { + do { + let result = try jsonDecoder.decode(BananaSplitPassphrase.self, from: itemAsData) + return .success(result) + } catch { + return .failure(.dataDecodingError) + } + } + return .failure(.fetchError) + } + + func removeBananaSplitBackup(seedName: String) -> Result { + let bananaSplitQuery = queryProvider.query(for: KeychainBananaSplitQuery.delete(seedName: seedName)) + var osStatus = keychainService.delete(bananaSplitQuery) + if osStatus != errSecSuccess { + let errorMessage = SecCopyErrorMessageString(osStatus, nil) as? String ?? "" + return .failure(.deleteError(message: errorMessage)) + } + let passphraseQuery = queryProvider + .passhpraseQuery(for: KeychainBananaSplitPassphraseQuery.delete(seedName: seedName)) + osStatus = keychainService.delete(passphraseQuery) + if osStatus != errSecSuccess { + let errorMessage = SecCopyErrorMessageString(osStatus, nil) as? String ?? "" + return .failure(.deleteError(message: errorMessage)) + } + return .success(()) + } + + func checkIfBananaSplitAlreadyExists(seedName: String) -> Result { + let query = queryProvider.query(for: .check(seedName: seedName)) + var queryResult: AnyObject? + let osStatus = keychainService.copyMatching(query, &queryResult) + switch osStatus { + case errSecItemNotFound: + return .success(false) + case errSecSuccess: + return .success(true) + default: + return .failure(.checkError) + } + } +} diff --git a/ios/PolkadotVault/Core/Keychain/KeychainAccessAdapter.swift b/ios/PolkadotVault/Core/Keychain/Seeds/KeychainSeedsAccessAdapter.swift similarity index 93% rename from ios/PolkadotVault/Core/Keychain/KeychainAccessAdapter.swift rename to ios/PolkadotVault/Core/Keychain/Seeds/KeychainSeedsAccessAdapter.swift index 875224e6a5..bbb411afff 100644 --- a/ios/PolkadotVault/Core/Keychain/KeychainAccessAdapter.swift +++ b/ios/PolkadotVault/Core/Keychain/Seeds/KeychainSeedsAccessAdapter.swift @@ -1,5 +1,5 @@ // -// KeychainAccessAdapter.swift +// KeychainSeedsAccessAdapter.swift // Polkadot Vault // // Created by Krzysztof Rodak on 26/08/2022. @@ -12,7 +12,7 @@ struct FetchSeedsPayload { } /// Protocol that provides access to Keychain's C-like API using modern approach -protocol KeychainAccessAdapting: AnyObject { +protocol KeychainSeedsAccessAdapting: AnyObject { /// Attempts to fetch list of seeds name from Keychain /// - Returns: closure with `.success` and requested `FetchSeedsPayload` /// otherwise `.failure` with `KeychainError` @@ -45,15 +45,15 @@ protocol KeychainAccessAdapting: AnyObject { func removeAllSeeds() -> Bool } -final class KeychainAccessAdapter: KeychainAccessAdapting { +final class KeychainSeedsAccessAdapter: KeychainSeedsAccessAdapting { private let keychainService: KeychainServicing - private let queryProvider: KeychainQueryProviding + private let queryProvider: KeychainSeedsQueryProviding private let acccessControlProvider: AccessControlProviding init( keychainService: KeychainServicing = KeychainService(), acccessControlProvider: AccessControlProviding = AccessControlProvidingAssembler().assemble(), - queryProvider: KeychainQueryProviding = KeychainQueryProvider() + queryProvider: KeychainSeedsQueryProviding = KeychainSeedsQueryProvider() ) { self.keychainService = keychainService self.acccessControlProvider = acccessControlProvider @@ -69,6 +69,10 @@ final class KeychainAccessAdapter: KeychainAccessAdapting { let seedNames = resultAsItems .compactMap { seed in seed[kSecAttrAccount as String] as? String } .sorted() + .filter { + !$0.hasSuffix(KeychainBananaSplitQueryProvider.Constants.bananaSplitSuffix) + && !$0.hasSuffix(KeychainBananaSplitQueryProvider.Constants.passphraseSuffix) + } return .success(FetchSeedsPayload(seeds: seedNames)) } // Keychain returned success but no data diff --git a/ios/PolkadotVault/Core/Keychain/KeychainQueryProvider.swift b/ios/PolkadotVault/Core/Keychain/Seeds/KeychainSeedsQueryProvider.swift similarity index 87% rename from ios/PolkadotVault/Core/Keychain/KeychainQueryProvider.swift rename to ios/PolkadotVault/Core/Keychain/Seeds/KeychainSeedsQueryProvider.swift index e0c47ebbbd..d236e4daf9 100644 --- a/ios/PolkadotVault/Core/Keychain/KeychainQueryProvider.swift +++ b/ios/PolkadotVault/Core/Keychain/Seeds/KeychainSeedsQueryProvider.swift @@ -1,5 +1,5 @@ // -// KeychainQueryProvider.swift +// KeychainSeedsQueryProvider.swift // Polkadot Vault // // Created by Krzysztof Rodak on 26/08/2022. @@ -8,7 +8,7 @@ import Foundation /// Available queries for accessing Keychain -enum KeychainQuery { +enum KeychainSeedsQuery { case fetch case fetchWithData case check @@ -20,15 +20,15 @@ enum KeychainQuery { // sourcery: AutoMockable /// Protocol that provides access to query payload -protocol KeychainQueryProviding: AnyObject { +protocol KeychainSeedsQueryProviding: AnyObject { /// Generates payload query for given query type with given input /// - Parameter queryType: query type and payload if needed /// - Returns: query payload as dictionary that can be used in Keychain querying - func query(for queryType: KeychainQuery) -> CFDictionary + func query(for queryType: KeychainSeedsQuery) -> CFDictionary } -final class KeychainQueryProvider: KeychainQueryProviding { - func query(for queryType: KeychainQuery) -> CFDictionary { +final class KeychainSeedsQueryProvider: KeychainSeedsQueryProviding { + func query(for queryType: KeychainSeedsQuery) -> CFDictionary { var dictionary: [CFString: Any] = [ kSecClass: kSecClassGenericPassword ] diff --git a/ios/PolkadotVault/Core/Keychain/SeedsMediator.swift b/ios/PolkadotVault/Core/Keychain/Seeds/SeedsMediator.swift similarity index 95% rename from ios/PolkadotVault/Core/Keychain/SeedsMediator.swift rename to ios/PolkadotVault/Core/Keychain/Seeds/SeedsMediator.swift index 201fcc9de3..5d02cbfa4f 100644 --- a/ios/PolkadotVault/Core/Keychain/SeedsMediator.swift +++ b/ios/PolkadotVault/Core/Keychain/Seeds/SeedsMediator.swift @@ -11,6 +11,7 @@ import Foundation enum KeychainError: Error, Equatable { case fetchError case checkError + case dataDecodingError case saveError(message: String) case deleteError(message: String) case accessControlNotAvailable @@ -62,8 +63,8 @@ protocol SeedsMediating: AnyObject { /// As this class contains logic related to UI state and data handling, /// it should not interact with Keychain directly, but through injected dependencies final class SeedsMediator: SeedsMediating { - private let queryProvider: KeychainQueryProviding - private let keychainAccessAdapter: KeychainAccessAdapting + private let queryProvider: KeychainSeedsQueryProviding + private let keychainAccessAdapter: KeychainSeedsAccessAdapting private let databaseMediator: DatabaseMediating private let authenticationStateMediator: AuthenticatedStateMediator var seedNamesSubject = CurrentValueSubject<[String], Never>([]) @@ -76,8 +77,8 @@ final class SeedsMediator: SeedsMediating { } init( - queryProvider: KeychainQueryProviding = KeychainQueryProvider(), - keychainAccessAdapter: KeychainAccessAdapting = KeychainAccessAdapter(), + queryProvider: KeychainSeedsQueryProviding = KeychainSeedsQueryProvider(), + keychainAccessAdapter: KeychainSeedsAccessAdapting = KeychainSeedsAccessAdapter(), databaseMediator: DatabaseMediating = DatabaseMediator(), authenticationStateMediator: AuthenticatedStateMediator = ServiceLocator.authenticationStateMediator ) { diff --git a/ios/PolkadotVault/Core/Runtime/RuntimePropertiesProvider.swift b/ios/PolkadotVault/Core/Runtime/RuntimePropertiesProvider.swift index 812c4b683a..88c257301f 100644 --- a/ios/PolkadotVault/Core/Runtime/RuntimePropertiesProvider.swift +++ b/ios/PolkadotVault/Core/Runtime/RuntimePropertiesProvider.swift @@ -22,6 +22,12 @@ protocol RuntimePropertiesProviding: AnyObject { /// Wrapper for accessing `RuntimeProperties` and other application runtime values final class RuntimePropertiesProvider: RuntimePropertiesProviding { + enum Properties: String, CustomStringConvertible { + case testConfiguration = "XCTestConfigurationFilePath" + + var description: String { rawValue } + } + private enum PropertiesValues: String, CustomStringConvertible { case `true` case `false` @@ -30,11 +36,14 @@ final class RuntimePropertiesProvider: RuntimePropertiesProviding { } private let appInformationContainer: ApplicationInformationContaining.Type + private let processInfo: ProcessInfoProtocol init( - appInformationContainer: ApplicationInformationContaining.Type = ApplicationInformation.self + appInformationContainer: ApplicationInformationContaining.Type = ApplicationInformation.self, + processInfo: ProcessInfoProtocol = ProcessInfo.processInfo ) { self.appInformationContainer = appInformationContainer + self.processInfo = processInfo } var runtimeMode: ApplicationRuntimeMode { @@ -44,6 +53,10 @@ final class RuntimePropertiesProvider: RuntimePropertiesProviding { var dynamicDerivationsEnabled: Bool { appInformationContainer.dynamicDerivationsEnabled == PropertiesValues.true.rawValue } + + var isRunningTests: Bool { + processInfo.environment[Properties.testConfiguration.description] != nil + } } extension ApplicationInformation: ApplicationInformationContaining {} diff --git a/ios/PolkadotVault/Design/Heights.swift b/ios/PolkadotVault/Design/Heights.swift index f004278bae..fd9392151c 100644 --- a/ios/PolkadotVault/Design/Heights.swift +++ b/ios/PolkadotVault/Design/Heights.swift @@ -21,6 +21,8 @@ enum Heights { static let navigationBarHeight: CGFloat = 54 /// All variants of `NavbarButton`, 40 pt static let navigationButton: CGFloat = 40 + /// All variants of `IconButton`, 36 pt + static let iconButton: CGFloat = 36 /// All variants of `MenuButton`, 48 pt static let menuButton: CGFloat = 48 /// All variants of `ActionSheetButton`, 44 pt diff --git a/ios/PolkadotVault/Extensions/Localizable+Formatted.swift b/ios/PolkadotVault/Extensions/Localizable+Formatted.swift index 1e421f0cbe..2088a454ba 100644 --- a/ios/PolkadotVault/Extensions/Localizable+Formatted.swift +++ b/ios/PolkadotVault/Extensions/Localizable+Formatted.swift @@ -270,4 +270,21 @@ extension Localizable { } return attributedString } + + static func bananaSplitBackupQRCodeInfo() -> AttributedString { + let mainText = Localizable.BananaSplitBackupQRCode.Label.info.string + let highlightedText = Localizable.BananaSplitBackupQRCode.Label.Info.highlight.string + + let attributedString = NSMutableAttributedString(string: mainText) + attributedString.addAttribute( + .foregroundColor, + value: Color(.textAndIconsTertiary), + range: NSRange(location: 0, length: mainText.count) + ) + + let range = (mainText as NSString).range(of: highlightedText) + attributedString.setAttributes([.foregroundColor: UIColor(.accentPink300)], range: range) + + return AttributedString(attributedString) + } } diff --git a/ios/PolkadotVault/Modals/Alerts/HorizontalActionsBottomModal.swift b/ios/PolkadotVault/Modals/Alerts/HorizontalActionsBottomModal.swift index dc7232f866..4b8d5cd8a6 100644 --- a/ios/PolkadotVault/Modals/Alerts/HorizontalActionsBottomModal.swift +++ b/ios/PolkadotVault/Modals/Alerts/HorizontalActionsBottomModal.swift @@ -15,6 +15,13 @@ struct HorizontalActionsBottomModalViewModel { var mainActionStyle: ActionButtonStyle = .primaryDestructive() var alignment: HorizontalAlignment = .center + static let bananaSplitDeleteBackup = HorizontalActionsBottomModalViewModel( + title: Localizable.BananaSplitDeleteBackup.Label.title.string, + content: Localizable.BananaSplitDeleteBackup.Label.content.string, + dismissActionLabel: Localizable.BananaSplitDeleteBackup.Action.cancel.key, + mainActionLabel: Localizable.BananaSplitDeleteBackup.Action.remove.key + ) + static let forgetKeySet = HorizontalActionsBottomModalViewModel( title: Localizable.KeySetsModal.Confirmation.Label.title.string, content: Localizable.KeySetsModal.Confirmation.Label.content.string, diff --git a/ios/PolkadotVault/Modals/KeySet/KeyDetailsActionsModal.swift b/ios/PolkadotVault/Modals/KeySet/KeyDetailsActionsModal.swift deleted file mode 100644 index a5046d76d7..0000000000 --- a/ios/PolkadotVault/Modals/KeySet/KeyDetailsActionsModal.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// KeyDetailsActionsModal.swift -// Polkadot Vault -// -// Created by Krzysztof Rodak on 06/09/2022. -// - -import SwiftUI - -struct KeyDetailsActionsModal: View { - @State private var animateBackground: Bool = false - @Binding var isShowingActionSheet: Bool - @Binding var shouldPresentRemoveConfirmationModal: Bool - @Binding var shouldPresentBackupModal: Bool - @Binding var shouldPresentExportKeysSelection: Bool - - var body: some View { - FullScreenRoundedModal( - backgroundTapAction: { animateDismissal() }, - animateBackground: $animateBackground, - content: { - VStack(alignment: .leading, spacing: 0) { - // Export Keys - ActionSheetButton( - action: { animateDismissal { shouldPresentExportKeysSelection.toggle() } }, - icon: Image(.exportKeys), - text: Localizable.KeySetsModal.Action.export.key - ) - ActionSheetButton( - action: { - animateDismissal { shouldPresentBackupModal.toggle() } - }, - icon: Image(.backupKey), - text: Localizable.KeySetsModal.Action.backup.key - ) - ActionSheetButton( - action: { animateDismissal { shouldPresentRemoveConfirmationModal.toggle() } }, - icon: Image(.delete), - text: Localizable.KeySetsModal.Action.delete.key, - style: .destructive - ) - ActionButton( - action: { animateDismissal() }, - text: Localizable.AddKeySet.Button.cancel.key, - style: .emptySecondary() - ) - } - .padding(.horizontal, Spacing.large) - .padding(.top, -Spacing.extraSmall) - .padding(.bottom, Spacing.medium) - } - ) - } - - private func animateDismissal(_ completion: @escaping () -> Void = {}) { - Animations.chainAnimation( - animateBackground.toggle(), - delayedAnimationClosure: { - isShowingActionSheet = false - completion() - }() - ) - } -} diff --git a/ios/PolkadotVault/PolkadotVaultApp.swift b/ios/PolkadotVault/PolkadotVaultApp.swift index d6bba1d750..e08e413bf8 100644 --- a/ios/PolkadotVault/PolkadotVaultApp.swift +++ b/ios/PolkadotVault/PolkadotVaultApp.swift @@ -17,7 +17,9 @@ struct PolkadotVaultApp: App { var body: some Scene { WindowGroup { - if jailbreakDetectionPublisher.isJailbroken { + if RuntimePropertiesProvider().isRunningTests { + EmptyView() + } else if jailbreakDetectionPublisher.isJailbroken { JailbreakDetectedView() } else { MainScreenContainer( diff --git a/ios/PolkadotVault/Resources/Assets.xcassets/key_set/banana_split_backup.imageset/Contents.json b/ios/PolkadotVault/Resources/Assets.xcassets/key_set/banana_split_backup.imageset/Contents.json new file mode 100644 index 0000000000..bd7a54b40c --- /dev/null +++ b/ios/PolkadotVault/Resources/Assets.xcassets/key_set/banana_split_backup.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "banana_split_backup.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ios/PolkadotVault/Resources/Assets.xcassets/key_set/banana_split_backup.imageset/banana_split_backup.svg b/ios/PolkadotVault/Resources/Assets.xcassets/key_set/banana_split_backup.imageset/banana_split_backup.svg new file mode 100644 index 0000000000..702037135d --- /dev/null +++ b/ios/PolkadotVault/Resources/Assets.xcassets/key_set/banana_split_backup.imageset/banana_split_backup.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ios/PolkadotVault/Resources/Assets.xcassets/key_set/show_passphrase.imageset/Contents.json b/ios/PolkadotVault/Resources/Assets.xcassets/key_set/show_passphrase.imageset/Contents.json new file mode 100644 index 0000000000..4e357382f5 --- /dev/null +++ b/ios/PolkadotVault/Resources/Assets.xcassets/key_set/show_passphrase.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "show_passphrase.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/ios/PolkadotVault/Resources/Assets.xcassets/key_set/show_passphrase.imageset/show_passphrase.svg b/ios/PolkadotVault/Resources/Assets.xcassets/key_set/show_passphrase.imageset/show_passphrase.svg new file mode 100644 index 0000000000..0cc0f1305b --- /dev/null +++ b/ios/PolkadotVault/Resources/Assets.xcassets/key_set/show_passphrase.imageset/show_passphrase.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/PolkadotVault/Resources/Assets.xcassets/refresh_passphrase.imageset/Contents.json b/ios/PolkadotVault/Resources/Assets.xcassets/refresh_passphrase.imageset/Contents.json new file mode 100644 index 0000000000..190ec8646b --- /dev/null +++ b/ios/PolkadotVault/Resources/Assets.xcassets/refresh_passphrase.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "refreshPassphrase.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ios/PolkadotVault/Resources/Assets.xcassets/refresh_passphrase.imageset/refreshPassphrase.svg b/ios/PolkadotVault/Resources/Assets.xcassets/refresh_passphrase.imageset/refreshPassphrase.svg new file mode 100644 index 0000000000..bf1a540ebd --- /dev/null +++ b/ios/PolkadotVault/Resources/Assets.xcassets/refresh_passphrase.imageset/refreshPassphrase.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/PolkadotVault/Resources/en.lproj/Localizable.strings b/ios/PolkadotVault/Resources/en.lproj/Localizable.strings index 073948a55c..77db3d1269 100644 --- a/ios/PolkadotVault/Resources/en.lproj/Localizable.strings +++ b/ios/PolkadotVault/Resources/en.lproj/Localizable.strings @@ -105,8 +105,8 @@ "KeySets.Label.Empty.Subtitle" = "Add a new Key Set to Store Keys and Sign Transactions"; "KeySetsModal.Action.Export" = "Export Keys"; -"KeySetsModal.Action.Derive" = "Derive from Key"; -"KeySetsModal.Action.Backup" = "Backup Key Set"; +"KeySetsModal.Action.BananaSplit" = "Banana Split Backup"; +"KeySetsModal.Action.Backup" = "Manual Backup"; "KeySetsModal.Action.Delete" = "Delete"; "KeySetsModal.Confirmation.Label.Title" = "Forget this Key Set?"; @@ -620,3 +620,27 @@ "NoKeySets.Label.Subheader" = "Add a new key set or recover an existing one by entering the secret recovery phrase to get started"; "NoKeySets.Action.Add" = "Add new Key Set"; "NoKeySets.Action.Recover" = "Recover Key Set"; + +"BananaSplitBackup.Label.Title" = "Banana Split Backup"; +"BananaSplitBackup.Label.Header" = "Backup your key set by turning the secret phrase into sharded QR codes with passphrase protection"; +"BananaSplitBackup.Label.Shards.Header" = "Number of QR Code Shards"; +"BananaSplitBackup.Label.Shards.Footer" = "%@ shards out of %@ to reconstruct"; +"BananaSplitBackup.Label.Passphrase.Header" = "Passphrase for the Recovery"; +"BananaSplitBackup.Label.Passphrase.Footer" = "Write down your passphrase. You'll need it to recover from Banana Split."; +"BananaSplitBackup.Label.Passphrase.Info" = "Banana Split backup will recover the key set without derived keys. To back up derived keys, use the manual backup option. Each key will have to be added individually by entering the derivation path name."; +"BananaSplitBackup.Action.Create" = "Create"; +"BananaSplitBackup.Error.CouldNotLoad" = "Banana Split Backup could not be loaded"; + +"BananaSplitBackupQRCode.Label.Info" = "Scan this animated QR code into the bs.parity.io app to print QR code shards."; +"BananaSplitBackupQRCode.Label.Info.Highlight" = "bs.parity.io"; + +"BananaSplitActionModal.Action.Passphrase" = "Show Passphrase"; +"BananaSplitActionModal.Action.Remove" = "Remove Backup"; +"BananaSplitActionModal.Action.Cancel" = "Cancel"; + +"BananaSplitPassphraseModal.Label.Header" = "Passphrase"; +"BananaSplitDeleteBackup.Label.Title" = "Remove Banana Split"; +"BananaSplitDeleteBackup.Label.Content" = "You can still use this backup if you have QR code shards printed and have passphrase written down on paper."; +"BananaSplitDeleteBackup.Action.Cancel" = "Cancel"; +"BananaSplitDeleteBackup.Action.Remove" = "Remove"; + diff --git a/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitActionModal.swift b/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitActionModal.swift new file mode 100644 index 0000000000..90684c1b81 --- /dev/null +++ b/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitActionModal.swift @@ -0,0 +1,89 @@ +// +// BananaSplitActionModal.swift +// PolkadotVault +// +// Created by Krzysztof Rodak on 23/02/2024. +// + +import SwiftUI + +struct BananaSplitActionModal: View { + @StateObject var viewModel: ViewModel + + var body: some View { + FullScreenRoundedModal( + backgroundTapAction: { viewModel.dismissActionSheet() }, + animateBackground: $viewModel.animateBackground, + content: { + VStack(alignment: .leading, spacing: 0) { + // Show Passphrase Keys + ActionSheetButton( + action: viewModel.showPassphrase, + icon: Image(.showPassphrase), + text: Localizable.BananaSplitActionModal.Action.passphrase.key + ) + // Remove Keys + ActionSheetButton( + action: viewModel.removeBackup, + icon: Image(.delete), + text: Localizable.BananaSplitActionModal.Action.remove.key, + style: .destructive + ) + // Cancel + ActionButton( + action: viewModel.dismissActionSheet, + text: Localizable.BananaSplitActionModal.Action.cancel.key, + style: .emptySecondary() + ) + } + .padding(.horizontal, Spacing.large) + .padding(.top, -Spacing.extraSmall) + .padding(.bottom, Spacing.medium) + } + ) + } +} + +extension BananaSplitActionModal { + final class ViewModel: ObservableObject { + @Published var animateBackground: Bool = false + @Binding var isPresented: Bool + @Binding var shouldPresentDeleteBackupWarningModal: Bool + @Binding var shouldPresentPassphraseModal: Bool + + init( + isPresented: Binding, + shouldPresentDeleteBackupWarningModal: Binding, + shouldPresentPassphraseModal: Binding + ) { + _isPresented = isPresented + _shouldPresentDeleteBackupWarningModal = shouldPresentDeleteBackupWarningModal + _shouldPresentPassphraseModal = shouldPresentPassphraseModal + } + + func removeBackup() { + shouldPresentDeleteBackupWarningModal = true + dismissActionSheet() + } + + func showPassphrase() { + shouldPresentPassphraseModal = true + dismissActionSheet() + } + + func dismissActionSheet() { + animateDismissal() + } + + func animateDismissal() { + Animations.chainAnimation( + animateBackground.toggle(), + // swiftformat:disable all + delayedAnimationClosure: self.hide() + ) + } + private func hide() { + isPresented = false + } + } +} diff --git a/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitModal.swift b/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitModal.swift new file mode 100644 index 0000000000..4aef1eea80 --- /dev/null +++ b/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitModal.swift @@ -0,0 +1,309 @@ +// +// BananaSplitModal.swift +// PolkadotVault +// +// Created by Krzysztof Rodak on 21/02/2024. +// + +import SwiftUI + +struct BananaSplitModalView: View { + @StateObject var viewModel: ViewModel + @FocusState private var textFieldFocused: Bool + + var body: some View { + NavigationView { + GeometryReader { geo in + VStack(spacing: 0) { + NavigationBarView( + viewModel: .init( + leftButtons: [.init( + type: .xmark, + action: viewModel.onBackTap + )], + rightButtons: [.init( + type: .activeAction( + Localizable.BananaSplitBackup.Action.create.key, + .constant(!viewModel.isActionAvailable()) + ), + action: { + textFieldFocused = false + viewModel.onCreateTap() + } + )] + ) + ) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + mainContent() + passphraseView() + infoView() + Spacer() + } + } + } + .frame( + minWidth: geo.size.width, + minHeight: geo.size.height + ) + .background(.backgroundPrimary) + NavigationLink( + destination: + BananaSplitQRCodeModalView( + viewModel: .init( + seedName: viewModel.seedName, + bananaSplitBackup: viewModel.bananaSplitBackup, + onCompletion: viewModel.onQRCodeCompletion + ) + ) + .navigationBarHidden(true), + isActive: $viewModel.isPresentingQRCode + ) { EmptyView() } + } + .navigationBarHidden(true) + .navigationViewStyle(.stack) + .fullScreenModal( + isPresented: $viewModel.isPresentingError + ) { + ErrorBottomModal( + viewModel: viewModel.presentableError, + isShowingBottomAlert: $viewModel.isPresentingError + ) + .clearModalBackground() + } + } + } + + @ViewBuilder + func mainContent() -> some View { + VStack(alignment: .leading, spacing: 0) { + Localizable.BananaSplitBackup.Label.title.text + .foregroundColor(.textAndIconsPrimary) + .font(PrimaryFont.titleL.font) + .padding(.top, Spacing.extraSmall) + .padding(.horizontal, Spacing.extraSmall) + Localizable.BananaSplitBackup.Label.header.text + .foregroundColor(.textAndIconsTertiary) + .font(PrimaryFont.bodyM.font) + .padding(.vertical, Spacing.extraSmall) + .padding(.horizontal, Spacing.extraSmall) + Localizable.BananaSplitBackup.Label.Shards.header.text + .foregroundColor(.textAndIconsPrimary) + .font(PrimaryFont.bodyL.font) + .padding(.vertical, Spacing.extraSmall) + .padding(.horizontal, Spacing.extraSmall) + TextField("", text: $viewModel.totalShards) + .submitLabel(.done) + .primaryTextFieldStyle( + Localizable.NewSeed.Name.Label.placeholder.string, + keyboardType: .asciiCapableNumberPad, + text: $viewModel.totalShards + ) + .focused($textFieldFocused) + .onSubmit { + textFieldFocused = false + viewModel.onSubmitTap() + } + .padding(.vertical, Spacing.medium) + Text(Localizable.BananaSplitBackup.Label.Shards.footer( + viewModel.requiredShardsCount, + viewModel.totalShards + )) + .foregroundColor(.textAndIconsTertiary) + .font(PrimaryFont.captionM.font) + .padding(.horizontal, Spacing.extraSmall) + } + .padding(.horizontal, Spacing.medium) + .padding(.bottom, Spacing.medium) + } + + @ViewBuilder + func passphraseView() -> some View { + VStack(alignment: .leading, spacing: Spacing.medium) { + HStack(alignment: .center, spacing: 0) { + VStack(alignment: .leading, spacing: Spacing.extraExtraSmall) { + Localizable.BananaSplitBackup.Label.Passphrase.header.text + .foregroundColor(.textAndIconsTertiary) + .font(PrimaryFont.bodyM.font) + Text(viewModel.recoveryPassphrase) + .foregroundColor(.textAndIconsPrimary) + .font(PrimaryFont.bodyL.font) + } + Spacer() + IconButton( + action: viewModel.refreshPassphrase, + icon: .refreshPassphrase + ) + } + .padding(.vertical, Spacing.medium) + .padding(.leading, Spacing.medium) + .padding(.trailing, Spacing.extraSmall) + .overlay( + RoundedRectangle(cornerRadius: Spacing.medium) + .stroke(.fill12, lineWidth: 1) + ) + Localizable.BananaSplitBackup.Label.Passphrase.footer.text + .foregroundColor(.textAndIconsTertiary) + .font(PrimaryFont.captionM.font) + .padding(.horizontal, Spacing.extraSmall) + } + .padding(.horizontal, Spacing.medium) + } + + @ViewBuilder + func infoView() -> some View { + HStack(alignment: .center, spacing: Spacing.medium) { + Localizable.BananaSplitBackup.Label.Passphrase.info.text + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.accentPink300) + .font(PrimaryFont.captionM.font) + Image(.infoIconBold) + .foregroundColor(.accentPink300) + } + .padding(Spacing.medium) + .background( + RoundedRectangle(cornerRadius: CornerRadius.medium) + .foregroundColor(.accentPink300Fill8) + ) + .padding(.horizontal, Spacing.medium) + .padding(.top, Spacing.extraExtraLarge) + .padding(.bottom, Spacing.medium) + } +} + +extension BananaSplitModalView { + private enum Constants { + static let passphraseWords: UInt32 = 4 + static let defaultTotalShards: UInt32 = 3 + } + + enum OnCompletionAction: Equatable { + case create([QrData]) + case cancel + case close + } + + final class ViewModel: ObservableObject { + @Published var totalShards: String = .init(Constants.defaultTotalShards) { + didSet { + updateRequiredShards() + } + } + + @Published var requiredShardsCount: UInt32 = 2 + @Published var recoveryPassphrase: String = "" + @Published var isPresentingError: Bool = false + @Published var isPresentingQRCode: Bool = false + @Published var bananaSplitBackup: BananaSplitBackup = .init(qrCodes: []) + @Published var presentableError: ErrorBottomModalViewModel! + @Binding var isPresented: Bool + let onCompletion: (BananaSplitModalView.OnCompletionAction) -> Void + + let seedName: String + private let bananaSplitMediator: KeychainBananaSplitAccessMediating + private let seedsMediator: SeedsMediating + private let service: BananaSplitServicing + private var totalShardsCount: UInt32 { + UInt32(totalShards) ?? Constants.defaultTotalShards + } + + init( + seedName: String, + bananaSplitMediator: KeychainBananaSplitAccessMediating = KeychainBananaSplitMediator(), + seedsMediator: SeedsMediating = ServiceLocator.seedsMediator, + service: BananaSplitServicing = BananaSplitService(), + isPresented: Binding, + onCompletion: @escaping (BananaSplitModalView.OnCompletionAction) -> Void + ) { + self.seedName = seedName + self.bananaSplitMediator = bananaSplitMediator + self.seedsMediator = seedsMediator + self.service = service + self.onCompletion = onCompletion + _isPresented = isPresented + refreshPassphrase() + } + + func onBackTap() { + isPresented = false + } + + func onCreateTap() { + service.encrypt( + secret: seedsMediator.getSeed(seedName: seedName), + title: seedName, + passphrase: recoveryPassphrase, + totalShards: totalShardsCount, + requiredShards: requiredShardsCount + ) { result in + switch result { + case let .success(bananaSplitBackup): + self.bananaSplitBackup = bananaSplitBackup + let result = self.bananaSplitMediator.saveBananaSplit( + with: self.seedName, + bananaSplitBackup: bananaSplitBackup, + passphrase: .init(passphrase: self.recoveryPassphrase) + ) + switch result { + case .success: + self.isPresentingQRCode = true + case let .failure(error): + self.presentableError = .alertError(message: error.localizedDescription) + self.isPresentingError = true + } + case let .failure(error): + self.presentableError = .alertError(message: error.backendDisplayError) + self.isPresentingError = true + } + } + } + + func isActionAvailable() -> Bool { + !totalShards.isEmpty + } + + func refreshPassphrase() { + service.generatePassphrase(with: Constants.passphraseWords) { result in + switch result { + case let .success(newPassphrase): + self.recoveryPassphrase = newPassphrase + case let .failure(error): + self.presentableError = .alertError(message: error.localizedDescription) + self.isPresentingError = true + } + } + } + + func onQRCodeCompletion(_: BananaSplitQRCodeModalView.OnCompletionAction) { + isPresented = false + onCompletion(.close) + } + } +} + +private extension BananaSplitModalView.ViewModel { + func onSubmitTap() { + guard isActionAvailable() else { return } + onCreateTap() + } + + func updateRequiredShards() { + requiredShardsCount = totalShardsCount / 2 + 1 + } +} + +#if DEBUG + struct BananaSplitModalView_Previews: PreviewProvider { + static var previews: some View { + BananaSplitModalView( + viewModel: .init( + seedName: "Key Set", + isPresented: .constant(true), + onCompletion: { _ in } + ) + ) + .previewLayout(.sizeThatFits) + } + } +#endif diff --git a/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitPassphraseModal.swift b/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitPassphraseModal.swift new file mode 100644 index 0000000000..b34472d032 --- /dev/null +++ b/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitPassphraseModal.swift @@ -0,0 +1,84 @@ +// +// BananaSplitPassphraseModal.swift +// PolkadotVault +// +// Created by Krzysztof Rodak on 26/02/2024. +// + +import SwiftUI + +struct BananaSplitPassphraseModal: View { + @StateObject var viewModel: ViewModel + + var body: some View { + FullScreenRoundedModal( + backgroundTapAction: { viewModel.dismissActionSheet() }, + animateBackground: $viewModel.animateBackground, + content: { + VStack(alignment: .leading, spacing: Spacing.medium) { + HStack { + Localizable.BananaSplitPassphraseModal.Label.header.text + .foregroundColor(.textAndIconsPrimary) + .font(PrimaryFont.titleS.font) + Spacer() + CloseModalButton(action: viewModel.dismissActionSheet) + } + Text(viewModel.passphrase) + .multilineTextAlignment(.leading) + .padding(.vertical, Spacing.medium) + } + .padding(.leading, Spacing.large) + .padding(.trailing, Spacing.medium) + .padding(.top, Spacing.small) + .padding(.bottom, Spacing.medium) + } + ) + } +} + +extension BananaSplitPassphraseModal { + final class ViewModel: ObservableObject { + @Published var animateBackground: Bool = false + @Published var passphrase: String = "" + @Binding var isPresented: Bool + private let seedName: String + private let bananaSplitMediator: KeychainBananaSplitAccessMediating + + init( + seedName: String, + isPresented: Binding, + bananaSplitMediator: KeychainBananaSplitAccessMediating = KeychainBananaSplitMediator() + ) { + _isPresented = isPresented + self.seedName = seedName + self.bananaSplitMediator = bananaSplitMediator + loadPassphrase() + } + + func dismissActionSheet() { + animateDismissal() + } + + func animateDismissal() { + Animations.chainAnimation( + animateBackground.toggle(), + // swiftformat:disable all + delayedAnimationClosure: self.hide() + ) + } + + private func hide() { + isPresented = false + } + + private func loadPassphrase() { + switch bananaSplitMediator.retrieveBananaSplitPassphrase(with: seedName) { + case let .success(passphrase): + self.passphrase = passphrase.passphrase + case .failure: + () + } + + } + } +} diff --git a/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitQRCodeModal.swift b/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitQRCodeModal.swift new file mode 100644 index 0000000000..b816c90a4a --- /dev/null +++ b/ios/PolkadotVault/Screens/KeyDetails/BananaSplit/BananaSplitQRCodeModal.swift @@ -0,0 +1,173 @@ +// +// BananaSplitQRCodeModal.swift +// PolkadotVault +// +// Created by Krzysztof Rodak on 28/02/2024. +// + +import Combine +import SwiftUI + +struct BananaSplitQRCodeModalView: View { + @StateObject var viewModel: ViewModel + + var body: some View { + GeometryReader { geo in + VStack(spacing: 0) { + // Navigation bar + NavigationBarView( + viewModel: .init( + leftButtons: [.init(type: .xmark, action: { viewModel.onCloseTap() })], + rightButtons: [.init(type: .more, action: viewModel.onMoreButtonTap)] + ) + ) + VStack(spacing: 0) { + // QR Code container + Spacer() + VStack(spacing: 0) { + AnimatedQRCodeView( + viewModel: Binding.constant( + .init( + qrCodes: viewModel.bananaSplitBackup.qrCodes + ) + ) + ) + } + .strokeContainerBackground() + // Info + AttributedInfoBoxView(text: Localizable.bananaSplitBackupQRCodeInfo()) + .padding(.vertical, Spacing.extraSmall) + Spacer() + Spacer() + Spacer() + } + .padding(.horizontal, Spacing.large) + .padding(.top, Spacing.extraSmall) + } + .frame( + minWidth: geo.size.width, + minHeight: geo.size.height + ) + .background(.backgroundPrimary) + } + // Action sheet + .fullScreenModal( + isPresented: $viewModel.isPresentingActionSheet, + onDismiss: { + // iOS 15 handling of following .fullscreen presentation after dismissal, we need to dispatch this async + DispatchQueue.main.async { viewModel.checkForActionsPresentation() } + } + ) { + BananaSplitActionModal( + viewModel: .init( + isPresented: $viewModel.isPresentingActionSheet, + shouldPresentDeleteBackupWarningModal: $viewModel.shouldPresentDeleteBackupWarningModal, + shouldPresentPassphraseModal: $viewModel.shouldPresentPassphraseModal + ) + ) + .clearModalBackground() + } + // Passphrase + .fullScreenModal( + isPresented: $viewModel.isPresentingPassphraseModal + ) { + BananaSplitPassphraseModal( + viewModel: .init( + seedName: viewModel.seedName, + isPresented: $viewModel.isPresentingPassphraseModal + ) + ) + .clearModalBackground() + } + .fullScreenModal(isPresented: $viewModel.isPresentingDeleteBackupWarningModal) { + HorizontalActionsBottomModal( + viewModel: .bananaSplitDeleteBackup, + mainAction: viewModel.onDeleteBackupTap(), + isShowingBottomAlert: $viewModel.isPresentingDeleteBackupWarningModal + ) + .clearModalBackground() + } + } +} + +extension BananaSplitQRCodeModalView { + enum OnCompletionAction: Equatable { + case close + case backupDeleted + } + + final class ViewModel: ObservableObject { + let seedName: String + @Published var bananaSplitBackup: BananaSplitBackup + @Published var isPresentingActionSheet = false + @Published var isPresentingDeleteBackupWarningModal = false + @Published var isPresentingPassphraseModal = false + @Published var shouldPresentDeleteBackupWarningModal = false + @Published var shouldPresentPassphraseModal = false + @Published var isPresentingError: Bool = false + @Published var presentableError: ErrorBottomModalViewModel = .alertError(message: "") + private let bananaSplitMediator: KeychainBananaSplitAccessMediating + private let onCompletion: (OnCompletionAction) -> Void + + init( + seedName: String, + bananaSplitBackup: BananaSplitBackup, + bananaSplitMediator: KeychainBananaSplitAccessMediating = KeychainBananaSplitMediator(), + onCompletion: @escaping (OnCompletionAction) -> Void + ) { + _bananaSplitBackup = .init(initialValue: bananaSplitBackup) + self.seedName = seedName + self.bananaSplitMediator = bananaSplitMediator + self.onCompletion = onCompletion + } + + func onMoreButtonTap() { + isPresentingActionSheet = true + } + + func onCloseTap() { + onCompletion(.close) + } + + func checkForActionsPresentation() { + if shouldPresentPassphraseModal { + shouldPresentPassphraseModal.toggle() + isPresentingPassphraseModal = true + } + if shouldPresentDeleteBackupWarningModal { + shouldPresentDeleteBackupWarningModal.toggle() + isPresentingDeleteBackupWarningModal = true + } + } + + func onDeleteBackupTap() { + switch bananaSplitMediator.removeBananaSplitBackup(seedName: seedName) { + case .success: + onCompletion(.backupDeleted) + case let .failure(error): + presentableError = .alertError(message: error.localizedDescription) + isPresentingError = true + } + } + } +} + +#if DEBUG + struct BananaSplitQRCodeModalView_Previews: PreviewProvider { + static var previews: some View { + Group { + BananaSplitQRCodeModalView( + viewModel: .init( + seedName: "seed name", + bananaSplitBackup: .init(qrCodes: [[]]), + onCompletion: { _ in + } + ) + ) + } + .previewLayout(.sizeThatFits) + .preferredColorScheme(.dark) + .environmentObject(ConnectivityMediator()) + } + } +#endif diff --git a/ios/PolkadotVault/Screens/KeyDetails/ExportKeys/ExportKeysSelectionModal.swift b/ios/PolkadotVault/Screens/KeyDetails/Modals/ExportKeysSelectionModal.swift similarity index 100% rename from ios/PolkadotVault/Screens/KeyDetails/ExportKeys/ExportKeysSelectionModal.swift rename to ios/PolkadotVault/Screens/KeyDetails/Modals/ExportKeysSelectionModal.swift diff --git a/ios/PolkadotVault/Screens/KeyDetails/ExportKeys/ExportMultipleKeysModal+ViewModel.swift b/ios/PolkadotVault/Screens/KeyDetails/Modals/ExportMultipleKeysModal+ViewModel.swift similarity index 100% rename from ios/PolkadotVault/Screens/KeyDetails/ExportKeys/ExportMultipleKeysModal+ViewModel.swift rename to ios/PolkadotVault/Screens/KeyDetails/Modals/ExportMultipleKeysModal+ViewModel.swift diff --git a/ios/PolkadotVault/Screens/KeyDetails/ExportKeys/ExportMultipleKeysModal.swift b/ios/PolkadotVault/Screens/KeyDetails/Modals/ExportMultipleKeysModal.swift similarity index 100% rename from ios/PolkadotVault/Screens/KeyDetails/ExportKeys/ExportMultipleKeysModal.swift rename to ios/PolkadotVault/Screens/KeyDetails/Modals/ExportMultipleKeysModal.swift diff --git a/ios/PolkadotVault/Screens/KeyDetails/Modals/KeyDetailsActionsModal.swift b/ios/PolkadotVault/Screens/KeyDetails/Modals/KeyDetailsActionsModal.swift new file mode 100644 index 0000000000..754d3c0167 --- /dev/null +++ b/ios/PolkadotVault/Screens/KeyDetails/Modals/KeyDetailsActionsModal.swift @@ -0,0 +1,117 @@ +// +// KeyDetailsActionsModal.swift +// Polkadot Vault +// +// Created by Krzysztof Rodak on 06/09/2022. +// + +import SwiftUI + +struct KeyDetailsActionsModal: View { + @StateObject var viewModel: ViewModel + + var body: some View { + FullScreenRoundedModal( + backgroundTapAction: { viewModel.dismissActionSheet() }, + animateBackground: $viewModel.animateBackground, + content: { + VStack(alignment: .leading, spacing: 0) { + // Export Keys + ActionSheetButton( + action: viewModel.exportKeysAction, + icon: Image(.exportKeys), + text: Localizable.KeySetsModal.Action.export.key + ) + // Banana Split Backup + ActionSheetButton( + action: viewModel.bananaSplitBackup, + icon: Image(.bananaSplitBackup), + text: Localizable.KeySetsModal.Action.bananaSplit.key + ) + // Manual Backup + ActionSheetButton( + action: viewModel.manualBackupKeysAction, + icon: Image(.backupKey), + text: Localizable.KeySetsModal.Action.backup.key + ) + // Remove Keys + ActionSheetButton( + action: viewModel.removeKeysAction, + icon: Image(.delete), + text: Localizable.KeySetsModal.Action.delete.key, + style: .destructive + ) + // Cancel + ActionButton( + action: viewModel.dismissActionSheet, + text: Localizable.AddKeySet.Button.cancel.key, + style: .emptySecondary() + ) + } + .padding(.horizontal, Spacing.large) + .padding(.top, -Spacing.extraSmall) + .padding(.bottom, Spacing.medium) + } + ) + } +} + +extension KeyDetailsActionsModal { + final class ViewModel: ObservableObject { + @Published var animateBackground: Bool = false + @Binding var isPresented: Bool + @Binding var shouldPresentRemoveConfirmationModal: Bool + @Binding var shouldPresentManualBackupModal: Bool + @Binding var shouldPresentBananaSplitModal: Bool + @Binding var shouldPresentExportKeysSelection: Bool + + init( + isPresented: Binding, + shouldPresentRemoveConfirmationModal: Binding, + shouldPresentBananaSplitModal: Binding, + shouldPresentManualBackupModal: Binding, + shouldPresentExportKeysSelection: Binding + ) { + _isPresented = isPresented + _shouldPresentRemoveConfirmationModal = shouldPresentRemoveConfirmationModal + _shouldPresentBananaSplitModal = shouldPresentBananaSplitModal + _shouldPresentManualBackupModal = shouldPresentManualBackupModal + _shouldPresentExportKeysSelection = shouldPresentExportKeysSelection + } + + func exportKeysAction() { + shouldPresentExportKeysSelection = true + dismissActionSheet() + } + + func bananaSplitBackup() { + shouldPresentBananaSplitModal = true + dismissActionSheet() + } + + func manualBackupKeysAction() { + shouldPresentManualBackupModal = true + dismissActionSheet() + } + + func removeKeysAction() { + shouldPresentRemoveConfirmationModal = true + dismissActionSheet() + } + + func dismissActionSheet() { + animateDismissal() + } + + func animateDismissal() { + Animations.chainAnimation( + animateBackground.toggle(), + // swiftformat:disable all + delayedAnimationClosure: self.hide() + ) + } + private func hide() { + isPresented = false + } + } +} diff --git a/ios/PolkadotVault/Screens/KeyDetails/Views/KeyDetails+ViewModel.swift b/ios/PolkadotVault/Screens/KeyDetails/Views/KeyDetails+ViewModel.swift index e68c70a32a..25c39dfa3e 100644 --- a/ios/PolkadotVault/Screens/KeyDetails/Views/KeyDetails+ViewModel.swift +++ b/ios/PolkadotVault/Screens/KeyDetails/Views/KeyDetails+ViewModel.swift @@ -9,6 +9,12 @@ import Combine import Foundation import SwiftUI +enum BananaSplitPresentationState { + case createBackup(BananaSplitModalView.ViewModel) + case qrCode(BananaSplitQRCodeModalView.ViewModel) + case empty +} + extension KeyDetailsView { enum OnCompletionAction: Equatable { case keySetDeleted @@ -27,14 +33,18 @@ extension KeyDetailsView { private let exportPrivateKeyService: PrivateKeyQRCodeService private let keyDetailsActionsService: KeyDetailsActionService private let seedsMediator: SeedsMediating + private let bananaSplitMediator: KeychainBananaSplitAccessMediating @Published var keyName: String @Published var keysData: MKeysNew? + @Published var bananaSplitPresentationState: BananaSplitPresentationState = .empty @Published var shouldPresentRemoveConfirmationModal = false - @Published var shouldPresentBackupModal = false + @Published var shouldPresentBananaSplitBackupModal = false + @Published var shouldPresentManualBackupModal = false @Published var shouldPresentExportKeysSelection = false @Published var isShowingActionSheet = false @Published var isShowingRemoveConfirmation = false + @Published var isPresentingBananaSplitBackupModal = false @Published var isShowingBackupModal = false @Published var isPresentingExportKeySelection = false @Published var isPresentingRootDetails = false @@ -76,7 +86,8 @@ extension KeyDetailsView { keyDetailsService: KeyDetailsService = KeyDetailsService(), networksService: GetManagedNetworksService = GetManagedNetworksService(), keyDetailsActionsService: KeyDetailsActionService = KeyDetailsActionService(), - seedsMediator: SeedsMediating = ServiceLocator.seedsMediator + seedsMediator: SeedsMediating = ServiceLocator.seedsMediator, + bananaSplitMediator: KeychainBananaSplitAccessMediating = KeychainBananaSplitMediator() ) { self.onDeleteCompletion = onDeleteCompletion self.exportPrivateKeyService = exportPrivateKeyService @@ -84,6 +95,7 @@ extension KeyDetailsView { self.networksService = networksService self.keyDetailsActionsService = keyDetailsActionsService self.seedsMediator = seedsMediator + self.bananaSplitMediator = bananaSplitMediator _keyName = .init(initialValue: initialKeyName) subscribeToNetworkChanges() } @@ -117,6 +129,7 @@ extension KeyDetailsView { self.isPresentingError = true } } + updateBananaSplitPresentationState() } func refreshNetworks() { @@ -252,6 +265,16 @@ extension KeyDetailsView { ) isSnackbarPresented = true } + + func onBananaSplitModalCompletion(_: BananaSplitModalView.OnCompletionAction) { + updateBananaSplitPresentationState() + isPresentingBananaSplitBackupModal = false + } + + func onBananaSplitBackupModalCompletion(_: BananaSplitQRCodeModalView.OnCompletionAction) { + updateBananaSplitPresentationState() + isPresentingBananaSplitBackupModal = false + } } } @@ -328,8 +351,8 @@ extension KeyDetailsView.ViewModel { shouldPresentRemoveConfirmationModal.toggle() isShowingRemoveConfirmation.toggle() } - if shouldPresentBackupModal { - shouldPresentBackupModal.toggle() + if shouldPresentManualBackupModal { + shouldPresentManualBackupModal.toggle() keyDetailsActionsService.performBackupSeed(seedName: keyName) { result in switch result { case .success: @@ -340,6 +363,10 @@ extension KeyDetailsView.ViewModel { } } } + if shouldPresentBananaSplitBackupModal { + shouldPresentBananaSplitBackupModal.toggle() + isPresentingBananaSplitBackupModal = true + } if shouldPresentExportKeysSelection { shouldPresentExportKeysSelection = false isPresentingExportKeySelection = true @@ -357,6 +384,50 @@ extension KeyDetailsView.ViewModel { base58: keysData?.root?.base58 ?? "" ) } + + func updateBananaSplitPresentationState() { + switch bananaSplitMediator.checkIfBananaSplitAlreadyExists(seedName: keyName) { + case let .success(exists): + if exists { + switch bananaSplitMediator.retrieveBananaSplit(with: keyName) { + case let .success(bananaSplitBackup): + bananaSplitPresentationState = .qrCode( + .init( + seedName: keyName, + bananaSplitBackup: bananaSplitBackup, + onCompletion: onBananaSplitBackupModalCompletion(_:) + ) + ) + case .failure: + bananaSplitPresentationState = .createBackup( + .init( + seedName: keyName, + isPresented: Binding( + get: { self.isPresentingBananaSplitBackupModal }, + set: { self.isPresentingBananaSplitBackupModal = $0 } + ), + onCompletion: onBananaSplitModalCompletion(_:) + ) + ) + } + } else { + bananaSplitPresentationState = .createBackup( + .init( + seedName: keyName, + isPresented: Binding( + get: { self.isPresentingBananaSplitBackupModal }, + set: { self.isPresentingBananaSplitBackupModal = $0 } + ), + onCompletion: onBananaSplitModalCompletion(_:) + ) + ) + } + case let .failure(failure): + presentableError = .alertError(message: failure.localizedDescription) + isPresentingError = true + bananaSplitPresentationState = .empty + } + } } private extension KeyDetailsView.ViewModel { diff --git a/ios/PolkadotVault/Screens/KeyDetails/Views/KeyDetailsView.swift b/ios/PolkadotVault/Screens/KeyDetails/Views/KeyDetailsView.swift index 92706945ef..1730fdc1ce 100644 --- a/ios/PolkadotVault/Screens/KeyDetails/Views/KeyDetailsView.swift +++ b/ios/PolkadotVault/Screens/KeyDetails/Views/KeyDetailsView.swift @@ -69,10 +69,13 @@ struct KeyDetailsView: View { } ) { KeyDetailsActionsModal( - isShowingActionSheet: $viewModel.isShowingActionSheet, - shouldPresentRemoveConfirmationModal: $viewModel.shouldPresentRemoveConfirmationModal, - shouldPresentBackupModal: $viewModel.shouldPresentBackupModal, - shouldPresentExportKeysSelection: $viewModel.shouldPresentExportKeysSelection + viewModel: .init( + isPresented: $viewModel.isShowingActionSheet, + shouldPresentRemoveConfirmationModal: $viewModel.shouldPresentRemoveConfirmationModal, + shouldPresentBananaSplitModal: $viewModel.shouldPresentBananaSplitBackupModal, + shouldPresentManualBackupModal: $viewModel.shouldPresentManualBackupModal, + shouldPresentExportKeysSelection: $viewModel.shouldPresentExportKeysSelection + ) ) .clearModalBackground() } @@ -110,6 +113,16 @@ struct KeyDetailsView: View { ) .clearModalBackground() } + .fullScreenModal(isPresented: $viewModel.isPresentingBananaSplitBackupModal) { + switch viewModel.bananaSplitPresentationState { + case let .createBackup(viewModel): + BananaSplitModalView(viewModel: viewModel) + case let .qrCode(viewModel): + BananaSplitQRCodeModalView(viewModel: viewModel) + case .empty: + EmptyView() + } + } .fullScreenModal( isPresented: $viewModel.isShowingBackupModal, onDismiss: viewModel.clearBackupModalState diff --git a/ios/PolkadotVault/Modals/KeySet/PublicKeyActionsModal.swift b/ios/PolkadotVault/Screens/PublicKey/PublicKeyActionsModal.swift similarity index 100% rename from ios/PolkadotVault/Modals/KeySet/PublicKeyActionsModal.swift rename to ios/PolkadotVault/Screens/PublicKey/PublicKeyActionsModal.swift diff --git a/ios/PolkadotVaultTests/Core/Keychain/BananaSplit/KeychainBananaSplitQueryProviderTests.swift b/ios/PolkadotVaultTests/Core/Keychain/BananaSplit/KeychainBananaSplitQueryProviderTests.swift new file mode 100644 index 0000000000..e232c0e323 --- /dev/null +++ b/ios/PolkadotVaultTests/Core/Keychain/BananaSplit/KeychainBananaSplitQueryProviderTests.swift @@ -0,0 +1,161 @@ +// +// KeychainBananaSplitQueryProviderTests.swift +// PolkadotVaultTests +// +// Created by Krzysztof Rodak on 04/03/2024. +// + +import Foundation +@testable import PolkadotVault +import Security +import XCTest + +final class KeychainBananaSplitQueryProviderTests: XCTestCase { + private var subject: KeychainBananaSplitQueryProvider! + private var jsonEncoder: JSONEncoder! + + override func setUp() { + super.setUp() + jsonEncoder = JSONEncoder() + subject = KeychainBananaSplitQueryProvider(jsonEncoder: jsonEncoder) + } + + override func tearDown() { + jsonEncoder = nil + subject = nil + super.tearDown() + } + + func test_query_fetchBananaSplit_returnsExpectedValues() { + // Given + let seedName = "testSeed" + let queryType = KeychainBananaSplitQuery.fetch(seedName: seedName) + + // When + let result = subject.query(for: queryType) as! [CFString: Any] + + // Then + XCTAssertEqual(result[kSecClass] as! CFString, kSecClassGenericPassword) + XCTAssertEqual(result[kSecMatchLimit] as! CFString, kSecMatchLimitOne) + XCTAssertEqual( + result[kSecAttrAccount] as! String, + seedName + KeychainBananaSplitQueryProvider.Constants.bananaSplitSuffix + ) + XCTAssertEqual(result[kSecReturnData] as! Bool, true) + } + + func test_query_checkBananaSplit_returnsExpectedValues() { + // Given + let seedName = "testSeed" + let queryType = KeychainBananaSplitQuery.check(seedName: seedName) + + // When + let result = subject.query(for: queryType) as! [CFString: Any] + + // Then + XCTAssertEqual(result[kSecClass] as! CFString, kSecClassGenericPassword) + XCTAssertEqual(result[kSecMatchLimit] as! CFString, kSecMatchLimitOne) + XCTAssertEqual( + result[kSecAttrAccount] as! String, + seedName + KeychainBananaSplitQueryProvider.Constants.bananaSplitSuffix + ) + XCTAssertEqual(result[kSecReturnData] as! Bool, false) + } + + func test_query_deleteBananaSplit_returnsExpectedValues() { + // Given + let seedName = "testSeed" + let queryType = KeychainBananaSplitQuery.delete(seedName: seedName) + + // When + let result = subject.query(for: queryType) as! [CFString: Any] + + // Then + XCTAssertEqual(result[kSecClass] as! CFString, kSecClassGenericPassword) + XCTAssertEqual( + result[kSecAttrAccount] as! String, + seedName + KeychainBananaSplitQueryProvider.Constants.bananaSplitSuffix + ) + } + + func test_query_saveBananaSplit_returnsExpectedValues() { + // Given + let seedName = "testSeed" + let bananaSplit = BananaSplitBackup(qrCodes: [[10]]) + let queryType = KeychainBananaSplitQuery.save(seedName: seedName, bananaSplit: bananaSplit) + let expectedData = try? jsonEncoder.encode(bananaSplit) + + // When + let result = subject.query(for: queryType) as! [CFString: Any] + + // Then + XCTAssertEqual(result[kSecClass] as! CFString, kSecClassGenericPassword) + XCTAssertEqual( + result[kSecAttrAccount] as! String, + seedName + KeychainBananaSplitQueryProvider.Constants.bananaSplitSuffix + ) + XCTAssertEqual(result[kSecValueData] as? Data, expectedData) + XCTAssertEqual(result[kSecReturnData] as! Bool, false) + } + + func test_query_fetchPassphrase_returnsExpectedValues() { + // Given + let seedName = "testSeed" + let queryType = KeychainBananaSplitPassphraseQuery.fetch(seedName: seedName) + + // When + let result = subject.passhpraseQuery(for: queryType) as! [CFString: Any] + + // Then + XCTAssertEqual(result[kSecClass] as! CFString, kSecClassGenericPassword) + XCTAssertEqual(result[kSecMatchLimit] as! CFString, kSecMatchLimitOne) + XCTAssertEqual( + result[kSecAttrAccount] as! String, + seedName + KeychainBananaSplitQueryProvider.Constants.passphraseSuffix + ) + XCTAssertEqual(result[kSecReturnData] as! Bool, true) + } + + func test_query_deletePassphrase_returnsExpectedValues() { + // Given + let seedName = "testSeed" + let queryType = KeychainBananaSplitPassphraseQuery.delete(seedName: seedName) + + // When + let result = subject.passhpraseQuery(for: queryType) as! [CFString: Any] + + // Then + XCTAssertEqual(result[kSecClass] as! CFString, kSecClassGenericPassword) + XCTAssertEqual( + result[kSecAttrAccount] as! String, + seedName + KeychainBananaSplitQueryProvider.Constants.passphraseSuffix + ) + } + + func test_query_savePassphrase_returnsExpectedValues() { + // Given + let seedName = "testSeed" + let passphrase = BananaSplitPassphrase(passphrase: "dummyPassphrase") + let expectedAccessControl: SecAccessControl! = try? SimulatorAccessControlProvider() + .accessControl() // it's fine to use it instead of mock, as! it's just dedicated to be used on simulator + let queryType = KeychainBananaSplitPassphraseQuery.save( + seedName: seedName, + passphrase: passphrase, + accessControl: expectedAccessControl + ) + let expectedData = try? jsonEncoder.encode(passphrase) + + // When + let result = subject.passhpraseQuery(for: queryType) as! [CFString: Any] + + // Then + XCTAssertEqual(result[kSecClass] as! CFString, kSecClassGenericPassword) + XCTAssertEqual( + result[kSecAttrAccount] as! String, + seedName + KeychainBananaSplitQueryProvider.Constants.passphraseSuffix + ) + XCTAssertEqual(result[kSecValueData] as? Data, expectedData) + XCTAssertTrue(result[kSecAttrAccessControl] as! SecAccessControl === expectedAccessControl) + XCTAssertEqual(result[kSecReturnData] as! Bool, false) + } +} diff --git a/ios/PolkadotVaultTests/Core/Keychain/KeychainAccessAdapterTests.swift b/ios/PolkadotVaultTests/Core/Keychain/Seeds/KeychainSeedsAccessAdapterTests.swift similarity index 97% rename from ios/PolkadotVaultTests/Core/Keychain/KeychainAccessAdapterTests.swift rename to ios/PolkadotVaultTests/Core/Keychain/Seeds/KeychainSeedsAccessAdapterTests.swift index 4f09c6bc4d..4c681c6d3e 100644 --- a/ios/PolkadotVaultTests/Core/Keychain/KeychainAccessAdapterTests.swift +++ b/ios/PolkadotVaultTests/Core/Keychain/Seeds/KeychainSeedsAccessAdapterTests.swift @@ -1,5 +1,5 @@ // -// KeychainAccessAdapterTests.swift +// KeychainSeedsAccessAdapterTests.swift // PolkadotVaultTests // // Created by Krzysztof Rodak on 22/11/2023. @@ -9,18 +9,18 @@ import Foundation @testable import PolkadotVault import XCTest -final class KeychainAccessAdapterTests: XCTestCase { - private var keychainQueryProviderMock: KeychainQueryProvidingMock! +final class KeychainSeedsAccessAdapterTests: XCTestCase { + private var keychainQueryProviderMock: KeychainSeedsQueryProvidingMock! private var accessControlProviderMock: AccessControlProvidingMock! private var keychainService: KeychainServiceMock! - private var keychainAccessAdapter: KeychainAccessAdapter! + private var keychainAccessAdapter: KeychainSeedsAccessAdapter! override func setUp() { super.setUp() - keychainQueryProviderMock = KeychainQueryProvidingMock() + keychainQueryProviderMock = KeychainSeedsQueryProvidingMock() accessControlProviderMock = AccessControlProvidingMock() keychainService = KeychainServiceMock() - keychainAccessAdapter = KeychainAccessAdapter( + keychainAccessAdapter = KeychainSeedsAccessAdapter( keychainService: keychainService, acccessControlProvider: accessControlProviderMock, queryProvider: keychainQueryProviderMock diff --git a/ios/PolkadotVaultTests/Core/Keychain/KeychainQueryProviderTests.swift b/ios/PolkadotVaultTests/Core/Keychain/Seeds/KeychainSeedsQueryProviderTests.swift similarity index 87% rename from ios/PolkadotVaultTests/Core/Keychain/KeychainQueryProviderTests.swift rename to ios/PolkadotVaultTests/Core/Keychain/Seeds/KeychainSeedsQueryProviderTests.swift index 505bb74741..12e07072bd 100644 --- a/ios/PolkadotVaultTests/Core/Keychain/KeychainQueryProviderTests.swift +++ b/ios/PolkadotVaultTests/Core/Keychain/Seeds/KeychainSeedsQueryProviderTests.swift @@ -1,5 +1,5 @@ // -// KeychainQueryProviderTests.swift +// KeychainSeedsQueryProviderTests.swift // PolkadotVaultTests // // Created by Krzysztof Rodak on 29/08/2022. @@ -9,17 +9,17 @@ import XCTest // swiftlint:disable force_cast -final class KeychainQueryProviderTests: XCTestCase { - private var subject: KeychainQueryProvider! +final class KeychainSeedsQueryProviderTests: XCTestCase { + private var subject: KeychainSeedsQueryProvider! override func setUp() { super.setUp() - subject = KeychainQueryProvider() + subject = KeychainSeedsQueryProvider() } func test_query_fetch_returnsExpectedValues() { // Given - let queryType: KeychainQuery = .fetch + let queryType: KeychainSeedsQuery = .fetch let expectedSecClass = kSecClassGenericPassword let expectedMatchLimit = kSecMatchLimitAll let expectedReturnAttributes = true @@ -37,7 +37,7 @@ final class KeychainQueryProviderTests: XCTestCase { func test_query_deleteAll_returnsExpectedValues() { // Given - let queryType: KeychainQuery = .deleteAll + let queryType: KeychainSeedsQuery = .deleteAll let expectedSecClass = kSecClassGenericPassword // When @@ -49,7 +49,7 @@ final class KeychainQueryProviderTests: XCTestCase { func test_query_check_returnsExpectedValues() { // Given - let queryType: KeychainQuery = .check + let queryType: KeychainSeedsQuery = .check let expectedSecClass = kSecClassGenericPassword let expectedMatchLimit = kSecMatchLimitAll let expectedReturnData = true @@ -66,7 +66,7 @@ final class KeychainQueryProviderTests: XCTestCase { func test_query_search_returnsExpectedValues() { // Given let seedName = "account" - let queryType: KeychainQuery = .search(seedName: seedName) + let queryType: KeychainSeedsQuery = .search(seedName: seedName) let expectedSecClass = kSecClassGenericPassword let expectedMatchLimit = kSecMatchLimitOne let expectedReturnData = true @@ -85,7 +85,7 @@ final class KeychainQueryProviderTests: XCTestCase { // Given let seedName = "account" let expectedSecClass = kSecClassGenericPassword - let queryType: KeychainQuery = .delete(seedName: seedName) + let queryType: KeychainSeedsQuery = .delete(seedName: seedName) // When let result = subject.query(for: queryType) as! [CFString: Any] @@ -103,7 +103,7 @@ final class KeychainQueryProviderTests: XCTestCase { let expectedAccessControl: SecAccessControl! = try? SimulatorAccessControlProvider() .accessControl() // it's fine to use it instead of mock, as! it's just dedicated to be used on simulator let expectedReturnData = true - let queryType: KeychainQuery = .restoreQuery( + let queryType: KeychainSeedsQuery = .restoreQuery( seedName: seedName, finalSeedPhrase: finalSeedPhrase, accessControl: expectedAccessControl diff --git a/ios/PolkadotVaultTests/Core/Keychain/SeedsMediatorTests.swift b/ios/PolkadotVaultTests/Core/Keychain/Seeds/SeedsMediatorTests.swift similarity index 99% rename from ios/PolkadotVaultTests/Core/Keychain/SeedsMediatorTests.swift rename to ios/PolkadotVaultTests/Core/Keychain/Seeds/SeedsMediatorTests.swift index 51e0a86212..b2581e39a9 100644 --- a/ios/PolkadotVaultTests/Core/Keychain/SeedsMediatorTests.swift +++ b/ios/PolkadotVaultTests/Core/Keychain/Seeds/SeedsMediatorTests.swift @@ -407,7 +407,7 @@ final class DatabaseMediatorMock: DatabaseMediating { } } -final class KeychainAccessAdapterMock: KeychainAccessAdapting { +final class KeychainAccessAdapterMock: KeychainSeedsAccessAdapting { // Properties to track method calls and arguments var fetchSeedNamesCallsCount = 0 var saveSeedCallsCount = 0 diff --git a/ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitActionModalViewModelTests.swift b/ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitActionModalViewModelTests.swift new file mode 100644 index 0000000000..7bcae7e444 --- /dev/null +++ b/ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitActionModalViewModelTests.swift @@ -0,0 +1,101 @@ +// +// BananaSplitActionModalViewModelTests.swift +// PolkadotVaultTests +// +// Created by Krzysztof Rodak on 04/03/2024. +// + +import Combine +import Foundation +@testable import PolkadotVault +import SwiftUI +import XCTest + +final class BananaSplitActionModalViewModelTests: XCTestCase { + private var viewModel: BananaSplitActionModal.ViewModel! + private var isPresented: Bool = false + private var shouldPresentDeleteBackupWarningModal: Bool! + private var shouldPresentPassphraseModal: Bool! + + override func setUp() { + super.setUp() + isPresented = true + shouldPresentDeleteBackupWarningModal = false + shouldPresentPassphraseModal = false + viewModel = BananaSplitActionModal.ViewModel( + isPresented: Binding( + get: { self.isPresented }, + + set: { self.isPresented = $0 } + ), + shouldPresentDeleteBackupWarningModal: Binding( + get: { self.shouldPresentDeleteBackupWarningModal }, + set: { self.shouldPresentDeleteBackupWarningModal = $0 } + ), + shouldPresentPassphraseModal: Binding( + get: { self.shouldPresentPassphraseModal }, + set: { self.shouldPresentPassphraseModal = $0 } + ) + ) + } + + override func tearDown() { + shouldPresentDeleteBackupWarningModal = nil + shouldPresentPassphraseModal = nil + isPresented = false + viewModel = nil + super.tearDown() + } + + func testRemoveBackup_TriggerWarningModalAndDismiss() { + // Given + let expectation = expectation(description: "RemoveBackupDismissal") + + // When + viewModel.removeBackup() + + // Then + XCTAssertTrue(shouldPresentDeleteBackupWarningModal) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + XCTAssertFalse(self.isPresented) + expectation.fulfill() + } + + waitForExpectations(timeout: 2) + } + + func testShowPassphrase_TriggerPassphraseModalAndDismiss() { + // Given + let expectation = expectation(description: "ShowPassphraseDismissal") + + // When + viewModel.showPassphrase() + + // Then + XCTAssertTrue(shouldPresentPassphraseModal) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + XCTAssertFalse(self.isPresented) + expectation.fulfill() + } + + waitForExpectations(timeout: 2) + } + + func testDismissActionSheet_TriggersAnimationAndDismissal() { + // Given + let expectation = expectation(description: "AnimateDismissal") + + // When + viewModel.dismissActionSheet() + + // Then + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + XCTAssertFalse(self.isPresented) + expectation.fulfill() + } + + waitForExpectations(timeout: 2) + } +} diff --git a/ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitPassphraseModalViewModelTests.swift b/ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitPassphraseModalViewModelTests.swift new file mode 100644 index 0000000000..79ea709239 --- /dev/null +++ b/ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitPassphraseModalViewModelTests.swift @@ -0,0 +1,93 @@ +// +// BananaSplitPassphraseModalViewModelTests.swift +// PolkadotVaultTests +// +// Created by Krzysztof Rodak on 04/03/2024. +// + +import Combine +import Foundation +@testable import PolkadotVault +import SwiftUI +import XCTest + +final class BananaSplitPassphraseModalViewModelTests: XCTestCase { + private var viewModel: BananaSplitPassphraseModal.ViewModel! + private var mediatorMock: KeychainBananaSplitAccessMediatingMock! + private var isPresented: Bool! + private var seedName: String! + + override func setUp() { + super.setUp() + seedName = "testSeed" + mediatorMock = KeychainBananaSplitAccessMediatingMock() + isPresented = true + } + + override func tearDown() { + viewModel = nil + mediatorMock = nil + seedName = nil + isPresented = nil + super.tearDown() + } + + func testInit_LoadsPassphraseOnSuccess() { + // Given + let expectedPassphrase = "loadedPassphrase" + mediatorMock + .retrieveBananaSplitPassphraseWithReturnValue = + .success(BananaSplitPassphrase(passphrase: expectedPassphrase)) + + // When + viewModel = BananaSplitPassphraseModal.ViewModel( + seedName: seedName, + isPresented: Binding(get: { self.isPresented }, set: { self.isPresented = $0 }), + bananaSplitMediator: mediatorMock + ) + + // Then + XCTAssertEqual(mediatorMock.retrieveBananaSplitPassphraseWithCallsCount, 1) + XCTAssertEqual(mediatorMock.retrieveBananaSplitPassphraseWithReceivedSeedName, [seedName]) + XCTAssertEqual(viewModel.passphrase, expectedPassphrase) + } + + func testInit_DoesNotLoadPassphraseOnFailure() { + // Given + mediatorMock.retrieveBananaSplitPassphraseWithReturnValue = .failure(.fetchError) + + // When + viewModel = BananaSplitPassphraseModal.ViewModel( + seedName: "testSeed", + isPresented: Binding(get: { self.isPresented }, set: { self.isPresented = $0 }), + bananaSplitMediator: mediatorMock + ) + + // Then + XCTAssertEqual(mediatorMock.retrieveBananaSplitPassphraseWithCallsCount, 1) + XCTAssertEqual(mediatorMock.retrieveBananaSplitPassphraseWithReceivedSeedName, ["testSeed"]) + XCTAssertEqual(viewModel.passphrase, "") + } + + func testDismissActionSheet_TriggersAnimationAndDismissal() { + // Given + let expectation = expectation(description: "AnimateDismissal") + mediatorMock.retrieveBananaSplitPassphraseWithReturnValue = .failure(.fetchError) + viewModel = BananaSplitPassphraseModal.ViewModel( + seedName: seedName, + isPresented: Binding(get: { self.isPresented }, set: { self.isPresented = $0 }), + bananaSplitMediator: mediatorMock + ) + + // When + viewModel.dismissActionSheet() + + // Then + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + XCTAssertFalse(self.isPresented) + expectation.fulfill() + } + + waitForExpectations(timeout: 2, handler: nil) + } +} diff --git a/ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitQRCodeModalViewModelTests.swift b/ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitQRCodeModalViewModelTests.swift new file mode 100644 index 0000000000..d243e287ac --- /dev/null +++ b/ios/PolkadotVaultTests/Screens/KeyDetails/BananaSplit/BananaSplitQRCodeModalViewModelTests.swift @@ -0,0 +1,106 @@ +// +// BananaSplitQRCodeModalViewModelTests.swift +// PolkadotVaultTests +// +// Created by Krzysztof Rodak on 04/03/2024. +// + +import Combine +import Foundation +@testable import PolkadotVault +import SwiftUI +import XCTest + +final class BananaSplitQRCodeModalViewModelTests: XCTestCase { + private var viewModel: BananaSplitQRCodeModalView.ViewModel! + private var mediatorMock: KeychainBananaSplitAccessMediatingMock! + private var completionAction: BananaSplitQRCodeModalView.OnCompletionAction? + private var testSeedName: String! + private var testBananaSplitBackup: BananaSplitBackup! + + override func setUp() { + super.setUp() + testSeedName = "testSeed" + testBananaSplitBackup = BananaSplitBackup(qrCodes: [[10]]) + mediatorMock = KeychainBananaSplitAccessMediatingMock() + viewModel = BananaSplitQRCodeModalView.ViewModel( + seedName: testSeedName, + bananaSplitBackup: testBananaSplitBackup, + bananaSplitMediator: mediatorMock, + onCompletion: { [weak self] action in + self?.completionAction = action + } + ) + } + + override func tearDown() { + testSeedName = nil + testBananaSplitBackup = nil + viewModel = nil + mediatorMock = nil + completionAction = nil + super.tearDown() + } + + func testOnMoreButtonTap_PresentsActionSheet() { + // When + viewModel.onMoreButtonTap() + + // Then + XCTAssertTrue(viewModel.isPresentingActionSheet) + } + + func testOnCloseTap_CompletesWithClose() { + // When + viewModel.onCloseTap() + + // Then + XCTAssertEqual(completionAction, .close) + } + + func testCheckForActionsPresentation_PresentsPassphraseModal() { + // Given + viewModel.shouldPresentPassphraseModal = true + + // When + viewModel.checkForActionsPresentation() + + // Then + XCTAssertTrue(viewModel.isPresentingPassphraseModal) + } + + func testCheckForActionsPresentation_PresentsDeleteBackupWarningModal() { + // Given + viewModel.shouldPresentDeleteBackupWarningModal = true + + // When + viewModel.checkForActionsPresentation() + + // Then + XCTAssertTrue(viewModel.isPresentingDeleteBackupWarningModal) + } + + func testOnDeleteBackupTap_DeletesBackupSuccessfully() { + // Given + mediatorMock.removeBananaSplitBackupSeedNameReturnValue = .success(()) + + // When + viewModel.onDeleteBackupTap() + + // Then + XCTAssertEqual(completionAction, .backupDeleted) + } + + func testOnDeleteBackupTap_FailsToDeleteBackup() { + // Given + let expectedError = KeychainError.checkError + mediatorMock.removeBananaSplitBackupSeedNameReturnValue = .failure(expectedError) + + // When + viewModel.onDeleteBackupTap() + + // Then + XCTAssertTrue(viewModel.isPresentingError) + XCTAssertEqual(viewModel.presentableError, .alertError(message: expectedError.localizedDescription)) + } +}