diff --git a/Authenticator.xcodeproj/project.pbxproj b/Authenticator.xcodeproj/project.pbxproj index 29f46938..2ddaa3f0 100644 --- a/Authenticator.xcodeproj/project.pbxproj +++ b/Authenticator.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 51002C2E267C95D9005D5A7C /* YubiKeyInformationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51002C2D267C95D9005D5A7C /* YubiKeyInformationViewModel.swift */; }; - 51394C5826CFE15F009F366D /* Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51394C5726CFE15F009F366D /* Menu.swift */; }; + 51394C5826CFE15F009F366D /* YubiMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51394C5726CFE15F009F366D /* YubiMenu.swift */; }; 51394C5A26D40E64009F366D /* UIGestureRecognizer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51394C5926D40E64009F366D /* UIGestureRecognizer+Extensions.swift */; }; 51394C5C26D4D460009F366D /* MenuGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51394C5B26D4D460009F366D /* MenuGestureRecognizer.swift */; }; 513D4DF22660D6570022C53D /* AddCredentialController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513D4DF12660D6570022C53D /* AddCredentialController.swift */; }; @@ -39,7 +39,6 @@ 5156D05F265D3CEF007A94F8 /* TokenRequestViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5156D05E265D3CEF007A94F8 /* TokenRequestViewModel.swift */; }; 5180974326DE185100A122C1 /* ResetOATHViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5180974226DE185100A122C1 /* ResetOATHViewModel.swift */; }; 51A162862678A1F100C3FA1E /* OATHConfigurationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A162852678A1F100C3FA1E /* OATHConfigurationController.swift */; }; - 51AFD4D227103E36008F2630 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51AFD4D127103E36008F2630 /* SearchBar.swift */; }; 51AFD4D42716FC78008F2630 /* NFCSettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51AFD4D32716FC78008F2630 /* NFCSettingsController.swift */; }; 51AFD4D62716FCDB008F2630 /* ApplicationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51AFD4D52716FCDB008F2630 /* ApplicationSettingsViewModel.swift */; }; 51AFD4D827196AB6008F2630 /* VersionHistory.plist in Resources */ = {isa = PBXBuildFile; fileRef = 51AFD4D727196AB6008F2630 /* VersionHistory.plist */; }; @@ -47,7 +46,6 @@ 51BBE37F273982D700DA47CC /* YKFOATHSession+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BBE37E273982D700DA47CC /* YKFOATHSession+Extensions.swift */; }; 51D1E84C264134F300BDA3FF /* UIAlertController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D1E84B264134F300BDA3FF /* UIAlertController+Extensions.swift */; }; 51D1E84E26427F7600BDA3FF /* PasswordCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D1E84D26427F7600BDA3FF /* PasswordCache.swift */; }; - 51E9BE3A26D9038C00F9E2FC /* OATHCodeDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E9BE3926D9038C00F9E2FC /* OATHCodeDetailsView.swift */; }; 51EEC532246D34ED00061A8F /* YKFKeyVersion+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51EEC531246D34ED00061A8F /* YKFKeyVersion+Extensions.swift */; }; 51EEC536246D38B700061A8F /* YKFKeyVersionExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51EEC535246D38B700061A8F /* YKFKeyVersionExtensionsTests.swift */; }; 51F8E3BD263847BD0010686B /* ManagementViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F8E3BC263847BD0010686B /* ManagementViewModel.swift */; }; @@ -59,17 +57,10 @@ 816C685223440EF900209342 /* SecureStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816C685123440EF900209342 /* SecureStoreTests.swift */; }; 816C685E234697BF00209342 /* PasswordPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816C685D234697BE00209342 /* PasswordPreferences.swift */; }; 817DBA0D2368B7B7001FC18D /* UIColorAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 817DBA0C2368B7B7001FC18D /* UIColorAdditions.swift */; }; - 818866B722DFD729006BC0A8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818866B622DFD729006BC0A8 /* AppDelegate.swift */; }; 818866BE22DFD729006BC0A8 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 818866BC22DFD729006BC0A8 /* Main.storyboard */; }; 818866C022DFD72C006BC0A8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 818866BF22DFD72C006BC0A8 /* Assets.xcassets */; }; 818866C322DFD72C006BC0A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 818866C122DFD72C006BC0A8 /* LaunchScreen.storyboard */; }; 818866CE22DFD72C006BC0A8 /* AuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818866CD22DFD72C006BC0A8 /* AuthenticatorTests.swift */; }; - 818866E922E18134006BC0A8 /* OATHViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818866E822E18134006BC0A8 /* OATHViewController.swift */; }; - 818866F822E8E19D006BC0A8 /* CredentialTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818866F722E8E19D006BC0A8 /* CredentialTableViewCell.swift */; }; - 818866FA22E96B47006BC0A8 /* PieProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818866F922E96B47006BC0A8 /* PieProgressBar.swift */; }; - 818BE8DF22F39A9000061026 /* GlobalTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818BE8DE22F39A9000061026 /* GlobalTimer.swift */; }; - 81C00FDB22EB70A500C54903 /* OATHViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81C00FDA22EB70A500C54903 /* OATHViewModel.swift */; }; - 81C00FDD22EBEF0D00C54903 /* Credential.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81C00FDC22EBEF0D00C54903 /* Credential.swift */; }; 81C4E43D22F94B7B003AFBB8 /* KeySessionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81C4E43C22F94B7B003AFBB8 /* KeySessionError.swift */; }; 81DF8FD3237A090A00737268 /* ApplicationSessionObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81DF8FD2237A090A00737268 /* ApplicationSessionObserver.swift */; }; 81FA3C32231AF289009C22AB /* SetPasswordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81FA3C31231AF289009C22AB /* SetPasswordViewController.swift */; }; @@ -88,10 +79,30 @@ A5E9DEB0237DE1660011FBF4 /* SettingsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E9DEAF237DE1660011FBF4 /* SettingsConfig.swift */; }; B40327742847AB5000DF4DB0 /* LicensingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40327732847AB5000DF4DB0 /* LicensingViewController.swift */; }; B40327762847AE0A00DF4DB0 /* Licensing.md in Resources */ = {isa = PBXBuildFile; fileRef = B40327752847AE0A00DF4DB0 /* Licensing.md */; }; + B411242F29D423A300D58001 /* ListStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B411242E29D423A300D58001 /* ListStatusView.swift */; }; B432B1BF28B65B8600A7182F /* YubiKit in Frameworks */ = {isa = PBXBuildFile; productRef = B432B1BE28B65B8600A7182F /* YubiKit */; }; + B452EC1F2A1E4F460045E5D9 /* YubiOtpRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B452EC1E2A1E4F460045E5D9 /* YubiOtpRowView.swift */; }; + B452EC3D2A264A620045E5D9 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B452EC3C2A264A620045E5D9 /* ToastView.swift */; }; + B452EC442A2A06940045E5D9 /* ToastPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B452EC432A2A06940045E5D9 /* ToastPresenter.swift */; }; + B46E378729C348F100CA0B67 /* EditAccountWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46E378629C348F100CA0B67 /* EditAccountWrapper.swift */; }; B4712B7028DDB5F6009B270D /* AccessKeyCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4712B6F28DDB5F6009B270D /* AccessKeyCache.swift */; }; + B4719B17298AA6E7006CDAEA /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4719B16298AA6E7006CDAEA /* MainView.swift */; }; + B4719B19298AA757006CDAEA /* AuthenticatorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4719B18298AA757006CDAEA /* AuthenticatorApp.swift */; }; + B4719B1B298AB641006CDAEA /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4719B1A298AB641006CDAEA /* MainViewModel.swift */; }; + B4719B2C29914051006CDAEA /* AccountRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4719B2B29914051006CDAEA /* AccountRowView.swift */; }; + B4719B322993EFEE006CDAEA /* AccountDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4719B312993EFEE006CDAEA /* AccountDetailsView.swift */; }; B4B1711827DF8C48002A62DE /* ScanAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B1711727DF8C48002A62DE /* ScanAccountView.swift */; }; - B4EE055F2A0CDB3C002F30D4 /* OTPTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4EE055E2A0CDB3C002F30D4 /* OTPTableViewCell.swift */; }; + B4C93E60299D156C00C2A8B8 /* ErrorAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C93E5F299D156C00C2A8B8 /* ErrorAlertView.swift */; }; + B4C93E63299FB51A00C2A8B8 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C93E62299FB51A00C2A8B8 /* Account.swift */; }; + B4C93E65299FC67800C2A8B8 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C93E64299FC67800C2A8B8 /* View+Extensions.swift */; }; + B4C93E8929B89DE300C2A8B8 /* DetachedMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C93E8829B89DE300C2A8B8 /* DetachedMenu.swift */; }; + B4C93E9129C0B70B00C2A8B8 /* ConfigurationWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C93E9029C0B70B00C2A8B8 /* ConfigurationWrapper.swift */; }; + B4C93E9329C1B2BC00C2A8B8 /* AboutWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C93E9229C1B2BC00C2A8B8 /* AboutWrapper.swift */; }; + B4C93E9529C1B90900C2A8B8 /* AddAccountWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C93E9429C1B90900C2A8B8 /* AddAccountWrapper.swift */; }; + B4DB228A299BC373003110ED /* OATHSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4DB2289299BC373003110ED /* OATHSession.swift */; }; + B4FE90D02A42028400B59170 /* VersionHistoryWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FE90CF2A42028400B59170 /* VersionHistoryWrapper.swift */; }; + B4FE90D22A4431AB00B59170 /* NotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FE90D12A4431AB00B59170 /* NotificationsViewModel.swift */; }; + B4FE90D42A443D8400B59170 /* TokenRequestWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FE90D32A443D8400B59170 /* TokenRequestWrapper.swift */; }; B9F0FF11F842A39183974083 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; /* End PBXBuildFile section */ @@ -128,7 +139,7 @@ /* Begin PBXFileReference section */ 51002C2D267C95D9005D5A7C /* YubiKeyInformationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YubiKeyInformationViewModel.swift; sourceTree = ""; }; - 51394C5726CFE15F009F366D /* Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Menu.swift; sourceTree = ""; }; + 51394C5726CFE15F009F366D /* YubiMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YubiMenu.swift; sourceTree = ""; }; 51394C5926D40E64009F366D /* UIGestureRecognizer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIGestureRecognizer+Extensions.swift"; sourceTree = ""; }; 51394C5B26D4D460009F366D /* MenuGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuGestureRecognizer.swift; sourceTree = ""; }; 513D4DF12660D6570022C53D /* AddCredentialController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCredentialController.swift; sourceTree = ""; }; @@ -163,7 +174,6 @@ 5156D05E265D3CEF007A94F8 /* TokenRequestViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenRequestViewModel.swift; sourceTree = ""; }; 5180974226DE185100A122C1 /* ResetOATHViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetOATHViewModel.swift; sourceTree = ""; }; 51A162852678A1F100C3FA1E /* OATHConfigurationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OATHConfigurationController.swift; sourceTree = ""; }; - 51AFD4D127103E36008F2630 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; 51AFD4D32716FC78008F2630 /* NFCSettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCSettingsController.swift; sourceTree = ""; }; 51AFD4D52716FCDB008F2630 /* ApplicationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationSettingsViewModel.swift; sourceTree = ""; }; 51AFD4D727196AB6008F2630 /* VersionHistory.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = VersionHistory.plist; sourceTree = ""; }; @@ -171,7 +181,6 @@ 51BBE37E273982D700DA47CC /* YKFOATHSession+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "YKFOATHSession+Extensions.swift"; sourceTree = ""; }; 51D1E84B264134F300BDA3FF /* UIAlertController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Extensions.swift"; sourceTree = ""; }; 51D1E84D26427F7600BDA3FF /* PasswordCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordCache.swift; sourceTree = ""; }; - 51E9BE3926D9038C00F9E2FC /* OATHCodeDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OATHCodeDetailsView.swift; sourceTree = ""; }; 51EEC531246D34ED00061A8F /* YKFKeyVersion+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "YKFKeyVersion+Extensions.swift"; sourceTree = ""; }; 51EEC535246D38B700061A8F /* YKFKeyVersionExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YKFKeyVersionExtensionsTests.swift; sourceTree = ""; }; 51F8E3BC263847BD0010686B /* ManagementViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagementViewModel.swift; sourceTree = ""; }; @@ -184,7 +193,6 @@ 816C685D234697BE00209342 /* PasswordPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPreferences.swift; sourceTree = ""; }; 817DBA0C2368B7B7001FC18D /* UIColorAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColorAdditions.swift; sourceTree = ""; }; 818866B322DFD729006BC0A8 /* Authenticator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Authenticator.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 818866B622DFD729006BC0A8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 818866BD22DFD729006BC0A8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 818866BF22DFD72C006BC0A8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 818866C222DFD72C006BC0A8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -192,14 +200,8 @@ 818866C922DFD72C006BC0A8 /* AuthenticatorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AuthenticatorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 818866CD22DFD72C006BC0A8 /* AuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatorTests.swift; sourceTree = ""; }; 818866CF22DFD72C006BC0A8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 818866E822E18134006BC0A8 /* OATHViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OATHViewController.swift; path = Authenticator/UI/Authentication/OATHViewController.swift; sourceTree = SOURCE_ROOT; }; 818866F122E18804006BC0A8 /* Authenticator-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Authenticator-Bridging-Header.h"; sourceTree = ""; }; 818866F622E7A877006BC0A8 /* Authenticator.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Authenticator.entitlements; sourceTree = ""; }; - 818866F722E8E19D006BC0A8 /* CredentialTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CredentialTableViewCell.swift; path = Authenticator/UI/Authentication/CredentialTableViewCell.swift; sourceTree = SOURCE_ROOT; }; - 818866F922E96B47006BC0A8 /* PieProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PieProgressBar.swift; path = Authenticator/UI/Authentication/PieProgressBar.swift; sourceTree = SOURCE_ROOT; }; - 818BE8DE22F39A9000061026 /* GlobalTimer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlobalTimer.swift; sourceTree = ""; }; - 81C00FDA22EB70A500C54903 /* OATHViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OATHViewModel.swift; sourceTree = ""; }; - 81C00FDC22EBEF0D00C54903 /* Credential.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Credential.swift; sourceTree = ""; }; 81C4E43C22F94B7B003AFBB8 /* KeySessionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeySessionError.swift; sourceTree = ""; }; 81DF8FD2237A090A00737268 /* ApplicationSessionObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationSessionObserver.swift; sourceTree = ""; }; 81FA3C31231AF289009C22AB /* SetPasswordViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPasswordViewController.swift; sourceTree = ""; }; @@ -218,10 +220,30 @@ A5E9DEAF237DE1660011FBF4 /* SettingsConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsConfig.swift; sourceTree = ""; }; B40327732847AB5000DF4DB0 /* LicensingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicensingViewController.swift; sourceTree = ""; }; B40327752847AE0A00DF4DB0 /* Licensing.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Licensing.md; sourceTree = ""; }; + B411242E29D423A300D58001 /* ListStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStatusView.swift; sourceTree = ""; }; + B452EC1E2A1E4F460045E5D9 /* YubiOtpRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YubiOtpRowView.swift; sourceTree = ""; }; + B452EC3C2A264A620045E5D9 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; + B452EC432A2A06940045E5D9 /* ToastPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastPresenter.swift; sourceTree = ""; }; + B46E378629C348F100CA0B67 /* EditAccountWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccountWrapper.swift; sourceTree = ""; }; B4712B6F28DDB5F6009B270D /* AccessKeyCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessKeyCache.swift; sourceTree = ""; }; + B4719B16298AA6E7006CDAEA /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + B4719B18298AA757006CDAEA /* AuthenticatorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatorApp.swift; sourceTree = ""; }; + B4719B1A298AB641006CDAEA /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = ""; }; + B4719B2B29914051006CDAEA /* AccountRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountRowView.swift; sourceTree = ""; }; + B4719B312993EFEE006CDAEA /* AccountDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDetailsView.swift; sourceTree = ""; }; B4B1711727DF8C48002A62DE /* ScanAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanAccountView.swift; sourceTree = ""; }; + B4C93E5F299D156C00C2A8B8 /* ErrorAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlertView.swift; sourceTree = ""; }; + B4C93E62299FB51A00C2A8B8 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; + B4C93E64299FC67800C2A8B8 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; + B4C93E8829B89DE300C2A8B8 /* DetachedMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetachedMenu.swift; sourceTree = ""; }; + B4C93E9029C0B70B00C2A8B8 /* ConfigurationWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationWrapper.swift; sourceTree = ""; }; + B4C93E9229C1B2BC00C2A8B8 /* AboutWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutWrapper.swift; sourceTree = ""; }; + B4C93E9429C1B90900C2A8B8 /* AddAccountWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountWrapper.swift; sourceTree = ""; }; + B4DB2289299BC373003110ED /* OATHSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OATHSession.swift; sourceTree = ""; }; B4E9D46228B65B5100D51FFC /* yubikit-ios */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "yubikit-ios"; path = "../yubikit-ios"; sourceTree = ""; }; - B4EE055E2A0CDB3C002F30D4 /* OTPTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPTableViewCell.swift; sourceTree = ""; }; + B4FE90CF2A42028400B59170 /* VersionHistoryWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionHistoryWrapper.swift; sourceTree = ""; }; + B4FE90D12A4431AB00B59170 /* NotificationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsViewModel.swift; sourceTree = ""; }; + B4FE90D32A443D8400B59170 /* TokenRequestWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenRequestWrapper.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -256,11 +278,6 @@ 513D4DEF2660D5960022C53D /* Authentication */ = { isa = PBXGroup; children = ( - 818866E822E18134006BC0A8 /* OATHViewController.swift */, - 818866F722E8E19D006BC0A8 /* CredentialTableViewCell.swift */, - 51E9BE3926D9038C00F9E2FC /* OATHCodeDetailsView.swift */, - 818866F922E96B47006BC0A8 /* PieProgressBar.swift */, - B4EE055E2A0CDB3C002F30D4 /* OTPTableViewCell.swift */, 513F34BC24633A4A00FCE030 /* EditCredential */, 81FA3C2B2319839B009C22AB /* AddCredential */, ); @@ -270,6 +287,7 @@ 513D4DF02660D5AB0022C53D /* YubiKeyConfiguration */ = { isa = PBXGroup; children = ( + B4C93E9029C0B70B00C2A8B8 /* ConfigurationWrapper.swift */, 513D4DF42660EBA40022C53D /* ConfigurationController.swift */, 51A162852678A1F100C3FA1E /* OATHConfigurationController.swift */, A5D4E86C24083CF300FD63A0 /* OTPConfigurationController.swift */, @@ -284,6 +302,7 @@ isa = PBXGroup; children = ( 5156D05C265D2602007A94F8 /* TokenRequestViewController.swift */, + B4FE90D32A443D8400B59170 /* TokenRequestWrapper.swift */, ); path = TokenSession; sourceTree = ""; @@ -291,6 +310,7 @@ 513F34BC24633A4A00FCE030 /* EditCredential */ = { isa = PBXGroup; children = ( + B46E378629C348F100CA0B67 /* EditAccountWrapper.swift */, 513F34C12463F44300FCE030 /* EditCredentialController.swift */, 513F34C72464658F00FCE030 /* SettingsRowView.swift */, 513F34BF2463F0C900FCE030 /* EditFieldController.swift */, @@ -365,10 +385,13 @@ 513F34DB246B144300FCE030 /* UIViewAdditions.swift */, 81FA3C38231AF4F0009C22AB /* UIViewControllerAdditions.swift */, 513D4DF6266634280022C53D /* ShowTime.swift */, - 51394C5726CFE15F009F366D /* Menu.swift */, + 51394C5726CFE15F009F366D /* YubiMenu.swift */, 51394C5926D40E64009F366D /* UIGestureRecognizer+Extensions.swift */, 51394C5B26D4D460009F366D /* MenuGestureRecognizer.swift */, - 51AFD4D127103E36008F2630 /* SearchBar.swift */, + B4C93E64299FC67800C2A8B8 /* View+Extensions.swift */, + B4C93E8829B89DE300C2A8B8 /* DetachedMenu.swift */, + B452EC3C2A264A620045E5D9 /* ToastView.swift */, + B452EC432A2A06940045E5D9 /* ToastPresenter.swift */, ); path = Helpers; sourceTree = ""; @@ -402,7 +425,7 @@ 818BE8DA22F3485600061026 /* UI */, 818866F622E7A877006BC0A8 /* Authenticator.entitlements */, 818866F122E18804006BC0A8 /* Authenticator-Bridging-Header.h */, - 818866B622DFD729006BC0A8 /* AppDelegate.swift */, + B4719B18298AA757006CDAEA /* AuthenticatorApp.swift */, 818866BF22DFD72C006BC0A8 /* Assets.xcassets */, 818866C422DFD72C006BC0A8 /* Info.plist */, 51AFD4D727196AB6008F2630 /* VersionHistory.plist */, @@ -434,6 +457,12 @@ A591412023835F4600CCCF67 /* Constants.swift */, 818866BC22DFD729006BC0A8 /* Main.storyboard */, 818866C122DFD72C006BC0A8 /* LaunchScreen.storyboard */, + B4719B16298AA6E7006CDAEA /* MainView.swift */, + B411242E29D423A300D58001 /* ListStatusView.swift */, + B4719B2B29914051006CDAEA /* AccountRowView.swift */, + B452EC1E2A1E4F460045E5D9 /* YubiOtpRowView.swift */, + B4719B312993EFEE006CDAEA /* AccountDetailsView.swift */, + B4C93E5F299D156C00C2A8B8 /* ErrorAlertView.swift */, ); path = UI; sourceTree = ""; @@ -446,12 +475,9 @@ 816C684423430DC000209342 /* SecureStore */, 81DF8FD2237A090A00737268 /* ApplicationSessionObserver.swift */, 51F8E3BE263848E30010686B /* Connection.swift */, - 81C00FDC22EBEF0D00C54903 /* Credential.swift */, A5588BF8239B0A7F003E4CA5 /* FavoritesStorage.swift */, - 818BE8DE22F39A9000061026 /* GlobalTimer.swift */, 81C4E43C22F94B7B003AFBB8 /* KeySessionError.swift */, 51F8E3BC263847BD0010686B /* ManagementViewModel.swift */, - 81C00FDA22EB70A500C54903 /* OATHViewModel.swift */, 51D1E84D26427F7600BDA3FF /* PasswordCache.swift */, B4712B6F28DDB5F6009B270D /* AccessKeyCache.swift */, 515542612649C88900B19C59 /* PasswordConfigurationViewModel.swift */, @@ -463,6 +489,10 @@ 5180974226DE185100A122C1 /* ResetOATHViewModel.swift */, 51AFD4D52716FCDB008F2630 /* ApplicationSettingsViewModel.swift */, A5E9DEAF237DE1660011FBF4 /* SettingsConfig.swift */, + B4DB2289299BC373003110ED /* OATHSession.swift */, + B4C93E62299FB51A00C2A8B8 /* Account.swift */, + B4719B1A298AB641006CDAEA /* MainViewModel.swift */, + B4FE90D12A4431AB00B59170 /* NotificationsViewModel.swift */, ); path = Model; sourceTree = ""; @@ -470,6 +500,7 @@ 81FA3C2B2319839B009C22AB /* AddCredential */ = { isa = PBXGroup; children = ( + B4C93E9429C1B90900C2A8B8 /* AddAccountWrapper.swift */, 513D4DF12660D6570022C53D /* AddCredentialController.swift */, B4B1711727DF8C48002A62DE /* ScanAccountView.swift */, 81FA3C33231AF2D8009C22AB /* AdvancedSettingsViewController.swift */, @@ -481,7 +512,9 @@ 81FA3C30231AF198009C22AB /* Help */ = { isa = PBXGroup; children = ( + B4C93E9229C1B2BC00C2A8B8 /* AboutWrapper.swift */, 811CD95622FB276A00E2BCBB /* HelpViewController.swift */, + B4FE90CF2A42028400B59170 /* VersionHistoryWrapper.swift */, A5B531F12437CD16008C501C /* VersionHistoryViewController.swift */, B40327732847AB5000DF4DB0 /* LicensingViewController.swift */, ); @@ -668,46 +701,53 @@ A591412123835F4600CCCF67 /* Constants.swift in Sources */, 515542852656A30B00B19C59 /* UIButton+Extensions.swift in Sources */, 51BBE37F273982D700DA47CC /* YKFOATHSession+Extensions.swift in Sources */, + B411242F29D423A300D58001 /* ListStatusView.swift in Sources */, 816C684823430F8E00209342 /* SecureStoreQueryable.swift in Sources */, 515542882656F64100B19C59 /* Data+Extensions.swift in Sources */, 51002C2E267C95D9005D5A7C /* YubiKeyInformationViewModel.swift in Sources */, + B4FE90D22A4431AB00B59170 /* NotificationsViewModel.swift in Sources */, + B4719B17298AA6E7006CDAEA /* MainView.swift in Sources */, + B452EC442A2A06940045E5D9 /* ToastPresenter.swift in Sources */, 5156D05D265D2602007A94F8 /* TokenRequestViewController.swift in Sources */, + B4FE90D02A42028400B59170 /* VersionHistoryWrapper.swift in Sources */, A525965B23A45501006AA3C0 /* UIImageAdditions.swift in Sources */, 51A162862678A1F100C3FA1E /* OATHConfigurationController.swift in Sources */, 515542622649C88900B19C59 /* PasswordConfigurationViewModel.swift in Sources */, + B4C93E60299D156C00C2A8B8 /* ErrorAlertView.swift in Sources */, A591411D23830EB800CCCF67 /* UIApplicationExtension.swift in Sources */, 81FA3C34231AF2D8009C22AB /* AdvancedSettingsViewController.swift in Sources */, - 818866B722DFD729006BC0A8 /* AppDelegate.swift in Sources */, 817DBA0D2368B7B7001FC18D /* UIColorAdditions.swift in Sources */, 513F34C82464658F00FCE030 /* SettingsRowView.swift in Sources */, 51F8E3BF263848E30010686B /* Connection.swift in Sources */, + B46E378729C348F100CA0B67 /* EditAccountWrapper.swift in Sources */, B4712B7028DDB5F6009B270D /* AccessKeyCache.swift in Sources */, 513F34DC246B144300FCE030 /* UIViewAdditions.swift in Sources */, A5E9DEB0237DE1660011FBF4 /* SettingsConfig.swift in Sources */, + B4719B2C29914051006CDAEA /* AccountRowView.swift in Sources */, B40327742847AB5000DF4DB0 /* LicensingViewController.swift in Sources */, - 51AFD4D227103E36008F2630 /* SearchBar.swift in Sources */, + B4DB228A299BC373003110ED /* OATHSession.swift in Sources */, 811CD95722FB276A00E2BCBB /* HelpViewController.swift in Sources */, - B4EE055F2A0CDB3C002F30D4 /* OTPTableViewCell.swift in Sources */, - 81C00FDB22EB70A500C54903 /* OATHViewModel.swift in Sources */, - 818866E922E18134006BC0A8 /* OATHViewController.swift in Sources */, + B4719B322993EFEE006CDAEA /* AccountDetailsView.swift in Sources */, 51D1E84C264134F300BDA3FF /* UIAlertController+Extensions.swift in Sources */, 513D4DF9266A21250022C53D /* DelegateStack.swift in Sources */, 81FA3C32231AF289009C22AB /* SetPasswordViewController.swift in Sources */, + B4FE90D42A443D8400B59170 /* TokenRequestWrapper.swift in Sources */, 5155428D2657015D00B19C59 /* TokenCertificateStorage.swift in Sources */, - 51E9BE3A26D9038C00F9E2FC /* OATHCodeDetailsView.swift in Sources */, - 818866F822E8E19D006BC0A8 /* CredentialTableViewCell.swift in Sources */, - 81C00FDD22EBEF0D00C54903 /* Credential.swift in Sources */, 513F34C22463F44300FCE030 /* EditCredentialController.swift in Sources */, + B4C93E9129C0B70B00C2A8B8 /* ConfigurationWrapper.swift in Sources */, 513D4DF22660D6570022C53D /* AddCredentialController.swift in Sources */, 51D1E84E26427F7600BDA3FF /* PasswordCache.swift in Sources */, A5D4E86D24083CF300FD63A0 /* OTPConfigurationController.swift in Sources */, 5156D05F265D3CEF007A94F8 /* TokenRequestViewModel.swift in Sources */, 5155426A26554CAB00B19C59 /* SmartCardViewModel.swift in Sources */, - 818866FA22E96B47006BC0A8 /* PieProgressBar.swift in Sources */, + B4C93E9329C1B2BC00C2A8B8 /* AboutWrapper.swift in Sources */, 515542682654413600B19C59 /* SmartCardConfigurationController.swift in Sources */, 5155428326569ADD00B19C59 /* UIControl+Extensions.swift in Sources */, + B452EC1F2A1E4F460045E5D9 /* YubiOtpRowView.swift in Sources */, A544948F23CE546B003E1E07 /* TutorialPagesViewControllers.swift in Sources */, 51AFD4D42716FC78008F2630 /* NFCSettingsController.swift in Sources */, + B4719B19298AA757006CDAEA /* AuthenticatorApp.swift in Sources */, + 51AFD4D42716FC78008F2630 /* NFCSettingsController.swift in Sources */, 51394C5C26D4D460009F366D /* MenuGestureRecognizer.swift in Sources */, A5588BF9239B0A7F003E4CA5 /* FavoritesStorage.swift in Sources */, B4B1711827DF8C48002A62DE /* ScanAccountView.swift in Sources */, @@ -715,21 +755,26 @@ 513F34C02463F0C900FCE030 /* EditFieldController.swift in Sources */, 5155428A2656F79500B19C59 /* SecCertificate+Extensions.swift in Sources */, 816C685E234697BF00209342 /* PasswordPreferences.swift in Sources */, - 818BE8DF22F39A9000061026 /* GlobalTimer.swift in Sources */, + B4C93E9529C1B90900C2A8B8 /* AddAccountWrapper.swift in Sources */, + B452EC3D2A264A620045E5D9 /* ToastView.swift in Sources */, 513D4DF7266634280022C53D /* ShowTime.swift in Sources */, 513D4DF52660EBA40022C53D /* ConfigurationController.swift in Sources */, + B4C93E8929B89DE300C2A8B8 /* DetachedMenu.swift in Sources */, 81C4E43D22F94B7B003AFBB8 /* KeySessionError.swift in Sources */, A544948E23CE546B003E1E07 /* TutorialViewController.swift in Sources */, 51F8E3BD263847BD0010686B /* ManagementViewModel.swift in Sources */, 51EEC532246D34ED00061A8F /* YKFKeyVersion+Extensions.swift in Sources */, - 51394C5826CFE15F009F366D /* Menu.swift in Sources */, + B4719B1B298AB641006CDAEA /* MainViewModel.swift in Sources */, + 51394C5826CFE15F009F366D /* YubiMenu.swift in Sources */, A5B531F22437CD16008C501C /* VersionHistoryViewController.swift in Sources */, + B4C93E65299FC67800C2A8B8 /* View+Extensions.swift in Sources */, 816C685023440D2200209342 /* SecureStoreError.swift in Sources */, 515542602649BDDB00B19C59 /* PasswordStatusViewModel.swift in Sources */, 5180974326DE185100A122C1 /* ResetOATHViewModel.swift in Sources */, 81FA3C39231AF4F0009C22AB /* UIViewControllerAdditions.swift in Sources */, 816C684623430E0700209342 /* SecureStore.swift in Sources */, 51394C5A26D40E64009F366D /* UIGestureRecognizer+Extensions.swift in Sources */, + B4C93E63299FB51A00C2A8B8 /* Account.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -783,7 +828,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = TokenExtension/TokenExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 115; DEVELOPMENT_TEAM = LQA3CS5MM7; INFOPLIST_FILE = TokenExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.5; @@ -792,7 +837,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.7.4; + MARKETING_VERSION = 1.7.5; PRODUCT_BUNDLE_IDENTIFIER = com.yubico.Authenticator.TokenExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -806,7 +851,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = TokenExtension/TokenExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 115; DEVELOPMENT_TEAM = LQA3CS5MM7; INFOPLIST_FILE = TokenExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.5; @@ -815,7 +860,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.7.4; + MARKETING_VERSION = 1.7.5; PRODUCT_BUNDLE_IDENTIFIER = com.yubico.Authenticator.TokenExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -876,7 +921,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -932,7 +977,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -949,17 +994,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Authenticator/Authenticator.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 115; DEVELOPMENT_TEAM = LQA3CS5MM7; HEADER_SEARCH_PATHS = "../Submodules/YubiKit/**"; INFOPLIST_FILE = Authenticator/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = ""; - MARKETING_VERSION = 1.7.4; + MARKETING_VERSION = 1.7.5; OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = com.yubico.Authenticator; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -977,17 +1022,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Authenticator/Authenticator.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 110; + CURRENT_PROJECT_VERSION = 115; DEVELOPMENT_TEAM = LQA3CS5MM7; HEADER_SEARCH_PATHS = "../Submodules/YubiKit/**"; INFOPLIST_FILE = Authenticator/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = ""; - MARKETING_VERSION = 1.7.4; + MARKETING_VERSION = 1.7.5; OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = com.yubico.Authenticator; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Authenticator/AppDelegate.swift b/Authenticator/AppDelegate.swift deleted file mode 100644 index 0fe84f07..00000000 --- a/Authenticator/AppDelegate.swift +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (C) 2022 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import UIKit - -@UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - if YubiKitDeviceCapabilities.supportsMFIAccessoryKey { - YubiKitManager.shared.startAccessoryConnection() - } - if let main = UIApplication.shared.windows.first?.rootViewController?.children.first as? OATHViewController { - UNUserNotificationCenter.current().delegate = main - } - return true - } - - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS mess age) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. - } - - func applicationDidEnterBackground(_ application: UIApplication) { - if #available(iOS 16.0, *) { - YubiKitManager.shared.stopSmartCardConnection() - } - - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - if #available(iOS 16, *) { - YubiKitManager.shared.startSmartCardConnection() - } - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - } - - func application(_ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:] ) -> Bool { - - - if let main = UIApplication.shared.windows.first?.rootViewController?.children.first as? OATHViewController { - main.addCredential(url: url) - } - - let sendingAppID = options[.sourceApplication] - print("source application = \(sendingAppID ?? "Unknown")") - - return true - } - - func application(_ application: UIApplication, shouldSaveSecureApplicationState coder: NSCoder) -> Bool { - coder.encode(1.6, forKey: "AppVersion") - return true - } - - func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void - ) -> Bool { - - if let main = UIApplication.shared.windows.first?.rootViewController?.children.first as? OATHViewController { - if let url = userActivity.webpageURL { - var otp: String - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) - if let fragment = components?.fragment { - otp = fragment - } else { - otp = url.lastPathComponent - } - main.dismiss(animated: false) { - main.otp = otp - } - } - - if SettingsConfig.isNFCOnOTPLaunchEnabled { - main.dismiss(animated: false) { - main.refreshData() - } - } - } - - return true - } - - func application(_ application: UIApplication, shouldRestoreSecureApplicationState coder: NSCoder) -> Bool { - let version = coder.decodeFloat(forKey: "AppVersion") - if version == 1.6 { - return true - } - return false - } - -} - diff --git a/Authenticator/Assets.xcassets/CustomColors/ListSectionHeaderColor.colorset/Contents.json b/Authenticator/Assets.xcassets/CustomColors/ListSectionHeaderColor.colorset/Contents.json new file mode 100644 index 00000000..d594e76f --- /dev/null +++ b/Authenticator/Assets.xcassets/CustomColors/ListSectionHeaderColor.colorset/Contents.json @@ -0,0 +1,34 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "0.926" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Authenticator/AuthenticatorApp.swift b/Authenticator/AuthenticatorApp.swift new file mode 100644 index 00000000..1b069350 --- /dev/null +++ b/Authenticator/AuthenticatorApp.swift @@ -0,0 +1,43 @@ +/* + * Copyright (C) Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +@main +struct AuthenticatorApp: App { + + @Environment(\.scenePhase) var scenePhase + @StateObject var toastPresenter = ToastPresenter() + @StateObject var notificationsViewModel = NotificationsViewModel() + + var body: some Scene { + WindowGroup { + ZStack { + MainView() + .toast(isPresenting: $toastPresenter.isPresenting, message: toastPresenter.message) + } + .fullScreenCover(isPresented: $notificationsViewModel.showPIVTokenView) { + TokenRequestView(userInfo: notificationsViewModel.userInfo) + } + .transaction { transaction in + transaction.disablesAnimations = notificationsViewModel.showPIVTokenView + } + .navigationViewStyle(.stack) + .environmentObject(toastPresenter) + .environmentObject(notificationsViewModel) + } + } +} diff --git a/Authenticator/Info.plist b/Authenticator/Info.plist index d22216d4..49ecd685 100644 --- a/Authenticator/Info.plist +++ b/Authenticator/Info.plist @@ -16,6 +16,8 @@ $(PRODUCT_NAME) CFBundlePackageType APPL + ITSAppUsesNonExemptEncryption + CFBundleShortVersionString $(MARKETING_VERSION) CFBundleURLTypes diff --git a/Authenticator/Model/Account.swift b/Authenticator/Model/Account.swift new file mode 100644 index 00000000..b1cdb02e --- /dev/null +++ b/Authenticator/Model/Account.swift @@ -0,0 +1,194 @@ +/* + * Copyright (C) Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI +import Combine + +class Account: ObservableObject { + + enum AccountState: Equatable { + case requiresCalculation + case countingdown(Double) + case expired + } + + @Published var otp: OATHSession.OTP? + @Published var title: String + @Published var subTitle: String? + @Published var state: AccountState + @Published var isPinned: Bool + @Published var requiresTouch: Bool + + var id = UUID() + var accountId: String { credential.id } + var credential: OATHSession.Credential + var keyVersion: YKFVersion + var color: Color = .red + var enableRefresh: Bool = true + private var timeLeft: Double? + private var timer: Timer? = nil + private var requestRefresh: PassthroughSubject + private var connectionType: OATHSession.ConnectionType + private var calculateCompletion: ((OATHSession.OTP) -> ())? = nil + private var isValid = true + + init(credential: OATHSession.Credential, code: OATHSession.OTP?, keyVersion: YKFVersion, requestRefresh: PassthroughSubject, connectionType: OATHSession.ConnectionType, isPinned: Bool) { + self.credential = credential + title = credential.title + subTitle = credential.subTitle + self.isPinned = isPinned + self.keyVersion = keyVersion + self.requiresTouch = credential.requiresTouch + + if code == nil { + state = .requiresCalculation + } else { + enableRefresh = false + state = .countingdown(1.0) + } + self.connectionType = connectionType + self.requestRefresh = requestRefresh + self.update(otp: code) + } + + func calculate(completion: ((OATHSession.OTP) -> ())? = nil) { + calculateCompletion = completion + requestRefresh.send(self) + } + + func updateTitles() { + title = credential.title + subTitle = credential.subTitle + } + + func update(otp: OATHSession.OTP?) { + guard self.otp != otp, let otp else { return } + self.otp = otp + + if let calculateCompletion { + calculateCompletion(otp) + } + self.calculateCompletion = nil + + if self.credential.type == .totp { + self.timeLeft = otp.validity.end.timeIntervalSinceNow + + // Schedule refresh if connection is wired + if let timeLeft, connectionType == .wired { + DispatchQueue.main.asyncAfter(deadline: .now() + timeLeft) { + guard self.isValid else { return } + self.requestRefresh.send(self) + } + } + updateState() + startTimer() + } else { + self.state = .requiresCalculation + } + } + + var formattedCode: String? { + guard let otp else { return nil } + if self.credential.isSteam { + return otp.code + } else { + // make it pretty by splitting in halves + var formattedCode = otp.code + formattedCode.insert(" ", at: formattedCode.index(formattedCode.startIndex, offsetBy: formattedCode.count / 2)) + return formattedCode + } + } + + var iconColor: Color { +#if DEBUG + // return hard coded nice looking colors for app store screen shots + switch self.title { + case "Twitter": + return Color("Color5") + case "Microsoft": + return Color("Color7") + case "GitHub": + return Color("Color8") + default: + break + } +#endif + let value = abs(accountId.hash) % UIColor.colorSetForAccountIcons.count + return Color(UIColor.colorSetForAccountIcons[value] ?? .primaryText) + } + + + func startTimer() { + self.timer?.invalidate() + guard otp?.validity != nil else { return } + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.updateState() + } + } + + func updateState() { + if let validInterval = otp?.validity { + let timeLeft = validInterval.end.timeIntervalSince(Date()) + self.timeLeft = timeLeft + if timeLeft > 0 { + self.state = .countingdown(timeLeft / validInterval.duration) + self.enableRefresh = false + } else if timeLeft < 0 { + self.state = .expired + self.timer?.invalidate() + self.timer = nil + self.enableRefresh = true + } else if connectionType == .nfc { + self.state = .expired + self.timer?.invalidate() + self.timer = nil + self.enableRefresh = true + } + } + } + + func invalidate() { + self.timer?.invalidate() + self.isValid = false + } +} + +extension Account: Comparable { + static func == (lhs: Account, rhs: Account) -> Bool { + return lhs.accountId.lowercased() == rhs.accountId.lowercased() + } + + static func < (lhs: Account, rhs: Account) -> Bool { + return lhs.accountId.lowercased() < rhs.accountId.lowercased() + } +} + +extension OATHSession.Credential { + var title: String { + if let issuer, issuer.isEmpty == false { + return issuer + } else { + return accountName + } + } + var subTitle: String? { + if issuer != nil && issuer?.isEmpty == false { + return accountName + } else { + return nil + } + } +} diff --git a/Authenticator/Model/Credential.swift b/Authenticator/Model/Credential.swift deleted file mode 100644 index d0bceef1..00000000 --- a/Authenticator/Model/Credential.swift +++ /dev/null @@ -1,271 +0,0 @@ -/* - * Copyright (C) 2022 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -protocol CredentialExpirationDelegate : AnyObject { - func calculateResultDidExpire(_ credential: Credential) -} - -/*! Model class that represent data for each account/credetial - * Does not have any knowledge about UI and how it's going to be represented - * Doesn't have any delegates, so it's suggested to use observers to watch changes in that object - * It is using observers to get updates on it's properties (code/remainingTime/activeTime/state) - * It's responsibility of user to start observers and remove them before deallocating, otherwise observer could lead to crash - * Make sure that you don't have multithreading issue with observers (e.g. do not stop observer while it's just started on another thread) - */ -class Credential: NSObject { - static let DEFAULT_PERIOD: UInt = 30 - private static let STEAM_ISSUER = "steam" - - /*! - Firmware version of the key that generated the credential. - */ - let keyVersion: YKFVersion - - /*! - The credential type (HOTP or TOTP). - */ - let type: YKFOATHCredentialType - - /*! - The Issuer of the credential as defined in the Key URI Format specifications: - https://github.com/google/google-authenticator/wiki/Key-Uri-Format - */ - @objc dynamic var issuer: String? - - /*! - The validity period for a TOTP code, in seconds. The default value for this property is 30. - If the credential is of HOTP type, this property returns 0. - */ - let period: UInt - - /*! - The account name extracted from the label. If the label does not contain the issuer, the - name is the same as the label. - */ - @objc dynamic var account: String - - - let requiresTouch: Bool - - var validity : DateInterval - weak var delegate: CredentialExpirationDelegate? - private var timerObservation: NSKeyValueObservation? - - var isSteam: Bool { - get { - return type == .TOTP && issuer?.lowercased() == Credential.STEAM_ISSUER - } - } - - @objc dynamic var code: String - @objc dynamic var remainingTime : Double - @objc dynamic var activeTime : Double - @objc dynamic var state : CredentialState = .idle - - /*! This is reference to static timer - * watching its ticks to calculate how much time since last OTP recalculation(activeTime) and how much time it's still valid (remainingTime) - * - */ - @objc dynamic private var globalTimer = GlobalTimer.shared - - init(type: YKFOATHCredentialType = .TOTP, account: String, issuer: String, period: UInt = DEFAULT_PERIOD, code: String, requiresTouch: Bool = false, keyVersion: YKFVersion) { - self.keyVersion = keyVersion - self.type = type - self.account = account - self.issuer = issuer - self.period = period - self.validity = type == .TOTP ? DateInterval(start: Date(timeIntervalSinceNow: 0), duration: TimeInterval(period)) : - DateInterval(start: Date(timeIntervalSinceNow: 0), end: Date.distantFuture) - self.requiresTouch = requiresTouch - self.remainingTime = validity.end.timeIntervalSince(Date()) - self.activeTime = 0 - self.code = code - - super.init() - if !code.isEmpty { - state = .active - } - } - - init(credential: YKFOATHCredentialWithCode, keyVersion: YKFVersion) { - self.keyVersion = keyVersion - type = credential.credential.type - account = credential.credential.accountName - issuer = credential.credential.issuer - period = credential.credential.period - validity = credential.code?.validity ?? DateInterval() - remainingTime = credential.code?.validity.end.timeIntervalSince(Date()) ?? 0 - activeTime = 0 - requiresTouch = credential.credential.requiresTouch - code = credential.code?.otp ?? "" - - super.init() - if !code.isEmpty { - state = .active - } - } - - // uniqueId is used to store a set of Favorites in UserDefaults. - // Changing/removing uniqueId will brake FavoritesStorage. - var uniqueId: String { - get { - var id = "" - if let issuer = issuer { - id += issuer + ":" - } - id += account - if type == .TOTP && period != Credential.DEFAULT_PERIOD { - id += "/" + String(period) - } - - return id - } - } - - var requiresRefresh: Bool { - get { - if code.isEmpty { - return true - } - if state == .expired || state == .idle { - return true - } - if type == .TOTP && self.remainingTime < -100 { // kludge of the week - return true - } - if type == .HOTP && self.activeTime > 10 { - return true - } - return false - } - } - - func setCode(code: String, validity : DateInterval) { - self.code = code - self.validity = validity - remainingTime = validity.end.timeIntervalSince(Date()) - activeTime = 0 - } - - var ykCredential : YKFOATHCredential { - let credential = YKFOATHCredential() - credential.accountName = account - credential.type = type - credential.issuer = issuer - credential.period = period - return credential - } - - // MARK: - Observation - - // set up timer to get notified about expiration (by watching global timer changes) - func setupTimerObservation() { - if self.code.isEmpty { - return - } - timerObservation = observe(\.globalTimer.tick, options: [.initial], changeHandler: { [weak self] (object, change) in - guard let self = self else { - return - } - if self.timerObservation == nil { - // timer is ignored - return - } - - self.activeTime += 1; - - // HOTP credential track only active time and no remaining time - if self.type == .HOTP { - return - } - - self.remainingTime = self.validity.end.timeIntervalSince(Date()) - if self.remainingTime <= 0 { - DispatchQueue.main.async { [weak self] in - // we don't update automatically credentials that require touch - guard let self = self else { - return - } - if !self.requiresTouch { - self.delegate?.calculateResultDidExpire(self) - } else { - self.state = .expired - } - - // we need to remove observers on UI thread because we can have other operations - // (e.g. calculateAll or delete) asynchronously change that state - // TODO: provide another dispatcher for it (other than main thread) - self.removeTimerObservation() - } - } - }) - } - - func removeTimerObservation() { - timerObservation = nil - } - - /*! Variation of states for credential - * idle - just created from list - * calculating - the operation of calculation is poped from queue and started execution - * expired - */ - @objc enum CredentialState : Int { - case idle - case calculating - case expired - case active - } -} - -extension Credential: Comparable { - static func == (lhs: Credential, rhs: Credential) -> Bool { - return lhs.uniqueId == rhs.uniqueId - } - - static func < (lhs: Credential, rhs: Credential) -> Bool { - return lhs.uniqueId.lowercased() < rhs.uniqueId.lowercased() - } -} - -extension Credential { - var formattedName: String { - return issuer?.isEmpty == false ? "\(issuer!) (\(account))" : account - } - - var formattedCode: String { - var otp = self.code.isEmpty ? "••••••" : self.code - if self.isSteam { - return otp - } else { - // make it pretty by splitting in halves - otp.insert(" ", at: otp.index(otp.startIndex, offsetBy: otp.count / 2)) - return otp - } - } - - var iconLetter: String { - if let issuer = issuer?.first?.uppercased() { - return issuer - } else if let account = account.first?.uppercased() { - return account - } else { - return "Y" - } - } -} diff --git a/Authenticator/Model/Extensions/YKFOATHSession+Extensions.swift b/Authenticator/Model/Extensions/YKFOATHSession+Extensions.swift index de8560c9..b23209c9 100644 --- a/Authenticator/Model/Extensions/YKFOATHSession+Extensions.swift +++ b/Authenticator/Model/Extensions/YKFOATHSession+Extensions.swift @@ -15,7 +15,7 @@ */ extension YKFOATHSession { - func calculateSteamTOTP(credential: Credential, completion: @escaping ((String?, DateInterval?, Error?) -> Void)) { + func calculateSteamTOTP(credential: YKFOATHCredential, completion: @escaping ((String?, DateInterval?, Error?) -> Void)) { var challenge = Data() let timestamp = Date().addingTimeInterval(10) let value: UInt64 = UInt64(timestamp.timeIntervalSince1970 / TimeInterval(credential.period)) @@ -23,7 +23,8 @@ extension YKFOATHSession { withUnsafePointer(to: &bigEndianVal) { challenge.append(UnsafeBufferPointer(start: $0, count: 1)) } - self.calculateResponse(forCredentialID: Data(credential.uniqueId.utf8), challenge: challenge) { response, error in + + self.calculateResponse(forCredentialID: Data(credential.id.utf8), challenge: challenge) { response, error in guard let response = response else { completion(nil, nil, error!) return @@ -44,3 +45,9 @@ extension YKFOATHSession { } } } + +extension YKFOATHCredential { + var id: String { + YKFOATHCredentialUtils.key(fromAccountName: accountName, issuer: issuer, period: period, type: type) + } +} diff --git a/Authenticator/Model/MainViewModel.swift b/Authenticator/Model/MainViewModel.swift new file mode 100644 index 00000000..e3f0f9ba --- /dev/null +++ b/Authenticator/Model/MainViewModel.swift @@ -0,0 +1,403 @@ +/* + * Copyright (C) Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import SwiftUI +import Combine + + +class MainViewModel: ObservableObject { + + @Environment(\.scenePhase) var scenePhase + + @Published var accounts: [Account] = [] + @Published var pinnedAccounts: [Account] = [] + @Published var otherAccounts: [Account] = [] + @Published var accountsLoaded: Bool = false + @Published var presentPasswordEntry: Bool = false + @Published var presentPasswordSaveType: Bool = false + @Published var passwordEntryMessage: String = "" + @Published var isKeyPluggedIn: Bool = false + @Published var error: Error? + + @Published var showTouchToast: Bool = false + + var accessKeyMemoryCache = AccessKeyCache() + let accessKeySecureStore = SecureStore(secureStoreQueryable: PasswordQueryable(service: "OATH")) + let passwordPreferences = PasswordPreferences() + + public var password = PassthroughSubject() + private var passwordCancellable: AnyCancellable? = nil + + public var passwordSaveType = PassthroughSubject() + private var passwordSaveTypeCancellable: AnyCancellable? = nil + + private var requestRefresh = PassthroughSubject() + private var requestRefreshCancellable: AnyCancellable? = nil + + private var sessionTask: Task<(), Never>? = nil + + private var favoritesStorage = FavoritesStorage() + private var favorites: Set = [] + private var favoritesCancellables = [AnyCancellable]() + + private var refreshRequestCount = 0 + + init() { + // Make sure to instantiate the OATHSessionHandler first to get it to be the root delegate in + // the DelegateStack. + _ = OATHSessionHandler.shared + + requestRefreshCancellable = requestRefresh + .map { [weak self] account in + self?.refreshRequestCount += 1 + return account + } + .debounce(for: .milliseconds(100), scheduler: RunLoop.main) + .sink { [weak self] account in + if self?.refreshRequestCount == 1 { + Task { [weak self] in + await self?.updateAccount(account) + } + } else { + Task { [weak self] in + await self?.updateAccounts() + } + } + self?.refreshRequestCount = 0 + } + self.favorites = favoritesStorage.readFavorites() + } + + @MainActor func start() { + sessionTask = Task { [weak self] in + for await session in OATHSessionHandler.shared.wiredSessions() { + self?.isKeyPluggedIn = true + await self?.updateAccounts(using: session) + let error = await session.sessionDidEnd() + await MainActor.run { [weak self] in + self?.favoritesCancellables.forEach { $0.cancel() } + self?.favoritesCancellables.removeAll() + self?.accounts.removeAll() + self?.pinnedAccounts.removeAll() + self?.otherAccounts.removeAll() + self?.accountsLoaded = false + self?.isKeyPluggedIn = false + self?.error = error + } + } + } + } + + @MainActor func stop() { + sessionTask?.cancel() + sessionTask = nil + accounts.removeAll() + pinnedAccounts.removeAll() + otherAccounts.removeAll() + accountsLoaded = false + isKeyPluggedIn = false + error = nil + } + + @MainActor private func updateAccount(_ account: Account) async { + do { + let session = try await OATHSessionHandler.shared.anySession() + + // We can't know if a HOTP requires touch. Instead we wait for 0.5 seconds for a response and if + // the key doesn't return we assume it requires touch. + let showTouchAlert = DispatchWorkItem { + guard session.type == .wired else { return } + self.showTouchToast = true + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.error) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.showTouchToast = false } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: showTouchAlert) + + let otp = try await session.calculate(credential: account.credential) + + showTouchAlert.cancel() + + if let account = (accounts.filter { $0.accountId == account.accountId }).first { + account.update(otp: otp) + } + + session.endNFC(message: "Code calculated") + } catch { + handle(error: error, retry: { Task { await self.updateAccount(account) }}) + } + } + + @MainActor private func updateAccounts(using session: OATHSession? = nil) async { + do { + favoritesCancellables.forEach { $0.cancel() } + favoritesCancellables.removeAll() + let useSession: OATHSession + if let session { + useSession = session + } else { + useSession = try await OATHSessionHandler.shared.anySession() + } + + let credentials = try await useSession.calculateAll() + let updatedAccounts = try await credentials.asyncMap { (credential, otp) in + if credential.type == .totp && ( + (!credential.requiresTouch && (credential.period != 30 || credential.isSteam)) || + (useSession.type == .nfc && credential.requiresTouch && SettingsConfig.isBypassTouchEnabled)) { + let otp = try await useSession.calculate(credential: credential) + return self.account(credential: credential, code: otp, keyVersion: useSession.version, requestRefresh: requestRefresh, connectionType: useSession.type) + } else { + return self.account(credential: credential, code: otp, keyVersion: useSession.version, requestRefresh: requestRefresh, connectionType: useSession.type) + } + } + + self.pinnedAccounts = updatedAccounts.filter { $0.isPinned }.sorted() + self.otherAccounts = updatedAccounts.filter { !$0.isPinned }.sorted() + self.accounts = updatedAccounts.sorted() + + updatedAccounts.forEach { account in + // We need to drop the first value since the Publisher sends the initial value when we start subscribing + let cancellable = account.$isPinned.dropFirst().sink { [weak self, weak account] isPinned in + guard let self, let account else { return } + if isPinned { + self.favorites.insert(account.accountId) + self.pinnedAccounts.append(account) + self.pinnedAccounts = self.pinnedAccounts.sorted() + self.otherAccounts.removeAll { $0.accountId == account.accountId } + } else { + self.favorites.remove(account.accountId) + self.pinnedAccounts.removeAll { $0.accountId == account.accountId } + self.otherAccounts.append(account) + self.otherAccounts = self.otherAccounts.sorted() + } + self.favoritesStorage.saveFavorites(self.favorites) + } + favoritesCancellables.append(cancellable) + } + + self.accountsLoaded = true + let message = SettingsConfig.showNFCSwipeHint ? "Success!\nHint: swipe down to dismiss" : "Successfully read" + useSession.endNFC(message: message) + } catch { + handle(error: error, retry: { Task { await self.updateAccounts() }}) + } + } + + private func account(credential: OATHSession.Credential, code: OATHSession.OTP?, keyVersion: YKFVersion, requestRefresh: PassthroughSubject, connectionType: OATHSession.ConnectionType) -> Account { + if let account = (accounts.filter { $0.credential.id == credential.id }).first { + account.update(otp: code) + return account + } else { + let account = Account(credential: credential, code: code, keyVersion: keyVersion, requestRefresh: requestRefresh, connectionType: connectionType, isPinned: favorites.contains(credential.id)) + return account + } + } + + @MainActor func updateAccountsOverNFC() { + Task { + do { + let session = try await OATHSessionHandler.shared.nfcSession() + await updateAccounts(using: session) + } catch { + YubiKitManager.shared.stopNFCConnection(withErrorMessage: "Something went wrong") + self.error = error + } + } + } + + @MainActor func addAccount(_ template: YKFOATHCredentialTemplate, requiresTouch: Bool) { + Task { + do { + let session = try await OATHSessionHandler.shared.anySession() + try await session.addCredential(template: template, requiresTouch: requiresTouch) + await updateAccounts(using: session) + } catch { + handle(error: error, retry: { self.addAccount(template, requiresTouch: requiresTouch) }) + } + } + } + + @MainActor func renameAccount(_ account: Account, issuer: String, accountName: String, completion: @escaping () -> Void) { + Task { + do { + let wasPinned = account.isPinned + let session = try await OATHSessionHandler.shared.anySession() + try await session.renameCredential(account.credential, issuer: issuer, accountName: accountName) + if wasPinned { + account.isPinned = false + } + account.credential.issuer = issuer + account.credential.accountName = accountName + account.updateTitles() + account.isPinned = wasPinned + await updateAccounts(using: session) + YubiKitManager.shared.stopNFCConnection(withMessage: "Account renamed") + completion() + } catch { + handle(error: error, retry: { self.renameAccount(account, issuer: issuer, accountName: accountName, completion: completion) }) + } + } + } + + @MainActor func deleteAccount(_ account: Account, completion: @escaping () -> Void) { + Task { + do { + let session = try await OATHSessionHandler.shared.anySession() + try await session.deleteCredential(account.credential) + accounts.removeAll { $0.accountId == account.accountId } + pinnedAccounts.removeAll { $0.accountId == account.accountId } + otherAccounts.removeAll { $0.accountId == account.accountId } + session.endNFC(message: "Account deleted") + completion() + } catch { + handle(error: error, retry: { self.deleteAccount(account, completion: completion) }) + } + } + } + + func collectPasswordAndUnlock(isRetry: Bool = false, completion: @escaping (Error?) -> Void) { + DispatchQueue.main.async { + YubiKitManager.shared.stopNFCConnection(withErrorMessage: "Key is password protected") + self.passwordEntryMessage = isRetry ? "Incorrect password. Re-enter password." : "To prevent unauthorized access this YubiKey is protected with a password." + self.presentPasswordEntry = true + self.passwordCancellable = self.password.sink { password in + if let password { + Task { + let session = try await OATHSessionHandler.shared.anySession() + do { + let accessKey = try await session.unlock(withPassword: password) + self.accessKeyMemoryCache.setAccessKey(accessKey, forKey: session.deviceId) + self.handleAccessKeyStorage(accessKey: accessKey, forKey: session.deviceId) + completion(nil) + } catch { + completion(error) + } + } + } + } + } + } + + func handle(error: Error, retry: (() -> Void)? = nil) { + if let oathError = error as? YKFOATHError, oathError.code == YKFOATHErrorCode.authenticationRequired.rawValue { + self.cachedAccessKey { [self] accessKey in + if let accessKey { + Task { + do { + let session = try await OATHSessionHandler.shared.anySession() + try await session.unlock(withAccessKey: accessKey) + retry?() + } catch { + self.collectPasswordAndUnlock() { error in + if let error { + YubiKitManager.shared.stopNFCConnection(withErrorMessage: "Something went wrong") + self.handle(error: error, retry: retry) + } else { + retry?() + } + } + } + } + } else { + self.collectPasswordAndUnlock() { error in + if let error { + YubiKitManager.shared.stopNFCConnection(withErrorMessage: "Something went wrong") + self.handle(error: error, retry: retry) + } else { + retry?() + } + } + } + } + } else if let oathError = error as? YKFOATHError, oathError.code == YKFOATHErrorCode.wrongPassword.rawValue { + collectPasswordAndUnlock(isRetry: true) { error in + if let error { + YubiKitManager.shared.stopNFCConnection(withErrorMessage: "Something went wrong") + self.handle(error: error, retry: retry) + } else { + retry?() + } + } + } else { + YubiKitManager.shared.stopNFCConnection() + self.error = error + } + } + + private func cachedAccessKey(completion: @escaping (Data?) -> Void) { + Task { + do { + let session = try await OATHSessionHandler.shared.anySession() + let keyIdentifier = session.deviceId + // Check memory cache + if let accessKey = self.accessKeyMemoryCache.accessKey(forKey: keyIdentifier) { + completion(accessKey) + return + } + // Finally check key chain + self.accessKeySecureStore.getValue(for: keyIdentifier) { result in + let accessKey = try? result.get() + completion(accessKey) + } + } catch { + completion(nil) + } + } + } + + func handleAccessKeyStorage(accessKey: Data, forKey keyIdentifier: String) { + guard !self.passwordPreferences.neverSavePassword(keyIdentifier: keyIdentifier) else { return } + self.accessKeySecureStore.getValue(for: keyIdentifier) { (result: Result) -> Void in + DispatchQueue.main.async { + let currentAccessKey: Data? = try? result.get() + if accessKey != currentAccessKey { + self.presentPasswordSaveType = true + self.passwordSaveTypeCancellable = self.passwordSaveType.sink { [weak self] type in + defer { self?.passwordSaveTypeCancellable = nil } + guard let type, let self else { return } + self.passwordPreferences.setPasswordPreference(saveType: type, keyIdentifier: keyIdentifier) + if type == .save || type == .lock { + do { + try self.accessKeySecureStore.setValue(accessKey, useAuthentication: self.passwordPreferences.useScreenLock(keyIdentifier: keyIdentifier), for: keyIdentifier) + } catch { + self.passwordPreferences.resetPasswordPreference(keyIdentifier: keyIdentifier) + self.error = error + } + } + } + } + } + } + } +} + +extension OATHSession.Credential { + var id: String { + YKFOATHCredentialUtils.key(fromAccountName: accountName, issuer: issuer, period: period, type: type == .totp ? .TOTP : .HOTP) + } +} + + +extension Sequence { + func asyncMap(_ transform: (Element) async throws -> T) async rethrows -> [T] { + var values = [T]() + for element in self { + try await values.append(transform(element)) + } + return values + } +} diff --git a/Authenticator/Model/GlobalTimer.swift b/Authenticator/Model/NotificationsViewModel.swift similarity index 52% rename from Authenticator/Model/GlobalTimer.swift rename to Authenticator/Model/NotificationsViewModel.swift index ff571100..977fc891 100644 --- a/Authenticator/Model/GlobalTimer.swift +++ b/Authenticator/Model/NotificationsViewModel.swift @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,29 +14,21 @@ * limitations under the License. */ -@objc class GlobalTimer: NSObject { +import SwiftUI - private var timer: Timer! +class NotificationsViewModel: NSObject, ObservableObject, UNUserNotificationCenterDelegate { - // Observe to get ticks - @objc dynamic private(set) var tick: UInt8 = 0 - - static var shared: GlobalTimer = GlobalTimer() + @Published var showPIVTokenView: Bool = false + var userInfo: [AnyHashable: Any]? override init() { super.init() - timer = Timer(timeInterval: 1.0, repeats: true, block: { [weak self] (timer) in - guard let self = self else { - return - } - - self.tick = ~self.tick - }) - RunLoop.main.add(timer, forMode: .common) + UNUserNotificationCenter.current().delegate = self } - deinit { - self.timer = nil - print("deinit GlobalTimer") + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + userInfo = response.notification.request.content.userInfo + showPIVTokenView = true + completionHandler() } } diff --git a/Authenticator/Model/OATHSession.swift b/Authenticator/Model/OATHSession.swift new file mode 100644 index 00000000..0796bc0d --- /dev/null +++ b/Authenticator/Model/OATHSession.swift @@ -0,0 +1,419 @@ +/* + * Copyright (C) Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +enum OATHSessionError: Error, LocalizedError { + case credentialAlreadyPresent(YKFOATHCredentialTemplate); + + public var errorDescription: String? { + switch self { + case .credentialAlreadyPresent(let credential): + return "There's already an account named \(credential.issuer.isEmpty == false ? "\(credential.issuer), \(credential.accountName)" : credential.accountName) on this YubiKey." + } + } +} + + +class OATHSessionHandler: NSObject, YKFManagerDelegate { + + typealias ClosingCallback = ((_ error: Error?) -> Void) + + private var nfcConnection: YKFNFCConnection? + private var smartCardConnection: YKFSmartCardConnection? + private var accessoryConnection: YKFAccessoryConnection? + + private var currentSession: YKFOATHSession? + + private var nfcConnectionCallback: ((_ connection: YKFConnectionProtocol) -> Void)? + private var wiredConnectionCallback: ((_ connection: YKFConnectionProtocol) -> Void)? + fileprivate var closingCallback: ClosingCallback? + + func didConnectNFC(_ connection: YKFNFCConnection) { + nfcConnection = connection + nfcConnectionCallback?(connection) + nfcConnectionCallback = nil + } + + func didDisconnectNFC(_ connection: YKFNFCConnection, error: Error?) { + nfcConnection = nil + closingCallback?(error) + closingCallback = nil + currentSession = nil + } + + func didConnectAccessory(_ connection: YKFAccessoryConnection) { + accessoryConnection = connection + wiredConnectionCallback?(connection) + wiredConnectionCallback = nil + } + + func didDisconnectAccessory(_ connection: YKFAccessoryConnection, error: Error?) { + accessoryConnection = nil + closingCallback?(error) + closingCallback = nil + currentSession = nil + } + + func didConnectSmartCard(_ connection: YKFSmartCardConnection) { + smartCardConnection = connection + wiredConnectionCallback?(connection) + wiredConnectionCallback = nil + } + + func didDisconnectSmartCard(_ connection: YKFSmartCardConnection, error: Error?) { + smartCardConnection = nil + closingCallback?(error) + closingCallback = nil + currentSession = nil + } + + struct WiredOATHSessions: AsyncSequence { + typealias Element = OATHSession + var current: OATHSession? = nil + struct AsyncIterator: AsyncIteratorProtocol { + mutating func next() async -> Element? { + while true { + return try? await OATHSessionHandler.shared.newWiredSession() + } + } + } + + func makeAsyncIterator() -> AsyncIterator { + AsyncIterator() + } + } + + + static let shared = OATHSessionHandler() + private override init() { + super.init() + DelegateStack.shared.setDelegate(self) + if YubiKitDeviceCapabilities.supportsMFIAccessoryKey { + YubiKitManager.shared.startAccessoryConnection() + } + if #available(iOS 16.0, *) { + YubiKitManager.shared.startSmartCardConnection() + } + } + + func wiredSessions() -> OATHSessionHandler.WiredOATHSessions { + return WiredOATHSessions() + } + + func anySession() async throws -> OATHSession { + if let currentSession { + let type: OATHSession.ConnectionType = accessoryConnection == nil && smartCardConnection == nil ? .nfc : .wired + return OATHSession(session: currentSession, type: type) + } else if let smartCardConnection { + let session = try await smartCardConnection.oathSession() + currentSession = session + return OATHSession(session: session, type: .wired) + } else if let accessoryConnection { + let session = try await accessoryConnection.oathSession() + currentSession = session + return OATHSession(session: session, type: .wired) + } else { + return try await nfcSession() + } + } + + var wiredContinuation: CheckedContinuation? + private func newWiredSession() async throws -> OATHSession { + if YubiKitDeviceCapabilities.supportsMFIAccessoryKey { + YubiKitManager.shared.startAccessoryConnection() + } + if #available(iOS 16.0, *) { + YubiKitManager.shared.startSmartCardConnection() + } + return try await withTaskCancellationHandler { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.wiredContinuation = continuation + self.wiredConnectionCallback = { connection in + connection.oathSession { session, error in + if let session { + self.currentSession = session + continuation.resume(returning: OATHSession(session: session, type: .wired)) + } else { + continuation.resume(throwing: error!) + } + self.wiredContinuation = nil + } + } + } + } onCancel: { + wiredContinuation?.resume(throwing: "Connection cancelled") + wiredContinuation = nil + wiredConnectionCallback = nil + YubiKitManager.shared.stopAccessoryConnection() + if #available(iOS 16.0, *) { + YubiKitManager.shared.stopSmartCardConnection() + } + } + } + + var nfcContinuation: CheckedContinuation? + func nfcSession() async throws -> OATHSession { + return try await withTaskCancellationHandler { + return try await withCheckedThrowingContinuation { continuation in + self.nfcContinuation = continuation + self.nfcConnectionCallback = { connection in + connection.oathSession { session, error in + if let session { + self.currentSession = session + continuation.resume(returning: OATHSession(session: session, type: .nfc)) + } else { + continuation.resume(throwing: error!) + } + self.nfcContinuation = nil + } + } + YubiKitManager.shared.startNFCConnection() + } + } onCancel: { + nfcContinuation?.resume(throwing: "Connection cancelled") + nfcContinuation = nil + nfcConnectionCallback = nil + YubiKitManager.shared.stopNFCConnection() + } + } + +} + +class OATHSession { + + enum ConnectionType { + case nfc + case wired + } + + enum CredentialType { + case totp, hotp + } + + class Credential: Equatable { + static func == (lhs: OATHSession.Credential, rhs: OATHSession.Credential) -> Bool { + return lhs.ykfCredential == rhs.ykfCredential + } + let type: CredentialType + var label: String? { + ykfCredential.label + } + var issuer: String? { + get { ykfCredential.issuer } + set { ykfCredential.issuer = newValue } + } + var accountName: String { + get { ykfCredential.accountName } + set { ykfCredential.accountName = newValue } + } + let period: UInt + let requiresTouch: Bool + var isSteam: Bool { + ykfCredential.type == .TOTP && issuer?.lowercased() == "steam" + } + fileprivate let ykfCredential: YKFOATHCredential + + init(ykfCredential: YKFOATHCredential) { + self.type = ykfCredential.type == .TOTP ? .totp : .hotp + self.period = ykfCredential.period + self.requiresTouch = ykfCredential.requiresTouch + self.ykfCredential = ykfCredential + } + } + + struct OTP: Comparable { + static func < (lhs: OATHSession.OTP, rhs: OATHSession.OTP) -> Bool { + return lhs.code == rhs.code && lhs.validity == lhs.validity + } + + let code: String + let validity: DateInterval + } + + private let session: YKFOATHSession + public let type: ConnectionType + public var version: YKFVersion { + session.version + } + public var deviceId: String { + session.deviceId + } + + fileprivate init(session: YKFOATHSession, type: ConnectionType) { + self.session = session + self.type = type + } + + func sessionDidEnd() async -> Error? { + return await withCheckedContinuation { continuation in + OATHSessionHandler.shared.closingCallback = { error in + continuation.resume(returning: error) + } + } + } + + func addCredential(template: YKFOATHCredentialTemplate, requiresTouch: Bool) async throws { + + let credentials = try await session.listCredentials() + let key = YKFOATHCredentialUtils.key(fromAccountName: template.accountName, issuer: template.issuer, period: template.period, type: template.type) + let keys = credentials.map { YKFOATHCredentialUtils.key(fromAccountName: $0.accountName, issuer: $0.issuer, period: $0.period, type: $0.type) } + guard !keys.contains(key) else { + YubiKitManager.shared.stopNFCConnection(withErrorMessage: "Duplicate accounts!") + throw OATHSessionError.credentialAlreadyPresent(template) + } + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.put(template, requiresTouch: requiresTouch) { error in + if let error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: Void()) + } + } + } + + func deleteCredential(_ credential: Credential) async throws { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.delete(credential.ykfCredential) { error in + if let error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: Void()) + } + } + } + + func renameCredential(_ credential: Credential, issuer: String, accountName: String) async throws { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.renameCredential(credential.ykfCredential, newIssuer: issuer, newAccount: accountName) { error in + if let error { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: Void()) + } + } + } + + func list() async throws -> [Credential] { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[Credential], Error>) in + session.listCredentials { credentials, error in + if let credentials { + continuation.resume(returning: credentials.map { Credential(ykfCredential: $0) }) + } else if let error { + continuation.resume(throwing: error) + } else { + fatalError() + } + } + } + } + + func calculateAll(timestamp: Date = Date().addingTimeInterval(10) ) async throws -> [(Credential, OTP?)] { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[(Credential, OTP?)], Error>) in + session.calculateAll(withTimestamp: timestamp) { credentials, error in + if let credentials { + continuation.resume(returning: credentials.map { (Credential(ykfCredential: $0.credential), $0.code?.otpCode) }) + } else if let error { + continuation.resume(throwing: error) + } else { + fatalError() + } + } + } + } + + func calculate(credential: Credential, timestamp: Date = Date().addingTimeInterval(10)) async throws -> OTP { + return try await withCheckedThrowingContinuation { continuation in + if credential.isSteam { + session.calculateSteamTOTP(credential: credential.ykfCredential) { code, validity, error in + if let code, let validity { + continuation.resume(returning: OTP(code: code, validity: validity)) + } else if let error { + continuation.resume(throwing: error) + } else { + fatalError() + } + } + } else { + session.calculate(credential.ykfCredential, timestamp: timestamp) { code, error in + if let code, let otp = code.otp { + continuation.resume(returning: OTP(code: otp, validity: code.validity)) + } else if let error { + continuation.resume(throwing: error) + } else { + fatalError() + } + } + } + } + } + + func unlock(password: String) async throws { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.unlock(withPassword: password) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + + func unlock(withPassword password: String) async throws -> Data { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let accessKey = session.deriveAccessKey(password) + session.unlock(withAccessKey: accessKey) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(with: .success(accessKey)) + } + } + } + } + + func unlock(withAccessKey accessKey: Data) async throws { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.unlock(withAccessKey: accessKey) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + + func endNFC(message: String? = nil) { + if let message { + YubiKitManager.shared.stopNFCConnection(withMessage: message) + } else { + YubiKitManager.shared.stopNFCConnection() + } + } +} + +extension YKFOATHCode { + var otpCode: OATHSession.OTP? { + guard let otp else { return nil } + return OATHSession.OTP(code: otp, validity: validity) + } +} diff --git a/Authenticator/Model/OATHViewModel.swift b/Authenticator/Model/OATHViewModel.swift deleted file mode 100644 index e2f32146..00000000 --- a/Authenticator/Model/OATHViewModel.swift +++ /dev/null @@ -1,892 +0,0 @@ -/* - * Copyright (C) 2022 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -protocol CredentialViewModelDelegate: AnyObject { - func showAlert(title: String, message: String?) - func onError(error: Error) - func onOperationCompleted(operation: OperationName) - func onShowToastMessage(message: String) - func onCredentialDelete(credential: Credential) - func collectPassword(isPasswordEntryRetry: Bool, completion: @escaping (String?) -> Void) - func collectPasswordPreferences(completion: @escaping (PasswordSaveType) -> Void) -} - -enum OATHViewModelModelError: Error, LocalizedError { - case credentialAlreadyPresent(YKFOATHCredentialTemplate); - - public var errorDescription: String? { - switch self { - case .credentialAlreadyPresent(let credential): - return "There's already an account named \(credential.issuer.isEmpty == false ? "\(credential.issuer), \(credential.accountName)" : credential.accountName) on this YubiKey." - } - } - - var shouldClearState: Bool { - switch self { - case .credentialAlreadyPresent(_): - return false - } - } -} - -/*! This is main view model class that talks to YubiKit - * It's recommended to use only methods of this class to talk to YubiKitManager (even if it's a singleton and can be accessed anywhere in code) - * Every view controller that communicates with YubiKey should have this object initialized in contructor - */ -class OATHViewModel: NSObject, YKFManagerDelegate { - - private var nfcConnection: YKFNFCConnection? - - private var lastNFCStartTimestamp: Date? - private var lastNFCEndTimestamp: Date? - - var accessKeyMemoryCache = AccessKeyCache() - let accessKeySecureStore = SecureStore(secureStoreQueryable: PasswordQueryable(service: "OATH")) - let passwordPreferences = PasswordPreferences() - - var didNFCEndRecently: Bool { - guard let ts = lastNFCEndTimestamp else { return false } - return ts.addingTimeInterval(5) > Date() - } - - var didNFCStartRecently: Bool { - guard let ts = lastNFCStartTimestamp else { return false } - return ts.addingTimeInterval(2) > Date() - } - - func didConnectNFC(_ connection: YKFNFCConnection) { - nfcConnection = connection - lastNFCStartTimestamp = Date() - if let callback = connectionCallback { - callback(connection) - } - } - - func didDisconnectNFC(_ connection: YKFNFCConnection, error: Error?) { - lastNFCEndTimestamp = Date() - nfcConnection = nil - session = nil - } - - func didFailConnectingNFC(_ error: Error) { - lastNFCEndTimestamp = Date() - } - - private var accessoryConnection: YKFAccessoryConnection? - - func didConnectAccessory(_ connection: YKFAccessoryConnection) { - wiredConnectionStatusCallbacks.forEach { callback in - callback(.connected) - } - accessoryConnection = connection - calculateAll() - } - - func didDisconnectAccessory(_ connection: YKFAccessoryConnection, error: Error?) { - wiredConnectionStatusCallbacks.forEach { callback in - callback(.disconnected) - } - accessoryConnection = nil - session = nil - self.cleanUp() - } - - private var smartCardConnection: YKFSmartCardConnection? - - func didConnectSmartCard(_ connection: YKFSmartCardConnection) { - wiredConnectionStatusCallbacks.forEach { callback in - callback(.connected) - } - smartCardConnection = connection - if let callback = connectionCallback { - callback(connection) - } - calculateAll() - } - - func didDisconnectSmartCard(_ connection: YKFSmartCardConnection, error: Error?) { - wiredConnectionStatusCallbacks.forEach { callback in - callback(.disconnected) - } - smartCardConnection = nil - session = nil - self.cleanUp() - } - - private var connectionCallback: ((_ connection: YKFConnectionProtocol) -> Void)? - - private func connection(completion: @escaping (_ connection: YKFConnectionProtocol) -> Void) { - if let connection = accessoryConnection { - completion(connection) - } else if let connection = smartCardConnection { - completion(connection) - } else { - connectionCallback = completion - if YubiKitDeviceCapabilities.supportsISO7816NFCTags { - YubiKitManager.shared.startNFCConnection() - } - } - } - - private var session: YKFOATHSession? - - private func session(completion: @escaping (_ session: YKFOATHSession) -> Void) { - if let session = session { - completion(session) - return - } - connection { connection in - connection.oathSession { session, error in - if let error { - self.onError(error: error) - } else if let session { - self.cachedKeyIdentifier = session.deviceId - self.session = session - completion(session) - } else { - fatalError("YubiKit returned neither a session nor an error.") - } - } - } - } - - override init() { - super.init() - self.favoritesStorage.migrate() - DelegateStack.shared.setDelegate(self) - } - - deinit { - DelegateStack.shared.removeDelegate(self) - } - - enum WiredConnectionStatus { - case connected - case disconnected - } - - typealias WiredConnectionStatusCallback = (WiredConnectionStatus) -> () - - private var wiredConnectionStatusCallbacks = [WiredConnectionStatusCallback]() - - func wiredConnectionStatus(callback: @escaping WiredConnectionStatusCallback) { - wiredConnectionStatusCallbacks.append(callback) - } - - /*! - * The OperationDelegate callbacks and the completion block handlers for OATH operation will be dispatched on this queue. - */ - weak var delegate: CredentialViewModelDelegate? - var filter: String? - - /*! - * Allows to pause calculation of expired credentials in background - */ - /*! - * Allows to detect whether credentials list empty because device doesn't have any credentials or it's not loaded from device yet - */ - var state: State = { - if !YubiKitDeviceCapabilities.supportsMFIAccessoryKey - && !YubiKitDeviceCapabilities.supportsISO7816NFCTags - && !YubiKitDeviceCapabilities.supportsSmartCardOverUSBC { - return .notSupported - } else { - return .idle - } - }() - - private var _credentials = [Credential]() - - /*! Property that should give you a list of credentials with applied filter (if user is searching) */ - var credentials: [Credential] { - return credentials(pinned: false) - } - - var pinnedCredentials: [Credential] { - return credentials(pinned: true) - } - - private func credentials(pinned: Bool) -> [Credential] { - let credentials = _credentials.filter { - pinned == isPinned(credential: $0) - }.sorted() - - if let filter = filter, !filter.isEmpty { - return credentials.filter { - $0.issuer?.lowercased().contains(filter) == true || $0.account.lowercased().contains(filter) - } - } else { - return credentials - } - } - - private var favoritesStorage = FavoritesStorage() - private var favorites: Set = [] - - // cashedId is used as a key to store a set of Favorites in UserDefaults. - var cachedKeyIdentifier: String? - var cachedKeyConfig: YKFManagementInterfaceConfiguration? - var cachedKeyVersion: YKFVersion? - - var hasFilter: Bool { - return self.filter != nil && !self.filter!.isEmpty - } - - // MARK: - Public methods - - public func calculateAll() { - session { session in - session.calculateAll(withTimestamp: Date().addingTimeInterval(10)) { result, error in - guard let result = result else { - self.onError(error: error!, retry: { - self.calculateAll() - }) - return - } - let credentials = result.map { credential in - return Credential(credential: credential, keyVersion: session.version) - } - - credentials.forEach { credential in - let bypassTouch = (self.nfcConnection != nil && SettingsConfig.isBypassTouchEnabled) - if credential.isSteam && (!credential.requiresTouch || bypassTouch) { - self.calculateSteamTOTP(credential: credential, stopNFCWhenDone: false) - } else if credential.type == .TOTP && - credential.requiresTouch && - bypassTouch { - session.calculate(credential.ykCredential, timestamp: Date().addingTimeInterval(10)) { code, error in - guard let code = code, let otp = code.otp else { return } - credential.setCode(code: otp, validity: code.validity) - credential.state = .active - } - } else if credential.type == .TOTP && - !credential.requiresTouch && credential.period != 30 { - // Calculate TOTP credentials with time period != 30 individually - session.calculate(credential.ykCredential, timestamp: Date().addingTimeInterval(10)) { code, error in - guard let code = code, let otp = code.otp else { return } - credential.setCode(code: otp, validity: code.validity) - } - } - } - - session.dispatchAfterCurrentCommands { - self.onUpdate(credentials: credentials) - let message = SettingsConfig.showNFCSwipeHint ? "Success!\nSwipe down to dismiss" : "Successfully read" - YubiKitManager.shared.stopNFCConnection(withMessage: message) - } - } - } - } - - public func calculate(credential: Credential, completion: ((String) -> Void)? = nil) { - if credential.isSteam { - calculateSteamTOTP(credential: credential, stopNFCWhenDone: true, completion: completion) - } else if credential.type == .TOTP { - calculateTOTP(credential: credential, completion: completion) - } else { - calculateHOTP(credential: credential, completion: completion) - } - } - - public func calculateTOTP(credential: Credential, completion: ((String) -> Void)? = nil) { - session { session in - if credential.requiresTouch { - self.onTouchRequired() - } - // Adding 10 extra seconds to current timestamp as boost and improvement for quick code expiration: - // If < 10 seconds remain on the validity of a code at time of generation, - // increment the timeslot for the challenge and increase the validity time by the period of the credential. - // For example, if 7 seconds remain at time of generation, on a 30 second credential, - // generate a code for the next timeslot and show a timer for 37 seconds. - // Even if the user is very quick to enter and submit the code to the server, - // it is very likely that it will be accepted as servers typically allow for some clock drift. - session.calculate(credential.ykCredential, timestamp: Date().addingTimeInterval(10)) { code, error in - guard error == nil else { - self.onError(error: error!) { - self.calculate(credential: credential) - } - return - } - YubiKitManager.shared.stopNFCConnection(withMessage: "Code calculated") - guard let code = code, let otp = code.otp else { - if let error = error { - self.onError(error: error) - } - return - } - credential.setCode(code: otp, validity: code.validity) - credential.state = .active - if let completion = completion { - completion(otp) - } - self.onUpdate(credential: credential) - } - } - } - - public func calculateHOTP(credential: Credential, completion: ((String) -> Void)? = nil) { - session { session in - // We can't know if a HOTP requires touch. Instead we wait for 0.5 seconds for a response and if - // the key doesn't return we assume it requires touch. - let showTouchAlert = DispatchWorkItem { self.onTouchRequired() } - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: showTouchAlert) - - session.calculate(credential.ykCredential) { code, error in - showTouchAlert.cancel() - guard error == nil else { - self.onError(error: error!) { - self.calculate(credential: credential) - } - return - } - YubiKitManager.shared.stopNFCConnection(withMessage: "Code calculated") - guard let code = code, let otp = code.otp else { - if let error = error { - self.onError(error: error) - } - return - } - credential.setCode(code: otp, validity: code.validity) - credential.state = .active - if let completion = completion { - completion(otp) - } - self.onUpdate(credential: credential) - } - } - } - - public func calculateSteamTOTP(credential: Credential, stopNFCWhenDone: Bool, completion: ((String) -> Void)? = nil) { - session { session in - if credential.requiresTouch { - self.onTouchRequired() - } - - session.calculateSteamTOTP(credential: credential) { code, validity, error in - guard let code = code, let validity = validity else { - self.onError(error: error!) { - self.calculate(credential: credential) - } - return - } - if stopNFCWhenDone { - YubiKitManager.shared.stopNFCConnection(withMessage: "Code calculated") - } - credential.setCode(code: code, validity: validity) - credential.state = .active - if let completion = completion { - completion(code) - } - self.onUpdate(credential: credential) - } - } - } - - public func addCredential(credential: YKFOATHCredentialTemplate, requiresTouch: Bool) { - session { session in - session.listCredentials { credentials, error in - guard let credentials else { - self.onError(error: error!) { - self.addCredential(credential: credential, requiresTouch: requiresTouch) - } - return - } - - let key = YKFOATHCredentialUtils.key(fromAccountName: credential.accountName, issuer: credential.issuer, period: credential.period, type: credential.type) - - let keys = credentials.map { YKFOATHCredentialUtils.key(fromAccountName: $0.accountName, issuer: $0.issuer, period: $0.period, type: $0.type) } - - guard !keys.contains(key) else { - self.onError(error: OATHViewModelModelError.credentialAlreadyPresent(credential)) - return - } - - session.put(credential, requiresTouch: requiresTouch) { error in - guard error == nil else { - self.onError(error: error!) { - self.addCredential(credential: credential, requiresTouch: requiresTouch) - } - return - } - self.calculateAll() - } - } - } - } - - public func deleteCredential(credential: Credential) { - session { session in - session.delete(credential.ykCredential) { error in - guard error == nil else { - self.onError(error: error!) { - self.deleteCredential(credential: credential) - } - return - } - self.calculateAll() - self.onDelete(credential: credential) - } - } - } - - public func renameCredential(credential: Credential, issuer: String, account: String) { - session { session in - let wasPinned = self.isPinned(credential: credential) - - session.renameCredential(credential.ykCredential, newIssuer: issuer, newAccount: account) { error in - guard error == nil else { - self.onError(error: error!) { - self.renameCredential(credential: credential, issuer: issuer, account: account) - } - return - } - - if wasPinned { - self.unPin(credential: credential) - } - - credential.issuer = issuer - credential.account = account - YubiKitManager.shared.stopNFCConnection(withMessage: "Account renamed") - - if wasPinned { - self.pin(credential: credential) - } - - self.onUpdate(credential: credential) - } - } - } - - func cachedAccessKey(completion: @escaping (Data?) -> Void) { - session { session in - let keyIdentifier = session.deviceId - // access key memory cach - if let accessKey = self.accessKeyMemoryCache.accessKey(forKey: keyIdentifier) { - completion(accessKey) - return - } - - // persistent legacy password cache - if let legacyKeyIdentifier = self.legacyKeyIdentifier { - self.accessKeySecureStore.getValue(for: legacyKeyIdentifier) { legacyResult in - switch legacyResult { - case .success(let password): - self.passwordPreferences.migrate(fromKeyIdentifier: legacyKeyIdentifier, toKeyIdentifier: keyIdentifier) - let accesskey = session.deriveAccessKey(password) - try? self.accessKeySecureStore.removeValue(for: legacyKeyIdentifier) // remove legacy password - try? self.accessKeySecureStore.setValue(accesskey, useAuthentication: self.passwordPreferences.useScreenLock(keyIdentifier: keyIdentifier), for: keyIdentifier) // store access key instead - completion(accesskey) - case .failure(_): - // persistent access key cache - self.accessKeySecureStore.getValue(for: keyIdentifier) { result in - let accessKey = try? result.get() - completion(accessKey) - return - } - } - } - } else { - self.accessKeySecureStore.getValue(for: keyIdentifier) { result in - let accessKey = try? result.get() - completion(accessKey) - return - } - } - } - } - - public func stop() { - cleanUp() - accessoryConnection = nil - smartCardConnection = nil - nfcConnection = nil - session = nil - } - - public func cleanUp() { - guard YubiKitDeviceCapabilities.supportsMFIAccessoryKey - || YubiKitDeviceCapabilities.supportsSmartCardOverUSBC - || YubiKitDeviceCapabilities.supportsISO7816NFCTags else { - return - } - - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - guard let delegate = self.delegate else { - return - } - - self._credentials.forEach { credential in - credential.removeTimerObservation() - } - - self._credentials.removeAll() - self.cachedKeyIdentifier = nil - self.favorites = [] - - self.state = .idle - delegate.onOperationCompleted(operation: .cleanup) - } - } - - public func applyFilter(filter: String?) { - self.filter = filter?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) - self.delegate?.onOperationCompleted(operation: .filter) - } - - public func copyToClipboard(value: String, message: String = "Copied to clipboard") { - // copy to clipbboard - UIPasteboard.general.string = value - self.delegate?.onShowToastMessage(message: message) - } - - public func emulateSomeRecords() { - let credential1 = Credential(account: "account@gmail.com", issuer: "YubiKey 5.4.2", code: "063313", keyVersion: YKFVersion(string: "5.4.2")) - let credential2 = Credential(account: "john.b.doe@gmail.com", issuer: "YubiKey 5.2.6", code: "87254433", keyVersion: YKFVersion(string: "5.2.6")) - let credential3 = Credential(account: "account@gmail.com", issuer: "Github", code: "", requiresTouch: true, keyVersion: YKFVersion(string: "5.4.2")) - let credential4 = Credential(account: "account@yubico.com", issuer: "Yubico", code: "767691", keyVersion: YKFVersion(bytes: 5, minor: 1, micro: 1)) - let credential5 = Credential(account: "short-period@yubico.com", issuer: "15 sec period", period: 15, code: "740921", keyVersion: YKFVersion(string: "5.4.2")) - let credential6 = Credential(account: "jane.elaine.doe@dropbox.com", issuer: "Dropbox with a much loonger name", code: "555555", keyVersion: YKFVersion(string: "5.4.2")) - let credential7 = Credential(type: .HOTP, account: "hotp@yubico.com", issuer: "HOTP", code: "343344", keyVersion: YKFVersion(string: "5.4.2")) - let credential8 = Credential(account: "account@tesla.com", issuer: "Tesla", code: "420420", keyVersion: YKFVersion(string: "5.4.2")) - let credential9 = Credential(account: "jane.elaine.doe@yubico.com", issuer: "", code: "420420", keyVersion: YKFVersion(string: "5.4.2")) - credentials.forEach { credential in - credential.setupTimerObservation() - } - let credentials = [credential1, credential2, credential3, credential4, credential5, credential6, credential7, credential8, credential9] - self.onUpdate(credentials: credentials) - } -} - -// - -// MARK: - CredentialExpirationDelegate - -// -extension OATHViewModel: CredentialExpirationDelegate { - func calculateResultDidExpire(_ credential: Credential) { - // recalculate automatically only if key is plugged in and view model is not paused (the view is in background, behind another view controller) - if keyPluggedIn { - self.calculate(credential: credential) - } else { - // if we can't recalculate credential set state to expired - credential.state = .expired - } - } -} - -// - -// MARK: - CredentialExpirationDelegate - -// -extension OATHViewModel { //}: OperationDelegate { - /*! Invoked in case we started executing operation, but it requires touch and we need to notify user about it */ - func onTouchRequired() { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - guard let delegate = self.delegate else { - return - } - - // only if key is attached require touch (otherwise user can't touch and tap YubiKey) - // YubiKey will calculate credential over NFC connection even credential requires touch - if self.keyPluggedIn { - delegate.onShowToastMessage(message: "Touch your YubiKey") - } - } - } - - func unlock(withPassword password: String, completion: (() -> Void)? = nil) { - session { session in - let accessKey = session.deriveAccessKey(password) - self.unlock(withAccessKey: accessKey, cachedKey: false, completion: completion) - } - } - - func unlock(withAccessKey accessKey: Data, cachedKey: Bool = true, completion: (() -> Void)? = nil) { - session { session in - session.unlock(withAccessKey: accessKey, completion: { error in - if let error { - self.onError(error: error, retry: completion) - YubiKitManager.shared.stopNFCConnection(withErrorMessage: "Wrong password") - } else { - self.accessKeyMemoryCache.setAccessKey(accessKey, forKey: session.deviceId) - if !cachedKey { - self.handleAccessKeyStorage(accessKey: accessKey, forKey: session.deviceId) - } - completion?() - } - }) - } - } - - func handleAccessKeyStorage(accessKey: Data, forKey keyIdentifier: String) { - guard !self.passwordPreferences.neverSavePassword(keyIdentifier: keyIdentifier) else { return } - self.accessKeySecureStore.getValue(for: keyIdentifier) { (result: Result) -> Void in - let currentAccessKey: Data? = try? result.get() - if accessKey != currentAccessKey { - self.delegate?.collectPasswordPreferences { type in - self.passwordPreferences.setPasswordPreference(saveType: type, keyIdentifier: keyIdentifier) - if self.passwordPreferences.useSavedPassword(keyIdentifier: keyIdentifier) || self.passwordPreferences.useScreenLock(keyIdentifier: keyIdentifier) { - do { - try self.accessKeySecureStore.setValue(accessKey, useAuthentication: self.passwordPreferences.useScreenLock(keyIdentifier: keyIdentifier), for: keyIdentifier) - } catch { - self.passwordPreferences.resetPasswordPreference(keyIdentifier: keyIdentifier) - self.delegate?.showAlert(title: "Password was not saved", message: error.localizedDescription) - } - } - } - } - } - } - - /*! Invoked when operation/request to YubiKey failed */ - func onError(error: Error, retry: (() -> Void)? = nil) { - // Try cached passwords and then ask user for password - if let oathError = error as? YKFOATHError, oathError.code == YKFOATHErrorCode.authenticationRequired.rawValue { - self.cachedAccessKey { accessKey in - if let accessKey { - self.unlock(withAccessKey: accessKey, completion: retry) - } else { - YubiKitManager.shared.stopNFCConnection(withErrorMessage: error.localizedDescription) - self.delegate?.collectPassword(isPasswordEntryRetry: false) { password in - guard let password else { return } - self.unlock(withPassword: password, completion: retry) - } - } - } - // Ask user for the correct password - } else if let oathError = error as? YKFOATHError, oathError.code == YKFOATHErrorCode.wrongPassword.rawValue { - self.delegate?.collectPassword(isPasswordEntryRetry: true) { password in - guard let password else { return } - self.unlock(withPassword: password, completion: retry) - } - } else if let sessionError = error as? YKFSessionError, sessionError.code == YKFSessionErrorCode - .invalidSessionStateStatusCode.rawValue { - session = nil - retry?() - } else { - // Stop everything and pass error to delegate - if let viewModelError = error as? OATHViewModelModelError { - if viewModelError.shouldClearState { - session = nil - cleanUp() - } - } else { - session = nil - cleanUp() - } - YubiKitManager.shared.stopNFCConnection(withErrorMessage: error.localizedDescription) - delegate?.onError(error: error) - } - } - - - - /*! Invoked when some operation completed but doesn't change list of credentials or its data */ - func onCompleted(operation: OperationName) { - if operation == .validate { - self.state = .loading - } - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - self.delegate?.onOperationCompleted(operation: operation) - } - } - - /*! Invoked when we've got new list of credentials from YubiKey */ - func onUpdate(credentials: [Credential]) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - guard let delegate = self.delegate else { - return - } - - // timer observers better to set up on main thread to avoid - // thread racing between operations - self._credentials.forEach { - $0.removeTimerObservation() - } - - // using dictionary with uinique id as a key for quick search of existing credential object - let oldCredentials = Dictionary(uniqueKeysWithValues: self._credentials.compactMap { $0 }.map { ($0.uniqueId, $0) }) - // not adding credentials with '_hidden' prefix to our list. - self._credentials = credentials.filter { !$0.uniqueId.starts(with: "_hidden:") }.map { - if $0.type == .HOTP { - // make update smarter and update only those that need to be updated - // in case HOTP and require touch keep old credential objects, because calculate all doesn't have them - if let oldCredential = oldCredentials[$0.uniqueId] { - oldCredential.setupTimerObservation() - return oldCredential - } - } - - $0.setupTimerObservation() - $0.delegate = self - return $0 - } - self.favorites = self.favoritesStorage.readFavorites() - self.state = .loaded - delegate.onOperationCompleted(operation: .calculateAll) - } - } - - func onDelete(credential: Credential) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - credential.removeTimerObservation() - self.delegate?.onCredentialDelete(credential: credential) - } - } - - /*! Invoked when specific credential gets recalculated */ - func onUpdate(credential: Credential) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - guard let delegate = self.delegate else { - return - } - - // timer observers better to set up on main thread to avoid - // thread racing between operations - // making sure that credential was not removed or updated with calculate all operation - if self._credentials.contains(credential) { - credential.setupTimerObservation() - } - - delegate.onOperationCompleted(operation: .calculate) - } - } - - func onGetConfiguration(configuration: YKFManagementInterfaceConfiguration) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - self.cachedKeyConfig = configuration - - self.delegate?.onOperationCompleted(operation: .getConfig) - } - } - - func onSetConfiguration() { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - - self.delegate?.onOperationCompleted(operation: .setConfig) - } - } - - func onGetKeyVersion(version: YKFVersion) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - self.cachedKeyVersion = version - - self.delegate?.onOperationCompleted(operation: .getKeyVersion) - } - } -} - -// MARK: - Properties to YubikitManager sessions - -extension OATHViewModel { - /*! - * Checks if accessory key is plugged in - */ - var keyPluggedIn: Bool { - return accessoryConnection != nil || smartCardConnection != nil - } - - var legacyKeyIdentifier: String? { - if let accessoryConnection { - return accessoryConnection.accessoryDescription?.serialNumber - } - if let nfcConnection { - return nfcConnection.tagDescription?.identifier.hex - } - return nil - } - - var keyDescription: YKFAccessoryDescription? { - return accessoryConnection?.accessoryDescription - } -} - -// MARK: - Operations with Favorites set. - -extension OATHViewModel { - - func isPinned(credential: Credential) -> Bool { - return self.favorites.contains(credential.uniqueId) - } - - func pin(credential: Credential) { - self.favorites.insert(credential.uniqueId) - self.favoritesStorage.saveFavorites(self.favorites) - delegate?.onOperationCompleted(operation: .calculateAll) - } - - func unPin(credential: Credential) { - self.favorites.remove(credential.uniqueId) - self.favoritesStorage.saveFavorites(self.favorites) - delegate?.onOperationCompleted(operation: .calculateAll) - } -} - -enum OperationName : String { - case put = "put" - case calculate = "calculate" - case calculateAll = "calculate all" - case delete = "delete" - case rename = "rename" - case setCode = "set code" - case validate = "validate" - case reset = "reset" - case cleanup = "cleanup" - case filter = "filter" - case scan = "scan" - case getConfig = "get configuration" - case setConfig = "set configuration" - case getKeyVersion = "get version" -} - -enum State { - case idle - case loading - case locked - case loaded - case notSupported -} diff --git a/Authenticator/Model/SecureStore/PasswordPreferences.swift b/Authenticator/Model/SecureStore/PasswordPreferences.swift index c63ff479..1b39e8fd 100644 --- a/Authenticator/Model/SecureStore/PasswordPreferences.swift +++ b/Authenticator/Model/SecureStore/PasswordPreferences.swift @@ -73,7 +73,7 @@ class PasswordPreferences { } if !hasBiometricAuthentication { - return .passcode + return .none } if #available(iOS 11.0, *) { diff --git a/Authenticator/UI/AccountDetailsView.swift b/Authenticator/UI/AccountDetailsView.swift new file mode 100644 index 00000000..30fdf6ad --- /dev/null +++ b/Authenticator/UI/AccountDetailsView.swift @@ -0,0 +1,288 @@ +/* + * Copyright (C) Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +struct AccountDetailsData: Equatable { + static func == (lhs: AccountDetailsData, rhs: AccountDetailsData) -> Bool { + lhs.account.accountId == rhs.account.accountId + } + + @ObservedObject var account: Account + let estimatedCodeFrame: CGRect + let codeFrame: CGRect + let statusIconFrame: CGRect + let cellFrame: CGRect + let titleFrame: CGRect + let subTitleFrame: CGRect +} + +struct AccountDetailsView: View { + + private let initialCodeFontSize = 17.0 + private let finalCodeFontSize = 30.0 + + @EnvironmentObject var model: MainViewModel + @EnvironmentObject var toastPresenter: ToastPresenter + @Binding var data: AccountDetailsData? + @ObservedObject var account: Account + @State private var backgroundAlpha = 0.0 + @State private var titleOrigin: CGPoint + @State private var subTitleOrigin: CGPoint + @State private var codeOrigin: CGPoint + @State private var codeBackgroundOrigin: CGPoint + @State private var statusIconOrigin: CGPoint + @State private var modalAlpha: CGFloat + @State private var modalRect: CGRect + @State private var codeFontSize: CGFloat + @State private var menuAlpha: CGFloat = 0.0 + @State private var menuScale: CGFloat = 0.3 + + @State private var showEditing = false + @State private var showDeleteConfirmation = false + + @State private var codeFrame: CGRect = .zero + @State private var statusIconFrame: CGRect = .zero + @State private var menuFrame: CGRect = .zero + + @State private var codeOpacity: Double + private let codeColor = Color(.secondaryLabel) + + init(data: Binding) { + guard let detailsData = data.wrappedValue else { fatalError("Initializing AccountDetailsView while AccountDetailsData is nil is a fatal error.") } + self._data = data + account = detailsData.account + titleOrigin = CGRect.adjustedPosition(from: detailsData.titleFrame) + subTitleOrigin = CGRect.adjustedPosition(from: detailsData.subTitleFrame) + codeOrigin = CGRect.adjustedPosition(from: detailsData.estimatedCodeFrame) + codeBackgroundOrigin = CGRect.adjustedPosition(from: detailsData.cellFrame) + codeFontSize = initialCodeFontSize + statusIconOrigin = CGRect.adjustedPosition(from: detailsData.statusIconFrame) + modalRect = CGRect(origin: CGRect.adjustedPosition(from: detailsData.cellFrame), size: detailsData.cellFrame.size) + modalAlpha = 0.1 + if detailsData.account.state == .expired { + codeOpacity = 0.4 + } else { + codeOpacity = 1.0 + } + } + + var body: some View { + if let data { + GeometryReader { reader in + ZStack { + Color.clear // full screen cover + .background(.ultraThinMaterial.opacity(backgroundAlpha)) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + backgroundAlpha = 0.0 + codeFontSize = initialCodeFontSize + titleOrigin = CGRect.adjustedPosition(from: data.titleFrame) + subTitleOrigin = CGRect.adjustedPosition(from: data.subTitleFrame) + // I have no idea where these two points are coming from but a guess is the scale change has something to do with it + codeOrigin = CGRect.adjustedPosition(from: data.estimatedCodeFrame, xAdjustment: -2.0) + codeBackgroundOrigin = CGRect.adjustedPosition(from: data.cellFrame) + statusIconOrigin = CGRect.adjustedPosition(from: data.statusIconFrame) + modalAlpha = 0.1 + modalRect = CGRect(origin: CGRect.adjustedPosition(from: data.cellFrame), size: data.cellFrame.size) + } + withAnimation(.easeInOut(duration: 0.1)) { + menuScale = 0.3 + menuAlpha = 0.0 + } + + Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in + self.data = nil + } + } + Color(.systemBackground) // details view background + .cornerRadius(15.0) + .shadow(color: .black.opacity(0.07), radius: 3.0) + .opacity(modalAlpha) + .frame(width: modalRect.size.width, height: modalRect.size.height) + .position(modalRect.origin) + .ignoresSafeArea() + Color(.secondarySystemBackground) // Code background + .cornerRadius(10.0) + .opacity(modalAlpha) + .frame(width:modalRect.size.width - 30.0, height: 50.0) + .position(codeBackgroundOrigin) + .ignoresSafeArea() + Text(data.account.title) // Title + .font(.subheadline.weight(.medium)) + .lineLimit(1) + .minimumScaleFactor(0.1) + .position(titleOrigin) + .ignoresSafeArea() + data.account.subTitle.map { // Subtitle + Text($0) + .font(.footnote) + .lineLimit(1) + .minimumScaleFactor(0.1) + .foregroundColor(Color(.secondaryLabel)) + .position(subTitleOrigin) + .ignoresSafeArea() + } + + ZStack { + if let otp = data.account.formattedCode { + Text(otp) // code + .font(Font.system(size: codeFontSize)) + .bold() + .foregroundColor(codeColor) + .opacity(codeOpacity) + .position(codeOrigin) + .ignoresSafeArea() + } else { + Text("*** *** ") + .font(Font.system(size: codeFontSize)) + .bold() + .foregroundColor(codeColor) + .opacity(codeOpacity) + .position(codeOrigin) + .padding(.top, 4) + .ignoresSafeArea() + } + Text("888 888") + .font(Font.system(size: codeFontSize)) + .bold() + .foregroundColor(.clear) + .readFrame($codeFrame) + .position(codeOrigin) + .ignoresSafeArea() + } + switch(account.state) { + case .requiresCalculation, .expired: + if !account.requiresTouch { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.system(size: 22.0)) + .foregroundStyle(codeColor) + .opacity(codeOpacity) + .frame(width: 22.0, height: 22.0) + .readFrame($statusIconFrame) + .position(statusIconOrigin) + .ignoresSafeArea() + } else { + Image(systemName: "hand.tap.fill") + .font(.system(size: 18.0)) + .foregroundStyle(codeColor) + .opacity(codeOpacity) + .frame(width: 22.0, height: 22.0) + .readFrame($statusIconFrame) + .position(statusIconOrigin) + .ignoresSafeArea() + } + case .countingdown(let remaining): + PieProgressView(progress: remaining, color: codeColor) + .frame(width: 22.0, height: 22.0) + .readFrame($statusIconFrame) + .position(statusIconOrigin) + .opacity(codeOpacity) + .ignoresSafeArea() + } + + DetachedMenu(menuActions: [ + DetachedMenuAction(style: .default, isEnabled: account.enableRefresh, title: "Calculate", systemImage: "arrow.clockwise", action: { + self.account.calculate() + }), + DetachedMenuAction(style: .default, isEnabled: account.state != .expired && account.otp != nil, title: "Copy", systemImage: "square.and.arrow.up", action: { + guard let otp = account.otp?.code else { return } + toastPresenter.copyToClipboard(otp) + }), + DetachedMenuAction(style: .default, isEnabled: true, title: account.isPinned ? "Unpin" : "Pin", systemImage: "pin", action: { + account.isPinned.toggle() + }), + account.keyVersion >= YKFVersion(string: "5.3.0") ? DetachedMenuAction(style: .default, isEnabled: true, title: "Rename", systemImage: "square.and.pencil", action: { + showEditing.toggle() + }) : nil, + DetachedMenuAction(style: .destructive, isEnabled: true, title: "Delete", systemImage: "trash", action: { + showDeleteConfirmation = true + }) + ].compactMap { $0 } ) + .readFrame($menuFrame) + .position(CGPoint(x: reader.size.width / 2.0, + y: reader.size.height / 2.0 + 2.0 + menuFrame.size.height / 2.0 + (UIDevice.current.userInterfaceIdiom == .pad ? 70.0 : 40.0))) + .opacity(menuAlpha) + .scaleEffect(menuScale, anchor: UnitPoint(x: 0.5, y: 0.5)) + + } + .sheet(isPresented: $showEditing) { + EditView(account: account, viewModel: model, showEditing: $showEditing) + } + .alert(isPresented: $showDeleteConfirmation) { + Alert(title: Text("Delete account?"), + message: Text("This will permanently delete the account from the YubiKey, and your ability to generate codes for it!"), + primaryButton: .default(Text("Cancel")), + secondaryButton: .destructive( + Text("Delete"), + action: { + model.deleteAccount(account) { + self.data = nil + } + } + ) + ) + } + .onChange(of: model.accountsLoaded) { newValue in + self.data = nil + } + .onChange(of: account.state) { state in + DispatchQueue.main.async { + withAnimation { + if state == .expired { + codeOpacity = 0.4 + } else { + codeOpacity = 1.0 + } + } + } + } + .onAppear { + let codeWidth = max(self.data?.codeFrame.size.width ?? 0, self.data?.estimatedCodeFrame.size.width ?? 0) + DispatchQueue.main.asyncAfter(deadline: .now()) { // we need to wait one runloop for the frames to be set + withAnimation(.easeInOut(duration: 0.2)) { + backgroundAlpha = 1.0 + codeFontSize = finalCodeFontSize + modalAlpha = 1.0 + titleOrigin = CGPoint(x: reader.size.width / 2.0, + y: reader.size.height / 2.0 - (self.account.subTitle == nil ? 25.0 : 40.0)) + subTitleOrigin = CGPoint(x: reader.size.width / 2.0, + y: reader.size.height / 2.0 - 15.0) + codeOrigin = CGPoint(x: reader.size.width / 2.0 + 5.0 + statusIconFrame.width / 2.0, + y: reader.size.height / 2.0 + 35.0) + codeBackgroundOrigin = CGPoint(x: reader.size.width / 2.0, + y: reader.size.height / 2.0 + 35.0) + statusIconOrigin = CGPoint(x: (reader.size.width / 2.0) - (codeWidth * finalCodeFontSize / initialCodeFontSize) / 2.0 - 5.0, + y: reader.size.height / 2.0 + 35.0) + modalRect = CGRect(x: reader.size.width / 2.0, y: reader.size.height / 2.0, width: 300.0, height: 150.0) + } + } + withAnimation(.easeInOut(duration: 0.15).delay(0.15)) { + menuAlpha = 1.0 + menuScale = 1.0 + } + } + } + } + } +} + +extension CGRect { + static func adjustedPosition(from rect: CGRect, xAdjustment: CGFloat = 0.0) -> CGPoint { + return CGPoint(x: rect.origin.x + rect.size.width / 2.0 + xAdjustment, y: rect.origin.y + rect.size.height / 2.0) + } +} diff --git a/Authenticator/UI/AccountRowView.swift b/Authenticator/UI/AccountRowView.swift new file mode 100644 index 00000000..1c8034c2 --- /dev/null +++ b/Authenticator/UI/AccountRowView.swift @@ -0,0 +1,211 @@ +/* + * Copyright (C) Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + + +struct AccountRowView: View { + + @EnvironmentObject var toastPresenter: ToastPresenter + @ObservedObject var account: Account + @Binding var showAccountDetails: AccountDetailsData? + @State private var contentSize: CGSize = .zero + @State private var estimatedCodeFrame: CGRect = .zero + @State private var codeFrame: CGRect = .zero + @State private var statusIconFrame: CGRect = .zero + @State private var cellFrame: CGRect = .zero + @State private var titleFrame: CGRect = .zero + @State private var subTitleFrame: CGRect = .zero + + @State private var pillScaling: CGFloat = 1.0 + @State private var pillOpacity: Double + private let pillColor = Color(.secondaryLabel) + + init(account: Account, showAccountDetails: Binding) { + self.account = account + self._showAccountDetails = showAccountDetails + if account.state == .expired || account.otp == nil { + self.pillOpacity = 0.5 + } else { + self.pillOpacity = 1.0 + } + } + + var body: some View { + HStack { + Text(String(account.title.first ?? "?")) + .frame(width:40, height: 40) + .background(account.iconColor) + .cornerRadius(20) + .padding(.trailing, 5) + VStack(alignment: .leading) { + Text(account.title) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + .minimumScaleFactor(0.1) + .readFrame($titleFrame) + account.subTitle.map { + Text($0) + .font(.footnote) + .lineLimit(1) + .minimumScaleFactor(0.1) + .foregroundColor(Color(.secondaryLabel)) + .readFrame($subTitleFrame) + } + } + Spacer() + HStack { + switch(account.state) { + case .requiresCalculation, .expired: + if !account.requiresTouch { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.system(size: 22)) + .frame(width: 22.0, height: 22.0) + .padding(1) + .readFrame($statusIconFrame) + } else { + Image(systemName: "hand.tap.fill") + .font(.system(size: 18)) + .frame(width: 22.0, height: 22.0) + .padding(1) + .readFrame($statusIconFrame) + } + case .countingdown(let remaining): + PieProgressView(progress: remaining, color: pillColor) + .frame(width: 22, height: 22) + .padding(1) + .readFrame($statusIconFrame) + } + ZStack { + if let otp = account.formattedCode { + Text(otp) + .font(.system(size: 17)) + .bold() + .padding(.trailing, 4) + .readFrame($codeFrame) + } else { + Text("*** *** ") + .font(.system(size: 17)) + .bold() + .padding(.trailing, 4) + .padding(.top, 3.5) + .padding(.bottom, -3.5) + } + Text("888 888") + .font(.system(size: 17)) + .bold() + .foregroundColor(.clear) + .padding(.trailing, 4) + .readFrame($estimatedCodeFrame) + } + } + .foregroundColor(pillColor) + .padding(.all, 4) + .overlay { + Capsule() + .stroke(pillColor, lineWidth: 1) + } + .opacity(pillOpacity) + .scaleEffect(pillScaling) + } + .listRowSeparator(.hidden) + .background(Color(.systemBackground)) // without the background set, taps outside the Texts will be ignored + .onTapGesture { + let data = AccountDetailsData(account: account, + estimatedCodeFrame: estimatedCodeFrame, + codeFrame: codeFrame, + statusIconFrame: statusIconFrame, + cellFrame: cellFrame, + titleFrame: titleFrame, + subTitleFrame: subTitleFrame) + showAccountDetails = data + } + .onChange(of: account.state) { state in + // Not sure why we have to schedule this in the next runloop + DispatchQueue.main.async { + withAnimation { + if state == .expired || account.otp == nil { + pillOpacity = 0.5 + } else { + pillOpacity = 1.0 + } + } + } + } + .onLongPressGesture { + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.1)) { + pillScaling = 1.4 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.easeInOut(duration: 0.25)) { + pillScaling = 1.0 + } + } + } + + if account.state != .expired, let otp = account.otp?.code { + toastPresenter.copyToClipboard(otp) + } else { + account.calculate { otp in + toastPresenter.copyToClipboard(otp.code) + } + } + } + .onDisappear { + account.invalidate() + } + .readFrame($cellFrame) + } +} + +struct PieProgressView: View { + + var progress: Double + var color: Color? = nil + + var body: some View { + PieShape(progress: self.progress) + .foregroundColor(color) + .animation(.linear(duration: self.progress == 1.0 ? 0.0 : 1.0), value: self.progress) + } +} + +private struct PieShape: Shape { + + var animatableData: Double { + get { self.progress } + set { self.progress = newValue } + } + + var progress: Double = 0.0 + private let start: Double = Double.pi * 1.5 + private var end: Double { + get { + return self.start - Double.pi * 2 * self.progress + } + } + + func path(in rect: CGRect) -> Path { + var path = Path() + let center = CGPoint(x: rect.size.width / 2, y: rect.size.width / 2) + let radius = rect.size.width / 2 + path.move(to: center) + path.addArc(center: center, radius: radius, startAngle: Angle(radians: start), endAngle: Angle(radians: end), clockwise: true) + path.closeSubpath() + return path + } +} diff --git a/Authenticator/UI/Authentication/AddCredential/AddAccountWrapper.swift b/Authenticator/UI/Authentication/AddCredential/AddAccountWrapper.swift new file mode 100644 index 00000000..d58e0dcc --- /dev/null +++ b/Authenticator/UI/Authentication/AddCredential/AddAccountWrapper.swift @@ -0,0 +1,76 @@ +/* + * Copyright (C) Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI +import Combine + +struct AddAccountView: View { + + @Binding var showAddAccount: Bool + var accountSubject: PassthroughSubject<(YKFOATHCredentialTemplate?, Bool), Never> + var oathURL: URL? + let navigationBarAppearance = UINavigationBarAppearance() + + init(showAddCredential: Binding, accountSubject: PassthroughSubject<(YKFOATHCredentialTemplate?, Bool), Never>, oathURL: URL?) { + _showAddAccount = showAddCredential + self.oathURL = oathURL + self.accountSubject = accountSubject + navigationBarAppearance.shadowColor = .secondarySystemBackground + navigationBarAppearance.backgroundColor = .secondarySystemBackground + UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance + } + + var body: some View { + AddCredentialWrapper(accountSubject: accountSubject, oathURL: oathURL) + .navigationTitle("Add Credential") + .navigationBarItems(trailing: Button("Close") { + showAddAccount.toggle() + }) + .ignoresSafeArea(.all, edges: .bottom) + } +} + +struct AddCredentialWrapper: UIViewControllerRepresentable { + + var accountSubject: PassthroughSubject<(YKFOATHCredentialTemplate?, Bool), Never> + var oathURL: URL? + + func makeUIViewController(context: Context) -> UINavigationController { + let sb = UIStoryboard(name: "AddCredential", bundle: nil) + let vc = sb.instantiateViewController(identifier: "AddCredentialController") as! UINavigationController + + guard let credentialController = vc.topViewController as? AddCredentialController else { fatalError() } + if let oathURL { + let template = YKFOATHCredentialTemplate(url: oathURL) + credentialController.credential = template + } + + credentialController.accountSubject = accountSubject + + return vc + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { + } + + typealias UIViewControllerType = UINavigationController +} + +struct AddCredentialWrapper_Previews: PreviewProvider { + static var previews: some View { + HelpWrapper() + } +} diff --git a/Authenticator/UI/Authentication/AddCredential/AddCredential.storyboard b/Authenticator/UI/Authentication/AddCredential/AddCredential.storyboard index bce65b7f..847af55a 100644 --- a/Authenticator/UI/Authentication/AddCredential/AddCredential.storyboard +++ b/Authenticator/UI/Authentication/AddCredential/AddCredential.storyboard @@ -1,9 +1,9 @@ - + - + @@ -313,7 +313,7 @@ - + diff --git a/Authenticator/UI/Authentication/AddCredential/AddCredentialController.swift b/Authenticator/UI/Authentication/AddCredential/AddCredentialController.swift index 8b60cab2..30b8311e 100644 --- a/Authenticator/UI/Authentication/AddCredential/AddCredentialController.swift +++ b/Authenticator/UI/Authentication/AddCredential/AddCredentialController.swift @@ -15,9 +15,12 @@ */ import UIKit +import Combine class AddCredentialController: UITableViewController { + var accountSubject: PassthroughSubject<(YKFOATHCredentialTemplate?, Bool), Never>? + enum EntryMode { case manual, prefilled } @@ -107,6 +110,11 @@ class AddCredentialController: UITableViewController { super.viewWillTransition(to: size, with: coordinator) } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + accountSubject?.send((nil, true)) + } + // MARK: - Button handlers @IBAction func cancel(_ sender: Any) { @@ -147,7 +155,8 @@ class AddCredentialController: UITableViewController { return } self.requiresTouch = requiresTouchSwitch.isOn - self.performSegue(withIdentifier: .unwindToMainViewController, sender: sender) + self.accountSubject?.send((credential!, requiresTouchSwitch.isOn)) + self.dismiss(animated: true) } // MARK: - Table view cell sizes diff --git a/Authenticator/UI/Authentication/CredentialTableViewCell.swift b/Authenticator/UI/Authentication/CredentialTableViewCell.swift deleted file mode 100644 index a3be2d38..00000000 --- a/Authenticator/UI/Authentication/CredentialTableViewCell.swift +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright (C) 2022 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import UIKit -import Combine - -class CredentialTableViewCell: UITableViewCell { - - var viewModel: OATHViewModel! - - @IBOutlet weak var issuer: UILabel! - @IBOutlet weak var account: UILabel! - @IBOutlet weak var onlyAccount: UILabel! - @IBOutlet weak var codeView: UIView! - @IBOutlet weak var noCodeCalculated: UILabel! - @IBOutlet weak var code: UILabel! - @IBOutlet weak var progress: PieProgressBar! - @IBOutlet weak var actionIcon: UIImageView! - @IBOutlet weak var credentialIcon: UILabel! - @IBOutlet weak var progressScalingConstraint: NSLayoutConstraint! - - @objc dynamic private var credential: Credential? - private var timerObservation: NSKeyValueObservation? - private var otpObservation: NSKeyValueObservation? - private var progressObservation: NSKeyValueObservation? - private var issuerObservation: NSKeyValueObservation? - private var accountObservation: NSKeyValueObservation? - - private var credentialIconColor: UIColor = .primaryText - - override func awakeFromNib() { - super.awakeFromNib() - prepareForReuse() - - // Dynamic type adjustment of icons is only done when up-scaling - let progressScaling = UIFontMetrics.default.scaledValue(for: progressScalingConstraint.constant) - if progressScaling > progressScalingConstraint.constant { - progressScalingConstraint.constant = progressScaling - } - - codeView.layer.borderWidth = 1 - codeView.layer.borderColor = UIColor(named: "CodeBorder")?.cgColor - - code.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: UIFont.monospacedDigitSystemFont(ofSize: 17, weight: .regular)) - issuer.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: UIFont.monospacedDigitSystemFont(ofSize: 17, weight: .regular)) - onlyAccount.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: UIFont.monospacedDigitSystemFont(ofSize: 17, weight: .regular)) - } - - override open func layoutSubviews() { - super.layoutSubviews() - // Wait for the next runloop before setting the cornerRadius - DispatchQueue.main.async { - self.codeView.layer.cornerRadius = self.codeView.bounds.height / 2.0 - } - } - - override func prepareForReuse() { - actionIcon.isHidden = true - progress.isHidden = true - } - - // this method is invoked when table view reloaded and UI got data/list of credentials - // each cell is responsible to show 1 credential and cell can be reused by updating credential with this method - func updateView(credential: Credential) { - self.credential = credential - refreshName() - if credential.type == .HOTP { - let size = UIFontMetrics.default.scaledValue(for: 17) - let config = UIImage.SymbolConfiguration(pointSize: size, weight: .medium, scale: .medium) - actionIcon.image = UIImage(systemName: "arrow.clockwise.circle.fill", withConfiguration: config) - } else { - let size = UIFontMetrics.default.scaledValue(for: 15) - let config = UIImage.SymbolConfiguration(pointSize: size, weight: .medium, scale: .medium) - actionIcon.image = UIImage(systemName: "hand.tap.fill", withConfiguration: config) - } - actionIcon.isHidden = !credential.showActionIcon - progress.isHidden = !credential.showProgress - credentialIcon.text = credential.iconLetter - credentialIconColor = credential.iconColor - credentialIcon.backgroundColor = credentialIconColor - progress.setupView() - refreshCode() - refreshProgress() - - setupModelObservation() - } - - func animateCode() { - UIView.animate(withDuration: 0.1, animations: { - self.codeView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) - }, completion: { _ in - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { - self.codeView.transform = CGAffineTransform.identity - } - }) - } - - // MARK: - Model Observation - // this allows you to avoid reloading tableview when data in specific credential changes - // watching changes in view model/credential object and update UI - private func setupModelObservation() { - if credential?.type == .TOTP { - timerObservation = observe(\.credential?.remainingTime, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshProgress() - } - }) - } else { - timerObservation = observe(\.credential?.activeTime, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshProgress() - } - }) - } - otpObservation = observe(\.credential?.code, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshCode() - } - }) - progressObservation = observe(\.credential?.state, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshProgress() - } - }) - accountObservation = observe(\.credential?.account, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshName() - } - }) - issuerObservation = observe(\.credential?.issuer, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshName() - } - }) - } - - // MARK: - UI Refresh - func refreshName() { - guard let credential = self.credential else { return } - if credential.issuer?.isEmpty != true && credential.issuer != nil { - account.text = credential.account - account.alpha = 1 - issuer.text = credential.issuer - issuer.alpha = 1 - onlyAccount.text = nil - } else { - onlyAccount.text = credential.account - issuer.text = "-" - issuer.alpha = 0 - account.text = "-" - account.alpha = 0 - } - } - - func refreshProgress() { - guard let credential = self.credential else { - return - } - if credential.type == .TOTP { - if credential.remainingTime > 0 { - progress.setProgress(to: credential.remainingTime / Double(credential.period)) - } else { - // keeping old value of code on screen even if it's expired already - progress.setProgress(to: Double(0.0)) - } - } - self.actionIcon.isHidden = !credential.showActionIcon - UIView.animate(withDuration: 0.3) { - self.progress.isHidden = !credential.showProgress - } - code.textColor = credential.codeColor - } - - func refreshCode() { - guard let credential = self.credential else { - return - } - // There's no font with fixed width for both digits and the dots we use for not calculated codes - if credential.code == "" { - noCodeCalculated.isHidden = false - code.isHidden = true - code.text = "111 111" - } else { - noCodeCalculated.isHidden = true - code.isHidden = false - let otp = credential.formattedCode - code.text = otp - } - } -} - -extension Credential { - - // picking up color for icon from set of colors using hash of unique Id, - // so that user keeps seeing the same color for item every time he launches the app - // and we don't need to have map between credential and colors - var iconColor: UIColor { -#if DEBUG - // return hard coded nice looking colors for app store screen shots - if let issuer = self.issuer { - switch issuer { - case "Twitter": - return UIColor(named: "Color5")! - case "Microsoft": - return UIColor(named: "Color7")! - case "GitHub": - return UIColor(named: "Color8")! - default: - break - } - } -#endif - let value = abs(uniqueId.hash) % UIColor.colorSetForAccountIcons.count - return UIColor.colorSetForAccountIcons[value] ?? .primaryText - } - - var showProgress: Bool { - if showActionIcon { - return false - } else { - return !requiresRefresh - } - } - - var showActionIcon: Bool { - return type == .HOTP || (requiresRefresh && requiresTouch && !SettingsConfig.isBypassTouchEnabled) - } - - var codeColor: UIColor { - switch type { - case .HOTP: - return code.isEmpty ? UIColor.secondaryText : UIColor.primaryText - case .TOTP: - return requiresRefresh ? UIColor.secondaryText : UIColor.primaryText - default: - return .label // fallback to safe default color - } - } -} diff --git a/Authenticator/UI/Authentication/EditCredential/EditAccountWrapper.swift b/Authenticator/UI/Authentication/EditCredential/EditAccountWrapper.swift new file mode 100644 index 00000000..33841382 --- /dev/null +++ b/Authenticator/UI/Authentication/EditCredential/EditAccountWrapper.swift @@ -0,0 +1,70 @@ +/* + * Copyright (C) Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + + +struct EditView: View { + + @Binding var showEditing: Bool + + let viewModel: MainViewModel + let account: Account + + let navigationBarAppearance = UINavigationBarAppearance() + + init(account: Account, viewModel: MainViewModel, showEditing: Binding ) { + self.account = account + self.viewModel = viewModel + _showEditing = showEditing + navigationBarAppearance.shadowColor = .secondarySystemBackground + navigationBarAppearance.backgroundColor = .secondarySystemBackground + UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance + } + + var body: some View { + EditAccountWrapper(account: account, viewModel: viewModel) + .navigationTitle("Add Credential") + .navigationBarItems(trailing: Button("WAAAT") { + showEditing.toggle() + }) + .ignoresSafeArea(.all, edges: .bottom) + } +} + +struct EditAccountWrapper: UIViewControllerRepresentable { + + + let account: Account + let viewModel: MainViewModel + + func makeUIViewController(context: Context) -> UINavigationController { + let sb = UIStoryboard(name: "EditCredential", bundle: nil) + let vc = sb.instantiateViewController(identifier: "EditCredentialController") as! UINavigationController + + guard let editCredentialController = vc.topViewController as? EditCredentialController else { fatalError() } + editCredentialController.account = account + editCredentialController.viewModel = viewModel + + return vc + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { + } + + typealias UIViewControllerType = UINavigationController +} + diff --git a/Authenticator/UI/Authentication/EditCredential/EditCredential.storyboard b/Authenticator/UI/Authentication/EditCredential/EditCredential.storyboard index c04afd72..6f763f91 100644 --- a/Authenticator/UI/Authentication/EditCredential/EditCredential.storyboard +++ b/Authenticator/UI/Authentication/EditCredential/EditCredential.storyboard @@ -1,9 +1,9 @@ - + - + @@ -19,14 +19,14 @@ - + - + - + @@ -40,14 +40,14 @@ - + - + - + @@ -100,14 +100,14 @@ - + - + - + @@ -140,9 +140,9 @@ - + - + diff --git a/Authenticator/UI/Authentication/EditCredential/EditCredentialController.swift b/Authenticator/UI/Authentication/EditCredential/EditCredentialController.swift index abaa39c5..95085916 100644 --- a/Authenticator/UI/Authentication/EditCredential/EditCredentialController.swift +++ b/Authenticator/UI/Authentication/EditCredential/EditCredentialController.swift @@ -17,15 +17,14 @@ import UIKit class EditCredentialController: UITableViewController { - public var credential: Credential? - public var viewModel: OATHViewModel? - public var model: CredentialViewModelDelegate? + public var account: Account? + public var viewModel: MainViewModel? @IBOutlet weak var issuerRow: SettingsRowView! @IBOutlet weak var accountRow: SettingsRowView! override func viewDidLoad() { - issuerRow.value = credential?.issuer - accountRow.value = credential?.account + issuerRow.value = account?.credential.issuer + accountRow.value = account?.credential.accountName super.viewDidLoad() } @@ -37,12 +36,16 @@ class EditCredentialController: UITableViewController { } @IBAction func save(_ sender: Any) { - guard let credential = credential, let issuer = issuerRow.value, let account = accountRow.value, account.count > 0 else { + guard let account, let issuer = issuerRow.value, let accountName = accountRow.value, accountName.count > 0 else { showAlertDialog(title: "Account not set", message: "Account name can not be empty") return } - - viewModel?.renameCredential(credential: credential, issuer: issuer, account: account) + viewModel?.renameAccount(account, issuer: issuer, accountName: accountName) { + let credential = account.credential + credential.issuer = issuer + credential.accountName = accountName + account.credential = credential + } self.dismiss(animated: true) } diff --git a/Authenticator/UI/Authentication/OATHCodeDetailsView.swift b/Authenticator/UI/Authentication/OATHCodeDetailsView.swift deleted file mode 100644 index d731de93..00000000 --- a/Authenticator/UI/Authentication/OATHCodeDetailsView.swift +++ /dev/null @@ -1,441 +0,0 @@ -/* - * Copyright (C) 2022 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -class OATHCodeDetailsView: UIVisualEffectView { - - weak var parentViewController: UIViewController? - let viewModel: OATHViewModel - - let containerHeightConstraint: NSLayoutConstraint - var containerTopConstraint: NSLayoutConstraint? - var containerCenterYConstraint: NSLayoutConstraint? - var containerLeadingConstraint: NSLayoutConstraint? - var containerTrailingConstraint: NSLayoutConstraint? - - var codeContainerRightConstraint: NSLayoutConstraint? - var codeContainerBottomConstraint: NSLayoutConstraint? - - var textStackTopConstraint: NSLayoutConstraint? - var textStackBottomConstraint: NSLayoutConstraint? - var textStackLeftConstraint: NSLayoutConstraint? - - private var timerObservation: NSKeyValueObservation? - private var otpObservation: NSKeyValueObservation? - private var progressObservation: NSKeyValueObservation? - private var accountObservation: NSKeyValueObservation? - private var issuerObservation: NSKeyValueObservation? - - var container: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = UIColor(named: "DetailsBackground") - view.alpha = 1 - view.layer.cornerRadius = 15 - view.layer.shadowColor = UIColor.black.cgColor - view.layer.shadowOpacity = 0.3 - view.layer.shadowOffset = CGSize.zero - view.layer.shadowRadius = 25 - view.alpha = 0 - return view - }() - - var codeBackground: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = UIColor(named: "DetailsCodeBackground") - view.alpha = 0 - view.layer.cornerRadius = 10 - return view - }() - lazy var codeContainer: UIView = { - let stack = UIStackView(arrangedSubviews: [actionIcon, progress, codeLabel]) - stack.translatesAutoresizingMaskIntoConstraints = false - stack.spacing = 5 - return stack - }() - var codeLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = UIFont.monospacedDigitSystemFont(ofSize: 23, weight: .regular) - return label - }() - let progress: PieProgressBar = { - let progress = PieProgressBar() - progress.translatesAutoresizingMaskIntoConstraints = false - progress.tintColor = .secondaryLabel - NSLayoutConstraint.activate([progress.widthAnchor.constraint(equalToConstant: 20), - progress.heightAnchor.constraint(equalToConstant: 20)]) - return progress - }() - var actionIcon: UIImageView = { - let imageView = UIImageView() - imageView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([imageView.widthAnchor.constraint(equalToConstant: 20), - imageView.heightAnchor.constraint(equalToConstant: 20)]) - imageView.tintColor = .secondaryLabel - return imageView - }() - - lazy var textStack: UIStackView = { - let textStack = UIStackView() - textStack.translatesAutoresizingMaskIntoConstraints = false - textStack.axis = .vertical - textStack.distribution = .fillEqually - textStack.spacing = 2 - textStack.addArrangedSubview(UIView()) - textStack.addArrangedSubview(titleLabel) - textStack.addArrangedSubview(subtitleLabel) - textStack.addArrangedSubview(UIView()) - return textStack - }() - var titleLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = UIFont.preferredFont(forTextStyle: .title3) - label.textColor = .label - return label - }() - var subtitleLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = UIFont.preferredFont(forTextStyle: .body) - label.textColor = .secondaryLabel - return label - }() - - @objc dynamic let credential: Credential - - var menu = Menu(actions: []) - var copyMenuAction: MenuAction? - var calculateMenuAction: MenuAction? - - init(credential: Credential, viewModel: OATHViewModel, parentViewController: UIViewController) { - self.parentViewController = parentViewController - self.credential = credential - self.viewModel = viewModel - progress.setProgress(to: !credential.code.isEmpty ? credential.remainingTime / Double(credential.period) : 0) - containerHeightConstraint = container.heightAnchor.constraint(equalToConstant: 88) - super.init(effect: nil) - - calculateMenuAction = credential.type == .HOTP || credential.requiresTouch || !viewModel.keyPluggedIn ? MenuAction(title: "Calculate", image: UIImage(systemName: "arrow.clockwise"), action: { - viewModel.calculate(credential: credential) - print("calculate") - }) : nil - - copyMenuAction = { - MenuAction(title: "Copy", image: UIImage(systemName: "square.and.arrow.up"), - action: { - viewModel.copyToClipboard(value: credential.code) - }) - }() - - let favoriteAction: MenuAction = { - if viewModel.isPinned(credential: credential) { - return MenuAction(title: "Unpin", - image: UIImage(systemName: "pin.slash"), - action: { [weak self] in - viewModel.unPin(credential: credential) - self?.dismiss() - }) - } else { - return MenuAction(title: "Pin", - image: UIImage(systemName: "pin"), - action: { [weak self] in - viewModel.pin(credential: credential) - self?.dismiss() - }) - } - }() - - let editAction: MenuAction? = { - if credential.keyVersion >= YKFVersion(bytes: 5, minor: 3, micro: 0) { - let action = { - let storyboard = UIStoryboard(name: "EditCredential", bundle: nil) - guard let navigationController = storyboard.instantiateInitialViewController() as? UINavigationController, - let editController = navigationController.children.first as? EditCredentialController - else { return } - editController.credential = credential - editController.viewModel = viewModel - parentViewController.present(navigationController, animated: true) - } - return MenuAction(title: "Rename", image: UIImage(systemName: "square.and.pencil"), action: action) - } else { - return nil - }}() - - let deleteAction: MenuAction = { - MenuAction(title: "Delete", - image: UIImage(systemName: "trash"), - action: { - parentViewController.showWarning(title: "Delete \"\(credential.formattedName)\"?", - message: "This will permanently delete the credential from the YubiKey, and your ability to generate codes for it", - okButtonTitle: "Delete") { [weak self] () -> Void in - viewModel.deleteCredential(credential: credential) - self?.dismiss() - } - }, - style: .destructive) - }() - - menu = Menu(actions: [calculateMenuAction, - copyMenuAction, - favoriteAction, - editAction, - deleteAction].compactMap{ $0 }) - menu.alpha = 0 - menu.translatesAutoresizingMaskIntoConstraints = false - menu.layer.anchorPoint = CGPoint(x: 0.5, y: 0) - menu.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) - - contentView.addSubview(container) - contentView.addSubview(menu) - - containerTopConstraint = container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0) - containerCenterYConstraint = container.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: -70) - containerCenterYConstraint?.priority = .defaultLow - - container.addSubview(codeBackground) - container.addSubview(codeContainer) - codeContainerBottomConstraint = codeContainer.centerYAnchor.constraint(equalTo: codeBackground.centerYAnchor, constant: 0) - codeContainerRightConstraint = codeContainer.rightAnchor.constraint(equalTo: container.rightAnchor, constant: 0) - containerLeadingConstraint = container.leftAnchor.constraint(equalTo: leftAnchor, constant: 0) - containerTrailingConstraint = container.rightAnchor.constraint(equalTo: rightAnchor, constant: 0) - - container.addSubview(textStack) - textStackTopConstraint = textStack.topAnchor.constraint(greaterThanOrEqualTo: container.topAnchor) - textStackBottomConstraint = textStack.bottomAnchor.constraint(equalTo: codeBackground.topAnchor, constant: 70) - textStackLeftConstraint = textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 65) - textStackLeftConstraint?.priority = .required - let textStackLeadingConstraint = textStack.leadingAnchor.constraint(equalTo: codeBackground.leadingAnchor) - textStackLeadingConstraint.priority = .defaultHigh - let textStackTrailingConstraint = textStack.trailingAnchor.constraint(equalTo: codeBackground.trailingAnchor) - textStackTrailingConstraint.priority = .defaultHigh - NSLayoutConstraint.activate([textStackLeadingConstraint, - textStackTrailingConstraint, - textStackLeftConstraint, - textStackTopConstraint, - textStackBottomConstraint].compactMap { $0 }) - - - NSLayoutConstraint.activate([containerTopConstraint, - containerCenterYConstraint, - containerLeadingConstraint, - containerTrailingConstraint, - containerHeightConstraint, - codeContainerBottomConstraint, - codeContainerRightConstraint, - menu.centerXAnchor.constraint(equalTo: centerXAnchor, constant: 0), - menu.centerYAnchor.constraint(equalTo: container.bottomAnchor, constant: 15), - codeBackground.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 15), - codeBackground.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -15), - codeBackground.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -15), - codeBackground.heightAnchor.constraint(equalToConstant: 57)].compactMap { $0 }) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func present(from: CGPoint) { - setupModelObservation() - - if let keyWindow = UIApplication.shared.connectedScenes - .filter({$0.activationState == .foregroundActive}) - .map({$0 as? UIWindowScene}) - .compactMap({$0}) - .first?.windows - .filter({$0.isKeyWindow}).first { - self.frame = keyWindow.bounds - keyWindow.addSubview(self) - } - - let tap = UITapGestureRecognizer(target: self, action: #selector(dismiss)) - contentView.addGestureRecognizer(tap) - - containerTopConstraint?.constant = from.y + 50 - - updateMenuItems() - refreshCode() - refreshName() - - if credential.type == .HOTP { - let size = UIFontMetrics.default.scaledValue(for: 17) - let config = UIImage.SymbolConfiguration(pointSize: size, weight: .medium, scale: .medium) - actionIcon.image = UIImage(systemName: "arrow.clockwise.circle.fill", withConfiguration: config) - } else { - let size = UIFontMetrics.default.scaledValue(for: 15) - let config = UIImage.SymbolConfiguration(pointSize: size, weight: .medium, scale: .medium) - actionIcon.image = UIImage(systemName: "hand.tap.fill", withConfiguration: config) - } - - actionIcon.isHidden = !credential.showActionIcon - progress.isHidden = !credential.showProgress - codeLabel.textColor = credential.codeColor - progress.alpha = 1 - - layoutIfNeeded() - progress.setProgress(to: credential.remainingTime / Double(credential.period)) - codeContainer.applyTransform(withScale: 0.8, anchorPoint: CGPoint(x: 0, y: 0.5)) - - UIView.animate(withDuration: 0.1) { - self.container.alpha = 1 - } - - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { - self.effect = UIBlurEffect(style: .systemUltraThinMaterialDark) - - self.codeContainerBottomConstraint?.constant = 0 - self.codeContainer.applyTransform(withScale: 1, anchorPoint: CGPoint(x: 0, y: 0.5)) - // The anchor point is on the middle left side so we have to calculate the margin manually - self.codeContainerRightConstraint?.constant = -(self.container.frame.width - 60 - self.codeContainer.frame.width) / 2 - self.codeBackground.alpha = 1 - - self.textStack.alignment = .center - self.textStackBottomConstraint?.constant = 0 - self.textStackTopConstraint?.constant = 0 - self.textStackLeftConstraint?.priority = .defaultLow - - self.containerLeadingConstraint?.constant = 30 - self.containerTrailingConstraint?.constant = -30 - self.containerTopConstraint?.priority = .defaultLow - self.containerCenterYConstraint?.priority = .required - self.containerHeightConstraint.constant = 160 - self.layoutIfNeeded() - } - - UIView.animate(withDuration: 0.1, delay: 0.1, options: .curveEaseInOut) { - self.menu.transform = .identity - self.menu.alpha = 1 - } - } - - @objc func dismiss() { - UIView.animate(withDuration: 0.1, delay: 0.15, options: .curveEaseOut) { - self.container.alpha = 0 - self.codeBackground.alpha = 0 - } completion: { _ in - self.removeFromSuperview() - } - - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { - self.effect = nil - self.textStack.alignment = .leading - - self.containerCenterYConstraint?.priority = .defaultLow - self.containerLeadingConstraint?.constant = 0 - self.containerTrailingConstraint?.constant = 0 - self.containerHeightConstraint.constant = 88 - - self.codeContainer.applyTransform(withScale: 0.8, anchorPoint: CGPoint(x: 0, y: 0.5)) - self.codeContainerBottomConstraint?.constant = 6 - self.codeContainerRightConstraint?.constant = 0 - - self.textStackBottomConstraint?.constant = 70 - self.textStackLeftConstraint?.priority = .required - - self.menu.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) - self.menu.alpha = 0 - self.layoutIfNeeded() - } - } - - private func setupModelObservation() { - if credential.type == .TOTP { - timerObservation = observe(\.credential.remainingTime, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshProgress() - } - }) - } else { - timerObservation = observe(\.credential.activeTime, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshProgress() - } - }) - } - otpObservation = observe(\.credential.code, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshCode() - } - }) - progressObservation = observe(\.credential.state, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshProgress() - } - }) - - accountObservation = observe(\.credential.account, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshName() - } - }) - issuerObservation = observe(\.credential.issuer, options: [], changeHandler: { (object, change) in - DispatchQueue.main.async { [weak self] in - self?.refreshName() - } - }) - } - - func refreshProgress() { - updateMenuItems() - - if credential.type == .TOTP { - if credential.remainingTime > 0 { - progress.setProgress(to: credential.remainingTime / Double(credential.period)) - } else { - // keeping old value of code on screen even if it's expired already - progress.setProgress(to: Double(0.0)) - } - } - - actionIcon.isHidden = !credential.showActionIcon - progress.isHidden = !credential.showProgress - codeLabel.textColor = credential.codeColor - } - - func refreshName() { - if let issuer = credential.issuer, !issuer.isEmpty { - titleLabel.text = issuer - subtitleLabel.text = credential.account - subtitleLabel.isHidden = false - } else { - titleLabel.text = credential.account - subtitleLabel.text = nil - subtitleLabel.isHidden = true - } - } - - func refreshCode() { - let otp = credential.formattedCode - codeLabel.text = otp - codeLabel.textColor = credential.codeColor - } - - func updateMenuItems() { - copyMenuAction?.isEnabled = (credential.type == .HOTP && credential.code != "") || (credential.type == .TOTP && !credential.requiresRefresh) - calculateMenuAction?.isEnabled = credential.type == .HOTP || credential.requiresRefresh - } -} - -extension UIView { - func applyTransform(withScale scale: CGFloat, anchorPoint: CGPoint) { - layer.anchorPoint = anchorPoint - let scale = scale != 0 ? scale : CGFloat.leastNonzeroMagnitude - let xPadding = 1 / scale * (anchorPoint.x - 0.5) * bounds.width - let yPadding = 1 / scale * (anchorPoint.y - 0.5) * bounds.height - transform = CGAffineTransform(scaleX: scale, y: scale).translatedBy(x: xPadding, y: yPadding) - } -} diff --git a/Authenticator/UI/Authentication/OATHViewController.swift b/Authenticator/UI/Authentication/OATHViewController.swift deleted file mode 100644 index b693c0ef..00000000 --- a/Authenticator/UI/Authentication/OATHViewController.swift +++ /dev/null @@ -1,715 +0,0 @@ -/* - * Copyright (C) 2022 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import UIKit -import Combine -import CryptoTokenKit - -class OATHViewController: UITableViewController { - - let viewModel = OATHViewModel() - var detailView: OATHCodeDetailsView? - - @IBOutlet weak var menuButton: UIBarButtonItem! - - private var lastDidResignActiveTimeStamp: Date? - private var cancellables = [Cancellable]() - private var showWhatsNewButton = SettingsConfig.showWhatsNewText - - private var searchBar = SearchBar() - private var applicationSessionObserver: ApplicationSessionObserver! - - private var backgroundView: UIView? { - willSet { - backgroundView?.removeFromSuperview() - if let newValue = newValue { - self.tableView.addSubview(newValue) - } - } - } - - private var hintView: UIView? - - var otp: String? = nil { - didSet { - self.tableView.reloadData() - if let otp, SettingsConfig.isCopyOTPEnabled { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { - self.viewModel.copyToClipboard(value: otp, message: "OTP copied to clipboard") - } - } - } - } - - private func setupMenu(enabled: Bool) { - self.menuButton.menu = nil - self.menuButton.menu = UIMenu(title: "", children: [ - UIAction(title: "Add account", - image: UIImage(systemName: "qrcode"), - attributes: enabled ? [] : .disabled, - handler: { [weak self] _ in - guard let self = self else { return } - let storyboard = UIStoryboard(name: "AddCredential", bundle: nil) - let vc = storyboard.instantiateViewController(withIdentifier: "AddCredential") - self.present(vc, animated: true) - }), - UIAction(title: "Configuration", - image: UIImage(systemName: "switch.2"), - attributes: enabled ? [] : [.disabled], - handler: { [weak self] _ in - guard let self = self else { return } - self.userFoundMenu() - self.performSegue(withIdentifier: "showConfiguration", sender: self) - }), - UIAction(title: "About", image: UIImage(systemName: "questionmark.circle"), handler: { [weak self] _ in - guard let self = self else { return } - self.userFoundMenu() - self.performSegue(withIdentifier: "ShowSettings", sender: self) - })]) - } - - override func viewDidLoad() { - super.viewDidLoad() - self.viewModel.delegate = self - - setupMenu(enabled: YubiKitDeviceCapabilities.supportsISO7816NFCTags || viewModel.keyPluggedIn) - setupRefreshControl() - - viewModel.wiredConnectionStatus { [weak self] _ in - guard let self else { return } - DispatchQueue.main.async { - self.setupMenu(enabled: YubiKitDeviceCapabilities.supportsISO7816NFCTags || self.viewModel.keyPluggedIn) - } - } - - guard let image = UIImage(named: "NavbarLogo.png") else { fatalError() } - let imageView = UIImageView(image: image) - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.tintColor = UIColor(named: "NavbarLogoColor") - imageView.contentMode = .scaleAspectFit - let aspectRatio = image.size.width / image.size.height - NSLayoutConstraint.activate([ - imageView.heightAnchor.constraint(equalToConstant: 18), - imageView.widthAnchor.constraint(equalToConstant: 18 * aspectRatio) - ]) - self.navigationItem.titleView = imageView - - // Uncomment the following line to preserve selection between presentations - // self.clearsSelectionOnViewWillAppear = false - - // Uncomment the following line to display an Edit button in the navigation bar for this view controller. - // self.navigationItem.rightBarButtonItem = self.editButtonItem - - // observe key plug-in/out changes even in background - // to make sure we don't leave credentials on screen when key was unplugged -// keySessionObserver = KeySessionObserver(accessoryDelegate: self, nfcDlegate: self) - - let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) - longPressGesture.minimumPressDuration = 0.5 - self.tableView.addGestureRecognizer(longPressGesture) - - applicationSessionObserver = ApplicationSessionObserver(delegate: self) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - if .freVersion > SettingsConfig.lastFreVersionShown { - self.performSegue(withIdentifier: .startTutorial, sender: self) - } else if VersionHistoryViewController.shouldShowOnAppLaunch { - let whatsNewController = VersionHistoryViewController() - whatsNewController.titleText = "What's new in\nYubico Authenticator" - whatsNewController.closeButtonText = "Continue" - whatsNewController.closeBlock = { [weak self] in - if SettingsConfig.isNFCOnAppLaunchEnabled { - self?.refreshData() - } - } - self.present(whatsNewController, animated: true) - } - - if let navigationView = self.navigationController?.view { - let height = UIFontMetrics.default.scaledValue(for: 51) - searchBar.frame = CGRect(x: 0, y: 0, width: navigationView.frame.width, height: height) - searchBar.install(inTopOf: navigationView) - searchBar.delegate = self - } - // update view in case if state has changed - self.tableView.reloadData() - refreshUIOnKeyStateUpdate() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - self.viewModel.stop() - } - - // MARK: - Add credential - func addCredential(url: URL) { - self.dismiss(animated: false) { - guard let template = try? YKFOATHCredentialTemplate(url: url, skip: [.issuer, .label]) else { - let alert = UIAlertController(title: "OATH URL is malformed.") - self.present(alert, animated: true) - return - } - let storyboard = UIStoryboard(name: "AddCredential", bundle: nil) - guard let nc = storyboard.instantiateViewController(withIdentifier: "AddCredential") as? UINavigationController else { return } - guard let addCredentialController = nc.topViewController as? AddCredentialController else { return } - addCredentialController.credential = template - addCredentialController.mode = .prefilled - self.present(nc, animated: true) - } - } - - // MARK: - Show search - @IBAction func showSearch(_ sender: Any) { - searchBar.isVisible = true - } - - // - // MARK: - Table view data source - // - override func numberOfSections(in tableView: UITableView) -> Int { - - var sections = 0 - // only pinned accounts or only non pinned accounts - if (viewModel.credentials.count > 0 && viewModel.pinnedCredentials.count == 0) - || (viewModel.credentials.count == 0 && viewModel.pinnedCredentials.count > 0) { - sections = 1 - } - - // pinned and non pinned accounts - if viewModel.credentials.count > 0 && viewModel.pinnedCredentials.count > 0 { - sections = 2 - } - - sections = otp != nil ? sections + 1 : sections - - if sections > 0 { - self.tableView.backgroundView = nil - backgroundView = nil - self.showHintView(false) - } else { - - showBackgroundView() - - if viewModel.state == .loaded { - DispatchQueue.main.asyncAfter(deadline: .now() + 6) { - guard SettingsConfig.userHasFoundMenu == false - && self.viewModel.state == .loaded - && self.viewModel.credentials.count == 0 - else { return } - self.showHintView(true) - } - } else { - self.showHintView(false) - } - } - return sections - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - if otp != nil && section == 0 { - return "Yubico OTP" - } - - if viewModel.pinnedCredentials.count > 0 && (section == 0 || otp != nil && section == 1) { - return "Pinned" - } - - if viewModel.pinnedCredentials.count == 0 && (section == 0 || otp != nil && section == 1) { - return "Accounts" - } - - return "Accounts" - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - - if otp != nil && section == 0 { - return 1 - } - - if (section == 0 || otp != nil && section == 1) && viewModel.pinnedCredentials.count > 0 { - return viewModel.pinnedCredentials.count - } - - return viewModel.credentials.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - if let credential = credentialAt(indexPath) { - let cell = tableView.dequeueReusableCell(withIdentifier: "CredentialCell", for: indexPath) as! CredentialTableViewCell - cell.viewModel = viewModel - cell.updateView(credential: credential) - - let backgroundContainerView = UIView() - let backgroundView = UIView() - backgroundView.layer.cornerRadius = 10 - backgroundView.backgroundColor = UIColor(named: "TableSelection") - backgroundView.translatesAutoresizingMaskIntoConstraints = false - backgroundContainerView.addSubview(backgroundView) - NSLayoutConstraint.activate([ - backgroundView.leadingAnchor.constraint(equalTo: backgroundContainerView.leadingAnchor), - backgroundView.trailingAnchor.constraint(equalTo: backgroundContainerView.trailingAnchor), - backgroundView.topAnchor.constraint(equalTo: backgroundContainerView.topAnchor), - backgroundView.bottomAnchor.constraint(equalTo: backgroundContainerView.bottomAnchor) - ]) - cell.selectedBackgroundView = backgroundContainerView - return cell - } else { - let cell = tableView.dequeueReusableCell(withIdentifier: "OTPCell", for: indexPath) as! OTPTableViewCell - cell.otp.text = otp - return cell - } - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - _ = searchBar.resignFirstResponder() - if let credential = credentialAt(indexPath) { - let details = OATHCodeDetailsView(credential: credential, viewModel: viewModel, parentViewController: self) - let rect = tableView.rectForRow(at: indexPath) - details.present(from: CGPoint(x: rect.midX, y: rect.midY)) - self.detailView = details - } - } - - // Override to support conditional editing of the table view. - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - // Return false if you do not want the specified item to be editable. - return false - } - - // MARK: - Navigation - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - - if #available(iOS 14.0, *) { - if segue.identifier == "handleTokenRequest" { - guard let tokenRequestController = segue.destination as? TokenRequestViewController, let userInfo = sender as? [AnyHashable: Any] else { assertionFailure(); return } - tokenRequestController.userInfo = userInfo - } - } - - if segue.identifier == .editCredential { - guard let navigationController = segue.destination as? UINavigationController, - let destination = navigationController.topViewController as? EditCredentialController, - let credential = sender as? Credential else { assertionFailure(); return } - destination.credential = credential - destination.viewModel = viewModel - } - - if segue.identifier == .startTutorial { - guard let navigationController = segue.destination as? UINavigationController, - let freViewController = navigationController.topViewController as? TutorialViewController else { assertionFailure(); return } - // passing userFreVersion and then setting current freVersion to userDefaults. - freViewController.userFreVersion = SettingsConfig.lastFreVersionShown - SettingsConfig.lastFreVersionShown = .freVersion - } - } - - @IBAction func unwindToMainViewController(segue: UIStoryboardSegue) { - if let sourceViewController = segue.source as? AddCredentialController, let credential = sourceViewController.credential { - // Add a new credential to table. - viewModel.addCredential(credential: credential, requiresTouch: sourceViewController.requiresTouch) - } - } - - // MARK: - private methods - @objc private func handleLongPress(longPressGesture: UILongPressGestureRecognizer) { - guard longPressGesture.state == .began else { return } - let location = longPressGesture.location(in: self.tableView) - let indexPath = self.tableView.indexPathForRow(at: location) - guard let indexPath = indexPath else { - return - } - - if UIDevice.current.userInterfaceIdiom == .phone { - let generator = UINotificationFeedbackGenerator() - generator.notificationOccurred(.success) - } - - if let credential = credentialAt(indexPath) { - if credential.requiresRefresh { - viewModel.calculate(credential: credential) { [self] _ in - DispatchQueue.main.async { - let cell = self.tableView.cellForRow(at: indexPath) as? CredentialTableViewCell - cell?.animateCode() - - } - self.viewModel.copyToClipboard(value: credential.code) - } - } else { - let cell = self.tableView.cellForRow(at: indexPath) as? CredentialTableViewCell - cell?.animateCode() - viewModel.copyToClipboard(value: credential.code) - } - } else if let otp = otp { - self.viewModel.copyToClipboard(value: otp, message: "OTP copied to clipboard") - } - } - - private func refreshCredentials() { - if YubiKitDeviceCapabilities.supportsMFIAccessoryKey || YubiKitDeviceCapabilities.supportsSmartCardOverUSBC { - if viewModel.keyPluggedIn { - viewModel.calculateAll() - tableView.reloadData() - } else { - // if YubiKey is unplugged do not show any OTP codes - viewModel.cleanUp() - } - } else { -#if DEBUG - // show some credentials on emulator - viewModel.emulateSomeRecords() -#endif - } - - tableView.reloadData() - } - - @objc func refreshData() { - if !viewModel.didNFCStartRecently { - viewModel.calculateAll() - refreshControl?.endRefreshing() - } - } - - // - // MARK: - UI Setup - // - - private func setupRefreshControl() { - if YubiKitDeviceCapabilities.supportsISO7816NFCTags { - let refreshControl = UIRefreshControl() - // setting background to refresh control changes behavior of spinner - // and it gets dragged with pull rather than sticks to the top of the view - refreshControl.backgroundColor = .clear - refreshControl.addTarget(self, action: #selector(refreshData), for: .valueChanged) - self.refreshControl = refreshControl - } - } - - private func refreshUIOnKeyStateUpdate() { - #if !targetEnvironment(simulator) - // allow to see add option on emulator and switch to manual add credential view - navigationItem.rightBarButtonItem?.isEnabled = true - #else - navigationItem.rightBarButtonItem?.isEnabled = viewModel.keyPluggedIn || YubiKitDeviceCapabilities.supportsISO7816NFCTags - #endif - - refreshCredentials() - } - - private func userFoundMenu() { - showHintView(false) - SettingsConfig.userHasFoundMenu = true - } - - private func showHintView(_ visible: Bool) { - if !visible { - hintView?.removeFromSuperview() - hintView = nil - return - } - guard hintView == nil else { return } - let hintView = UIView() - hintView.alpha = 0 - let label = UILabel() - label.text = "Add accounts here" - label.font = .preferredFont(forTextStyle: .body) - label.textColor = .secondaryLabel - label.sizeToFit() - hintView.addSubview(label) - let imageView = UIImageView() - imageView.tintColor = .secondaryLabel - let configuration = UIImage.SymbolConfiguration(pointSize: 20) - let image = UIImage(systemName: "arrow.turn.right.up")?.withConfiguration(configuration) - imageView.image = image - imageView.frame.size = image?.size ?? .zero - imageView.frame.origin = CGPoint(x: label.frame.width + 5, y: -7) - hintView.addSubview(imageView) - hintView.frame = CGRect(x: self.view.frame.width - (label.frame.width + 5 + imageView.frame.width + 18) , y: 20, width: label.frame.width + 5 + imageView.frame.width - , height: label.frame.height) - self.hintView = hintView - self.view.addSubview(hintView) - - UIView.animate(withDuration: 0.3) { - hintView.alpha = 1 - } - } - - // MARK: - Custom empty table view - private func showBackgroundView() { - self.tableView.setContentOffset(.zero, animated: false) - - let backgroundView = UIView() - backgroundView.frame.size = tableView.frame.size - backgroundView.center = tableView.center - - let contentView = UIView() - contentView.translatesAutoresizingMaskIntoConstraints = false - backgroundView.addSubview(contentView) - - let imageView = UIImageView() - imageView.contentMode = .bottom - imageView.image = getBackgroundImage()?.withRenderingMode(.alwaysTemplate).withConfiguration(UIImage.SymbolConfiguration(pointSize: 100)) - imageView.tintColor = UIColor.yubiBlue - imageView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(imageView) - - let label = UILabel() - label.font = .preferredFont(forTextStyle: .title2) - label.textColor = .label - label.numberOfLines = 0 - label.lineBreakMode = .byWordWrapping - label.textAlignment = .center - label.text = getTitle() - label.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(label) - - let whatsNewButton = UIButton() - whatsNewButton.translatesAutoresizingMaskIntoConstraints = false - whatsNewButton.isHidden = !showWhatsNewButton - cancellables.append(whatsNewButton.addHandler(for: .touchUpInside, block: { [weak self] in - SettingsConfig.didShowWhatsNewText() - let whatsNewController = VersionHistoryViewController() - whatsNewController.titleText = "What's new in\nYubico Authenticator" - self?.present(whatsNewController, animated: true) - })) - if #available(iOS 15, *) { - var see = AttributedString("See ") - see.foregroundColor = .secondaryLabel - see.font = .preferredFont(forTextStyle: .footnote) - var whatsNew = AttributedString("what's new ") - whatsNew.foregroundColor = .yubiBlue - whatsNew.font = .preferredFont(forTextStyle: .footnote) - var inThisVersion = AttributedString("in this version") - inThisVersion.foregroundColor = .secondaryLabel - inThisVersion.font = .preferredFont(forTextStyle: .footnote) - let attributedString = NSAttributedString(see + whatsNew + inThisVersion) - whatsNewButton.setAttributedTitle(attributedString, for: .normal) - } else { - whatsNewButton.setTitle("See what's new in this version", for: .normal) - whatsNewButton.setTitleColor(.yubiBlue, for: .normal) - whatsNewButton.titleLabel?.font = .preferredFont(forTextStyle: .footnote) - } - backgroundView.addSubview(whatsNewButton) - - NSLayoutConstraint.activate([ - imageView.topAnchor.constraint(equalTo: contentView.topAnchor), - imageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - label.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 25), - label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - label.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -20), - label.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 20), - label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - label.widthAnchor.constraint(lessThanOrEqualToConstant: 600), - contentView.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor, constant: -100), // we need to move the anchor up a bit since the table extends below the screen - contentView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor), - whatsNewButton.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor), - whatsNewButton.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor, constant: -150) - ]) - - self.backgroundView = backgroundView - } - - private func getBackgroundImage() -> UIImage? { - switch viewModel.state { - case .loaded: - // No accounts view - return viewModel.hasFilter ? UIImage(nameOrSystemName: "person.crop.circle.badge.questionmark") : UIImage(nameOrSystemName: "person.crop.circle") - case .notSupported: - return UIImage(nameOrSystemName: "info.circle") - default: - // YubiKey image - return UIImage(named: "yubikey") - } - } - - private func getTitle() -> String? { - switch viewModel.state { - case .idle: - if viewModel.keyPluggedIn { - return nil - } else { - return YubiKitDeviceCapabilities.supportsISO7816NFCTags ? "Insert YubiKey or pull down to activate NFC" : "Insert YubiKey" - } - case .loaded: - return viewModel.hasFilter ? "No matching accounts" : "No accounts on YubiKey" - case .notSupported: - if UIDevice.current.userInterfaceIdiom == .pad { - return "Yubico Authenticator requires iPadOS 16 for iPad with USB-C port." - } else { - return "Yubico Authenticator is not supported on this device." - } - default: - return nil - } - } -} - -extension OATHViewController: CredentialViewModelDelegate { - - // MARK: - CredentialViewModelDelegate - - func showAlert(title: String, message: String?) { - self.showAlertDialog(title: title, message: message, okHandler: { [weak self] () -> Void in - self?.dismiss(animated: true, completion: nil) - }) - } - - func onError(error: Error) { - let nsError = error as NSError - if nsError.domain == TKErrorDomain && nsError.code == -2 { - showAlert(title: "Require Touch currently unsupported on iPad", message: "Due to a limitation in the current USB smart card implementation for iPad, require touch unfortunately does not yet work on this device.") - return - } - showAlert(title: "Something went wrong", message: error.localizedDescription) - } - - func onOperationCompleted(operation: OperationName) { - switch operation { - case .setCode: - self.showAlertDialog(title: "Success", message: "The password has been successfully set", okHandler: { [weak self] () -> Void in - self?.dismiss(animated: true, completion: nil) - }) - case .reset: - self.showAlertDialog(title: "Success", message: "The application has been successfully reset", okHandler: { [weak self] () -> Void in - self?.dismiss(animated: true, completion: nil) - self?.tableView.reloadData() - }) - case .getConfig: - DispatchQueue.main.async { [weak self] in - self?.performSegue(withIdentifier: "ShowTagSettings", sender: self) - } - case .getKeyVersion: - DispatchQueue.main.async { [weak self] in - self?.performSegue(withIdentifier: "ShowDeviceInfo", sender: self) - } - case .calculateAll, .cleanup, .filter: - detailView?.dismiss() - detailView = nil - self.tableView.reloadData() - default: - // other operations do not change list of credentials - break - } - } - - func onShowToastMessage(message: String) { - self.displayToast(message: message) - } - - func onCredentialDelete(credential: Credential) { - self.tableView.reloadData() - } - - func collectPasswordPreferences(completion: @escaping (PasswordSaveType) -> Void) { - let passwordActionSheet = UIAlertController { type in - completion(type) - } - DispatchQueue.main.async { - self.present(passwordActionSheet, animated: true) - } - } - - func collectPassword(isPasswordEntryRetry: Bool, completion: @escaping (String?) -> Void) { - DispatchQueue.main.async { - let passwordEntryAlert = UIAlertController(passwordEntryType: isPasswordEntryRetry ? .retryPassword : .password) { password in - completion(password) - } - self.present(passwordEntryAlert, animated: true) - } - } - - private func credentialAt(_ indexPath: IndexPath) -> Credential? { - if otp != nil && indexPath.section == 0 { - return nil - } - if viewModel.pinnedCredentials.count > 0 && (indexPath.section == 0 || otp != nil && indexPath.section == 1) { - return viewModel.pinnedCredentials[indexPath.row] - } else { - return viewModel.credentials[indexPath.row] - } - } -} - -// MARK: ApplicationSessionObserverDelegate -extension OATHViewController: ApplicationSessionObserverDelegate { - func didEnterBackground() { - otp = nil - viewModel.cleanUp() - } - - func willResignActive() { - lastDidResignActiveTimeStamp = Date() - } - - func didBecomeActive() { - guard !VersionHistoryViewController.shouldShowOnAppLaunch else { return } - - if SettingsConfig.isNFCOnAppLaunchEnabled && !viewModel.didNFCEndRecently { - guard let lastDidResignActiveTimeStamp = lastDidResignActiveTimeStamp else { - refreshData() - return - } - - if Date() > lastDidResignActiveTimeStamp.addingTimeInterval(10) { - if let presented = presentedViewController { - presented.dismiss(animated: false) { - self.refreshData() - } - } else { - refreshData() - } - } - } - } -} - -extension OATHViewController: UNUserNotificationCenterDelegate { - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - view.window?.rootViewController?.dismiss(animated: false, completion: nil) - performSegue(withIdentifier: "handleTokenRequest", sender: response.notification.request.content.userInfo) - completionHandler() - } -} - -extension OATHViewController: SearchBarDelegate { - func searchBarDidChangeText(_ text: String) { - viewModel.applyFilter(filter: text) - } - func searchBarDidCancel() { - viewModel.applyFilter(filter: nil) - } -} - -extension SearchBar { - func install(inTopOf view: UIView) { - self.frame.origin.y = -self.frame.size.height - view.addSubview(self) - } -} - -extension YubiKitDeviceCapabilities { - static var isDeviceSupported: Bool { - return Self.supportsMFIAccessoryKey || Self.supportsISO7816NFCTags || Self.supportsSmartCardOverUSBC - } - -} diff --git a/Authenticator/UI/Authentication/OTPTableViewCell.swift b/Authenticator/UI/Authentication/OTPTableViewCell.swift deleted file mode 100644 index 2ab1df57..00000000 --- a/Authenticator/UI/Authentication/OTPTableViewCell.swift +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (C) 2023 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import UIKit -import Combine - -class OTPTableViewCell: UITableViewCell { - - var viewModel: OATHViewModel! - - @IBOutlet weak var otp: UILabel! - @IBOutlet weak var icon: UIImageView! - - override open func layoutSubviews() { - super.layoutSubviews() - // Wait for the next runloop before setting the cornerRadius - DispatchQueue.main.async { - self.icon.layer.cornerRadius = self.icon.bounds.height / 2.0 - } - } - -} diff --git a/Authenticator/UI/Authentication/PieProgressBar.swift b/Authenticator/UI/Authentication/PieProgressBar.swift deleted file mode 100644 index 5dee5e88..00000000 --- a/Authenticator/UI/Authentication/PieProgressBar.swift +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (C) 2022 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import UIKit - -/*! View that shows progress bar in each credential cell showing how much time left before expiration - * It uses UIBezierPath on CAShapeLayer to draw the circle - */ -class PieProgressBar: UIView { - - //MARK: awakeFromNib - - override func awakeFromNib() { - super.awakeFromNib() - setupView() - } - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - } - - convenience init() { - self.init(frame: .zero) - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupView() - } - - //MARK: Public - - public func setProgress(to progressConstant: Double) { - var progress: Double { - get { - if progressConstant > 1 { return 1 } - else if progressConstant < 0 { return 0 } - else { return progressConstant } - } - } - shapeLayer.fillColor = self.tintColor.cgColor - self.shapeLayer.path = getArchPath(progress: CGFloat(progress)) - } - - //MARK: Private - - private let shapeLayer = CAShapeLayer() - private var radius: CGFloat { - get{ - if self.frame.width < self.frame.height { return self.frame.width / 2.5 } - else { return self.frame.height / 2.5 } - } - } - - private var pathCenter: CGPoint{ get{ return self.convert(self.center, from:self.superview) } } - - private func getArchPath(progress: CGFloat) -> CGPath { - let startAngle = (-CGFloat.pi/2) - let endAngle = startAngle + 2 * CGFloat.pi - - let path = UIBezierPath(arcCenter: self.pathCenter, radius: self.radius, startAngle: startAngle + (1 - progress) - * 2 * CGFloat.pi, endAngle:endAngle, clockwise: true) - path.addLine(to: pathCenter) - return path.cgPath - } - - func setupView() { - self.layer.sublayers = nil - - shapeLayer.path = getArchPath(progress: 1) - shapeLayer.fillColor = self.tintColor.cgColor - self.layer.addSublayer(shapeLayer) - } -} diff --git a/Authenticator/UI/Base.lproj/Main.storyboard b/Authenticator/UI/Base.lproj/Main.storyboard index 64be85e9..df39c8db 100644 --- a/Authenticator/UI/Base.lproj/Main.storyboard +++ b/Authenticator/UI/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -11,251 +11,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -293,9 +48,9 @@ - + - + @@ -509,7 +264,7 @@ All rights reserved. - + @@ -525,7 +280,7 @@ All rights reserved. - + @@ -614,7 +369,7 @@ All rights reserved. - + @@ -648,22 +403,14 @@ All rights reserved. - - - - - - - - - + - + @@ -680,21 +427,21 @@ All rights reserved. - + - +