diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2022-04-29 10:30:55 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2022-04-29 10:30:55 +0200 |
| commit | 5937149e6a2c231dba0efaf37c5b0f9eb37224fb (patch) | |
| tree | adfd5f6a753de4e6e7c197faa90402804e9a0006 | |
| parent | 661bc50d67ef36e3a7f826cda5fd82568ba95a84 (diff) | |
| parent | d652c90c216752ddff6327c47cec99242f12b515 (diff) | |
| download | mullvadvpn-5937149e6a2c231dba0efaf37c5b0f9eb37224fb.tar.xz mullvadvpn-5937149e6a2c231dba0efaf37c5b0f9eb37224fb.zip | |
Merge branch 'device-api'
37 files changed, 1953 insertions, 856 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index a030a5546b..21cc7f746b 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -103,6 +103,12 @@ 5850367F25A481D800A43E93 /* IPAddressRange+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5850366725A47AC700A43E93 /* IPAddressRange+Codable.swift */; }; 5850368C25A49E2200A43E93 /* PrivateKeyWithMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B35322BB87C4003C19AD /* PrivateKeyWithMetadata.swift */; }; 5850368D25A49E2200A43E93 /* PrivateKeyWithMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B35322BB87C4003C19AD /* PrivateKeyWithMetadata.swift */; }; + 58554F73280AFA5A00013055 /* RESTAuthenticationProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F72280AFA5A00013055 /* RESTAuthenticationProxy.swift */; }; + 58554F75280AFAE900013055 /* RESTResponseDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F74280AFAE900013055 /* RESTResponseDecoder.swift */; }; + 58554F77280AFD5C00013055 /* RESTTaskIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F76280AFD5C00013055 /* RESTTaskIdentifier.swift */; }; + 58554F79280B037400013055 /* RESTAccessTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F78280B037400013055 /* RESTAccessTokenManager.swift */; }; + 58554F7B280B125F00013055 /* RESTAccountsProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F7A280B125F00013055 /* RESTAccountsProxy.swift */; }; + 58554F7D280D6FE000013055 /* RESTURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F7C280D6FE000013055 /* RESTURLSession.swift */; }; 58561C99239A5D1500BD6B5E /* IPEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */; }; 58561C9A239A5D1500BD6B5E /* IPEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */; }; 5856D13727450A8A00DFD627 /* UIImage+TintColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5856D13627450A8A00DFD627 /* UIImage+TintColor.swift */; }; @@ -180,6 +186,9 @@ 58871D2325D535D2002297FA /* IPAddressRange+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5850366725A47AC700A43E93 /* IPAddressRange+Codable.swift */; }; 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD82227B11080051EB06 /* SelectLocationCell.swift */; }; 5888AD87227B17950051EB06 /* SelectLocationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */; }; + 588BCF24280FE43D009ADCEC /* RESTDevicesProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588BCF23280FE43D009ADCEC /* RESTDevicesProxy.swift */; }; + 588BCF26280FE79A009ADCEC /* RESTProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588BCF25280FE79A009ADCEC /* RESTProxy.swift */; }; + 588BCF282816D664009ADCEC /* RESTResponseHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588BCF272816D664009ADCEC /* RESTResponseHandler.swift */; }; 588D2FE3248AC27F00E313F7 /* AsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E973DD24850EB600096F90 /* AsyncOperation.swift */; }; 58906DE02445C7A5002F0673 /* NEProviderStopReason+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */; }; 58907D9524D17B4E00CFC3F5 /* DisconnectSplitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */; }; @@ -212,6 +221,8 @@ 58B0A2AD238EE6EC00BC001D /* MullvadEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5840250322B11AB700E4CFEC /* MullvadEndpoint.swift */; }; 58B3F30F2742708B00A2DD38 /* HeaderBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B3F30E2742708B00A2DD38 /* HeaderBarButton.swift */; }; 58B43C1925F77DB60002C8C3 /* ConnectMainContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B43C1825F77DB60002C8C3 /* ConnectMainContentView.swift */; }; + 58B5A895280AACC4009FDE99 /* RESTRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B5A894280AACC4009FDE99 /* RESTRequestFactory.swift */; }; + 58B5A899280AB0D7009FDE99 /* RESTAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B5A898280AB0D7009FDE99 /* RESTAuthorization.swift */; }; 58B67B482602079E008EF58E /* RelaySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58781CD422AFBA39009B9D8E /* RelaySelector.swift */; }; 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B93A1226C3F13600A55733 /* TunnelState.swift */; }; 58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B993B02608A34500BA7811 /* LoginContentView.swift */; }; @@ -224,7 +235,7 @@ 58BFA5CD22A7CE1F00A6173D /* ApplicationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */; }; 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3A4B122456F1A00340BDB /* AccountInputGroupView.swift */; }; 58CAF4EF26025954007C5886 /* SimulatorTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BA693023EADA6A009DC256 /* SimulatorTunnelProvider.swift */; }; - 58CB0EE024B86751001EF0D8 /* RESTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CB0EDF24B86751001EF0D8 /* RESTClient.swift */; }; + 58CB0EE024B86751001EF0D8 /* RESTAPIProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CB0EDF24B86751001EF0D8 /* RESTAPIProxy.swift */; }; 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; }; 58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA00F224249A1004F3011 /* ConnectViewController.swift */; }; 58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA01122424D11004F3011 /* SettingsViewController.swift */; }; @@ -261,7 +272,7 @@ 58F558E32695D1D800F630D0 /* Preferences.strings in Resources */ = {isa = PBXBuildFile; fileRef = 58F558E12695D1D800F630D0 /* Preferences.strings */; }; 58F558E62695D1F200F630D0 /* ProblemReport.strings in Resources */ = {isa = PBXBuildFile; fileRef = 58F558E42695D1F200F630D0 /* ProblemReport.strings */; }; 58F558E92695D20F00F630D0 /* SelectLocation.strings in Resources */ = {isa = PBXBuildFile; fileRef = 58F558E72695D20F00F630D0 /* SelectLocation.strings */; }; - 58F558EC2695D26A00F630D0 /* RESTClient.strings in Resources */ = {isa = PBXBuildFile; fileRef = 58F558EA2695D26A00F630D0 /* RESTClient.strings */; }; + 58F558EC2695D26A00F630D0 /* REST.strings in Resources */ = {isa = PBXBuildFile; fileRef = 58F558EA2695D26A00F630D0 /* REST.strings */; }; 58F558EF2695D50D00F630D0 /* ProblemReportReview.strings in Resources */ = {isa = PBXBuildFile; fileRef = 58F558ED2695D50D00F630D0 /* ProblemReportReview.strings */; }; 58F558F92696EB1C00F630D0 /* StoreKitErrors.strings in Resources */ = {isa = PBXBuildFile; fileRef = 58F558F32696EB1C00F630D0 /* StoreKitErrors.strings */; }; 58F558FA2696EB1C00F630D0 /* TunnelManager.strings in Resources */ = {isa = PBXBuildFile; fileRef = 58F558F52696EB1C00F630D0 /* TunnelManager.strings */; }; @@ -278,6 +289,8 @@ 58F840B22464491D0044E708 /* ChainedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F840B12464491D0044E708 /* ChainedError.swift */; }; 58F840B32464491D0044E708 /* ChainedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F840B12464491D0044E708 /* ChainedError.swift */; }; 58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F8AC0D25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift */; }; + 58F97A1B280EEBC00050C2FC /* RESTProxyFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F97A1A280EEBC00050C2FC /* RESTProxyFactory.swift */; }; + 58F97A1E280FDE230050C2FC /* RESTRequestHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F97A1D280FDE230050C2FC /* RESTRequestHandler.swift */; }; 58FAEDEF245069C700CB0F5B /* KeychainAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */; }; 58FAEDF1245069CA00CB0F5B /* KeychainAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */; }; 58FAEDF4245088B300CB0F5B /* KeychainError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58AEEF642344A36000C9BBD5 /* KeychainError.swift */; }; @@ -410,6 +423,12 @@ 584D26C5270C8741004EA533 /* SettingsDNSTextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDNSTextCell.swift; sourceTree = "<group>"; }; 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Markdown.swift"; sourceTree = "<group>"; }; 5850366725A47AC700A43E93 /* IPAddressRange+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IPAddressRange+Codable.swift"; sourceTree = "<group>"; }; + 58554F72280AFA5A00013055 /* RESTAuthenticationProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTAuthenticationProxy.swift; sourceTree = "<group>"; }; + 58554F74280AFAE900013055 /* RESTResponseDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTResponseDecoder.swift; sourceTree = "<group>"; }; + 58554F76280AFD5C00013055 /* RESTTaskIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTTaskIdentifier.swift; sourceTree = "<group>"; }; + 58554F78280B037400013055 /* RESTAccessTokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTAccessTokenManager.swift; sourceTree = "<group>"; }; + 58554F7A280B125F00013055 /* RESTAccountsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTAccountsProxy.swift; sourceTree = "<group>"; }; + 58554F7C280D6FE000013055 /* RESTURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTURLSession.swift; sourceTree = "<group>"; }; 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPEndpoint.swift; sourceTree = "<group>"; }; 5856D13627450A8A00DFD627 /* UIImage+TintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+TintColor.swift"; sourceTree = "<group>"; }; 5857F24224C8662600CF6F47 /* SelectLocationHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationHeaderView.swift; sourceTree = "<group>"; }; @@ -463,6 +482,9 @@ 588527B5276B58B300BAA373 /* SetTunnelSettingsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetTunnelSettingsOperation.swift; sourceTree = "<group>"; }; 5888AD82227B11080051EB06 /* SelectLocationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationCell.swift; sourceTree = "<group>"; }; 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationViewController.swift; sourceTree = "<group>"; }; + 588BCF23280FE43D009ADCEC /* RESTDevicesProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTDevicesProxy.swift; sourceTree = "<group>"; }; + 588BCF25280FE79A009ADCEC /* RESTProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTProxy.swift; sourceTree = "<group>"; }; + 588BCF272816D664009ADCEC /* RESTResponseHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTResponseHandler.swift; sourceTree = "<group>"; }; 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NEProviderStopReason+Debug.swift"; sourceTree = "<group>"; }; 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectSplitButton.swift; sourceTree = "<group>"; }; 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+ProductVersion.swift"; sourceTree = "<group>"; }; @@ -485,6 +507,8 @@ 58B0A2A4238EE67E00BC001D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 58B3F30E2742708B00A2DD38 /* HeaderBarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBarButton.swift; sourceTree = "<group>"; }; 58B43C1825F77DB60002C8C3 /* ConnectMainContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectMainContentView.swift; sourceTree = "<group>"; }; + 58B5A894280AACC4009FDE99 /* RESTRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTRequestFactory.swift; sourceTree = "<group>"; }; + 58B5A898280AB0D7009FDE99 /* RESTAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTAuthorization.swift; sourceTree = "<group>"; }; 58B93A1226C3F13600A55733 /* TunnelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelState.swift; sourceTree = "<group>"; }; 58B993B02608A34500BA7811 /* LoginContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginContentView.swift; sourceTree = "<group>"; }; 58B9EB122488ED2100095626 /* AlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresenter.swift; sourceTree = "<group>"; }; @@ -494,7 +518,7 @@ 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationConfiguration.swift; sourceTree = "<group>"; }; 58C3A4B122456F1A00340BDB /* AccountInputGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInputGroupView.swift; sourceTree = "<group>"; }; 58C6B35322BB87C4003C19AD /* PrivateKeyWithMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateKeyWithMetadata.swift; sourceTree = "<group>"; }; - 58CB0EDF24B86751001EF0D8 /* RESTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTClient.swift; sourceTree = "<group>"; }; + 58CB0EDF24B86751001EF0D8 /* RESTAPIProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTAPIProxy.swift; sourceTree = "<group>"; }; 58CC40EE24A601900019D96E /* ObserverList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObserverList.swift; sourceTree = "<group>"; }; 58CCA00F224249A1004F3011 /* ConnectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewController.swift; sourceTree = "<group>"; }; 58CCA01122424D11004F3011 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; }; @@ -538,7 +562,7 @@ 58F558E22695D1D800F630D0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Preferences.strings; sourceTree = "<group>"; }; 58F558E52695D1F200F630D0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ProblemReport.strings; sourceTree = "<group>"; }; 58F558E82695D20F00F630D0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/SelectLocation.strings; sourceTree = "<group>"; }; - 58F558EB2695D26A00F630D0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/RESTClient.strings; sourceTree = "<group>"; }; + 58F558EB2695D26A00F630D0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/REST.strings; sourceTree = "<group>"; }; 58F558EE2695D50D00F630D0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ProblemReportReview.strings; sourceTree = "<group>"; }; 58F558F42696EB1C00F630D0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/StoreKitErrors.strings; sourceTree = "<group>"; }; 58F558F62696EB1C00F630D0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/TunnelManager.strings; sourceTree = "<group>"; }; @@ -554,6 +578,8 @@ 58F7D26427EB50A300E4D821 /* ResultOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultOperation.swift; sourceTree = "<group>"; }; 58F840B12464491D0044E708 /* ChainedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainedError.swift; sourceTree = "<group>"; }; 58F8AC0D25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportReviewViewController.swift; sourceTree = "<group>"; }; + 58F97A1A280EEBC00050C2FC /* RESTProxyFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTProxyFactory.swift; sourceTree = "<group>"; }; + 58F97A1D280FDE230050C2FC /* RESTRequestHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTRequestHandler.swift; sourceTree = "<group>"; }; 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainAttributes.swift; sourceTree = "<group>"; }; 58FAEDF6245088E100CB0F5B /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; }; 58FAEDFC24533A5500CB0F5B /* KeychainMatchLimit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainMatchLimit.swift; sourceTree = "<group>"; }; @@ -695,7 +721,7 @@ 587B7543266922BF00DEF7E9 /* Localizable.strings */, 58F558DE2695BD3E00F630D0 /* Login.strings */, 58F559052697002000F630D0 /* Main.strings */, - 58F558EA2695D26A00F630D0 /* RESTClient.strings */, + 58F558EA2695D26A00F630D0 /* REST.strings */, 58F558E12695D1D800F630D0 /* Preferences.strings */, 58F558E42695D1F200F630D0 /* ProblemReport.strings */, 58F558ED2695D50D00F630D0 /* ProblemReportReview.strings */, @@ -740,11 +766,24 @@ children = ( 5820674F26E6514100655B05 /* HTTP.swift */, 5820674D26E6510200655B05 /* REST.swift */, - 58CB0EDF24B86751001EF0D8 /* RESTClient.swift */, + 58554F78280B037400013055 /* RESTAccessTokenManager.swift */, + 58554F7A280B125F00013055 /* RESTAccountsProxy.swift */, + 58CB0EDF24B86751001EF0D8 /* RESTAPIProxy.swift */, + 58554F72280AFA5A00013055 /* RESTAuthenticationProxy.swift */, + 58B5A898280AB0D7009FDE99 /* RESTAuthorization.swift */, 585DA88926B027A300B8C587 /* RESTCoding.swift */, + 588BCF23280FE43D009ADCEC /* RESTDevicesProxy.swift */, 585DA88626B0277200B8C587 /* RESTError.swift */, 58095C522760EEC700890776 /* RESTNetworkOperation.swift */, + 588BCF25280FE79A009ADCEC /* RESTProxy.swift */, + 58F97A1A280EEBC00050C2FC /* RESTProxyFactory.swift */, + 58B5A894280AACC4009FDE99 /* RESTRequestFactory.swift */, + 58F97A1D280FDE230050C2FC /* RESTRequestHandler.swift */, + 58554F74280AFAE900013055 /* RESTResponseDecoder.swift */, + 588BCF272816D664009ADCEC /* RESTResponseHandler.swift */, 58095C582762155700890776 /* RESTRetryStrategy.swift */, + 58554F76280AFD5C00013055 /* RESTTaskIdentifier.swift */, + 58554F7C280D6FE000013055 /* RESTURLSession.swift */, 585DA88326B0270700B8C587 /* ServerRelaysResponse.swift */, 584789DF26529D72000E45FB /* SSLPinningURLSessionDelegate.swift */, ); @@ -1214,7 +1253,7 @@ 58F5590E2697002100F630D0 /* Main.strings in Resources */, 58F558FE2696F09100F630D0 /* KeyboardNavigation.strings in Resources */, 581FC4FA2695ACE100AA97BA /* Account.strings in Resources */, - 58F558EC2695D26A00F630D0 /* RESTClient.strings in Resources */, + 58F558EC2695D26A00F630D0 /* REST.strings in Resources */, 582CFEEA269463B80072883A /* Settings.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1312,6 +1351,8 @@ 5875960726F36B3A00BF6711 /* TunnelIPCError.swift in Sources */, 58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */, 58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */, + 588BCF282816D664009ADCEC /* RESTResponseHandler.swift in Sources */, + 58554F77280AFD5C00013055 /* RESTTaskIdentifier.swift in Sources */, 58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */, 582BB1B1229569620055B6EF /* CustomNavigationBar.swift in Sources */, 58B3F30F2742708B00A2DD38 /* HeaderBarButton.swift in Sources */, @@ -1335,10 +1376,11 @@ 58F2E146276A2C9900A79513 /* StopTunnelOperation.swift in Sources */, 585DA87A26B024F900B8C587 /* RelayCacheError.swift in Sources */, 5856D13727450A8A00DFD627 /* UIImage+TintColor.swift in Sources */, - 58CB0EE024B86751001EF0D8 /* RESTClient.swift in Sources */, + 58CB0EE024B86751001EF0D8 /* RESTAPIProxy.swift in Sources */, 58095C532760EEC700890776 /* RESTNetworkOperation.swift in Sources */, 58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */, 582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */, + 58554F7D280D6FE000013055 /* RESTURLSession.swift in Sources */, 5846227726E22A7C0035F7C2 /* AppStorePaymentManagerDelegate.swift in Sources */, 5871FB8325498CA20051A0A4 /* Swizzle.swift in Sources */, 58EF581125D69DB400AEBA94 /* StatusImageView.swift in Sources */, @@ -1357,6 +1399,7 @@ 5806767927048E8800C858CB /* Keychain.swift in Sources */, 588527B4276B4F2F00BAA373 /* SetAccountOperation.swift in Sources */, 585CA70F25F8C44600B47C62 /* UIMetrics.swift in Sources */, + 58F97A1B280EEBC00050C2FC /* RESTProxyFactory.swift in Sources */, 58095C592762155700890776 /* RESTRetryStrategy.swift in Sources */, 5840250422B11AB700E4CFEC /* MullvadEndpoint.swift in Sources */, 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */, @@ -1364,11 +1407,13 @@ 5820674E26E6510200655B05 /* REST.swift in Sources */, 5871FBA0254C26C00051A0A4 /* NSRegularExpression+IPAddress.swift in Sources */, 58F7CA882692E34000FC59FD /* WireguardKeysContentView.swift in Sources */, + 58554F7B280B125F00013055 /* RESTAccountsProxy.swift in Sources */, 5801C9A527A14B2A0031566A /* TunnelManagerState.swift in Sources */, 58095C4B2760B4F200890776 /* AddressCacheStoreError.swift in Sources */, 5868585524054096000B8131 /* AppButton.swift in Sources */, 58781CC922AE7CA8009B9D8E /* RelayConstraints.swift in Sources */, 584E96BC240FD4DA00D3334F /* Location.swift in Sources */, + 58554F73280AFA5A00013055 /* RESTAuthenticationProxy.swift in Sources */, 581503A124D6F01F00C9C50E /* LogRotation.swift in Sources */, 585E820327F3285E00939F0E /* SendAppStoreReceiptOperation.swift in Sources */, 584B17AB27637DE40057F3B8 /* ReloadTunnelOperation.swift in Sources */, @@ -1393,6 +1438,7 @@ 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */, 585DA89326B0323E00B8C587 /* TunnelIPCRequest.swift in Sources */, 5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */, + 588BCF26280FE79A009ADCEC /* RESTProxy.swift in Sources */, 5820676826E79E7B00655B05 /* Result+UIBackgroundFetchResult.swift in Sources */, 5807E2C02432038B00F5FF30 /* String+Split.swift in Sources */, 58CE5E66224146200008646E /* LoginViewController.swift in Sources */, @@ -1423,12 +1469,14 @@ 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */, 58B9EB152489139B00095626 /* DisplayChainedError.swift in Sources */, 587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */, + 58554F79280B037400013055 /* RESTAccessTokenManager.swift in Sources */, 58F2E144276A13F300A79513 /* StartTunnelOperation.swift in Sources */, 5868BD33261DCD2600E6027F /* CustomSplitViewController.swift in Sources */, 5806766E27048E5600C858CB /* KeychainMatchLimit.swift in Sources */, 58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */, 586E54FB27A2DF6D0029B88B /* TunnelIPCRequestOperation.swift in Sources */, 584592612639B4A200EF967F /* ConsentContentView.swift in Sources */, + 58B5A895280AACC4009FDE99 /* RESTRequestFactory.swift in Sources */, 584EBDBD2747C98F00A0C9FD /* NSAttributedString+Markdown.swift in Sources */, 5875960A26F371FC00BF6711 /* TunnelIPCSession.swift in Sources */, 58FB865E26EA284E00F188BC /* LogFormatting.swift in Sources */, @@ -1444,14 +1492,18 @@ 581503A324D6F1EC00C9C50E /* ChainedError+Logger.swift in Sources */, 58E20771274672CA00DE5D77 /* LaunchViewController.swift in Sources */, 584D26C4270C855B004EA533 /* PreferencesDataSource.swift in Sources */, + 58B5A899280AB0D7009FDE99 /* RESTAuthorization.swift in Sources */, 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */, 58B43C1925F77DB60002C8C3 /* ConnectMainContentView.swift in Sources */, 58561C99239A5D1500BD6B5E /* IPEndpoint.swift in Sources */, + 58554F75280AFAE900013055 /* RESTResponseDecoder.swift in Sources */, + 58F97A1E280FDE230050C2FC /* RESTRequestHandler.swift in Sources */, 58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */, 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */, 58F840B22464491D0044E708 /* ChainedError.swift in Sources */, 58FAEDFF24533A7000CB0F5B /* KeychainReturn.swift in Sources */, + 588BCF24280FE43D009ADCEC /* RESTDevicesProxy.swift in Sources */, 58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */, 587EB67027143B6500123C75 /* DataSourceSnapshot.swift in Sources */, 585DA88A26B027A300B8C587 /* RESTCoding.swift in Sources */, @@ -1619,12 +1671,12 @@ name = SelectLocation.strings; sourceTree = "<group>"; }; - 58F558EA2695D26A00F630D0 /* RESTClient.strings */ = { + 58F558EA2695D26A00F630D0 /* REST.strings */ = { isa = PBXVariantGroup; children = ( 58F558EB2695D26A00F630D0 /* en */, ); - name = RESTClient.strings; + name = REST.strings; sourceTree = "<group>"; }; 58F558ED2695D50D00F630D0 /* ProblemReportReview.strings */ = { diff --git a/ios/MullvadVPN/Account.swift b/ios/MullvadVPN/Account.swift index 2ad08b0e49..79558024e7 100644 --- a/ios/MullvadVPN/Account.swift +++ b/ios/MullvadVPN/Account.swift @@ -60,6 +60,8 @@ class Account { return operationQueue }() + private let apiProxy = REST.ProxyFactory.shared.createAPIProxy() + /// Returns true if user agreed to terms of service, otherwise false var isAgreedToTermsOfService: Bool { return UserDefaults.standard.bool(forKey: UserDefaultsKeys.isAgreedToTermsOfService.rawValue) @@ -100,7 +102,7 @@ class Account { func loginWithNewAccount(completionHandler: @escaping (Result<REST.AccountResponse, Account.Error>) -> Void) { let operation = AsyncBlockOperation { operation in - _ = REST.Client.shared.createAccount(retryStrategy: .noRetry) { result in + _ = self.apiProxy.createAccount(retryStrategy: .noRetry) { result in switch result { case .success(let response): self.setupTunnel(accountToken: response.token, expiry: response.expires) { error in @@ -135,7 +137,7 @@ class Account { /// application preferences. func login(accountToken: String, completionHandler: @escaping (Result<REST.AccountResponse, Account.Error>) -> Void) { let operation = AsyncBlockOperation { operation in - _ = REST.Client.shared.getAccountExpiry(token: accountToken, retryStrategy: .default) { result in + _ = self.apiProxy.getAccountExpiry(accountNumber: accountToken, retryStrategy: .default) { result in switch result { case .success(let response): self.setupTunnel(accountToken: response.token, expiry: response.expires) { error in @@ -206,7 +208,7 @@ class Account { return } - _ = REST.Client.shared.getAccountExpiry(token: token, retryStrategy: .default) { completion in + _ = self.apiProxy.getAccountExpiry(accountNumber: token, retryStrategy: .default) { completion in switch completion { case .success(let response): if self.expiry != response.expires { diff --git a/ios/MullvadVPN/AddressCache/AddressCacheStore.swift b/ios/MullvadVPN/AddressCache/AddressCacheStore.swift index 37bf2f2cd7..745e83dc9d 100644 --- a/ios/MullvadVPN/AddressCache/AddressCacheStore.swift +++ b/ios/MullvadVPN/AddressCache/AddressCacheStore.swift @@ -76,15 +76,84 @@ extension AddressCache { /// The location of pre-bundled address cache file. private let prebundledCacheFileURL: URL - /// Queue used for synchronizing access to instance members. - private let stateQueue = DispatchQueue(label: "AddressCacheStoreQueue") + /// Lock used for synchronizing access to instance members. + private let nslock = NSLock() /// Designated initializer init(cacheFileURL: URL, prebundledCacheFileURL: URL) { self.cacheFileURL = cacheFileURL self.prebundledCacheFileURL = prebundledCacheFileURL - self.cachedAddresses = Self.defaultCachedAddresses + cachedAddresses = Self.defaultCachedAddresses + initializeStore() + } + + func getCurrentEndpoint() -> AnyIPEndpoint { + nslock.lock() + defer { nslock.unlock() } + return cachedAddresses.endpoints.first! + } + + func selectNextEndpoint(_ failedEndpoint: AnyIPEndpoint) -> AnyIPEndpoint { + nslock.lock() + defer { nslock.unlock() } + + var currentEndpoint = cachedAddresses.endpoints.first! + + if failedEndpoint == currentEndpoint { + cachedAddresses.endpoints.removeFirst() + cachedAddresses.endpoints.append(failedEndpoint) + + currentEndpoint = cachedAddresses.endpoints.first! + + logger.debug("Failed to communicate using \(failedEndpoint). Next endpoint: \(currentEndpoint)") + + if case .failure(let error) = writeToDisk() { + logger.error(chainedError: error, message: "Failed to write address cache after selecting next endpoint.") + } + } + + return currentEndpoint + } + + func setEndpoints(_ endpoints: [AnyIPEndpoint]) -> Result<Void, AddressCache.StoreError> { + nslock.lock() + defer { nslock.unlock() } + + guard !endpoints.isEmpty else { + return .failure(.emptyAddressList) + } + + if Set(cachedAddresses.endpoints) == Set(endpoints) { + cachedAddresses.updatedAt = Date() + } else { + // Shuffle new endpoints + var newEndpoints = endpoints.shuffled() + + // Move current endpoint to the top of the list + let currentEndpoint = cachedAddresses.endpoints.first! + if let index = newEndpoints.firstIndex(of: currentEndpoint) { + newEndpoints.remove(at: index) + newEndpoints.insert(currentEndpoint, at: 0) + } + + cachedAddresses = CachedAddresses( + updatedAt: Date(), + endpoints: newEndpoints + ) + } + + return writeToDisk() + } + + func getLastUpdateDate() -> Date { + nslock.lock() + defer { nslock.unlock() } + + return cachedAddresses.updatedAt + } + + private func initializeStore() { switch readFromCacheLocationWithFallback() { case .success(let readResult): if readResult.cachedAddresses.endpoints.isEmpty { @@ -105,7 +174,7 @@ extension AddressCache { logger.debug("Persist address list read from bundle.") - if case .failure(let error) = self.writeToDisk() { + if case .failure(let error) = writeToDisk() { logger.error(chainedError: error, message: "Failed to persist address cache after reading it from bundle.") } } @@ -122,79 +191,6 @@ extension AddressCache { } } - func getCurrentEndpoint(_ completionHandler: @escaping (AnyIPEndpoint) -> Void) { - stateQueue.async { - let currentEndpoint = self.cachedAddresses.endpoints.first! - - completionHandler(currentEndpoint) - } - } - - func selectNextEndpoint(_ failedEndpoint: AnyIPEndpoint, completionHandler: @escaping (AnyIPEndpoint) -> Void) { - stateQueue.async { - var currentEndpoint = self.cachedAddresses.endpoints.first! - - if failedEndpoint == currentEndpoint { - self.cachedAddresses.endpoints.removeFirst() - self.cachedAddresses.endpoints.append(failedEndpoint) - - currentEndpoint = self.cachedAddresses.endpoints.first! - - self.logger.debug("Failed to communicate using \(failedEndpoint). Next endpoint: \(currentEndpoint)") - - if case .failure(let error) = self.writeToDisk() { - self.logger.error(chainedError: error, message: "Failed to write address cache after selecting next endpoint.") - } - } - - completionHandler(currentEndpoint) - } - } - - func setEndpoints(_ endpoints: [AnyIPEndpoint], completionHandler: @escaping (AddressCache.StoreError?) -> Void) { - stateQueue.async { - guard !endpoints.isEmpty else { - completionHandler(.emptyAddressList) - return - } - - if Set(self.cachedAddresses.endpoints) == Set(endpoints) { - self.cachedAddresses.updatedAt = Date() - } else { - // Shuffle new endpoints - var newEndpoints = endpoints.shuffled() - - // Move current endpoint to the top of the list - let currentEndpoint = self.cachedAddresses.endpoints.first! - if let index = newEndpoints.firstIndex(of: currentEndpoint) { - newEndpoints.remove(at: index) - newEndpoints.insert(currentEndpoint, at: 0) - } - - self.cachedAddresses = CachedAddresses( - updatedAt: Date(), - endpoints: newEndpoints - ) - } - - let writeResult = self.writeToDisk() - - completionHandler(writeResult.error) - } - } - - func getLastUpdateDate(_ completionHandler: @escaping (Date) -> Void) { - stateQueue.async { - completionHandler(self.cachedAddresses.updatedAt) - } - } - - func getLastUpdateDateAndWait() -> Date { - return stateQueue.sync { - return self.cachedAddresses.updatedAt - } - } - private func readFromCacheLocationWithFallback() -> Result<ReadResult, AddressCache.StoreError> { return readFromCacheLocation() .map { addresses in diff --git a/ios/MullvadVPN/AddressCache/AddressCacheTracker.swift b/ios/MullvadVPN/AddressCache/AddressCacheTracker.swift index 390fc31b4c..29d83f258e 100644 --- a/ios/MullvadVPN/AddressCache/AddressCacheTracker.swift +++ b/ios/MullvadVPN/AddressCache/AddressCacheTracker.swift @@ -21,8 +21,8 @@ extension AddressCache { /// Logger. private let logger = Logger(label: "AddressCache.Tracker") - /// REST client - private let restClient: REST.Client + /// REST API proxy. + private let apiProxy: REST.APIProxy /// Store. private let store: AddressCache.Store @@ -47,8 +47,8 @@ extension AddressCache { private let stateQueue = DispatchQueue(label: "AddressCache.Tracker.stateQueue") /// Designated initializer - init(restClient: REST.Client, store: AddressCache.Store) { - self.restClient = restClient + init(apiProxy: REST.APIProxy, store: AddressCache.Store) { + self.apiProxy = apiProxy self.store = store } @@ -86,7 +86,7 @@ extension AddressCache { func updateEndpoints(completionHandler: ((_ completion: OperationCompletion<CacheUpdateResult, Error>) -> Void)? = nil) -> Cancellable { let operation = UpdateAddressCacheOperation( queue: stateQueue, - restClient: restClient, + apiProxy: apiProxy, store: store, updateInterval: Self.updateInterval, completionHandler: { [weak self] completion in @@ -138,7 +138,7 @@ extension AddressCache { if let lastFailureAttemptDate = lastFailureAttemptDate { return Date(timeInterval: Self.retryInterval, since: lastFailureAttemptDate) } else { - let updatedAt = store.getLastUpdateDateAndWait() + let updatedAt = store.getLastUpdateDate() return Date(timeInterval: Self.updateInterval, since: updatedAt) } diff --git a/ios/MullvadVPN/AddressCache/UpdateAddressCacheOperation.swift b/ios/MullvadVPN/AddressCache/UpdateAddressCacheOperation.swift index 2b2d42ac33..8d246827a4 100644 --- a/ios/MullvadVPN/AddressCache/UpdateAddressCacheOperation.swift +++ b/ios/MullvadVPN/AddressCache/UpdateAddressCacheOperation.swift @@ -20,15 +20,15 @@ extension AddressCache { class UpdateAddressCacheOperation: ResultOperation<CacheUpdateResult, Error> { private let queue: DispatchQueue - private let restClient: REST.Client + private let apiProxy: REST.APIProxy private let store: AddressCache.Store private let updateInterval: TimeInterval private var requestTask: Cancellable? - init(queue: DispatchQueue, restClient: REST.Client, store: AddressCache.Store, updateInterval: TimeInterval, completionHandler: CompletionHandler?) { + init(queue: DispatchQueue, apiProxy: REST.APIProxy, store: AddressCache.Store, updateInterval: TimeInterval, completionHandler: CompletionHandler?) { self.queue = queue - self.restClient = restClient + self.apiProxy = apiProxy self.store = store self.updateInterval = updateInterval @@ -56,7 +56,7 @@ extension AddressCache { return } - let lastUpdate = store.getLastUpdateDateAndWait() + let lastUpdate = store.getLastUpdateDate() let nextUpdate = Date(timeInterval: updateInterval, since: lastUpdate) guard nextUpdate <= Date() else { @@ -64,7 +64,7 @@ extension AddressCache { return } - requestTask = restClient.getAddressList(retryStrategy: .default) { completion in + requestTask = apiProxy.getAddressList(retryStrategy: .default) { completion in self.queue.async { self.handleResponse(completion) } @@ -74,12 +74,11 @@ extension AddressCache { private func handleResponse(_ completion: OperationCompletion<[AnyIPEndpoint], REST.Error>) { switch completion { case .success(let newEndpoints): - self.store.setEndpoints(newEndpoints) { error in - if let error = error { - self.finish(completion: .failure(error)) - } else { - self.finish(completion: .success(.finished)) - } + switch store.setEndpoints(newEndpoints) { + case .success: + self.finish(completion: .success(.finished)) + case .failure(let error): + self.finish(completion: .failure(error)) } case .failure(let error): diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index e2ea21f452..2f7c945073 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -38,7 +38,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private lazy var addressCacheTracker: AddressCache.Tracker = { return AddressCache.Tracker( - restClient: REST.Client.shared, + apiProxy: REST.ProxyFactory.shared.createAPIProxy(), store: AddressCache.Store.shared ) }() diff --git a/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift index 88d5a7c922..885ad3cbad 100644 --- a/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift +++ b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift @@ -28,6 +28,8 @@ class AppStorePaymentManager: NSObject, SKPaymentTransactionObserver { return queue }() + private let apiProxy = REST.ProxyFactory.shared.createAPIProxy() + private let exclusivityController = ExclusivityController() private let paymentQueue: SKPaymentQueue @@ -115,7 +117,7 @@ class AppStorePaymentManager: NSObject, SKPaymentTransactionObserver { } // Validate account token before adding new payment to the queue. - cancellableTask = REST.Client.shared.getAccountExpiry(token: accountToken, retryStrategy: .default) { result in + cancellableTask = apiProxy.getAccountExpiry(accountNumber: accountToken, retryStrategy: .default) { result in dispatchPrecondition(condition: .onQueue(.main)) switch result { @@ -176,7 +178,7 @@ class AppStorePaymentManager: NSObject, SKPaymentTransactionObserver { private func sendAppStoreReceipt(accountToken: String, forceRefresh: Bool, completionHandler: @escaping (OperationCompletion<REST.CreateApplePaymentResponse, Error>) -> Void) -> Cancellable { let operation = SendAppStoreReceiptOperation( - restClient: REST.Client.shared, + apiProxy: apiProxy, accountToken: accountToken, forceRefresh: forceRefresh, receiptProperties: nil, diff --git a/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift b/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift index 69ddf4459c..3bcfa7c4f3 100644 --- a/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift +++ b/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift @@ -10,77 +10,77 @@ import Foundation import Logging class SendAppStoreReceiptOperation: ResultOperation<REST.CreateApplePaymentResponse, AppStorePaymentManager.Error> { - private let restClient: REST.Client - private let accountToken: String - private let forceRefresh: Bool - private let receiptProperties: [String: Any]? - private var fetchReceiptTask: Cancellable? - private var submitReceiptTask: Cancellable? + private let apiProxy: REST.APIProxy + private let accountToken: String + private let forceRefresh: Bool + private let receiptProperties: [String: Any]? + private var fetchReceiptTask: Cancellable? + private var submitReceiptTask: Cancellable? - private let logger = Logger(label: "AppStorePaymentManager.SendAppStoreReceiptOperation") + private let logger = Logger(label: "AppStorePaymentManager.SendAppStoreReceiptOperation") - init(restClient: REST.Client, accountToken: String, forceRefresh: Bool, receiptProperties: [String: Any]?, completionHandler: @escaping CompletionHandler) { - self.restClient = restClient - self.accountToken = accountToken - self.forceRefresh = forceRefresh - self.receiptProperties = receiptProperties + init(apiProxy: REST.APIProxy, accountToken: String, forceRefresh: Bool, receiptProperties: [String: Any]?, completionHandler: @escaping CompletionHandler) { + self.apiProxy = apiProxy + self.accountToken = accountToken + self.forceRefresh = forceRefresh + self.receiptProperties = receiptProperties - super.init(completionQueue: .main, completionHandler: completionHandler) - } + super.init(completionQueue: .main, completionHandler: completionHandler) + } - override func cancel() { - super.cancel() + override func cancel() { + super.cancel() - DispatchQueue.main.async { - self.fetchReceiptTask?.cancel() - self.fetchReceiptTask = nil + DispatchQueue.main.async { + self.fetchReceiptTask?.cancel() + self.fetchReceiptTask = nil - self.submitReceiptTask?.cancel() - self.submitReceiptTask = nil - } - } + self.submitReceiptTask?.cancel() + self.submitReceiptTask = nil + } + } - override func main() { - DispatchQueue.main.async { - guard !self.isCancelled else { - self.finish(completion: .cancelled) - return - } + override func main() { + DispatchQueue.main.async { + guard !self.isCancelled else { + self.finish(completion: .cancelled) + return + } - self.fetchReceiptTask = AppStoreReceipt.fetch(forceRefresh: self.forceRefresh, receiptProperties: self.receiptProperties) { completion in - switch completion { - case .success(let receiptData): - self.sendReceipt(receiptData) + self.fetchReceiptTask = AppStoreReceipt.fetch(forceRefresh: self.forceRefresh, receiptProperties: self.receiptProperties) { completion in + switch completion { + case .success(let receiptData): + self.sendReceipt(receiptData) - case .failure(let error): - self.logger.error(chainedError: error, message: "Failed to fetch the AppStore receipt.") - self.finish(completion: .failure(.readReceipt(error))) + case .failure(let error): + self.logger.error(chainedError: error, message: "Failed to fetch the AppStore receipt.") + self.finish(completion: .failure(.readReceipt(error))) - case .cancelled: - self.finish(completion: .cancelled) - } - } - } - } + case .cancelled: + self.finish(completion: .cancelled) + } + } + } + } - private func sendReceipt(_ receiptData: Data) { - submitReceiptTask = restClient.createApplePayment( - token: self.accountToken, - receiptString: receiptData, - retryStrategy: .noRetry) { result in - switch result { - case .success(let response): - self.logger.info("AppStore receipt was processed. Time added: \(response.timeAdded), New expiry: \(response.newExpiry.logFormatDate())") - self.finish(completion: .success(response)) + private func sendReceipt(_ receiptData: Data) { + submitReceiptTask = apiProxy.createApplePayment( + token: self.accountToken, + receiptString: receiptData, + retryStrategy: .noRetry) { result in + switch result { + case .success(let response): + self.logger.info("AppStore receipt was processed. Time added: \(response.timeAdded), New expiry: \(response.newExpiry.logFormatDate())") + self.finish(completion: .success(response)) - case .failure(let error): - self.logger.error(chainedError: error, message: "Failed to send the AppStore receipt.") - self.finish(completion: .failure(.sendReceipt(error))) + case .failure(let error): + self.logger.error(chainedError: error, message: "Failed to send the AppStore receipt.") + self.finish(completion: .failure(.sendReceipt(error))) - case .cancelled: - self.logger.debug("Receipt submission cancelled.") - self.finish(completion: .cancelled) - } - } - } + case .cancelled: + self.logger.debug("Receipt submission cancelled.") + self.finish(completion: .cancelled) + } + } + } } diff --git a/ios/MullvadVPN/ApplicationConfiguration.swift b/ios/MullvadVPN/ApplicationConfiguration.swift index ad8102bd37..17bd6f04a1 100644 --- a/ios/MullvadVPN/ApplicationConfiguration.swift +++ b/ios/MullvadVPN/ApplicationConfiguration.swift @@ -44,9 +44,15 @@ extension ApplicationConfiguration { /// FAQ & Guides URL. static let faqAndGuidesURL = URL(string: "https://mullvad.net/help/tag/mullvad-app/")! - /// Default API endpoint + /// Default API hostname. + static let defaultAPIHostname = "api.mullvad.net" + + /// Default API endpoint. static let defaultAPIEndpoint = AnyIPEndpoint(string: "193.138.218.78:443")! + /// Default network timeout for API requests. + static let defaultAPINetworkTimeout: TimeInterval = 10 + /// Background fetch minimum interval static let minimumBackgroundFetchInterval: TimeInterval = 3600 diff --git a/ios/MullvadVPN/DisplayChainedError.swift b/ios/MullvadVPN/DisplayChainedError.swift index aab3fce95e..5e3d154f28 100644 --- a/ios/MullvadVPN/DisplayChainedError.swift +++ b/ios/MullvadVPN/DisplayChainedError.swift @@ -20,7 +20,7 @@ extension REST.Error: DisplayChainedError { return String( format: NSLocalizedString( "NETWORK_ERROR", - tableName: "RESTClient", + tableName: "REST", value: "Network error: %@", comment: "Network error. Use %@ placeholder to place localized failure description." ), @@ -33,7 +33,7 @@ extension REST.Error: DisplayChainedError { return String( format: NSLocalizedString( "SERVER_ERROR", - tableName: "RESTClient", + tableName: "REST", value: "Server error: %@", comment: "Server error. Use %@ placeholder to place localized failure description." ), @@ -43,21 +43,21 @@ extension REST.Error: DisplayChainedError { case .encodePayload: return NSLocalizedString( "SERVER_REQUEST_ENCODING_ERROR", - tableName: "RESTClient", + tableName: "REST", value: "Server request encoding error", comment: "Failure to encode the server request." ) case .decodeSuccessResponse: return NSLocalizedString( "SERVER_SUCCESS_RESPONSE_DECODING_ERROR", - tableName: "RESTClient", + tableName: "REST", value: "Server success response decoding error", comment: "Failure to decode the server success response." ) case .decodeErrorResponse: return NSLocalizedString( "SERVER_FAILURE_RESPONSE_DECODING_ERROR", - tableName: "RESTClient", + tableName: "REST", value: "Server error response decoding error", comment: "Failure to decode the server failure response." ) diff --git a/ios/MullvadVPN/Operations/AsyncBlockOperation.swift b/ios/MullvadVPN/Operations/AsyncBlockOperation.swift index 66a6d2aa0b..d88e5a5ec7 100644 --- a/ios/MullvadVPN/Operations/AsyncBlockOperation.swift +++ b/ios/MullvadVPN/Operations/AsyncBlockOperation.swift @@ -20,13 +20,18 @@ class AsyncBlockOperation: AsyncOperation { } override func main() { - executionBlock?(self) + stateLock.lock() + let block = executionBlock executionBlock = nil + stateLock.unlock() + + block?(self) } override func finish() { stateLock.lock() cancellationBlocks.removeAll() + executionBlock = nil stateLock.unlock() super.finish() @@ -56,3 +61,4 @@ class AsyncBlockOperation: AsyncOperation { } } } + diff --git a/ios/MullvadVPN/Operations/ResultOperation.swift b/ios/MullvadVPN/Operations/ResultOperation.swift index 3d9233ce2e..fcaeec7091 100644 --- a/ios/MullvadVPN/Operations/ResultOperation.swift +++ b/ios/MullvadVPN/Operations/ResultOperation.swift @@ -13,10 +13,11 @@ class ResultOperation<Success, Failure: Error>: AsyncOperation { typealias Completion = OperationCompletion<Success, Failure> typealias CompletionHandler = (Completion) -> Void - private let stateLock = NSLock() + fileprivate let stateLock = NSLock() private var completionValue: Completion? - private let completionQueue: DispatchQueue? - private var completionHandler: CompletionHandler? + private var _completionQueue: DispatchQueue? + private var _completionHandler: CompletionHandler? + private var pendingFinish = false var completion: Completion? { stateLock.lock() @@ -24,41 +25,80 @@ class ResultOperation<Success, Failure: Error>: AsyncOperation { return completionValue } + var completionQueue: DispatchQueue? { + get { + stateLock.lock() + defer { stateLock.unlock() } + + return _completionQueue + } + set { + stateLock.lock() + _completionQueue = newValue + stateLock.unlock() + } + } + + var completionHandler: CompletionHandler? { + get { + stateLock.lock() + defer { stateLock.unlock() } + + return _completionHandler + } + set { + stateLock.lock() + defer { stateLock.unlock() } + if !pendingFinish { + _completionHandler = newValue + } + } + } + init(completionQueue: DispatchQueue?, completionHandler: CompletionHandler?) { - self.completionQueue = completionQueue - self.completionHandler = completionHandler + _completionQueue = completionQueue + _completionHandler = completionHandler super.init() } @available(*, unavailable) override func finish() { - // Propagate cancellation if finish() is called directly from start(). - if isCancelled { - finish(completion: .cancelled) - } else { - preconditionFailure("Use finish(completion:) to finish operation.") - } + _finish() } func finish(completion: Completion) { stateLock.lock() + if completionValue == nil { + completionValue = completion + } + stateLock.unlock() + + _finish() + } + fileprivate func _finish() { + stateLock.lock() // Bail if operation is already finishing. - guard completionValue == nil else { + guard !pendingFinish else { stateLock.unlock() return } - // Store completion value. - completionValue = completion + // Mark that operation is pending finish. + pendingFinish = true // Copy completion handler. - let completionHandler: CompletionHandler? = self.completionHandler + let completionHandler = _completionHandler // Unset completion handler. - self.completionHandler = nil + _completionHandler = nil + + // Copy completion value. + let completion = completionValue ?? .cancelled + // Copy completion queue. + let completionQueue = _completionQueue stateLock.unlock() let block = { @@ -76,3 +116,71 @@ class ResultOperation<Success, Failure: Error>: AsyncOperation { } } } + +class ResultBlockOperation<Success, Failure: Error>: ResultOperation<Success, Failure> { + typealias ExecutionBlock = (ResultBlockOperation<Success, Failure>) -> Void + + private var executionBlock: ExecutionBlock? + private var cancellationBlocks: [() -> Void] = [] + + convenience init(executionBlock: @escaping ExecutionBlock) { + self.init( + executionBlock: executionBlock, + completionQueue: nil, + completionHandler: nil + ) + } + + init( + executionBlock: @escaping ExecutionBlock, + completionQueue: DispatchQueue?, + completionHandler: CompletionHandler? + ) + { + self.executionBlock = executionBlock + super.init(completionQueue: completionQueue, completionHandler: completionHandler) + } + + override func main() { + stateLock.lock() + let block = executionBlock + executionBlock = nil + stateLock.unlock() + + block?(self) + } + + override func cancel() { + super.cancel() + + stateLock.lock() + let blocks = cancellationBlocks + cancellationBlocks.removeAll() + stateLock.unlock() + + for block in blocks { + block() + } + } + + override func _finish() { + stateLock.lock() + cancellationBlocks.removeAll() + executionBlock = nil + stateLock.unlock() + + super._finish() + } + + func addCancellationBlock(_ block: @escaping () -> Void) { + stateLock.lock() + if isCancelled { + stateLock.unlock() + block() + } else { + cancellationBlocks.append(block) + stateLock.unlock() + } + } +} + diff --git a/ios/MullvadVPN/ProblemReportViewController.swift b/ios/MullvadVPN/ProblemReportViewController.swift index b988d34cdb..b9311174e7 100644 --- a/ios/MullvadVPN/ProblemReportViewController.swift +++ b/ios/MullvadVPN/ProblemReportViewController.swift @@ -10,6 +10,8 @@ import UIKit class ProblemReportViewController: UIViewController, UITextFieldDelegate, ConditionalNavigation { + private let apiProxy = REST.ProxyFactory.shared.createAPIProxy() + private var textViewKeyboardResponder: AutomaticKeyboardResponder? private var scrollViewKeyboardResponder: AutomaticKeyboardResponder? @@ -600,7 +602,7 @@ class ProblemReportViewController: UIViewController, UITextFieldDelegate, Condit willSendProblemReport() - _ = REST.Client.shared.sendProblemReport(request, retryStrategy: .default) { completion in + _ = apiProxy.sendProblemReport(request, retryStrategy: .default) { completion in self.didSendProblemReport(viewModel: viewModel, completion: completion) } } diff --git a/ios/MullvadVPN/REST/RESTAPIProxy.swift b/ios/MullvadVPN/REST/RESTAPIProxy.swift new file mode 100644 index 0000000000..15cc807c16 --- /dev/null +++ b/ios/MullvadVPN/REST/RESTAPIProxy.swift @@ -0,0 +1,498 @@ +// +// RESTAPIProxy.swift +// MullvadVPN +// +// Created by pronebird on 10/07/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import Network +import class WireGuardKitTypes.PublicKey +import struct WireGuardKitTypes.IPAddressRange + +extension REST { + class APIProxy: Proxy<ProxyConfiguration> { + init(configuration: ProxyConfiguration) { + super.init( + name: "APIProxy", + configuration: configuration, + requestFactory: RequestFactory.withDefaultAPICredentials( + pathPrefix: "/app/v1", + bodyEncoder: Coding.makeJSONEncoder() + ), + responseDecoder: ResponseDecoder( + decoder: Coding.makeJSONDecoder() + ) + ) + } + + func createAccount( + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping CompletionHandler<AccountResponse> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + let request = self.requestFactory.createURLRequest( + endpoint: endpoint, + method: .post, + path: "accounts" + ) + + return .success(request) + } + + let responseHandler = REST.defaultResponseHandler( + decoding: AccountResponse.self, + with: responseDecoder + ) + + return addOperation( + name: "create-account", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + + func getAddressList( + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping CompletionHandler<[AnyIPEndpoint]> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + let request = self.requestFactory.createURLRequest( + endpoint: endpoint, + method: .get, + path: "api-addrs" + ) + + return .success(request) + } + + let responseHandler = REST.defaultResponseHandler( + decoding: [AnyIPEndpoint].self, + with: responseDecoder + ) + + return addOperation( + name: "get-api-addrs", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + + func getRelays( + etag: String?, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping CompletionHandler<ServerRelaysCacheResponse> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + var requestBuilder = self.requestFactory.createURLRequestBuilder( + endpoint: endpoint, + method: .get, + path: "relays" + ) + + if let etag = etag { + requestBuilder.setETagHeader(etag: etag) + } + + return .success(requestBuilder.getURLRequest()) + } + + let responseHandler = AnyResponseHandler { response, data -> Result<ServerRelaysCacheResponse, REST.Error> in + if HTTPStatus.isSuccess(response.statusCode) { + return self.responseDecoder.decodeSuccessResponse(ServerRelaysResponse.self, from: data) + .map { serverRelays in + let newEtag = response.value(forCaseInsensitiveHTTPHeaderField: HTTPHeader.etag) + return .newContent(newEtag, serverRelays) + } + } else if response.statusCode == HTTPStatus.notModified && etag != nil { + return .success(.notModified) + } else { + return self.responseDecoder.decodeErrorResponseAndMapToServerError(from: data) + } + } + + return addOperation( + name: "get-relays", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + + func getAccountExpiry( + accountNumber: String, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping CompletionHandler<AccountResponse> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + var requestBuilder = self.requestFactory + .createURLRequestBuilder( + endpoint: endpoint, + method: .get, + path: "me" + ) + requestBuilder.setAuthorization(.accountNumber(accountNumber)) + + return .success(requestBuilder.getURLRequest()) + } + + let responseHandler = REST.defaultResponseHandler( + decoding: AccountResponse.self, + with: responseDecoder + ) + + return addOperation( + name: "get-account-expiry", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + + func getWireguardKey( + accountNumber: String, + publicKey: PublicKey, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping CompletionHandler<WireguardAddressesResponse> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + let urlEncodedPublicKey = publicKey.base64Key + .addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + let path = "wireguard-keys/".appending(urlEncodedPublicKey) + + var requestBuilder = self.requestFactory + .createURLRequestBuilder( + endpoint: endpoint, + method: .get, + path: path + ) + requestBuilder.setAuthorization(.accountNumber(accountNumber)) + + return .success(requestBuilder.getURLRequest()) + } + + let responseHandler = REST.defaultResponseHandler( + decoding: WireguardAddressesResponse.self, + with: responseDecoder + ) + + return addOperation( + name: "get-wireguard-key", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + + func pushWireguardKey( + accountNumber: String, + publicKey: PublicKey, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping CompletionHandler<WireguardAddressesResponse> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + var requestBuilder = self.requestFactory.createURLRequestBuilder( + endpoint: endpoint, + method: .post, + path: "wireguard-keys" + ) + requestBuilder.setAuthorization(.accountNumber(accountNumber)) + + return Result { + let body = PushWireguardKeyRequest( + pubkey: publicKey.rawValue + ) + try requestBuilder.setHTTPBody(value: body) + } + .mapError { error in + return .encodePayload(error) + } + .map { _ in + return requestBuilder.getURLRequest() + } + } + + let responseHandler = REST.defaultResponseHandler( + decoding: WireguardAddressesResponse.self, + with: responseDecoder + ) + + return addOperation( + name: "push-wireguard-key", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + + func replaceWireguardKey( + accountNumber: String, + oldPublicKey: PublicKey, + newPublicKey: PublicKey, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping CompletionHandler<WireguardAddressesResponse> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + var requestBuilder = self.requestFactory.createURLRequestBuilder( + endpoint: endpoint, + method: .post, + path: "replace-wireguard-key" + ) + requestBuilder.setAuthorization(.accountNumber(accountNumber)) + + return Result { + let body = ReplaceWireguardKeyRequest( + old: oldPublicKey.rawValue, + new: newPublicKey.rawValue + ) + try requestBuilder.setHTTPBody(value: body) + } + .mapError { error in + return .encodePayload(error) + } + .map { _ in + return requestBuilder.getURLRequest() + } + } + + let responseHandler = REST.defaultResponseHandler( + decoding: WireguardAddressesResponse.self, + with: responseDecoder + ) + + return addOperation( + name: "replace-wireguard-key", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + + func deleteWireguardKey( + accountNumber: String, + publicKey: PublicKey, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping CompletionHandler<Void> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + let urlEncodedPublicKey = publicKey.base64Key + .addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + + let path = "wireguard-keys/".appending(urlEncodedPublicKey) + var requestBuilder = self.requestFactory + .createURLRequestBuilder( + endpoint: endpoint, + method: .delete, + path: path + ) + requestBuilder.setAuthorization(.accountNumber(accountNumber)) + + return .success(requestBuilder.getURLRequest()) + } + + let responseHandler = AnyResponseHandler { response, data -> Result<Void, REST.Error> in + if HTTPStatus.isSuccess(response.statusCode) { + return .success(()) + } else { + return self.responseDecoder.decodeErrorResponseAndMapToServerError(from: data) + } + } + + return addOperation( + name: "delete-wireguard-key", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + + func createApplePayment( + accountNumber: String, + receiptString: Data, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping CompletionHandler<CreateApplePaymentResponse> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + var requestBuilder = self.requestFactory + .createURLRequestBuilder( + endpoint: endpoint, + method: .post, + path: "create-apple-payment" + ) + requestBuilder.setAuthorization(.accountNumber(accountNumber)) + + return Result { + let body = CreateApplePaymentRequest( + receiptString: receiptString + ) + try requestBuilder.setHTTPBody(value: body) + } + .mapError { error in + return .encodePayload(error) + } + .map { _ in + return requestBuilder.getURLRequest() + } + } + + let responseHandler = AnyResponseHandler { response, data -> Result<CreateApplePaymentResponse, REST.Error> in + if HTTPStatus.isSuccess(response.statusCode) { + return self.responseDecoder.decodeSuccessResponse(CreateApplePaymentRawResponse.self, from: data) + .map { (response) in + if response.timeAdded > 0 { + return .timeAdded(response.timeAdded, response.newExpiry) + } else { + return .noTimeAdded(response.newExpiry) + } + } + } else { + return self.responseDecoder.decodeErrorResponseAndMapToServerError(from: data) + } + } + + return addOperation( + name: "create-apple-payment", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + + func sendProblemReport( + _ body: ProblemReportRequest, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping CompletionHandler<Void> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + var requestBuilder = self.requestFactory.createURLRequestBuilder( + endpoint: endpoint, + method: .post, + path: "problem-report" + ) + + return Result { + try requestBuilder.setHTTPBody(value: body) + } + .mapError { error in + return .encodePayload(error) + } + .map { _ in + return requestBuilder.getURLRequest() + } + } + + let responseHandler = AnyResponseHandler { response, data -> Result<Void, REST.Error> in + if HTTPStatus.isSuccess(response.statusCode) { + return .success(()) + } else { + return self.responseDecoder.decodeErrorResponseAndMapToServerError(from: data) + } + } + + return addOperation( + name: "send-problem-report", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + } + } + + // MARK: - Response types + + struct AccountResponse: Decodable { + let token: String + let expires: Date + } + + enum ServerRelaysCacheResponse { + case notModified + case newContent(_ etag: String?, _ value: ServerRelaysResponse) + } + + struct WireguardAddressesResponse: Decodable { + let id: String + let pubkey: Data + let ipv4Address: IPAddressRange + let ipv6Address: IPAddressRange + } + + fileprivate struct PushWireguardKeyRequest: Encodable { + let pubkey: Data + } + + fileprivate struct ReplaceWireguardKeyRequest: Encodable { + let old: Data + let new: Data + } + + fileprivate struct CreateApplePaymentRequest: Encodable { + let receiptString: Data + } + + enum CreateApplePaymentResponse { + case noTimeAdded(_ expiry: Date) + case timeAdded(_ timeAdded: Int, _ newExpiry: Date) + + var newExpiry: Date { + switch self { + case .noTimeAdded(let expiry), .timeAdded(_, let expiry): + return expiry + } + } + + var timeAdded: TimeInterval { + switch self { + case .noTimeAdded: + return 0 + case .timeAdded(let timeAdded, _): + return TimeInterval(timeAdded) + } + } + + /// Returns a formatted string for the `timeAdded` interval, i.e "30 days" + var formattedTimeAdded: String? { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour] + formatter.unitsStyle = .full + + return formatter.string(from: self.timeAdded) + } + } + + fileprivate struct CreateApplePaymentRawResponse: Decodable { + let timeAdded: Int + let newExpiry: Date + } + + struct ProblemReportRequest: Encodable { + let address: String + let message: String + let log: String + let metadata: [String: String] + } + +} diff --git a/ios/MullvadVPN/REST/RESTAccessTokenManager.swift b/ios/MullvadVPN/REST/RESTAccessTokenManager.swift new file mode 100644 index 0000000000..1dc849f59f --- /dev/null +++ b/ios/MullvadVPN/REST/RESTAccessTokenManager.swift @@ -0,0 +1,74 @@ +// +// RESTAccessTokenManager.swift +// MullvadVPN +// +// Created by pronebird on 16/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import Logging + +extension REST { + + final class AccessTokenManager { + private let logger = Logger(label: "REST.AccessTokenManager") + private let operationQueue = OperationQueue() + private let dispatchQueue = DispatchQueue(label: "REST.AccessTokenManager.dispatchQueue") + private let proxy: AuthenticationProxy + private var tokens = [String: AccessTokenData]() + + init(authenticationProxy: AuthenticationProxy) { + operationQueue.name = "REST.AccessTokenManager.operationQueue" + operationQueue.maxConcurrentOperationCount = 1 + operationQueue.underlyingQueue = dispatchQueue + proxy = authenticationProxy + } + + func getAccessToken( + accountNumber: String, + retryStrategy: REST.RetryStrategy, + completionHandler: @escaping (OperationCompletion<REST.AccessTokenData, REST.Error>) -> Void + ) -> Cancellable + { + let operation = ResultBlockOperation<REST.AccessTokenData, REST.Error> { operation in + if let tokenData = self.tokens[accountNumber], tokenData.expiry > Date() { + operation.finish(completion: .success(tokenData)) + return + } + + let task = self.proxy.getAccessToken( + accountNumber: accountNumber, + retryStrategy: retryStrategy + ) { completion in + self.dispatchQueue.async { + switch completion { + case .success(let tokenData): + self.tokens[accountNumber] = tokenData + + case .failure(let error): + self.logger.error(chainedError: error, message: "Failed to fetch access token.") + + case .cancelled: + break + } + + operation.finish(completion: completion) + } + } + + operation.addCancellationBlock { + task.cancel() + } + } + + operation.completionQueue = .main + operation.completionHandler = completionHandler + + operationQueue.addOperation(operation) + + return operation + } + } + +} diff --git a/ios/MullvadVPN/REST/RESTAccountsProxy.swift b/ios/MullvadVPN/REST/RESTAccountsProxy.swift new file mode 100644 index 0000000000..2bc05e088c --- /dev/null +++ b/ios/MullvadVPN/REST/RESTAccountsProxy.swift @@ -0,0 +1,82 @@ +// +// RESTAccountsProxy.swift +// MullvadVPN +// +// Created by pronebird on 16/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension REST { + class AccountsProxy: Proxy<AuthProxyConfiguration> { + init(configuration: AuthProxyConfiguration) { + super.init( + name: "AccountsProxy", + configuration: configuration, + requestFactory: RequestFactory.withDefaultAPICredentials( + pathPrefix: "/accounts/v1-beta1", + bodyEncoder: Coding.makeJSONEncoder() + ), + responseDecoder: ResponseDecoder( + decoder: Coding.makeJSONDecoder() + ) + ) + } + + func getMyAccount( + accountNumber: String, + retryStrategy: REST.RetryStrategy, + completion: @escaping CompletionHandler<BetaAccountResponse> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler( + createURLRequest: { endpoint, authorization in + var requestBuilder = self.requestFactory.createURLRequestBuilder( + endpoint: endpoint, + method: .get, + path: "/accounts/me" + ) + + requestBuilder.setAuthorization(authorization) + + return .success(requestBuilder.getURLRequest()) + }, + requestAuthorization: { completion in + return self.configuration.accessTokenManager + .getAccessToken( + accountNumber: accountNumber, + retryStrategy: retryStrategy + ) { operationCompletion in + completion(operationCompletion.map { tokenData in + return .accessToken(tokenData.accessToken) + }) + } + } + ) + + let responseHandler = REST.defaultResponseHandler( + decoding: BetaAccountResponse.self, + with: responseDecoder + ) + + return addOperation( + name: "get-my-account", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completion + ) + } + } + + struct BetaAccountResponse: Decodable { + let id: String + let number: String + let expiry: Date + let maxPorts: Int + let canAddPorts: Bool + let maxDevices: Int + let canAddDevices: Bool + } +} diff --git a/ios/MullvadVPN/REST/RESTAuthenticationProxy.swift b/ios/MullvadVPN/REST/RESTAuthenticationProxy.swift new file mode 100644 index 0000000000..c4ab8e9d04 --- /dev/null +++ b/ios/MullvadVPN/REST/RESTAuthenticationProxy.swift @@ -0,0 +1,76 @@ +// +// RESTAuthenticationProxy.swift +// MullvadVPN +// +// Created by pronebird on 16/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension REST { + class AuthenticationProxy: Proxy<ProxyConfiguration> { + init(configuration: ProxyConfiguration) { + super.init( + name: "AuthenticationProxy", + configuration: configuration, + requestFactory: RequestFactory.withDefaultAPICredentials( + pathPrefix: "/auth/v1-beta1", + bodyEncoder: Coding.makeJSONEncoder() + ), + responseDecoder: ResponseDecoder( + decoder: Coding.makeJSONDecoder() + ) + ) + } + + func getAccessToken( + accountNumber: String, + retryStrategy: REST.RetryStrategy, + completion: @escaping CompletionHandler<AccessTokenData> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler { endpoint in + var requestBuilder = self.requestFactory.createURLRequestBuilder( + endpoint: endpoint, + method: .post, + path: "/token" + ) + + return Result { + let request = AccessTokenRequest(accountNumber: accountNumber) + + try requestBuilder.setHTTPBody(value: request) + } + .mapError { error in + return .encodePayload(error) + } + .map { _ in + return requestBuilder.getURLRequest() + } + } + + let responseHandler = REST.defaultResponseHandler( + decoding: AccessTokenData.self, + with: responseDecoder + ) + + return addOperation( + name: "get-access-token", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completion + ) + } + } + + struct AccessTokenData: Decodable { + let accessToken: String + let expiry: Date + } + + fileprivate struct AccessTokenRequest: Encodable { + let accountNumber: String + } +} diff --git a/ios/MullvadVPN/REST/RESTAuthorization.swift b/ios/MullvadVPN/REST/RESTAuthorization.swift new file mode 100644 index 0000000000..b9a701b1ac --- /dev/null +++ b/ios/MullvadVPN/REST/RESTAuthorization.swift @@ -0,0 +1,16 @@ +// +// RESTAuthorization.swift +// MullvadVPN +// +// Created by pronebird on 16/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension REST { + enum Authorization { + case accountNumber(String) + case accessToken(String) + } +} diff --git a/ios/MullvadVPN/REST/RESTClient.swift b/ios/MullvadVPN/REST/RESTClient.swift deleted file mode 100644 index 3d9ba2fb31..0000000000 --- a/ios/MullvadVPN/REST/RESTClient.swift +++ /dev/null @@ -1,524 +0,0 @@ -// -// RESTClient.swift -// MullvadVPN -// -// Created by pronebird on 10/07/2020. -// Copyright © 2020 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import Network -import class WireGuardKitTypes.PublicKey -import struct WireGuardKitTypes.IPAddressRange - -extension REST { - - class Client { - static let shared: Client = { - return Client(addressCacheStore: AddressCache.Store.shared) - }() - - /// URL session. - private let session: URLSession - - /// URL session delegate. - private let sessionDelegate: SSLPinningURLSessionDelegate - - /// REST API hostname. - private let apiHostname = "api.mullvad.net" - - /// REST API base path. - private let apiBasePath = "/app/v1" - - /// Network request timeout in seconds. - private let networkTimeout: TimeInterval = 10 - - /// Address cache store. - private let addressCacheStore: AddressCache.Store - - /// Operation queue used for running network requests. - private let operationQueue = OperationQueue() - - /// Network task counter. - private var networkTaskCounter: UInt32 = 0 - - /// Lock used for internal synchronization. - private var nslock = NSLock() - - /// Returns array of trusted root certificates - private static var trustedRootCertificates: [SecCertificate] { - let rootCertificate = Bundle.main.path(forResource: "le_root_cert", ofType: "cer")! - - return [rootCertificate].map { (path) -> SecCertificate in - let data = FileManager.default.contents(atPath: path)! - return SecCertificateCreateWithData(nil, data as CFData)! - } - } - - init(addressCacheStore: AddressCache.Store) { - sessionDelegate = SSLPinningURLSessionDelegate(sslHostname: apiHostname, trustedRootCertificates: Self.trustedRootCertificates) - session = URLSession(configuration: .ephemeral, delegate: sessionDelegate, delegateQueue: nil) - self.addressCacheStore = addressCacheStore - } - - // MARK: - Public - - func createAccount(retryStrategy: REST.RetryStrategy, completionHandler: @escaping (OperationCompletion<AccountResponse, REST.Error>) -> Void) -> Cancellable { - return scheduleOperation(name: "create-account", retryStrategy: retryStrategy, completionHandler: completionHandler) { endpoint, finishOperation in - let request = self.createURLRequestWithEndpoint(endpoint: endpoint, method: .post, path: "accounts") - - let dataTask = self.dataTask(request: request) { responseResult in - let restResult = responseResult - .mapError(Self.mapNetworkError) - .flatMap { httpResponse, data -> Result<AccountResponse, REST.Error> in - if HTTPStatus.isSuccess(httpResponse.statusCode) { - return Self.decodeSuccessResponse(AccountResponse.self, from: data) - } else { - return Self.decodeErrorResponseAndMapToServerError(from: data) - } - } - - finishOperation(restResult) - } - - return .success(dataTask) - } - } - - func getAddressList(retryStrategy: REST.RetryStrategy, completionHandler: @escaping (OperationCompletion<[AnyIPEndpoint], REST.Error>) -> Void) -> Cancellable { - return scheduleOperation(name: "get-api-addrs", retryStrategy: retryStrategy, completionHandler: completionHandler) { endpoint, finishOperation in - let request = self.createURLRequestWithEndpoint(endpoint: endpoint, method: .get, path: "api-addrs") - - let dataTask = self.dataTask(request: request) { responseResult in - let restResult = responseResult.mapError(Self.mapNetworkError) - .flatMap { httpResponse, data -> Result<[AnyIPEndpoint], REST.Error> in - if HTTPStatus.isSuccess(httpResponse.statusCode) { - return Self.decodeSuccessResponse([AnyIPEndpoint].self, from: data) - } else { - return Self.decodeErrorResponseAndMapToServerError(from: data) - } - } - - finishOperation(restResult) - } - - return .success(dataTask) - } - } - - func getRelays(etag: String?, retryStrategy: REST.RetryStrategy, completionHandler: @escaping (OperationCompletion<ServerRelaysCacheResponse, REST.Error>) -> Void) -> Cancellable { - return scheduleOperation(name: "get-relays", retryStrategy: retryStrategy, completionHandler: completionHandler) { endpoint, finishOperation in - var request = self.createURLRequestWithEndpoint(endpoint: endpoint, method: .get, path: "relays") - if let etag = etag { - Self.setETagHeader(etag: etag, request: &request) - } - - let dataTask = self.dataTask(request: request) { restResponse in - let restResult = restResponse.mapError(Self.mapNetworkError) - .flatMap { httpResponse, data -> Result<ServerRelaysCacheResponse, REST.Error> in - if HTTPStatus.isSuccess(httpResponse.statusCode) { - return Self.decodeSuccessResponse(ServerRelaysResponse.self, from: data) - .map { serverRelays in - let newEtag = httpResponse.value(forCaseInsensitiveHTTPHeaderField: HTTPHeader.etag) - return .newContent(newEtag, serverRelays) - } - } else if httpResponse.statusCode == HTTPStatus.notModified && etag != nil { - return .success(.notModified) - } else { - return Self.decodeErrorResponseAndMapToServerError(from: data) - } - } - - finishOperation(restResult) - } - - return .success(dataTask) - } - } - - func getAccountExpiry(token: String, retryStrategy: REST.RetryStrategy, completionHandler: @escaping (OperationCompletion<AccountResponse, REST.Error>) -> Void) -> Cancellable { - return scheduleOperation(name: "get-account-expiry", retryStrategy: retryStrategy, completionHandler: completionHandler) { endpoint, finishOperation in - var request = self.createURLRequestWithEndpoint(endpoint: endpoint, method: .get, path: "me") - - Self.setAuthenticationToken(token: token, request: &request) - - let dataTask = self.dataTask(request: request) { restResponse in - let restResult = restResponse.mapError(Self.mapNetworkError) - .flatMap { httpResponse, data -> Result<AccountResponse, REST.Error> in - if HTTPStatus.isSuccess(httpResponse.statusCode) { - return Self.decodeSuccessResponse(AccountResponse.self, from: data) - } else { - return Self.decodeErrorResponseAndMapToServerError(from: data) - } - } - - finishOperation(restResult) - } - - return .success(dataTask) - } - } - - func getWireguardKey(token: String, publicKey: PublicKey, retryStrategy: REST.RetryStrategy, completionHandler: @escaping (OperationCompletion<WireguardAddressesResponse, REST.Error>) -> Void) -> Cancellable { - return scheduleOperation(name: "get-wireguard-key", retryStrategy: retryStrategy, completionHandler: completionHandler) { endpoint, finishOperation in - let urlEncodedPublicKey = publicKey.base64Key - .addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - - let path = "wireguard-keys/".appending(urlEncodedPublicKey) - var request = self.createURLRequestWithEndpoint(endpoint: endpoint, method: .get, path: path) - - Self.setAuthenticationToken(token: token, request: &request) - - let dataTask = self.dataTask(request: request) { restResponse in - let restResult = restResponse.mapError(Self.mapNetworkError) - .flatMap { httpResponse, data -> Result<WireguardAddressesResponse, REST.Error> in - if HTTPStatus.isSuccess(httpResponse.statusCode) { - return Self.decodeSuccessResponse(WireguardAddressesResponse.self, from: data) - } else { - return Self.decodeErrorResponseAndMapToServerError(from: data) - } - } - finishOperation(restResult) - } - - return .success(dataTask) - } - } - - func pushWireguardKey(token: String, publicKey: PublicKey, retryStrategy: REST.RetryStrategy, completionHandler: @escaping (OperationCompletion<WireguardAddressesResponse, REST.Error>) -> Void) -> Cancellable { - return scheduleOperation(name: "push-wireguard-key", retryStrategy: retryStrategy, completionHandler: completionHandler) { endpoint, finishOperation in - var request = self.createURLRequestWithEndpoint(endpoint: endpoint, method: .post, path: "wireguard-keys") - let body = PushWireguardKeyRequest(pubkey: publicKey.rawValue) - - Self.setAuthenticationToken(token: token, request: &request) - - do { - try Self.setHTTPBody(value: body, request: &request) - } catch { - return .failure(.encodePayload(error)) - } - - let dataTask = self.dataTask(request: request) { restResponse in - let restResult = restResponse.mapError(Self.mapNetworkError) - .flatMap { httpResponse, data -> Result<WireguardAddressesResponse, REST.Error> in - if HTTPStatus.isSuccess(httpResponse.statusCode) { - return Self.decodeSuccessResponse(WireguardAddressesResponse.self, from: data) - } else { - return Self.decodeErrorResponseAndMapToServerError(from: data) - } - } - - finishOperation(restResult) - } - - return .success(dataTask) - } - } - - func replaceWireguardKey(token: String, oldPublicKey: PublicKey, newPublicKey: PublicKey, retryStrategy: REST.RetryStrategy, completionHandler: @escaping (OperationCompletion<WireguardAddressesResponse, REST.Error>) -> Void) -> Cancellable { - return scheduleOperation(name: "replace-wireguard-key", retryStrategy: retryStrategy, completionHandler: completionHandler) { endpoint, finishOperation in - var request = self.createURLRequestWithEndpoint(endpoint: endpoint, method: .post, path: "replace-wireguard-key") - let body = ReplaceWireguardKeyRequest(old: oldPublicKey.rawValue, new: newPublicKey.rawValue) - - Self.setAuthenticationToken(token: token, request: &request) - - do { - try Self.setHTTPBody(value: body, request: &request) - } catch { - return .failure(.encodePayload(error)) - } - - let dataTask = self.dataTask(request: request) { restResponse in - let restResult = restResponse.mapError(Self.mapNetworkError) - .flatMap { httpResponse, data -> Result<WireguardAddressesResponse, REST.Error> in - if HTTPStatus.isSuccess(httpResponse.statusCode) { - return Self.decodeSuccessResponse(WireguardAddressesResponse.self, from: data) - } else { - return Self.decodeErrorResponseAndMapToServerError(from: data) - } - } - - finishOperation(restResult) - } - - return .success(dataTask) - } - } - - func deleteWireguardKey(token: String, publicKey: PublicKey, retryStrategy: REST.RetryStrategy, completionHandler: @escaping (OperationCompletion<(), REST.Error>) -> Void) -> Cancellable { - return scheduleOperation(name: "delete-wireguard-key", retryStrategy: retryStrategy, completionHandler: completionHandler) { endpoint, finishOperation in - let urlEncodedPublicKey = publicKey.base64Key - .addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - - let path = "wireguard-keys/".appending(urlEncodedPublicKey) - var request = self.createURLRequestWithEndpoint(endpoint: endpoint, method: .delete, path: path) - - Self.setAuthenticationToken(token: token, request: &request) - - let dataTask = self.dataTask(request: request) { restResponse in - let restResult = restResponse.mapError(Self.mapNetworkError) - .flatMap { httpResponse, data -> Result<(), REST.Error> in - if HTTPStatus.isSuccess(httpResponse.statusCode) { - return .success(()) - } else { - return Self.decodeErrorResponseAndMapToServerError(from: data) - } - } - - finishOperation(restResult) - } - - return .success(dataTask) - } - } - - func createApplePayment(token: String, receiptString: Data, retryStrategy: REST.RetryStrategy, completionHandler: @escaping (OperationCompletion<CreateApplePaymentResponse, REST.Error>) -> Void) -> Cancellable { - return scheduleOperation(name: "create-apple-payment", retryStrategy: retryStrategy, completionHandler: completionHandler) { endpoint, finishOperation in - var request = self.createURLRequestWithEndpoint(endpoint: endpoint, method: .post, path: "create-apple-payment") - let body = CreateApplePaymentRequest(receiptString: receiptString) - - Self.setAuthenticationToken(token: token, request: &request) - - do { - try Self.setHTTPBody(value: body, request: &request) - } catch { - return .failure(.encodePayload(error)) - } - - let dataTask = self.dataTask(request: request) { restResponse in - let restResult = restResponse.mapError(Self.mapNetworkError) - .flatMap { httpResponse, data -> Result<CreateApplePaymentResponse, REST.Error> in - if HTTPStatus.isSuccess(httpResponse.statusCode) { - return REST.Client.decodeSuccessResponse(CreateApplePaymentRawResponse.self, from: data) - .map { (response) in - if response.timeAdded > 0 { - return .timeAdded(response.timeAdded, response.newExpiry) - } else { - return .noTimeAdded(response.newExpiry) - } - } - } else { - return Self.decodeErrorResponseAndMapToServerError(from: data) - } - } - finishOperation(restResult) - } - - return .success(dataTask) - } - } - - func sendProblemReport(_ body: ProblemReportRequest, retryStrategy: REST.RetryStrategy, completionHandler: @escaping (OperationCompletion<(), REST.Error>) -> Void) -> Cancellable { - return scheduleOperation(name: "send-problem-report", retryStrategy: retryStrategy, completionHandler: completionHandler) { endpoint, finishOperation in - var request = self.createURLRequestWithEndpoint(endpoint: endpoint, method: .post, path: "problem-report") - - do { - try Self.setHTTPBody(value: body, request: &request) - } catch { - return .failure(.encodePayload(error)) - } - - let dataTask = self.dataTask(request: request) { restResponse in - let restResult = restResponse.mapError(Self.mapNetworkError) - .flatMap { httpResponse, data -> Result<(), REST.Error> in - if HTTPStatus.isSuccess(httpResponse.statusCode) { - return .success(()) - } else { - return Self.decodeErrorResponseAndMapToServerError(from: data) - } - } - finishOperation(restResult) - } - - return .success(dataTask) - } - } - - // MARK: - Private - - private func nextTaskIdentifier() -> UInt32 { - nslock.lock() - let (partialValue, isOverflow) = networkTaskCounter.addingReportingOverflow(1) - let nextValue = isOverflow ? 1 : partialValue - networkTaskCounter = nextValue - nslock.unlock() - - return nextValue - } - - private func scheduleOperation<Response>(name: String, retryStrategy: REST.RetryStrategy, completionHandler: @escaping NetworkOperation<Response>.CompletionHandler, taskGenerator: @escaping NetworkOperation<Response>.Generator) -> Cancellable { - let operation = NetworkOperation( - taskIdentifier: nextTaskIdentifier(), - name: name, - networkTaskGenerator: taskGenerator, - addressCacheStore: addressCacheStore, - retryStrategy: retryStrategy, - completionHandler: completionHandler - ) - - operationQueue.addOperation(operation) - - return operation - } - - private func dataTask(request: URLRequest, completion: @escaping (Result<(HTTPURLResponse, Data), URLError>) -> Void) -> URLSessionDataTask { - return self.session.dataTask(with: request) { data, response, error in - if let error = error { - let urlError = error as? URLError ?? URLError(.unknown) - - completion(.failure(urlError)) - } else { - if let httpResponse = response as? HTTPURLResponse { - let data = data ?? Data() - let value = (httpResponse, data) - - completion(.success(value)) - } else { - completion(.failure(URLError(.unknown))) - } - } - } - } - - private func createURLRequestWithEndpoint(endpoint: AnyIPEndpoint, method: HTTPMethod, path: String) -> URLRequest { - var urlComponents = URLComponents() - urlComponents.scheme = "https" - urlComponents.path = apiBasePath - urlComponents.host = "\(endpoint.ip)" - urlComponents.port = Int(endpoint.port) - - let requestURL = urlComponents.url!.appendingPathComponent(path) - - var request = URLRequest( - url: requestURL, - cachePolicy: .useProtocolCachePolicy, - timeoutInterval: networkTimeout - ) - request.httpShouldHandleCookies = false - request.addValue(apiHostname, forHTTPHeaderField: HTTPHeader.host) - request.addValue("application/json", forHTTPHeaderField: HTTPHeader.contentType) - request.httpMethod = method.rawValue - return request - } - - /// Parse JSON response into the given `Decodable` type. - private static func decodeSuccessResponse<T: Decodable>(_ type: T.Type, from data: Data) -> Result<T, REST.Error> { - return Result { try REST.Coding.makeJSONDecoder().decode(type, from: data) } - .mapError { error in - return .decodeSuccessResponse(error) - } - } - - /// Parse JSON response in case of error (Any HTTP code except 2xx). - private static func decodeErrorResponse(from data: Data) -> Result<ServerErrorResponse, REST.Error> { - return Result { () -> ServerErrorResponse in - return try REST.Coding.makeJSONDecoder().decode(ServerErrorResponse.self, from: data) - }.mapError { error in - return .decodeErrorResponse(error) - } - } - - private static func decodeErrorResponseAndMapToServerError<T>(from data: Data) -> Result<T, REST.Error> { - return Self.decodeErrorResponse(from: data) - .flatMap { serverError in - return .failure(.server(serverError)) - } - } - - private static func mapNetworkError(_ error: URLError) -> REST.Error { - return .network(error) - } - - private static func setHTTPBody<T: Encodable>(value: T, request: inout URLRequest) throws { - request.httpBody = try REST.Coding.makeJSONEncoder().encode(value) - } - - private static func setETagHeader(etag: String, request: inout URLRequest) { - var etag = etag - // Enforce weak validator to account for some backend caching quirks. - if etag.starts(with: "\"") { - etag.insert(contentsOf: "W/", at: etag.startIndex) - } - request.setValue(etag, forHTTPHeaderField: HTTPHeader.ifNoneMatch) - } - - private static func setAuthenticationToken(token: String, request: inout URLRequest) { - request.addValue("Token \(token)", forHTTPHeaderField: HTTPHeader.authorization) - } - } - - // MARK: - Response types - - struct AccountResponse: Decodable { - let token: String - let expires: Date - } - - enum ServerRelaysCacheResponse { - case notModified - case newContent(_ etag: String?, _ value: ServerRelaysResponse) - } - - struct WireguardAddressesResponse: Decodable { - let id: String - let pubkey: Data - let ipv4Address: IPAddressRange - let ipv6Address: IPAddressRange - } - - fileprivate struct PushWireguardKeyRequest: Encodable { - let pubkey: Data - } - - fileprivate struct ReplaceWireguardKeyRequest: Encodable { - let old: Data - let new: Data - } - - fileprivate struct CreateApplePaymentRequest: Encodable { - let receiptString: Data - } - - enum CreateApplePaymentResponse { - case noTimeAdded(_ expiry: Date) - case timeAdded(_ timeAdded: Int, _ newExpiry: Date) - - var newExpiry: Date { - switch self { - case .noTimeAdded(let expiry), .timeAdded(_, let expiry): - return expiry - } - } - - var timeAdded: TimeInterval { - switch self { - case .noTimeAdded: - return 0 - case .timeAdded(let timeAdded, _): - return TimeInterval(timeAdded) - } - } - - /// Returns a formatted string for the `timeAdded` interval, i.e "30 days" - var formattedTimeAdded: String? { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.day, .hour] - formatter.unitsStyle = .full - - return formatter.string(from: self.timeAdded) - } - } - - fileprivate struct CreateApplePaymentRawResponse: Decodable { - let timeAdded: Int - let newExpiry: Date - } - - struct ProblemReportRequest: Encodable { - let address: String - let message: String - let log: String - let metadata: [String: String] - } - -} diff --git a/ios/MullvadVPN/REST/RESTCoding.swift b/ios/MullvadVPN/REST/RESTCoding.swift index e5515e439b..8a5356e3bc 100644 --- a/ios/MullvadVPN/REST/RESTCoding.swift +++ b/ios/MullvadVPN/REST/RESTCoding.swift @@ -17,8 +17,8 @@ extension REST.Coding { static func makeJSONEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase - encoder.dateEncodingStrategy = .iso8601 encoder.dataEncodingStrategy = .base64 + encoder.dateEncodingStrategy = .iso8601 return encoder } @@ -26,8 +26,37 @@ extension REST.Coding { static func makeJSONDecoder() -> JSONDecoder { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 decoder.dataDecodingStrategy = .base64 + + let iso8601Formatter = ISO8601DateFormatter() + + // Setup additional formatter to account for fractional seconds returned + // by some of the API calls. + lazy var iso8601WithSubSecondsFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions.insert(.withFractionalSeconds) + return formatter + }() + + decoder.dateDecodingStrategy = .custom({ decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + + let date = iso8601Formatter.date(from: value) ?? + iso8601WithSubSecondsFormatter.date(from: value) + + switch date { + case .some(let parsedDate): + return parsedDate + + case .none: + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected date string to be RFC3339 or ISO8601-formatted." + ) + } + }) + return decoder } } diff --git a/ios/MullvadVPN/REST/RESTDevicesProxy.swift b/ios/MullvadVPN/REST/RESTDevicesProxy.swift new file mode 100644 index 0000000000..698c51681b --- /dev/null +++ b/ios/MullvadVPN/REST/RESTDevicesProxy.swift @@ -0,0 +1,96 @@ +// +// RESTDevicesProxy.swift +// MullvadVPN +// +// Created by pronebird on 20/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import class WireGuardKitTypes.PublicKey +import struct WireGuardKitTypes.IPAddressRange + +extension REST { + class DevicesProxy: Proxy<AuthProxyConfiguration> { + init(configuration: AuthProxyConfiguration) { + super.init( + name: "DevicesProxy", + configuration: configuration, + requestFactory: RequestFactory.withDefaultAPICredentials( + pathPrefix: "/accounts/v1-beta1", + bodyEncoder: Coding.makeJSONEncoder() + ), + responseDecoder: ResponseDecoder( + decoder: Coding.makeJSONDecoder() + ) + ) + } + + func getDevices( + accountNumber: String, + retryStrategy: REST.RetryStrategy, + completion: @escaping CompletionHandler<[Device]> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler( + createURLRequest: { endpoint, authorization in + var requestBuilder = self.requestFactory.createURLRequestBuilder( + endpoint: endpoint, + method: .get, + path: "/devices" + ) + + requestBuilder.setAuthorization(authorization) + + return .success(requestBuilder.getURLRequest()) + }, + requestAuthorization: { completion in + return self.configuration.accessTokenManager + .getAccessToken( + accountNumber: accountNumber, + retryStrategy: retryStrategy + ) { operationCompletion in + completion(operationCompletion.map { tokenData in + return .accessToken(tokenData.accessToken) + }) + } + } + ) + + let responseHandler = REST.defaultResponseHandler( + decoding: [Device].self, + with: responseDecoder + ) + + return addOperation( + name: "get-devices", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completion + ) + } + + } + + struct Device: Decodable { + let id: String + let name: String + let pubkey: Data + let hijackDNS: Bool + let created: Date + let ipv4Address: IPAddressRange + let ipv6Address: IPAddressRange + let ports: [Port] + + private enum CodingKeys: String, CodingKey { + case hijackDNS = "hijackDns" + case id, name, pubkey, created, ipv4Address, ipv6Address, ports + } + } + + struct Port: Decodable { + let id: String + } + +} diff --git a/ios/MullvadVPN/REST/RESTError.swift b/ios/MullvadVPN/REST/RESTError.swift index 6d13a6e6f9..6ee7e74d26 100644 --- a/ios/MullvadVPN/REST/RESTError.swift +++ b/ios/MullvadVPN/REST/RESTError.swift @@ -10,7 +10,7 @@ import Foundation extension REST { - /// An error type returned by `REST.Client` + /// An error type returned by REST API classes. enum Error: ChainedError { /// A failure to encode the payload case encodePayload(Swift.Error) @@ -50,6 +50,7 @@ extension REST { case invalidAccount = "INVALID_ACCOUNT" case keyLimitReached = "KEY_LIMIT_REACHED" case pubKeyNotFound = "PUBKEY_NOT_FOUND" + case invalidAccessToken = "INVALID_ACCESS_TOKEN" static func ~= (pattern: Self, value: REST.ServerErrorResponse) -> Bool { return pattern.rawValue == value.code @@ -65,6 +66,9 @@ extension REST { static var pubKeyNotFound: Code { return .pubKeyNotFound } + static var invalidAccessToken: Code { + return .invalidAccessToken + } let code: String let error: String? @@ -74,19 +78,32 @@ extension REST { case Code.keyLimitReached.rawValue: return NSLocalizedString( "KEY_LIMIT_REACHED_ERROR_DESCRIPTION", - tableName: "RESTClient", + tableName: "REST", value: "Too many WireGuard keys in use.", comment: "" ) case Code.invalidAccount.rawValue: return NSLocalizedString( "INVALID_ACCOUNT_ERROR_DESCRIPTION", - tableName: "RESTClient", + tableName: "REST", value: "Invalid account.", comment: "" ) + + case Code.invalidAccessToken.rawValue: + return NSLocalizedString( + "INVALID_ACCESS_TOKEN_ERROR_DESCRIPTION", + tableName: "REST", + value: "Invalid access token.", + comment: "") default: - return nil + let localizedString = NSLocalizedString( + "UNKNOWN_ERROR_DESCRIPTION", + tableName: "REST", + value: "Unknown error: %@", + comment: "Use %@ placeholder to place the error code into the localized string." + ) + return String(format: localizedString, code) } } @@ -95,7 +112,7 @@ extension REST { case Code.keyLimitReached.rawValue: return NSLocalizedString( "KEY_LIMIT_REACHED_ERROR_RECOVERY_SUGGESTION", - tableName: "RESTClient", + tableName: "REST", value: "Please visit the website to revoke a key before login is possible.", comment: "" ) diff --git a/ios/MullvadVPN/REST/RESTNetworkOperation.swift b/ios/MullvadVPN/REST/RESTNetworkOperation.swift index cc20c526b0..97e7c0e9a6 100644 --- a/ios/MullvadVPN/REST/RESTNetworkOperation.swift +++ b/ios/MullvadVPN/REST/RESTNetworkOperation.swift @@ -10,24 +10,19 @@ import Foundation import Logging extension REST { - - enum RetryAction { - /// Retry request using next endpoint. - case useNextEndpoint - - /// Retry request using current endpoint. - case useCurrentEndpoint - - /// Fail immediately. - case failImmediately - } - class NetworkOperation<Success>: ResultOperation<Success, REST.Error> { - typealias Generator = (AnyIPEndpoint, @escaping (Result<Success, REST.Error>) -> Void) -> Result<URLSessionTask, REST.Error> + private let requestHandler: AnyRequestHandler + private let responseHandler: AnyResponseHandler<Success> - private let networkTaskGenerator: Generator + private let dispatchQueue: DispatchQueue + private let urlSession: URLSession private let addressCacheStore: AddressCache.Store - private var sessionTask: URLSessionTask? + + private var networkTask: URLSessionTask? + private var authorizationTask: Cancellable? + + private var requiresAuthorization = false + private var retryInvalidAccessTokenError = true private let retryStrategy: RetryStrategy private var retryTimer: DispatchSourceTimer? @@ -36,155 +31,236 @@ extension REST { private let logger = Logger(label: "REST.NetworkOperation") private let loggerMetadata: Logger.Metadata - init(taskIdentifier: UInt32, name: String, networkTaskGenerator: @escaping Generator, addressCacheStore: AddressCache.Store, retryStrategy: RetryStrategy, completionHandler: @escaping CompletionHandler) { - self.networkTaskGenerator = networkTaskGenerator - self.addressCacheStore = addressCacheStore + init( + name: String, + dispatchQueue: DispatchQueue, + configuration: ProxyConfiguration, + retryStrategy: RetryStrategy, + requestHandler: AnyRequestHandler, + responseHandler: AnyResponseHandler<Success>, + completionHandler: @escaping CompletionHandler + ) + { + self.dispatchQueue = dispatchQueue + self.urlSession = configuration.session + self.addressCacheStore = configuration.addressCacheStore self.retryStrategy = retryStrategy + self.requestHandler = requestHandler + self.responseHandler = responseHandler - loggerMetadata = ["taskIdentifier": .stringConvertible(taskIdentifier), "name": .string(name)] + loggerMetadata = ["name": .string(name)] super.init(completionQueue: .main, completionHandler: completionHandler) } override func cancel() { - DispatchQueue.main.async { - super.cancel() + super.cancel() - // Cancel pending retry + dispatchQueue.async { self.retryTimer?.cancel() + self.networkTask?.cancel() + self.authorizationTask?.cancel() - // Cancel active network task - self.sessionTask?.cancel() + self.retryTimer = nil + self.networkTask = nil + self.authorizationTask = nil } } override func main() { - DispatchQueue.main.async { - // Finish immediately if operation was cancelled before execution - guard !self.isCancelled else { - self.finish(completion: .cancelled) - return - } + dispatchQueue.async { + self.startRequest() + } + } + + private func startRequest() { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + + guard !isCancelled else { + finish(completion: .cancelled) + return + } + + let authorizationResult = requestHandler.requestAuthorization { completion in + self.dispatchQueue.async { + assert(self.requiresAuthorization, "Illegal use of completion handler.") + + switch completion { + case .success(let authorization): + self.didReceiveAuthorization(authorization) + + case .failure(let error): + self.didFailToRequestAuthorization(error) - // Get current endpoint - self.addressCacheStore.getCurrentEndpoint { endpoint in - DispatchQueue.main.async { - self.sendRequest(endpoint: endpoint) { [weak self] completion in - self?.finish(completion: completion) - } + case .cancelled: + self.finish(completion: .cancelled) } } } + + switch authorizationResult { + case .pending(let task): + requiresAuthorization = true + authorizationTask = task + + case .noRequirement: + requiresAuthorization = false + didReceiveAuthorization(nil) + } } - private func sendRequest(endpoint: AnyIPEndpoint, completionHandler: @escaping CompletionHandler) { - // Handle operation cancellation + private func didReceiveAuthorization(_ authorization: REST.Authorization?) { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + guard !isCancelled else { - completionHandler(.cancelled) + finish(completion: .cancelled) return } - // Create network task and execute it - let taskResult = networkTaskGenerator(endpoint) { [weak self] result in - DispatchQueue.main.async { - self?.handleResponse(endpoint: endpoint, result: result, completionHandler: completionHandler) - } - } + let endpoint = self.addressCacheStore.getCurrentEndpoint() - switch taskResult { - case .success(let dataTask): - logger.debug("Executing request using \(endpoint)", metadata: loggerMetadata) + let result = requestHandler.createURLRequest( + endpoint: endpoint, + authorization: authorization + ) - sessionTask = dataTask - dataTask.resume() + switch result { + case .success(let request): + didReceiveURLRequest(request, endpoint: endpoint) case .failure(let error): - logger.error(chainedError: error, message: "Failed to create data task", metadata: loggerMetadata) - - completionHandler(.failure(error)) + didFailToCreateURLRequest(error) } } - private func handleResponse(endpoint: AnyIPEndpoint, result: Result<Success, REST.Error>, completionHandler: @escaping CompletionHandler) { - guard case .failure(let error) = result else { - completionHandler(OperationCompletion(result: result)) - return - } + private func didFailToRequestAuthorization(_ error: REST.Error) { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + + logger.error( + chainedError: error, + message: "Failed to request authorization.", + metadata: loggerMetadata + ) - logger.debug("Failed to perform request to \(endpoint)", metadata: self.loggerMetadata) + finish(completion: .failure(error)) + } + + private func didReceiveURLRequest(_ urlRequest: URLRequest, endpoint: AnyIPEndpoint) { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + + logger.debug( + "Executing request using \(endpoint).", + metadata: loggerMetadata + ) + + networkTask = urlSession.dataTask(with: urlRequest) { [weak self] data, response, error in + guard let self = self else { return } - switch Self.evaluateError(error) { - case .useNextEndpoint: - // Pick next endpoint in the event of network error - addressCacheStore.selectNextEndpoint(endpoint) { nextEndpoint in - DispatchQueue.main.async { - self.retryRequest(endpoint: nextEndpoint, previousResult: result, completionHandler: completionHandler) + self.dispatchQueue.async { + if let error = error { + let urlError = error as! URLError + + self.didReceiveURLError(urlError, endpoint: endpoint) + } else { + let httpResponse = response as! HTTPURLResponse + let data = data ?? Data() + + self.didReceiveURLResponse(httpResponse, data: data, endpoint: endpoint) } } + } - case .useCurrentEndpoint: - // Retry request using the same endpoint otherwise - retryRequest(endpoint: endpoint, previousResult: result, completionHandler: completionHandler) + networkTask?.resume() + } - case .failImmediately: - // Fail immediately in case of other errors, like server errors - completionHandler(OperationCompletion(result: result)) - } + private func didFailToCreateURLRequest(_ error: REST.Error) { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + + logger.error( + chainedError: error, + message: "Failed to create URLRequest.", + metadata: loggerMetadata + ) + + finish(completion: .failure(error)) } - private func retryRequest(endpoint: AnyIPEndpoint, previousResult: Result<Success, REST.Error>, completionHandler: @escaping CompletionHandler) { - // Handle operation cancellation - guard !isCancelled else { - completionHandler(.cancelled) + private func didReceiveURLError(_ urlError: URLError, endpoint: AnyIPEndpoint) { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + + switch urlError.code { + case .cancelled: + finish(completion: .cancelled) return + + case .notConnectedToInternet, .internationalRoamingOff, .callIsActive: + break + + default: + _ = addressCacheStore.selectNextEndpoint(endpoint) } - // Increment retry count - retryCount += 1 + logger.error( + chainedError: AnyChainedError(urlError), + message: "Failed to perform request to \(endpoint).", + metadata: loggerMetadata + ) // Check if retry count is not exceeded. guard retryCount < retryStrategy.maxRetryCount else { - logger.debug("Ran out of retry attempts (\(retryStrategy.maxRetryCount))", metadata: loggerMetadata) + if retryStrategy.maxRetryCount > 0 { + logger.debug( + "Ran out of retry attempts (\(retryStrategy.maxRetryCount))", + metadata: loggerMetadata + ) + } - completionHandler(OperationCompletion(result: previousResult)) + finish(completion: OperationCompletion(result: .failure(.network(urlError)))) return } - // Retry immediatly if retry delay is set to .never + // Increment retry count. + retryCount += 1 + + // Retry immediatly if retry delay is set to never. guard retryStrategy.retryDelay != .never else { - sendRequest(endpoint: endpoint, completionHandler: completionHandler) + startRequest() return } - // Create timer to delay retry - retryTimer = DispatchSource.makeTimerSource(queue: .main) + // Create timer to delay retry. + let timer = DispatchSource.makeTimerSource(queue: dispatchQueue) - retryTimer?.setEventHandler { [weak self] in - self?.sendRequest(endpoint: endpoint, completionHandler: completionHandler) + timer.setEventHandler { [weak self] in + self?.startRequest() } - retryTimer?.setCancelHandler { - completionHandler(.cancelled) + timer.setCancelHandler { [weak self] in + self?.finish(completion: .cancelled) } - retryTimer?.schedule(wallDeadline: .now() + retryStrategy.retryDelay) - retryTimer?.activate() + timer.schedule(wallDeadline: .now() + retryStrategy.retryDelay) + timer.activate() + + retryTimer = timer } - private static func evaluateError(_ error: REST.Error) -> RetryAction { - guard case .network(let networkError) = error else { - return .failImmediately - } + private func didReceiveURLResponse(_ response: HTTPURLResponse, data: Data, endpoint: AnyIPEndpoint) { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) - switch networkError.code { - case .cancelled: - return .failImmediately + let result = responseHandler.handleURLResponse(response, data: data) - case .notConnectedToInternet, .internationalRoamingOff, .callIsActive: - return .useCurrentEndpoint - - default: - return .useNextEndpoint + if case .server(.invalidAccessToken) = result.error, + requiresAuthorization, retryInvalidAccessTokenError + { + logger.debug( + "Received invalid access token error. Retry once.", + metadata: loggerMetadata + ) + retryInvalidAccessTokenError = false + startRequest() + } else { + finish(completion: OperationCompletion(result: result)) } } } diff --git a/ios/MullvadVPN/REST/RESTProxy.swift b/ios/MullvadVPN/REST/RESTProxy.swift new file mode 100644 index 0000000000..611f4d9c34 --- /dev/null +++ b/ios/MullvadVPN/REST/RESTProxy.swift @@ -0,0 +1,91 @@ +// +// RESTProxy.swift +// MullvadVPN +// +// Created by pronebird on 20/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension REST { + class Proxy<ConfigurationType: ProxyConfiguration> { + typealias CompletionHandler<Success> = (OperationCompletion<Success, REST.Error>) -> Void + + /// Synchronization queue used by network operations. + let dispatchQueue: DispatchQueue + + /// Operation queue used for running network operations. + let operationQueue = OperationQueue() + + /// Proxy configuration. + let configuration: ConfigurationType + + /// URL request factory. + let requestFactory: REST.RequestFactory + + /// URL response decoder. + let responseDecoder: REST.ResponseDecoder + + init( + name: String, + configuration: ConfigurationType, + requestFactory: REST.RequestFactory, + responseDecoder: REST.ResponseDecoder + ) + { + dispatchQueue = DispatchQueue(label: "REST.\(name).dispatchQueue") + operationQueue.name = "REST.\(name).operationQueue" + + self.configuration = configuration + self.requestFactory = requestFactory + self.responseDecoder = responseDecoder + } + + func addOperation<Success>( + name: String, + retryStrategy: REST.RetryStrategy, + requestHandler: REST.AnyRequestHandler, + responseHandler: REST.AnyResponseHandler<Success>, + completionHandler: @escaping NetworkOperation<Success>.CompletionHandler + ) -> Cancellable + { + let operation = NetworkOperation( + name: getTaskIdentifier(name: name), + dispatchQueue: dispatchQueue, + configuration: configuration, + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completionHandler + ) + + operationQueue.addOperation(operation) + + return operation + } + } + + class ProxyConfiguration { + let session: URLSession + let addressCacheStore: AddressCache.Store + + init(session: URLSession, addressCacheStore: AddressCache.Store) { + self.session = session + self.addressCacheStore = addressCacheStore + } + } + + class AuthProxyConfiguration: ProxyConfiguration { + let accessTokenManager: AccessTokenManager + + init(proxyConfiguration: ProxyConfiguration, accessTokenManager: AccessTokenManager) { + self.accessTokenManager = accessTokenManager + + super.init( + session: proxyConfiguration.session, + addressCacheStore: proxyConfiguration.addressCacheStore + ) + } + } +} diff --git a/ios/MullvadVPN/REST/RESTProxyFactory.swift b/ios/MullvadVPN/REST/RESTProxyFactory.swift new file mode 100644 index 0000000000..34eeee6776 --- /dev/null +++ b/ios/MullvadVPN/REST/RESTProxyFactory.swift @@ -0,0 +1,51 @@ +// +// RESTProxyFactory.swift +// MullvadVPN +// +// Created by pronebird on 19/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension REST { + class ProxyFactory { + let configuration: AuthProxyConfiguration + + static let shared: ProxyFactory = { + let basicConfiguration = ProxyConfiguration( + session: REST.sharedURLSession, + addressCacheStore: AddressCache.Store.shared + ) + + let authenticationProxy = REST.AuthenticationProxy( + configuration: basicConfiguration + ) + let accessTokenManager = AccessTokenManager( + authenticationProxy: authenticationProxy + ) + + let authConfiguration = AuthProxyConfiguration( + proxyConfiguration: basicConfiguration, + accessTokenManager: accessTokenManager + ) + return ProxyFactory(configuration: authConfiguration) + }() + + init(configuration: AuthProxyConfiguration) { + self.configuration = configuration + } + + func createAPIProxy() -> REST.APIProxy { + return REST.APIProxy(configuration: configuration) + } + + func createAccountsProxy() -> REST.AccountsProxy { + return REST.AccountsProxy(configuration: configuration) + } + + func createDevicesProxy() -> REST.DevicesProxy { + return REST.DevicesProxy(configuration: configuration) + } + } +} diff --git a/ios/MullvadVPN/REST/RESTRequestFactory.swift b/ios/MullvadVPN/REST/RESTRequestFactory.swift new file mode 100644 index 0000000000..064af103fd --- /dev/null +++ b/ios/MullvadVPN/REST/RESTRequestFactory.swift @@ -0,0 +1,118 @@ +// +// RESTRequestFactory.swift +// MullvadVPN +// +// Created by pronebird on 16/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension REST { + class RequestFactory { + let hostname: String + let pathPrefix: String + let networkTimeout: TimeInterval + let bodyEncoder: JSONEncoder + + class func withDefaultAPICredentials(pathPrefix: String, bodyEncoder: JSONEncoder) -> RequestFactory { + return RequestFactory( + hostname: ApplicationConfiguration.defaultAPIHostname, + pathPrefix: pathPrefix, + networkTimeout: ApplicationConfiguration.defaultAPINetworkTimeout, + bodyEncoder: bodyEncoder + ) + } + + init( + hostname: String, + pathPrefix: String, + networkTimeout: TimeInterval, + bodyEncoder: JSONEncoder + ) + { + self.hostname = hostname + self.pathPrefix = pathPrefix + self.networkTimeout = networkTimeout + self.bodyEncoder = bodyEncoder + } + + func createURLRequest(endpoint: AnyIPEndpoint, method: HTTPMethod, path: String) -> URLRequest { + var urlComponents = URLComponents() + urlComponents.scheme = "https" + urlComponents.path = pathPrefix + urlComponents.host = "\(endpoint.ip)" + urlComponents.port = Int(endpoint.port) + + let requestURL = urlComponents.url!.appendingPathComponent(path) + + var request = URLRequest( + url: requestURL, + cachePolicy: .useProtocolCachePolicy, + timeoutInterval: networkTimeout + ) + request.httpShouldHandleCookies = false + request.addValue(hostname, forHTTPHeaderField: HTTPHeader.host) + request.addValue("application/json", forHTTPHeaderField: HTTPHeader.contentType) + request.httpMethod = method.rawValue + return request + } + + func createURLRequestBuilder( + endpoint: AnyIPEndpoint, + method: HTTPMethod, + path: String + ) -> RequestBuilder { + let request = createURLRequest( + endpoint: endpoint, + method: method, + path: path + ) + + return RequestBuilder( + request: request, + bodyEncoder: bodyEncoder + ) + } + } + + struct RequestBuilder { + private var request: URLRequest + private let bodyEncoder: JSONEncoder + + init(request: URLRequest, bodyEncoder: JSONEncoder) { + self.request = request + self.bodyEncoder = bodyEncoder + } + + mutating func setHTTPBody<T: Encodable>(value: T) throws { + request.httpBody = try bodyEncoder.encode(value) + } + + mutating func setETagHeader(etag: String) { + var etag = etag + // Enforce weak validator to account for some backend caching quirks. + if etag.starts(with: "\"") { + etag.insert(contentsOf: "W/", at: etag.startIndex) + } + request.setValue(etag, forHTTPHeaderField: HTTPHeader.ifNoneMatch) + } + + mutating func setAuthorization(_ authorization: REST.Authorization) { + let value: String + switch authorization { + case .accountNumber(let accountNumber): + value = "Token \(accountNumber)" + + case .accessToken(let accessToken): + value = "Bearer \(accessToken)" + } + + request.addValue(value, forHTTPHeaderField: HTTPHeader.authorization) + } + + func getURLRequest() -> URLRequest { + return request + } + } +} diff --git a/ios/MullvadVPN/REST/RESTRequestHandler.swift b/ios/MullvadVPN/REST/RESTRequestHandler.swift new file mode 100644 index 0000000000..d0f7b54dc3 --- /dev/null +++ b/ios/MullvadVPN/REST/RESTRequestHandler.swift @@ -0,0 +1,67 @@ +// +// RESTRequestHandler.swift +// MullvadVPN +// +// Created by pronebird on 20/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +protocol RESTRequestHandler { + typealias AuthorizationCompletion = (OperationCompletion<REST.Authorization, REST.Error>) -> Void + + func createURLRequest(endpoint: AnyIPEndpoint, authorization: REST.Authorization?) -> Result<URLRequest, REST.Error> + func requestAuthorization(completion: @escaping AuthorizationCompletion) -> REST.AuthorizationResult +} + +extension REST { + + enum AuthorizationResult { + /// There is no requirement for authorizing this request. + case noRequirement + + /// Authorization request is initiated. + /// Associated value contains a handle that can be used to cancel + /// the request. + case pending(Cancellable) + } + + final class AnyRequestHandler: RESTRequestHandler { + private let _createURLRequest: (AnyIPEndpoint, REST.Authorization?) -> Result<URLRequest, REST.Error> + private let _requestAuthorization: ((@escaping AuthorizationCompletion) -> AuthorizationResult)? + + init(createURLRequest: @escaping (AnyIPEndpoint) -> Result<URLRequest, REST.Error>) { + _createURLRequest = { endpoint, authorization in + createURLRequest(endpoint) + } + _requestAuthorization = nil + } + + init( + createURLRequest: @escaping (AnyIPEndpoint, REST.Authorization) -> Result<URLRequest, REST.Error>, + requestAuthorization: @escaping (@escaping AuthorizationCompletion) -> Cancellable + ) { + _createURLRequest = { endpoint, authorization in + return createURLRequest(endpoint, authorization!) + } + _requestAuthorization = { completion in + return .pending(requestAuthorization(completion)) + } + } + + func createURLRequest( + endpoint: AnyIPEndpoint, + authorization: REST.Authorization? + ) -> Result<URLRequest, REST.Error> { + return _createURLRequest(endpoint, authorization) + } + + func requestAuthorization( + completion: @escaping (OperationCompletion<REST.Authorization, REST.Error>) -> Void + ) -> REST.AuthorizationResult { + return _requestAuthorization?(completion) ?? .noRequirement + } + } + +} diff --git a/ios/MullvadVPN/REST/RESTResponseDecoder.swift b/ios/MullvadVPN/REST/RESTResponseDecoder.swift new file mode 100644 index 0000000000..2e79bbc9e3 --- /dev/null +++ b/ios/MullvadVPN/REST/RESTResponseDecoder.swift @@ -0,0 +1,46 @@ +// +// RESTResponseDecoder.swift +// MullvadVPN +// +// Created by pronebird on 16/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension REST { + struct ResponseDecoder { + let decoder: JSONDecoder + + init(decoder: JSONDecoder) { + self.decoder = decoder + } + + // Parse JSON response into the given `Decodable` type. + func decodeSuccessResponse<T: Decodable>(_ type: T.Type, from data: Data) -> Result<T, REST.Error> { + return Result { try decoder.decode(type, from: data) } + .mapError { error in + return .decodeSuccessResponse(error) + } + } + + /// Parse server error response from JSON. + func decodeErrorResponse(from data: Data) -> Result<REST.ServerErrorResponse, REST.Error> { + return Result { () -> REST.ServerErrorResponse in + return try decoder.decode(REST.ServerErrorResponse.self, from: data) + } + .mapError { error in + return .decodeErrorResponse(error) + } + } + + /// Parse server error response from JSON and map it to `RESTError.server` error kind. + func decodeErrorResponseAndMapToServerError<T>(from data: Data) -> Result<T, REST.Error> { + return decodeErrorResponse(from: data) + .flatMap { serverError in + return .failure(.server(serverError)) + } + } + } + +} diff --git a/ios/MullvadVPN/REST/RESTResponseHandler.swift b/ios/MullvadVPN/REST/RESTResponseHandler.swift new file mode 100644 index 0000000000..65ae4b6e2d --- /dev/null +++ b/ios/MullvadVPN/REST/RESTResponseHandler.swift @@ -0,0 +1,44 @@ +// +// RESTResponseHandler.swift +// MullvadVPN +// +// Created by pronebird on 25/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +protocol RESTResponseHandler { + associatedtype Success + + func handleURLResponse(_ response: HTTPURLResponse, data: Data) -> Result<Success, REST.Error> +} + +extension REST { + final class AnyResponseHandler<Success>: RESTResponseHandler { + typealias HandlerBlock = (HTTPURLResponse, Data) -> Result<Success, REST.Error> + + private let handlerBlock: HandlerBlock + + init(_ block: @escaping HandlerBlock) { + handlerBlock = block + } + + func handleURLResponse(_ response: HTTPURLResponse, data: Data) -> Result<Success, REST.Error> { + return handlerBlock(response, data) + } + } + + /// Returns default response handler that parses JSON response into the + /// given `Decodable` type when it encounters HTTP `2xx` code, otherwise + /// attempts to decode the server error. + static func defaultResponseHandler<T: Decodable>(decoding type: T.Type, with decoder: REST.ResponseDecoder) -> AnyResponseHandler<T> { + return AnyResponseHandler { response, data in + if HTTPStatus.isSuccess(response.statusCode) { + return decoder.decodeSuccessResponse(type, from: data) + } else { + return decoder.decodeErrorResponseAndMapToServerError(from: data) + } + } + } +} diff --git a/ios/MullvadVPN/REST/RESTTaskIdentifier.swift b/ios/MullvadVPN/REST/RESTTaskIdentifier.swift new file mode 100644 index 0000000000..412475bcbd --- /dev/null +++ b/ios/MullvadVPN/REST/RESTTaskIdentifier.swift @@ -0,0 +1,25 @@ +// +// RESTTaskIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 16/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension REST { + private static let nslock = NSLock() + private static var taskCount: UInt32 = 0 + + static func getTaskIdentifier(name: String) -> String { + nslock.lock() + defer { nslock.unlock() } + + let (partialValue, isOverflow) = taskCount.addingReportingOverflow(1) + let nextValue = isOverflow ? 1 : partialValue + taskCount = nextValue + + return "\(name).\(nextValue)" + } +} diff --git a/ios/MullvadVPN/REST/RESTURLSession.swift b/ios/MullvadVPN/REST/RESTURLSession.swift new file mode 100644 index 0000000000..1db1de2841 --- /dev/null +++ b/ios/MullvadVPN/REST/RESTURLSession.swift @@ -0,0 +1,30 @@ +// +// RESTURLSession.swift +// MullvadVPN +// +// Created by pronebird on 18/04/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension REST { + static let sharedURLSession: URLSession = { + let certificatePath = Bundle.main.path(forResource: "le_root_cert", ofType: "cer")! + let data = FileManager.default.contents(atPath: certificatePath)! + let secCertificate = SecCertificateCreateWithData(nil, data as CFData)! + + let sessionDelegate = SSLPinningURLSessionDelegate( + sslHostname: ApplicationConfiguration.defaultAPIHostname, + trustedRootCertificates: [secCertificate] + ) + + let session = URLSession( + configuration: .ephemeral, + delegate: sessionDelegate, + delegateQueue: nil + ) + + return session + }() +} diff --git a/ios/MullvadVPN/RelayCache/RelayCacheTracker.swift b/ios/MullvadVPN/RelayCache/RelayCacheTracker.swift index 9f7b9c0fc4..61614e92a9 100644 --- a/ios/MullvadVPN/RelayCache/RelayCacheTracker.swift +++ b/ios/MullvadVPN/RelayCache/RelayCacheTracker.swift @@ -101,7 +101,7 @@ extension RelayCache { func updateRelays(completionHandler: @escaping (OperationCompletion<RelayCache.FetchResult, RelayCache.Error>) -> Void) -> Cancellable { let operation = UpdateRelaysOperation( dispatchQueue: stateQueue, - restClient: REST.Client.shared, + apiProxy: REST.ProxyFactory.shared.createAPIProxy(), cacheFileURL: self.cacheFileURL, relayUpdateInterval: Self.relayUpdateInterval, updateHandler: { [weak self] newCachedRelays in @@ -302,7 +302,7 @@ fileprivate class UpdateRelaysOperation: ResultOperation<RelayCache.FetchResult, typealias UpdateHandler = (RelayCache.CachedRelays) -> Void private let dispatchQueue: DispatchQueue - private let restClient: REST.Client + private let apiProxy: REST.APIProxy private let cacheFileURL: URL private let relayUpdateInterval: TimeInterval @@ -312,13 +312,13 @@ fileprivate class UpdateRelaysOperation: ResultOperation<RelayCache.FetchResult, private var downloadCancellable: Cancellable? init(dispatchQueue: DispatchQueue, - restClient: REST.Client, + apiProxy: REST.APIProxy, cacheFileURL: URL, relayUpdateInterval: TimeInterval, updateHandler: @escaping UpdateHandler, completionHandler: @escaping CompletionHandler) { self.dispatchQueue = dispatchQueue - self.restClient = restClient + self.apiProxy = apiProxy self.cacheFileURL = cacheFileURL self.relayUpdateInterval = relayUpdateInterval self.updateHandler = updateHandler @@ -411,7 +411,7 @@ fileprivate class UpdateRelaysOperation: ResultOperation<RelayCache.FetchResult, } private func downloadRelays(previouslyCachedRelays: RelayCache.CachedRelays?) { - downloadCancellable = REST.Client.shared.getRelays(etag: previouslyCachedRelays?.etag, retryStrategy: .noRetry) { [weak self] result in + downloadCancellable = apiProxy.getRelays(etag: previouslyCachedRelays?.etag, retryStrategy: .noRetry) { [weak self] result in guard let self = self else { return } self.dispatchQueue.async { diff --git a/ios/MullvadVPN/TunnelManager/ReplaceKeyOperation.swift b/ios/MullvadVPN/TunnelManager/ReplaceKeyOperation.swift index ceca68b708..70fb221cdb 100644 --- a/ios/MullvadVPN/TunnelManager/ReplaceKeyOperation.swift +++ b/ios/MullvadVPN/TunnelManager/ReplaceKeyOperation.swift @@ -13,25 +13,24 @@ class ReplaceKeyOperation: ResultOperation<TunnelManager.KeyRotationResult, Tunn private let queue: DispatchQueue private let state: TunnelManager.State - private let restClient: REST.Client + private let apiProxy: REST.APIProxy private var restRequest: Cancellable? private let rotationInterval: TimeInterval? - private var completionHandler: CompletionHandler? private let logger = Logger(label: "TunnelManager.ReplaceKeyOperation") class func operationForKeyRotation( queue: DispatchQueue, state: TunnelManager.State, - restClient: REST.Client, + apiProxy: REST.APIProxy, rotationInterval: TimeInterval, completionHandler: @escaping CompletionHandler ) -> ReplaceKeyOperation { return ReplaceKeyOperation( queue: queue, state: state, - restClient: restClient, + apiProxy: apiProxy, rotationInterval: rotationInterval, completionHandler: completionHandler ) @@ -40,13 +39,13 @@ class ReplaceKeyOperation: ResultOperation<TunnelManager.KeyRotationResult, Tunn class func operationForKeyRegeneration( queue: DispatchQueue, state: TunnelManager.State, - restClient: REST.Client, + apiProxy: REST.APIProxy, completionHandler: @escaping (OperationCompletion<(), TunnelManager.Error>) -> Void ) -> ReplaceKeyOperation { return ReplaceKeyOperation( queue: queue, state: state, - restClient: restClient, + apiProxy: apiProxy, rotationInterval: nil ) { completion in let mappedCompletion = completion.map { keyRotationResult -> () in @@ -65,14 +64,14 @@ class ReplaceKeyOperation: ResultOperation<TunnelManager.KeyRotationResult, Tunn private init( queue: DispatchQueue, state: TunnelManager.State, - restClient: REST.Client, + apiProxy: REST.APIProxy, rotationInterval: TimeInterval?, completionHandler: @escaping CompletionHandler ) { self.queue = queue self.state = state - self.restClient = restClient + self.apiProxy = apiProxy self.rotationInterval = rotationInterval super.init(completionQueue: queue, completionHandler: completionHandler) @@ -153,8 +152,8 @@ class ReplaceKeyOperation: ResultOperation<TunnelManager.KeyRotationResult, Tunn logger.debug("Replacing old key with new key on server...") - restRequest = self.restClient.replaceWireguardKey( - token: tunnelInfo.token, + restRequest = self.apiProxy.replaceWireguardKey( + accountNumber: tunnelInfo.token, oldPublicKey: oldPublicKey, newPublicKey: newPrivateKey.publicKey, retryStrategy: .default diff --git a/ios/MullvadVPN/TunnelManager/SetAccountOperation.swift b/ios/MullvadVPN/TunnelManager/SetAccountOperation.swift index 88f7fde065..6952a4130e 100644 --- a/ios/MullvadVPN/TunnelManager/SetAccountOperation.swift +++ b/ios/MullvadVPN/TunnelManager/SetAccountOperation.swift @@ -15,16 +15,24 @@ class SetAccountOperation: ResultOperation<(), TunnelManager.Error> { private let queue: DispatchQueue private let state: TunnelManager.State - private let restClient: REST.Client + private let apiProxy: REST.APIProxy private let accountToken: String? private var willDeleteVPNConfigurationHandler: WillDeleteVPNConfigurationHandler? private let logger = Logger(label: "TunnelManager.SetAccountOperation") - init(queue: DispatchQueue, state: TunnelManager.State, restClient: REST.Client, accountToken: String?, willDeleteVPNConfigurationHandler: @escaping WillDeleteVPNConfigurationHandler, completionHandler: @escaping CompletionHandler) { + init( + queue: DispatchQueue, + state: TunnelManager.State, + apiProxy: REST.APIProxy, + accountToken: String?, + willDeleteVPNConfigurationHandler: @escaping WillDeleteVPNConfigurationHandler, + completionHandler: @escaping CompletionHandler + ) + { self.queue = queue self.state = state - self.restClient = restClient + self.apiProxy = apiProxy self.accountToken = accountToken self.willDeleteVPNConfigurationHandler = willDeleteVPNConfigurationHandler @@ -144,7 +152,7 @@ class SetAccountOperation: ResultOperation<(), TunnelManager.Error> { for (index, publicKey) in publicKeys.enumerated() { dispatchGroup.enter() - _ = REST.Client.shared.deleteWireguardKey(token: accountToken, publicKey: publicKey, retryStrategy: .default) { result in + _ = apiProxy.deleteWireguardKey(accountNumber: accountToken, publicKey: publicKey, retryStrategy: .default) { result in self.queue.async { switch result { case .success: @@ -217,7 +225,7 @@ class SetAccountOperation: ResultOperation<(), TunnelManager.Error> { } private func pushNewAccountKey(accountToken: String, publicKey: PublicKey, completionHandler: @escaping CompletionHandler) { - _ = restClient.pushWireguardKey(token: accountToken, publicKey: publicKey, retryStrategy: .default) { result in + _ = apiProxy.pushWireguardKey(accountNumber: accountToken, publicKey: publicKey, retryStrategy: .default) { result in self.queue.async { switch result { case .success(let associatedAddresses): diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift index 6f32182042..76f7c0945b 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift @@ -47,12 +47,12 @@ final class TunnelManager: TunnelManagerStateDelegate { } static let shared: TunnelManager = { - return TunnelManager(restClient: REST.Client.shared) + return TunnelManager(apiProxy: REST.ProxyFactory.shared.createAPIProxy()) }() // MARK: - Internal variables - private let restClient: REST.Client + private let apiProxy: REST.APIProxy private let logger = Logger(label: "TunnelManager") private let stateQueue = DispatchQueue(label: "TunnelManager.stateQueue") @@ -80,8 +80,8 @@ final class TunnelManager: TunnelManagerStateDelegate { return state.tunnelStatus.state } - private init(restClient: REST.Client) { - self.restClient = restClient + private init(apiProxy: REST.APIProxy) { + self.apiProxy = apiProxy self.state = TunnelManager.State(queue: stateQueue) self.state.delegate = self @@ -350,7 +350,7 @@ final class TunnelManager: TunnelManagerStateDelegate { } func regeneratePrivateKey(completionHandler: ((TunnelManager.Error?) -> Void)? = nil) { - let operation = ReplaceKeyOperation.operationForKeyRegeneration(queue: stateQueue, state: state, restClient: restClient) { [weak self] completion in + let operation = ReplaceKeyOperation.operationForKeyRegeneration(queue: stateQueue, state: state, apiProxy: apiProxy) { [weak self] completion in guard let self = self else { return } dispatchPrecondition(condition: .onQueue(self.stateQueue)) @@ -389,7 +389,7 @@ final class TunnelManager: TunnelManagerStateDelegate { let operation = ReplaceKeyOperation.operationForKeyRotation( queue: stateQueue, state: state, - restClient: restClient, + apiProxy: apiProxy, rotationInterval: TunnelManagerConfiguration.privateKeyRotationInterval ) { [weak self] completion in guard let self = self else { return } @@ -573,7 +573,7 @@ final class TunnelManager: TunnelManagerStateDelegate { return SetAccountOperation( queue: stateQueue, state: state, - restClient: restClient, + apiProxy: apiProxy, accountToken: accountToken, willDeleteVPNConfigurationHandler: { [weak self] in guard let self = self else { return } diff --git a/ios/MullvadVPN/WireguardKeysViewController.swift b/ios/MullvadVPN/WireguardKeysViewController.swift index 1c839a0c54..d53739edae 100644 --- a/ios/MullvadVPN/WireguardKeysViewController.swift +++ b/ios/MullvadVPN/WireguardKeysViewController.swift @@ -47,6 +47,8 @@ class WireguardKeysViewController: UIViewController, TunnelObserver { return .lightContent } + private let apiProxy = REST.ProxyFactory.shared.createAPIProxy() + override func viewDidLoad() { super.viewDidLoad() @@ -213,8 +215,8 @@ class WireguardKeysViewController: UIViewController, TunnelObserver { verifyKeyCancellable?.cancel() - verifyKeyCancellable = REST.Client.shared.getWireguardKey( - token: tunnelInfo.token, + verifyKeyCancellable = apiProxy.getWireguardKey( + accountNumber: tunnelInfo.token, publicKey: tunnelInfo.tunnelSettings.interface.publicKey, retryStrategy: .default ) { [weak self] result in diff --git a/ios/MullvadVPN/en.lproj/RESTClient.strings b/ios/MullvadVPN/en.lproj/REST.strings index f5a83c67a3..5c5a809eda 100644 --- a/ios/MullvadVPN/en.lproj/RESTClient.strings +++ b/ios/MullvadVPN/en.lproj/REST.strings @@ -21,3 +21,6 @@ /* Failure to decode the server success response. */ "SERVER_SUCCESS_RESPONSE_DECODING_ERROR" = "Server success response decoding error"; + +/* Use %@ placeholder to place the error code into the localized string. */ +"UNKNOWN_ERROR_DESCRIPTION" = "Unknown error: %@"; |
