diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 9e833a8ec805..c1800a1b5e5d 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -56,6 +56,9 @@ 581DA2732A1E227D0046ED47 /* RESTTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DA2722A1E227D0046ED47 /* RESTTypes.swift */; }; 581DA2752A1E283E0046ED47 /* WgKeyRotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DA2742A1E283E0046ED47 /* WgKeyRotation.swift */; }; 581DA2762A1E2FD10046ED47 /* WgKeyRotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DA2742A1E283E0046ED47 /* WgKeyRotation.swift */; }; + 581DFAEA2B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DFAE92B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift */; }; + 581DFAEC2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DFAEB2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift */; }; + 581DFAEE2B178DEA005D6D1C /* AccessMethodValidationError+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581DFAED2B178DEA005D6D1C /* AccessMethodValidationError+Helpers.swift */; }; 581F23AD2A8CF92100788AB6 /* DefaultPathObserverFake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581F23AC2A8CF92100788AB6 /* DefaultPathObserverFake.swift */; }; 581F23AF2A8CF94D00788AB6 /* PingerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581F23AE2A8CF94D00788AB6 /* PingerMock.swift */; }; 5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820676326E771DB00655B05 /* TunnelManagerErrors.swift */; }; @@ -65,6 +68,26 @@ 5823FA5426CE49F700283BF8 /* TunnelObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5823FA5326CE49F600283BF8 /* TunnelObserver.swift */; }; 582403822A827E1500163DE8 /* RelaySelectorWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582403812A827E1500163DE8 /* RelaySelectorWrapper.swift */; }; 5826B6CB2ABD83E200B1CA13 /* PacketTunnelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587C575226D2615F005EF767 /* PacketTunnelOptions.swift */; }; + 5827B0902B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B08F2B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift */; }; + 5827B0922B0CAB2800CCBBA1 /* ProxyConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0912B0CAB2800CCBBA1 /* ProxyConfigurationViewController.swift */; }; + 5827B0942B0CACC700CCBBA1 /* ProxyConfigurationSectionIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0932B0CACC700CCBBA1 /* ProxyConfigurationSectionIdentifier.swift */; }; + 5827B0962B0DB2C100CCBBA1 /* ProxyConfigurationItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0952B0DB2C100CCBBA1 /* ProxyConfigurationItemIdentifier.swift */; }; + 5827B09B2B0DEEF800CCBBA1 /* AccessMethodActionSheetPresentationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B09A2B0DEEF800CCBBA1 /* AccessMethodActionSheetPresentationDelegate.swift */; }; + 5827B09D2B0DEF1000CCBBA1 /* AccessMethodActionSheetDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B09C2B0DEF1000CCBBA1 /* AccessMethodActionSheetDelegate.swift */; }; + 5827B09F2B0E05E600CCBBA1 /* AddAccessMethodInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B09E2B0E05E600CCBBA1 /* AddAccessMethodInteractor.swift */; }; + 5827B0A12B0E064E00CCBBA1 /* AccessMethodRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A02B0E064E00CCBBA1 /* AccessMethodRepository.swift */; }; + 5827B0A42B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A32B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift */; }; + 5827B0A62B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A52B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift */; }; + 5827B0A82B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A72B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift */; }; + 5827B0AA2B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0A92B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift */; }; + 5827B0AC2B0F4CA500CCBBA1 /* AddAccessMethodViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0AB2B0F4CA500CCBBA1 /* AddAccessMethodViewControllerDelegate.swift */; }; + 5827B0AE2B0F4CBE00CCBBA1 /* ProxyConfigurationViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0AD2B0F4CBE00CCBBA1 /* ProxyConfigurationViewControllerDelegate.swift */; }; + 5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0AF2B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift */; }; + 5827B0B92B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0B82B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift */; }; + 5827B0BB2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0BA2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift */; }; + 5827B0BD2B14AC9200CCBBA1 /* AccessViewModel+TestingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0BC2B14AC9200CCBBA1 /* AccessViewModel+TestingStatus.swift */; }; + 5827B0BF2B14B37D00CCBBA1 /* Publisher+PreviousValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0BE2B14B37D00CCBBA1 /* Publisher+PreviousValue.swift */; }; + 5827B0C52B14D3E800CCBBA1 /* NSDiffableDataSourceSnapshot+Reconfigure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5827B0C42B14D3E800CCBBA1 /* NSDiffableDataSourceSnapshot+Reconfigure.swift */; }; 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58293FAC2510CA58005D0BB5 /* ProblemReportViewController.swift */; }; 58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58293FB025124117005D0BB5 /* CustomTextField.swift */; }; 58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58293FB2251241B3005D0BB5 /* CustomTextView.swift */; }; @@ -128,6 +151,26 @@ 586A950C290125EE007BAF2B /* AlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B9EB122488ED2100095626 /* AlertPresenter.swift */; }; 586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846226426E0D9630035F7C2 /* ProductsRequestOperation.swift */; }; 586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06AC114028F841390037AF9A /* AddressCacheTracker.swift */; }; + 586C0D762B03934000E7CDD7 /* AddAccessMethodInteractorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D752B03934000E7CDD7 /* AddAccessMethodInteractorProtocol.swift */; }; + 586C0D782B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D772B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift */; }; + 586C0D7A2B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D792B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift */; }; + 586C0D7C2B03BDD100E7CDD7 /* AccessMethodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D7B2B03BDD100E7CDD7 /* AccessMethodViewModel.swift */; }; + 586C0D812B03CA8400E7CDD7 /* CurrentValueSubject+UIActionBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D802B03CA8400E7CDD7 /* CurrentValueSubject+UIActionBindings.swift */; }; + 586C0D832B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D822B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift */; }; + 586C0D852B03D31E00E7CDD7 /* SocksSectionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D842B03D31E00E7CDD7 /* SocksSectionHandler.swift */; }; + 586C0D872B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D862B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift */; }; + 586C0D892B03D5E000E7CDD7 /* TextCellContentConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D882B03D5E000E7CDD7 /* TextCellContentConfiguration+Extensions.swift */; }; + 586C0D8B2B03D84400E7CDD7 /* AddAccessMethodItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D8A2B03D84400E7CDD7 /* AddAccessMethodItemIdentifier.swift */; }; + 586C0D8D2B03D86F00E7CDD7 /* AddAccessMethodSectionIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D8C2B03D86F00E7CDD7 /* AddAccessMethodSectionIdentifier.swift */; }; + 586C0D8F2B03D88100E7CDD7 /* ProxyProtocolConfigurationItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D8E2B03D88100E7CDD7 /* ProxyProtocolConfigurationItemIdentifier.swift */; }; + 586C0D912B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D902B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift */; }; + 586C0D932B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D922B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift */; }; + 586C0D952B03D92100E7CDD7 /* SocksItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D942B03D92100E7CDD7 /* SocksItemIdentifier.swift */; }; + 586C0D972B04E0AC00E7CDD7 /* PersistentAccessMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D962B04E0AC00E7CDD7 /* PersistentAccessMethod.swift */; }; + 586C0D992B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D982B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift */; }; + 586C0D9B2B051E6900E7CDD7 /* AccessMethodActionSheetContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D9A2B051E6900E7CDD7 /* AccessMethodActionSheetContainerView.swift */; }; + 586C0D9D2B05F62B00E7CDD7 /* AccessMethodActionSheetContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0D9C2B05F62B00E7CDD7 /* AccessMethodActionSheetContentView.swift */; }; + 586C0DA22B062B2900E7CDD7 /* AccessMethodActionSheetPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C0DA12B062B2900E7CDD7 /* AccessMethodActionSheetPresentation.swift */; }; 586C14582AC463BB00245C01 /* CommandChannelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C14572AC463BB00245C01 /* CommandChannelTests.swift */; }; 586C145A2AC4735F00245C01 /* PacketTunnelActor+Public.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586C14592AC4735F00245C01 /* PacketTunnelActor+Public.swift */; }; 586E54FB27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586E54FA27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift */; }; @@ -169,6 +212,11 @@ 588527B4276B4F2F00BAA373 /* SetAccountOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588527B3276B4F2F00BAA373 /* SetAccountOperation.swift */; }; 5888AD83227B11080051EB06 /* SelectLocationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD82227B11080051EB06 /* SelectLocationCell.swift */; }; 5888AD87227B17950051EB06 /* SelectLocationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */; }; + 588D7ED62AF3903F005DF40A /* ListAccessMethodViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7ED52AF3903F005DF40A /* ListAccessMethodViewController.swift */; }; + 588D7ED82AF3A533005DF40A /* AccessMethodKind+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7ED72AF3A533005DF40A /* AccessMethodKind+Extensions.swift */; }; + 588D7EDC2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EDB2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift */; }; + 588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EDD2AF3A585005DF40A /* ListAccessMethodItem.swift */; }; + 588D7EE02AF3A595005DF40A /* ListAccessMethodInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D7EDF2AF3A595005DF40A /* ListAccessMethodInteractor.swift */; }; 588E4EAE28FEEDD8008046E3 /* MullvadREST.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06799ABC28F98E1D00ACD94E /* MullvadREST.framework */; }; 58906DE02445C7A5002F0673 /* NEProviderStopReason+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */; }; 58907D9524D17B4E00CFC3F5 /* DisconnectSplitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */; }; @@ -233,6 +281,7 @@ 58BDEB992A98F4ED00F578F2 /* AnyTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB982A98F4ED00F578F2 /* AnyTransport.swift */; }; 58BDEB9B2A98F58600F578F2 /* TimeServerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB9A2A98F58600F578F2 /* TimeServerProxy.swift */; }; 58BDEB9D2A98F69E00F578F2 /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BDEB9C2A98F69E00F578F2 /* MemoryCache.swift */; }; + 58BE4B9D2B18A85B007EA1D3 /* NSAttributedString+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DFF7CF2B02560400F864E0 /* NSAttributedString+Markdown.swift */; }; 58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5C522A7C97F00A6173D /* RelayCacheTracker.swift */; }; 58BFA5CC22A7CE1F00A6173D /* ApplicationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */; }; 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C3A4B122456F1A00340BDB /* AccountInputGroupView.swift */; }; @@ -277,6 +326,17 @@ 58CE5E66224146200008646E /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CE5E65224146200008646E /* LoginViewController.swift */; }; 58CE5E6B224146210008646E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 58CE5E6A224146210008646E /* Assets.xcassets */; }; 58CE5E81224146470008646E /* PacketTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 58CE5E79224146470008646E /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 58CEB2E92AFBBA4A00E6E088 /* AddAccessMethodCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB2E82AFBBA4A00E6E088 /* AddAccessMethodCoordinator.swift */; }; + 58CEB2F32AFD0BA100E6E088 /* TextCellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB2F22AFD0BA100E6E088 /* TextCellContentView.swift */; }; + 58CEB2F52AFD0BB500E6E088 /* TextCellContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB2F42AFD0BB500E6E088 /* TextCellContentConfiguration.swift */; }; + 58CEB2F92AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB2F82AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift */; }; + 58CEB2FB2AFD13E600E6E088 /* UIListContentConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB2FA2AFD13E600E6E088 /* UIListContentConfiguration+Extensions.swift */; }; + 58CEB2FD2AFD19D300E6E088 /* UITableView+ReuseIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB2FC2AFD19D300E6E088 /* UITableView+ReuseIdentifier.swift */; }; + 58CEB3022AFD365600E6E088 /* SwitchCellContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB3012AFD365600E6E088 /* SwitchCellContentConfiguration.swift */; }; + 58CEB3042AFD36CE00E6E088 /* SwitchCellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB3032AFD36CE00E6E088 /* SwitchCellContentView.swift */; }; + 58CEB3082AFD484100E6E088 /* BasicCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB3072AFD484100E6E088 /* BasicCell.swift */; }; + 58CEB30A2AFD584700E6E088 /* CustomCellDisclosureHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB3092AFD584700E6E088 /* CustomCellDisclosureHandling.swift */; }; + 58CEB30C2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CEB30B2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift */; }; 58CF95A22AD6F35800B59F5D /* ObservedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CF95A12AD6F35800B59F5D /* ObservedState.swift */; }; 58D0C79E23F1CEBA00FE9BA7 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D0C79D23F1CEBA00FE9BA7 /* SnapshotHelper.swift */; }; 58D0C7A223F1CECF00FE9BA7 /* MullvadVPNScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D0C7A023F1CECF00FE9BA7 /* MullvadVPNScreenshots.swift */; }; @@ -339,6 +399,11 @@ 58D22435294C975B0029F5F8 /* Operations.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58D223A5294C8A480029F5F8 /* Operations.framework */; platformFilter = ios; }; 58DDA18F2ABC32380039C360 /* Timings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DDA18E2ABC32380039C360 /* Timings.swift */; }; 58DF28A52417CB4B00E836B0 /* StorePaymentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DF28A42417CB4B00E836B0 /* StorePaymentManager.swift */; }; + 58DFF7D02B02560400F864E0 /* NSAttributedString+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DFF7CF2B02560400F864E0 /* NSAttributedString+Markdown.swift */; }; + 58DFF7D22B0256A300F864E0 /* MarkdownStylingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DFF7D12B0256A300F864E0 /* MarkdownStylingOptions.swift */; }; + 58DFF7D32B02570000F864E0 /* MarkdownStylingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DFF7D12B0256A300F864E0 /* MarkdownStylingOptions.swift */; }; + 58DFF7D82B02774C00F864E0 /* ListItemPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DFF7D72B02774C00F864E0 /* ListItemPickerViewController.swift */; }; + 58DFF7DA2B02862E00F864E0 /* ShadowsocksCipher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DFF7D92B02862E00F864E0 /* ShadowsocksCipher.swift */; }; 58E0729F28814ACC008902F8 /* WireGuardLogLevel+Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E0729E28814ACC008902F8 /* WireGuardLogLevel+Logging.swift */; }; 58E0A98827C8F46300FE6BDD /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E0A98727C8F46300FE6BDD /* Tunnel.swift */; }; 58E11188292FA11F009FCA84 /* SettingsMigrationUIHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E11187292FA11F009FCA84 /* SettingsMigrationUIHandler.swift */; }; @@ -355,6 +420,18 @@ 58EE2E3B272FF814003BFF93 /* SettingsDataSourceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EE2E39272FF814003BFF93 /* SettingsDataSourceDelegate.swift */; }; 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */; }; 58EF581125D69DB400AEBA94 /* StatusImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF581025D69DB400AEBA94 /* StatusImageView.swift */; }; + 58EF874F2B16174A00C098B2 /* AccessMethodActionSheetPresentationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF874E2B16174A00C098B2 /* AccessMethodActionSheetPresentationConfiguration.swift */; }; + 58EF87512B16176300C098B2 /* AccessMethodActionSheetContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF87502B16176300C098B2 /* AccessMethodActionSheetContentConfiguration.swift */; }; + 58EF87532B161D7C00C098B2 /* AccessMethodActionSheetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF87522B161D7C00C098B2 /* AccessMethodActionSheetConfiguration.swift */; }; + 58EF87552B16282D00C098B2 /* AccessMethodActionSheetPresentationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF87542B16282D00C098B2 /* AccessMethodActionSheetPresentationView.swift */; }; + 58EF87572B16330B00C098B2 /* ProxyConfigurationTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF87562B16330B00C098B2 /* ProxyConfigurationTester.swift */; }; + 58EF875B2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF875A2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift */; }; + 58EF875D2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EF875C2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift */; }; + 58EFC76A2AFAC3B800E9F4CB /* ListAccessMethodHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EFC7692AFAC3B800E9F4CB /* ListAccessMethodHeaderView.swift */; }; + 58EFC76E2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EFC76D2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift */; }; + 58EFC7712AFB45E500E9F4CB /* SettingsChildCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EFC7702AFB45E500E9F4CB /* SettingsChildCoordinator.swift */; }; + 58EFC7732AFB471500E9F4CB /* AddAccessMethodViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EFC7722AFB471500E9F4CB /* AddAccessMethodViewController.swift */; }; + 58EFC7752AFB4CEF00E9F4CB /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EFC7742AFB4CEF00E9F4CB /* AboutViewController.swift */; }; 58F0974E2A20C31100DA2DAD /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 58F0974D2A20C31100DA2DAD /* WireGuardKitTypes */; }; 58F0974F2A20C31100DA2DAD /* WireGuardKitTypes in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 58F0974D2A20C31100DA2DAD /* WireGuardKitTypes */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 58F097512A20C35000DA2DAD /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 58F097502A20C35000DA2DAD /* WireGuardKitTypes */; }; @@ -399,6 +476,14 @@ 58FE65952AB1D90600E53CB5 /* MullvadTypes.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58D223D5294C8E5E0029F5F8 /* MullvadTypes.framework */; }; 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */; }; 58FF23A32AB09BEE003A2AF2 /* DeviceChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF23A22AB09BEE003A2AF2 /* DeviceChecker.swift */; }; + 58FF9FE02B075ABC00E4C97D /* EditAccessMethodViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FDF2B075ABC00E4C97D /* EditAccessMethodViewController.swift */; }; + 58FF9FE22B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FE12B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift */; }; + 58FF9FE42B075BDD00E4C97D /* EditAccessMethodItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FE32B075BDD00E4C97D /* EditAccessMethodItemIdentifier.swift */; }; + 58FF9FE82B07650A00E4C97D /* ButtonCellContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FE72B07650A00E4C97D /* ButtonCellContentConfiguration.swift */; }; + 58FF9FEA2B07653800E4C97D /* ButtonCellContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FE92B07653800E4C97D /* ButtonCellContentView.swift */; }; + 58FF9FEC2B07A7CB00E4C97D /* NSDirectionalEdgeInsets+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FEB2B07A7CB00E4C97D /* NSDirectionalEdgeInsets+Helpers.swift */; }; + 58FF9FF02B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FEF2B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift */; }; + 58FF9FF42B07C61B00E4C97D /* AccessMethodValidationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF9FF32B07C61B00E4C97D /* AccessMethodValidationError.swift */; }; 7A02D4EB2A9CEC7A00C19E31 /* MullvadVPNScreenshots.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 7A02D4EA2A9CEC7A00C19E31 /* MullvadVPNScreenshots.xctestplan */; }; 7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */; }; 7A0C0F632A979C4A0058EFCE /* Coordinator+Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */; }; @@ -483,7 +568,6 @@ 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */; }; 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */; }; 7AF9BE972A41C71F00DBFEDB /* RelayFilterChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */; }; - 7AF9BE992A4E0FE900DBFEDB /* MarkdownStylingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */; }; A900E9B82ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */; }; A900E9BA2ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */; }; A900E9BC2ACC609200C95F67 /* DevicesProxy+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */; }; @@ -534,7 +618,6 @@ A9A5F9E52ACB05160083449F /* CustomDateComponentsFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */; }; A9A5F9E62ACB05160083449F /* DeviceDataThrottling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58138E60294871C600684F0C /* DeviceDataThrottling.swift */; }; A9A5F9E72ACB05160083449F /* FirstTimeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */; }; - A9A5F9E82ACB05160083449F /* MarkdownStylingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */; }; A9A5F9E92ACB05160083449F /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; }; A9A5F9EA2ACB05160083449F /* Bundle+ProductVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */; }; A9A5F9EB2ACB05160083449F /* CharacterSet+IPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */; }; @@ -616,7 +699,6 @@ A9A5FA392ACB05910083449F /* UIColor+Palette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA0152242560B004F3011 /* UIColor+Palette.swift */; }; A9A5FA3A2ACB05910083449F /* UIEdgeInsets+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E034632ABB302000E59A5A /* UIEdgeInsets+Extensions.swift */; }; A9A5FA3B2ACB05910083449F /* UIMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CA70E25F8C44600B47C62 /* UIMetrics.swift */; }; - A9A5FA3C2ACB05B20083449F /* NSAttributedString+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */; }; A9A5FA3D2ACB05D90083449F /* DeviceCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FE65922AB1CDE000E53CB5 /* DeviceCheck.swift */; }; A9A5FA3E2ACB05D90083449F /* DeviceCheckOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FDF2D82A0BA11900C2B061 /* DeviceCheckOperation.swift */; }; A9A5FA3F2ACB05D90083449F /* DeviceCheckRemoteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58915D672A25FA080066445B /* DeviceCheckRemoteService.swift */; }; @@ -1155,6 +1237,9 @@ 5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAddDNSEntryCell.swift; sourceTree = ""; }; 581DA2722A1E227D0046ED47 /* RESTTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTTypes.swift; sourceTree = ""; }; 581DA2742A1E283E0046ED47 /* WgKeyRotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WgKeyRotation.swift; sourceTree = ""; }; + 581DFAE92B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistentProxyConfiguration+ViewModel.swift"; sourceTree = ""; }; + 581DFAEB2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessMethodViewModel+NavigationItem.swift"; sourceTree = ""; }; + 581DFAED2B178DEA005D6D1C /* AccessMethodValidationError+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessMethodValidationError+Helpers.swift"; sourceTree = ""; }; 581F23AC2A8CF92100788AB6 /* DefaultPathObserverFake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultPathObserverFake.swift; sourceTree = ""; }; 581F23AE2A8CF94D00788AB6 /* PingerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingerMock.swift; sourceTree = ""; }; 5820675A26E6576800655B05 /* RelayCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCache.swift; sourceTree = ""; }; @@ -1170,6 +1255,26 @@ 582403162A821FD700163DE8 /* TunnelDeviceInfoProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelDeviceInfoProtocol.swift; sourceTree = ""; }; 5824037F2A827DF300163DE8 /* RelaySelectorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelectorProtocol.swift; sourceTree = ""; }; 582403812A827E1500163DE8 /* RelaySelectorWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelectorWrapper.swift; sourceTree = ""; }; + 5827B08F2B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodCoordinator.swift; sourceTree = ""; }; + 5827B0912B0CAB2800CCBBA1 /* ProxyConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationViewController.swift; sourceTree = ""; }; + 5827B0932B0CACC700CCBBA1 /* ProxyConfigurationSectionIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ProxyConfigurationSectionIdentifier.swift; path = ../ProxyConfigurationSectionIdentifier.swift; sourceTree = ""; }; + 5827B0952B0DB2C100CCBBA1 /* ProxyConfigurationItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationItemIdentifier.swift; sourceTree = ""; }; + 5827B09A2B0DEEF800CCBBA1 /* AccessMethodActionSheetPresentationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetPresentationDelegate.swift; sourceTree = ""; }; + 5827B09C2B0DEF1000CCBBA1 /* AccessMethodActionSheetDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetDelegate.swift; sourceTree = ""; }; + 5827B09E2B0E05E600CCBBA1 /* AddAccessMethodInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodInteractor.swift; sourceTree = ""; }; + 5827B0A02B0E064E00CCBBA1 /* AccessMethodRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodRepository.swift; sourceTree = ""; }; + 5827B0A32B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodInteractorProtocol.swift; sourceTree = ""; }; + 5827B0A52B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodInteractor.swift; sourceTree = ""; }; + 5827B0A72B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationInteractorProtocol.swift; sourceTree = ""; }; + 5827B0A92B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodViewControllerDelegate.swift; sourceTree = ""; }; + 5827B0AB2B0F4CA500CCBBA1 /* AddAccessMethodViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodViewControllerDelegate.swift; sourceTree = ""; }; + 5827B0AD2B0F4CBE00CCBBA1 /* ProxyConfigurationViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationViewControllerDelegate.swift; sourceTree = ""; }; + 5827B0AF2B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodViewControllerDelegate.swift; sourceTree = ""; }; + 5827B0B82B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodTestingStatusCellContentConfiguration.swift; sourceTree = ""; }; + 5827B0BA2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodTestingStatusCellContentView.swift; sourceTree = ""; }; + 5827B0BC2B14AC9200CCBBA1 /* AccessViewModel+TestingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessViewModel+TestingStatus.swift"; sourceTree = ""; }; + 5827B0BE2B14B37D00CCBBA1 /* Publisher+PreviousValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+PreviousValue.swift"; sourceTree = ""; }; + 5827B0C42B14D3E800CCBBA1 /* NSDiffableDataSourceSnapshot+Reconfigure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSDiffableDataSourceSnapshot+Reconfigure.swift"; sourceTree = ""; }; 58293FAC2510CA58005D0BB5 /* ProblemReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportViewController.swift; sourceTree = ""; }; 58293FB025124117005D0BB5 /* CustomTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTextField.swift; sourceTree = ""; }; 58293FB2251241B3005D0BB5 /* CustomTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTextView.swift; sourceTree = ""; }; @@ -1217,7 +1322,6 @@ 584D26BE270C550B004EA533 /* AnyIPAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyIPAddress.swift; sourceTree = ""; }; 584D26C3270C855A004EA533 /* PreferencesDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesDataSource.swift; sourceTree = ""; }; 584D26C5270C8741004EA533 /* SettingsDNSTextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsDNSTextCell.swift; sourceTree = ""; }; - 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Markdown.swift"; sourceTree = ""; }; 58561C98239A5D1500BD6B5E /* IPv4Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv4Endpoint.swift; sourceTree = ""; }; 5859A55429CD9DD800F66591 /* changes.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = changes.txt; sourceTree = ""; }; 585A02E82A4B283000C6CAFF /* TCPUnsafeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPUnsafeListener.swift; sourceTree = ""; }; @@ -1247,6 +1351,26 @@ 58695A9F2A4ADA9200328DB3 /* TunnelObfuscationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationTests.swift; sourceTree = ""; }; 586A95112901321B007BAF2B /* IPv6Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv6Endpoint.swift; sourceTree = ""; }; 586A951329013235007BAF2B /* AnyIPEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyIPEndpoint.swift; sourceTree = ""; }; + 586C0D752B03934000E7CDD7 /* AddAccessMethodInteractorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodInteractorProtocol.swift; sourceTree = ""; }; + 586C0D772B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodProtocolPicker.swift; sourceTree = ""; }; + 586C0D792B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksCipherPicker.swift; sourceTree = ""; }; + 586C0D7B2B03BDD100E7CDD7 /* AccessMethodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodViewModel.swift; sourceTree = ""; }; + 586C0D802B03CA8400E7CDD7 /* CurrentValueSubject+UIActionBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CurrentValueSubject+UIActionBindings.swift"; sourceTree = ""; }; + 586C0D822B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksSectionHandler.swift; sourceTree = ""; }; + 586C0D842B03D31E00E7CDD7 /* SocksSectionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocksSectionHandler.swift; sourceTree = ""; }; + 586C0D862B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodCellReuseIdentifier.swift; sourceTree = ""; }; + 586C0D882B03D5E000E7CDD7 /* TextCellContentConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextCellContentConfiguration+Extensions.swift"; sourceTree = ""; }; + 586C0D8A2B03D84400E7CDD7 /* AddAccessMethodItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodItemIdentifier.swift; sourceTree = ""; }; + 586C0D8C2B03D86F00E7CDD7 /* AddAccessMethodSectionIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodSectionIdentifier.swift; sourceTree = ""; }; + 586C0D8E2B03D88100E7CDD7 /* ProxyProtocolConfigurationItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyProtocolConfigurationItemIdentifier.swift; sourceTree = ""; }; + 586C0D902B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodHeaderFooterReuseIdentifier.swift; sourceTree = ""; }; + 586C0D922B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksItemIdentifier.swift; sourceTree = ""; }; + 586C0D942B03D92100E7CDD7 /* SocksItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocksItemIdentifier.swift; sourceTree = ""; }; + 586C0D962B04E0AC00E7CDD7 /* PersistentAccessMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentAccessMethod.swift; sourceTree = ""; }; + 586C0D982B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessMethodViewModel+Persistent.swift"; sourceTree = ""; }; + 586C0D9A2B051E6900E7CDD7 /* AccessMethodActionSheetContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetContainerView.swift; sourceTree = ""; }; + 586C0D9C2B05F62B00E7CDD7 /* AccessMethodActionSheetContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetContentView.swift; sourceTree = ""; }; + 586C0DA12B062B2900E7CDD7 /* AccessMethodActionSheetPresentation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetPresentation.swift; sourceTree = ""; }; 586C14572AC463BB00245C01 /* CommandChannelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandChannelTests.swift; sourceTree = ""; }; 586C14592AC4735F00245C01 /* PacketTunnelActor+Public.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PacketTunnelActor+Public.swift"; sourceTree = ""; }; 586E54FA27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendTunnelProviderMessageOperation.swift; sourceTree = ""; }; @@ -1293,6 +1417,11 @@ 588527B3276B4F2F00BAA373 /* SetAccountOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAccountOperation.swift; sourceTree = ""; }; 5888AD82227B11080051EB06 /* SelectLocationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationCell.swift; sourceTree = ""; }; 5888AD86227B17950051EB06 /* SelectLocationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationViewController.swift; sourceTree = ""; }; + 588D7ED52AF3903F005DF40A /* ListAccessMethodViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodViewController.swift; sourceTree = ""; }; + 588D7ED72AF3A533005DF40A /* AccessMethodKind+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessMethodKind+Extensions.swift"; sourceTree = ""; }; + 588D7EDB2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodInteractorProtocol.swift; sourceTree = ""; }; + 588D7EDD2AF3A585005DF40A /* ListAccessMethodItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodItem.swift; sourceTree = ""; }; + 588D7EDF2AF3A595005DF40A /* ListAccessMethodInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodInteractor.swift; sourceTree = ""; }; 58900D0228BBDCC70094E4F0 /* FixedWidthInteger+Arithmetics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FixedWidthInteger+Arithmetics.swift"; sourceTree = ""; }; 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NEProviderStopReason+Debug.swift"; sourceTree = ""; }; 58907D9424D17B4E00CFC3F5 /* DisconnectSplitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectSplitButton.swift; sourceTree = ""; }; @@ -1388,6 +1517,17 @@ 58CE5E79224146470008646E /* PacketTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PacketTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 58CE5E7D224146470008646E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 58CE5E7E224146470008646E /* PacketTunnel.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PacketTunnel.entitlements; sourceTree = ""; }; + 58CEB2E82AFBBA4A00E6E088 /* AddAccessMethodCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodCoordinator.swift; sourceTree = ""; }; + 58CEB2F22AFD0BA100E6E088 /* TextCellContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextCellContentView.swift; sourceTree = ""; }; + 58CEB2F42AFD0BB500E6E088 /* TextCellContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextCellContentConfiguration.swift; sourceTree = ""; }; + 58CEB2F82AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+Extensions.swift"; sourceTree = ""; }; + 58CEB2FA2AFD13E600E6E088 /* UIListContentConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIListContentConfiguration+Extensions.swift"; sourceTree = ""; }; + 58CEB2FC2AFD19D300E6E088 /* UITableView+ReuseIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReuseIdentifier.swift"; sourceTree = ""; }; + 58CEB3012AFD365600E6E088 /* SwitchCellContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchCellContentConfiguration.swift; sourceTree = ""; }; + 58CEB3032AFD36CE00E6E088 /* SwitchCellContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchCellContentView.swift; sourceTree = ""; }; + 58CEB3072AFD484100E6E088 /* BasicCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicCell.swift; sourceTree = ""; }; + 58CEB3092AFD584700E6E088 /* CustomCellDisclosureHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCellDisclosureHandling.swift; sourceTree = ""; }; + 58CEB30B2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicBackgroundConfiguration.swift; sourceTree = ""; }; 58CF95A12AD6F35800B59F5D /* ObservedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservedState.swift; sourceTree = ""; }; 58D0C79323F1CE7000FE9BA7 /* MullvadVPNScreenshots.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MullvadVPNScreenshots.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 58D0C79D23F1CEBA00FE9BA7 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = ""; }; @@ -1403,6 +1543,10 @@ 58DDA18E2ABC32380039C360 /* Timings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timings.swift; sourceTree = ""; }; 58DF28A42417CB4B00E836B0 /* StorePaymentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePaymentManager.swift; sourceTree = ""; }; 58DF5B7E2852778600E92647 /* OperationSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationSmokeTests.swift; sourceTree = ""; }; + 58DFF7CF2B02560400F864E0 /* NSAttributedString+Markdown.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Markdown.swift"; sourceTree = ""; }; + 58DFF7D12B0256A300F864E0 /* MarkdownStylingOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarkdownStylingOptions.swift; sourceTree = ""; }; + 58DFF7D72B02774C00F864E0 /* ListItemPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItemPickerViewController.swift; sourceTree = ""; }; + 58DFF7D92B02862E00F864E0 /* ShadowsocksCipher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksCipher.swift; sourceTree = ""; }; 58E07298288031D5008902F8 /* WireGuardAdapterError+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WireGuardAdapterError+Localization.swift"; sourceTree = ""; }; 58E0729E28814ACC008902F8 /* WireGuardLogLevel+Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WireGuardLogLevel+Logging.swift"; sourceTree = ""; }; 58E0A98727C8F46300FE6BDD /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = ""; }; @@ -1425,6 +1569,18 @@ 58EE2E39272FF814003BFF93 /* SettingsDataSourceDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsDataSourceDelegate.swift; sourceTree = ""; }; 58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportSubmissionOverlayView.swift; sourceTree = ""; }; 58EF581025D69DB400AEBA94 /* StatusImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusImageView.swift; sourceTree = ""; }; + 58EF874E2B16174A00C098B2 /* AccessMethodActionSheetPresentationConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetPresentationConfiguration.swift; sourceTree = ""; }; + 58EF87502B16176300C098B2 /* AccessMethodActionSheetContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetContentConfiguration.swift; sourceTree = ""; }; + 58EF87522B161D7C00C098B2 /* AccessMethodActionSheetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetConfiguration.swift; sourceTree = ""; }; + 58EF87542B16282D00C098B2 /* AccessMethodActionSheetPresentationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodActionSheetPresentationView.swift; sourceTree = ""; }; + 58EF87562B16330B00C098B2 /* ProxyConfigurationTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationTester.swift; sourceTree = ""; }; + 58EF875A2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodRepositoryProtocol.swift; sourceTree = ""; }; + 58EF875C2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyConfigurationTesterProtocol.swift; sourceTree = ""; }; + 58EFC7692AFAC3B800E9F4CB /* ListAccessMethodHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodHeaderView.swift; sourceTree = ""; }; + 58EFC76D2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListAccessMethodCoordinator.swift; sourceTree = ""; }; + 58EFC7702AFB45E500E9F4CB /* SettingsChildCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsChildCoordinator.swift; sourceTree = ""; }; + 58EFC7722AFB471500E9F4CB /* AddAccessMethodViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessMethodViewController.swift; sourceTree = ""; }; + 58EFC7742AFB4CEF00E9F4CB /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; 58F1311427E0B2AB007AC5BC /* Result+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Extensions.swift"; sourceTree = ""; }; 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerActivityIndicatorView.swift; sourceTree = ""; }; 58F2E143276A13F300A79513 /* StartTunnelOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartTunnelOperation.swift; sourceTree = ""; }; @@ -1458,6 +1614,14 @@ 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticKeyboardResponder.swift; sourceTree = ""; }; 58FF23A22AB09BEE003A2AF2 /* DeviceChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceChecker.swift; sourceTree = ""; }; 58FF2C02281BDE02009EF542 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; + 58FF9FDF2B075ABC00E4C97D /* EditAccessMethodViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodViewController.swift; sourceTree = ""; }; + 58FF9FE12B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodSectionIdentifier.swift; sourceTree = ""; }; + 58FF9FE32B075BDD00E4C97D /* EditAccessMethodItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodItemIdentifier.swift; sourceTree = ""; }; + 58FF9FE72B07650A00E4C97D /* ButtonCellContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonCellContentConfiguration.swift; sourceTree = ""; }; + 58FF9FE92B07653800E4C97D /* ButtonCellContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonCellContentView.swift; sourceTree = ""; }; + 58FF9FEB2B07A7CB00E4C97D /* NSDirectionalEdgeInsets+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSDirectionalEdgeInsets+Helpers.swift"; sourceTree = ""; }; + 58FF9FEF2B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistentAccessMethod+ViewModel.swift"; sourceTree = ""; }; + 58FF9FF32B07C61B00E4C97D /* AccessMethodValidationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessMethodValidationError.swift; sourceTree = ""; }; 7A02D4EA2A9CEC7A00C19E31 /* MullvadVPNScreenshots.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = MullvadVPNScreenshots.xctestplan; sourceTree = ""; }; 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+FuzzyMatch.swift"; sourceTree = ""; }; 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Coordinator+Router.swift"; sourceTree = ""; }; @@ -1467,7 +1631,6 @@ 7A1A26462A29CF0800B978AA /* RelayFilterDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterDataSource.swift; sourceTree = ""; }; 7A1A26482A29D48A00B978AA /* RelayFilterCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterCellFactory.swift; sourceTree = ""; }; 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.swift; sourceTree = ""; }; - 7A1A264C2A29E00E00B978AA /* SettingsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = ""; }; 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITextField+Appearance.swift"; sourceTree = ""; }; 7A2960F52A963F7500389B82 /* AlertCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCoordinator.swift; sourceTree = ""; }; 7A2960FC2A964BB700389B82 /* AlertPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresentation.swift; sourceTree = ""; }; @@ -1479,7 +1642,6 @@ 7A3353962AAA0F8600F0A71C /* OperationBlockObserverSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationBlockObserverSupport.swift; sourceTree = ""; }; 7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageHandlerTests.swift; sourceTree = ""; }; 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = ""; }; - 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.swift; sourceTree = ""; }; 7A6B4F582AB8412E00123853 /* TunnelMonitorTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelMonitorTimings.swift; sourceTree = ""; }; 7A6F2FA42AFA3CB2006D0856 /* AccountExpiryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiryTests.swift; sourceTree = ""; }; 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiry.swift; sourceTree = ""; }; @@ -1529,10 +1691,8 @@ 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilter.swift; sourceTree = ""; }; 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModel.swift; sourceTree = ""; }; 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Sorting.swift"; sourceTree = ""; }; - 7AF9BE922A39F49E00DBFEDB /* RelayFilterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterCoordinator.swift; sourceTree = ""; }; 7AF9BE942A40461100DBFEDB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = ""; }; 7AF9BE962A41C71F00DBFEDB /* RelayFilterChipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterChipView.swift; sourceTree = ""; }; - 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownStylingOptions.swift; sourceTree = ""; }; A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountsProxy+Stubs.swift"; sourceTree = ""; }; A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RESTRequestExecutor+Stubs.swift"; sourceTree = ""; }; A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DevicesProxy+Stubs.swift"; sourceTree = ""; }; @@ -1881,6 +2041,29 @@ path = MullvadTypes; sourceTree = ""; }; + 581DFAEF2B187606005D6D1C /* Presentation */ = { + isa = PBXGroup; + children = ( + 586C0DA12B062B2900E7CDD7 /* AccessMethodActionSheetPresentation.swift */, + 58EF874E2B16174A00C098B2 /* AccessMethodActionSheetPresentationConfiguration.swift */, + 5827B09A2B0DEEF800CCBBA1 /* AccessMethodActionSheetPresentationDelegate.swift */, + 58EF87542B16282D00C098B2 /* AccessMethodActionSheetPresentationView.swift */, + ); + path = Presentation; + sourceTree = ""; + }; + 581DFAF02B187620005D6D1C /* Content view */ = { + isa = PBXGroup; + children = ( + 58EF87522B161D7C00C098B2 /* AccessMethodActionSheetConfiguration.swift */, + 586C0D9A2B051E6900E7CDD7 /* AccessMethodActionSheetContainerView.swift */, + 58EF87502B16176300C098B2 /* AccessMethodActionSheetContentConfiguration.swift */, + 586C0D9C2B05F62B00E7CDD7 /* AccessMethodActionSheetContentView.swift */, + 5827B09C2B0DEF1000CCBBA1 /* AccessMethodActionSheetDelegate.swift */, + ); + path = "Content view"; + sourceTree = ""; + }; 5823FA5726CE4A4100283BF8 /* TunnelManager */ = { isa = PBXGroup; children = ( @@ -1912,6 +2095,54 @@ path = TunnelManager; sourceTree = ""; }; + 5827B0972B0DBF3400CCBBA1 /* Pickers */ = { + isa = PBXGroup; + children = ( + 586C0D772B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift */, + 586C0D792B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift */, + 58DFF7D72B02774C00F864E0 /* ListItemPickerViewController.swift */, + ); + path = Pickers; + sourceTree = ""; + }; + 5827B0982B0DC01400CCBBA1 /* Common */ = { + isa = PBXGroup; + children = ( + 586C0D862B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift */, + 586C0D902B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift */, + 586C0D8E2B03D88100E7CDD7 /* ProxyProtocolConfigurationItemIdentifier.swift */, + 586C0D922B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift */, + 586C0D822B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift */, + 586C0D942B03D92100E7CDD7 /* SocksItemIdentifier.swift */, + 586C0D842B03D31E00E7CDD7 /* SocksSectionHandler.swift */, + ); + path = Common; + sourceTree = ""; + }; + 5827B0992B0DC0CA00CCBBA1 /* ProxyConfiguration */ = { + isa = PBXGroup; + children = ( + 5827B0952B0DB2C100CCBBA1 /* ProxyConfigurationItemIdentifier.swift */, + 5827B0932B0CACC700CCBBA1 /* ProxyConfigurationSectionIdentifier.swift */, + 5827B0912B0CAB2800CCBBA1 /* ProxyConfigurationViewController.swift */, + 5827B0AD2B0F4CBE00CCBBA1 /* ProxyConfigurationViewControllerDelegate.swift */, + ); + path = ProxyConfiguration; + sourceTree = ""; + }; + 5827B0A22B0E068800CCBBA1 /* AccessMethodRepository */ = { + isa = PBXGroup; + children = ( + 5827B0A02B0E064E00CCBBA1 /* AccessMethodRepository.swift */, + 58EF875A2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift */, + 586C0D962B04E0AC00E7CDD7 /* PersistentAccessMethod.swift */, + 58EF87562B16330B00C098B2 /* ProxyConfigurationTester.swift */, + 58EF875C2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift */, + 58DFF7D92B02862E00F864E0 /* ShadowsocksCipher.swift */, + ); + path = AccessMethodRepository; + sourceTree = ""; + }; 582CFEE1269448160072883A /* Localizations */ = { isa = PBXGroup; children = ( @@ -1936,8 +2167,8 @@ 583FE02029C1A0B1006E85F9 /* Account */, F0E8E4BF2A602C7D00ED26A3 /* AccountDeletion */, 7A2960F72A964A3500389B82 /* Alert */, - F0E8E4B92A55593300ED26A3 /* CreationAccount */, F0EF50D12A8FA47E0031E8DF /* ChangeLog */, + F0E8E4B92A55593300ED26A3 /* CreationAccount */, 583FE01D29C197C1006E85F9 /* DeviceList */, 583FE02529C1AD0E006E85F9 /* Launch */, 583FE02129C1A0F4006E85F9 /* Login */, @@ -2125,10 +2356,10 @@ 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */, 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */, 58E511E528DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift */, - 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */, 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */, + 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */, 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */, - 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */, + 58DFF7CF2B02560400F864E0 /* NSAttributedString+Markdown.swift */, 587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */, 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */, 06FAE67828F83CA50033DD93 /* RESTCreateApplePaymentResponse+Localization.swift */, @@ -2139,11 +2370,15 @@ E158B35F285381C60002F069 /* String+AccountFormatting.swift */, 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */, 5807E2BF2432038B00F5FF30 /* String+Split.swift */, + 58CEB2F82AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift */, 5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */, 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */, 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */, + 58CEB2FA2AFD13E600E6E088 /* UIListContentConfiguration+Extensions.swift */, + 58CEB2FC2AFD19D300E6E088 /* UITableView+ReuseIdentifier.swift */, 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */, 5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */, + 5827B0C42B14D3E800CCBBA1 /* NSDiffableDataSourceSnapshot+Reconfigure.swift */, ); path = Extensions; sourceTree = ""; @@ -2173,6 +2408,7 @@ children = ( 58CCA0152242560B004F3011 /* UIColor+Palette.swift */, A9E034632ABB302000E59A5A /* UIEdgeInsets+Extensions.swift */, + 58FF9FEB2B07A7CB00E4C97D /* NSDirectionalEdgeInsets+Helpers.swift */, 585CA70E25F8C44600B47C62 /* UIMetrics.swift */, ); path = "UI appearance"; @@ -2190,7 +2426,7 @@ 58138E60294871C600684F0C /* DeviceDataThrottling.swift */, 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */, 582AE30F2440A6CA00E6733A /* InputTextFormatter.swift */, - 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */, + 58DFF7D12B0256A300F864E0 /* MarkdownStylingOptions.swift */, 58CC40EE24A601900019D96E /* ObserverList.swift */, ); path = Classes; @@ -2322,6 +2558,50 @@ path = Operations; sourceTree = ""; }; + 586C0D7D2B03BDE500E7CDD7 /* Models */ = { + isa = PBXGroup; + children = ( + 588D7ED72AF3A533005DF40A /* AccessMethodKind+Extensions.swift */, + 58FF9FF32B07C61B00E4C97D /* AccessMethodValidationError.swift */, + 581DFAED2B178DEA005D6D1C /* AccessMethodValidationError+Helpers.swift */, + 586C0D7B2B03BDD100E7CDD7 /* AccessMethodViewModel.swift */, + 581DFAEB2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift */, + 586C0D982B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift */, + 5827B0BC2B14AC9200CCBBA1 /* AccessViewModel+TestingStatus.swift */, + 58FF9FEF2B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift */, + 581DFAE92B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift */, + ); + path = Models; + sourceTree = ""; + }; + 586C0D7E2B03BE2F00E7CDD7 /* Cells */ = { + isa = PBXGroup; + children = ( + 58CEB3072AFD484100E6E088 /* BasicCell.swift */, + 58FF9FE72B07650A00E4C97D /* ButtonCellContentConfiguration.swift */, + 58FF9FE92B07653800E4C97D /* ButtonCellContentView.swift */, + 58CEB3092AFD584700E6E088 /* CustomCellDisclosureHandling.swift */, + 58CEB30B2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift */, + 5827B0B82B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift */, + 5827B0BA2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift */, + 58CEB3012AFD365600E6E088 /* SwitchCellContentConfiguration.swift */, + 58CEB3032AFD36CE00E6E088 /* SwitchCellContentView.swift */, + 58CEB2F42AFD0BB500E6E088 /* TextCellContentConfiguration.swift */, + 586C0D882B03D5E000E7CDD7 /* TextCellContentConfiguration+Extensions.swift */, + 58CEB2F22AFD0BA100E6E088 /* TextCellContentView.swift */, + ); + path = Cells; + sourceTree = ""; + }; + 586C0DA02B05FAF200E7CDD7 /* Sheet */ = { + isa = PBXGroup; + children = ( + 581DFAF02B187620005D6D1C /* Content view */, + 581DFAEF2B187606005D6D1C /* Presentation */, + ); + path = Sheet; + sourceTree = ""; + }; 587B75422669034500DEF7E9 /* Notification Providers */ = { isa = PBXGroup; children = ( @@ -2579,7 +2859,7 @@ 7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */, 7A9CCCB02A96302800DD6A34 /* SafariCoordinator.swift */, 7A9CCCA72A96302700DD6A34 /* SelectLocationCoordinator.swift */, - 7A9CCCAD2A96302800DD6A34 /* SettingsCoordinator.swift */, + 58EFC76F2AFB3FA800E9F4CB /* Settings */, 7A9CCCA62A96302700DD6A34 /* SetupAccountCompletedCoordinator.swift */, 7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */, 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */, @@ -2615,7 +2895,6 @@ 7A88DCDD2A8FABBE00D2FF0E /* RoutingTests */, 58CE5E61224146200008646E /* Products */, 584F991F2902CBDD001F858D /* Frameworks */, - 7AC8A3A82ABC6F4800DC4939 /* Recovered References */, ); sourceTree = ""; }; @@ -2646,6 +2925,7 @@ 58CE5E62224146200008646E /* MullvadVPN */ = { isa = PBXGroup; children = ( + 5827B0A22B0E068800CCBBA1 /* AccessMethodRepository */, 58D7E3D629C78A130044B058 /* AddressCacheTracker */, 58CE5E63224146200008646E /* AppDelegate.swift */, 58C76A0A2A338E4300100D75 /* BackgroundTask.swift */, @@ -2684,6 +2964,52 @@ path = PacketTunnel; sourceTree = ""; }; + 58CEB2E72AFBB9F300E6E088 /* APIAccess */ = { + isa = PBXGroup; + children = ( + 58EFC7742AFB4CEF00E9F4CB /* AboutViewController.swift */, + 586C0D802B03CA8400E7CDD7 /* CurrentValueSubject+UIActionBindings.swift */, + 5827B0BE2B14B37D00CCBBA1 /* Publisher+PreviousValue.swift */, + 5827B0982B0DC01400CCBBA1 /* Common */, + 586C0D7E2B03BE2F00E7CDD7 /* Cells */, + 586C0D7D2B03BDE500E7CDD7 /* Models */, + 5827B0972B0DBF3400CCBBA1 /* Pickers */, + 586C0DA02B05FAF200E7CDD7 /* Sheet */, + 58CEB2EB2AFBBCDD00E6E088 /* Add */, + 58FF9FDE2B075AA700E4C97D /* Edit */, + 58CEB2EA2AFBBCBA00E6E088 /* List */, + ); + path = APIAccess; + sourceTree = ""; + }; + 58CEB2EA2AFBBCBA00E6E088 /* List */ = { + isa = PBXGroup; + children = ( + 58EFC76D2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift */, + 58EFC7692AFAC3B800E9F4CB /* ListAccessMethodHeaderView.swift */, + 588D7EDF2AF3A595005DF40A /* ListAccessMethodInteractor.swift */, + 588D7EDB2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift */, + 588D7EDD2AF3A585005DF40A /* ListAccessMethodItem.swift */, + 588D7ED52AF3903F005DF40A /* ListAccessMethodViewController.swift */, + 5827B0AF2B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift */, + ); + path = List; + sourceTree = ""; + }; + 58CEB2EB2AFBBCDD00E6E088 /* Add */ = { + isa = PBXGroup; + children = ( + 58CEB2E82AFBBA4A00E6E088 /* AddAccessMethodCoordinator.swift */, + 5827B09E2B0E05E600CCBBA1 /* AddAccessMethodInteractor.swift */, + 586C0D752B03934000E7CDD7 /* AddAccessMethodInteractorProtocol.swift */, + 586C0D8A2B03D84400E7CDD7 /* AddAccessMethodItemIdentifier.swift */, + 586C0D8C2B03D86F00E7CDD7 /* AddAccessMethodSectionIdentifier.swift */, + 58EFC7722AFB471500E9F4CB /* AddAccessMethodViewController.swift */, + 5827B0AB2B0F4CA500CCBBA1 /* AddAccessMethodViewControllerDelegate.swift */, + ); + path = Add; + sourceTree = ""; + }; 58D0C79423F1CE7000FE9BA7 /* MullvadVPNScreenshots */ = { isa = PBXGroup; children = ( @@ -2809,6 +3135,16 @@ path = Configurations; sourceTree = ""; }; + 58EFC76F2AFB3FA800E9F4CB /* Settings */ = { + isa = PBXGroup; + children = ( + 58CEB2E72AFBB9F300E6E088 /* APIAccess */, + 58EFC7702AFB45E500E9F4CB /* SettingsChildCoordinator.swift */, + 7A9CCCAD2A96302800DD6A34 /* SettingsCoordinator.swift */, + ); + path = Settings; + sourceTree = ""; + }; 58F3C0A824A50C0E003E76BE /* Assets */ = { isa = PBXGroup; children = ( @@ -2844,6 +3180,22 @@ path = MullvadRESTTests; sourceTree = ""; }; + 58FF9FDE2B075AA700E4C97D /* Edit */ = { + isa = PBXGroup; + children = ( + 5827B08F2B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift */, + 5827B0A52B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift */, + 5827B0A32B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift */, + 58FF9FE32B075BDD00E4C97D /* EditAccessMethodItemIdentifier.swift */, + 58FF9FE12B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift */, + 58FF9FDF2B075ABC00E4C97D /* EditAccessMethodViewController.swift */, + 5827B0A92B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift */, + 5827B0992B0DC0CA00CCBBA1 /* ProxyConfiguration */, + 5827B0A72B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift */, + ); + path = Edit; + sourceTree = ""; + }; 7A2960F72A964A3500389B82 /* Alert */ = { isa = PBXGroup; children = ( @@ -2885,16 +3237,6 @@ path = RoutingTests; sourceTree = ""; }; - 7AC8A3A82ABC6F4800DC4939 /* Recovered References */ = { - isa = PBXGroup; - children = ( - 7A1A264C2A29E00E00B978AA /* SettingsHeaderView.swift */, - 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */, - 7AF9BE922A39F49E00DBFEDB /* RelayFilterCoordinator.swift */, - ); - name = "Recovered References"; - sourceTree = ""; - }; 7AF9BE912A39F47D00DBFEDB /* RelayFilter */ = { isa = PBXGroup; children = ( @@ -4007,7 +4349,6 @@ A9A5FA402ACB05D90083449F /* DeviceCheckRemoteServiceProtocol.swift in Sources */, A9A5FA412ACB05D90083449F /* DeviceStateAccessor.swift in Sources */, A9A5FA422ACB05D90083449F /* DeviceStateAccessorProtocol.swift in Sources */, - A9A5FA3C2ACB05B20083449F /* NSAttributedString+Markdown.swift in Sources */, A9A5FA392ACB05910083449F /* UIColor+Palette.swift in Sources */, A9A5FA3A2ACB05910083449F /* UIEdgeInsets+Extensions.swift in Sources */, A9C342C52ACC42130045F00E /* ServerRelaysResponse+Stubs.swift in Sources */, @@ -4023,7 +4364,6 @@ A9A5F9E52ACB05160083449F /* CustomDateComponentsFormatting.swift in Sources */, A9A5F9E62ACB05160083449F /* DeviceDataThrottling.swift in Sources */, A9A5F9E72ACB05160083449F /* FirstTimeLaunch.swift in Sources */, - A9A5F9E82ACB05160083449F /* MarkdownStylingOptions.swift in Sources */, A9A5F9E92ACB05160083449F /* ObserverList.swift in Sources */, A9B6AC1B2ADEA3AD00F7802A /* MemoryCache.swift in Sources */, A9A5F9EA2ACB05160083449F /* Bundle+ProductVersion.swift in Sources */, @@ -4082,6 +4422,7 @@ A9A5FA152ACB05160083449F /* RedeemVoucherOperation.swift in Sources */, A9A5FA162ACB05160083449F /* RotateKeyOperation.swift in Sources */, F09D04B52AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift in Sources */, + 58BE4B9D2B18A85B007EA1D3 /* NSAttributedString+Markdown.swift in Sources */, A9A5FA172ACB05160083449F /* SendTunnelProviderMessageOperation.swift in Sources */, A9A5FA182ACB05160083449F /* SetAccountOperation.swift in Sources */, A9A5FA192ACB05160083449F /* StartTunnelOperation.swift in Sources */, @@ -4118,6 +4459,7 @@ A9A5FA312ACB05160083449F /* MockFileCache.swift in Sources */, A9A5FA322ACB05160083449F /* RelayCacheTests.swift in Sources */, A9A5FA332ACB05160083449F /* RelaySelectorTests.swift in Sources */, + 58DFF7D32B02570000F864E0 /* MarkdownStylingOptions.swift in Sources */, A9A5FA342ACB05160083449F /* StringTests.swift in Sources */, A9A5FA352ACB05160083449F /* WgKeyRotationTests.swift in Sources */, A9A5FA362ACB05160083449F /* TunnelManagerTests.swift in Sources */, @@ -4235,6 +4577,8 @@ buildActionMask = 2147483647; files = ( 7A9CCCC42A96302800DD6A34 /* TunnelCoordinator.swift in Sources */, + 5827B0A42B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift in Sources */, + 586C0D852B03D31E00E7CDD7 /* SocksSectionHandler.swift in Sources */, 58BFA5CC22A7CE1F00A6173D /* ApplicationConfiguration.swift in Sources */, 5891BF5125E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift in Sources */, 58E511E628DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift in Sources */, @@ -4245,15 +4589,19 @@ 587B75412668FD7800DEF7E9 /* AccountExpirySystemNotificationProvider.swift in Sources */, 587988C728A2A01F00E3DF54 /* AccountDataThrottling.swift in Sources */, F04FBE612A8379EE009278D7 /* AppPreferences.swift in Sources */, + 586C0DA22B062B2900E7CDD7 /* AccessMethodActionSheetPresentation.swift in Sources */, + 58DFF7D82B02774C00F864E0 /* ListItemPickerViewController.swift in Sources */, 5896CEF226972DEB00B0FAE8 /* AccountContentView.swift in Sources */, 7A3353932AAA089000F0A71C /* SimulatorTunnelInfo.swift in Sources */, 5867771429097BCD006F721F /* PaymentState.swift in Sources */, F0EF50D32A8FA47E0031E8DF /* ChangeLogInteractor.swift in Sources */, 7AC8A3AF2ABC71D600DC4939 /* TermsOfServiceCoordinator.swift in Sources */, + 58FF9FE22B075BA600E4C97D /* EditAccessMethodSectionIdentifier.swift in Sources */, F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */, 7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */, 587D96742886D87C00CD8F1C /* DeviceManagementContentView.swift in Sources */, 7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */, + 5827B0902B0CAA0500CCBBA1 /* EditAccessMethodCoordinator.swift in Sources */, 5846227126E229F20035F7C2 /* StoreSubscription.swift in Sources */, 58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */, F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */, @@ -4262,12 +4610,14 @@ 587EB672271451E300123C75 /* PreferencesViewModel.swift in Sources */, 586A950C290125EE007BAF2B /* AlertPresenter.swift in Sources */, 7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */, + 586C0D892B03D5E000E7CDD7 /* TextCellContentConfiguration+Extensions.swift in Sources */, 58C3F4F92964B08300D72515 /* MapViewController.swift in Sources */, 584D26C6270C8741004EA533 /* SettingsDNSTextCell.swift in Sources */, 58F2E148276A307400A79513 /* MapConnectionStatusOperation.swift in Sources */, 58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */, 7A9CCCB32A96302800DD6A34 /* WelcomeCoordinator.swift in Sources */, 587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */, + 5827B0922B0CAB2800CCBBA1 /* ProxyConfigurationViewController.swift in Sources */, 58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */, 5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */, 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */, @@ -4275,14 +4625,18 @@ 58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */, 5878A27B2909649A0096FC88 /* CustomOverlayRenderer.swift in Sources */, A91614D62B10B26B00F416EB /* TunnelControlViewModel.swift in Sources */, + 586C0D8F2B03D88100E7CDD7 /* ProxyProtocolConfigurationItemIdentifier.swift in Sources */, 588527B2276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift in Sources */, - 7AF9BE992A4E0FE900DBFEDB /* MarkdownStylingOptions.swift in Sources */, + 58DFF7D22B0256A300F864E0 /* MarkdownStylingOptions.swift in Sources */, 5867770E29096984006F721F /* OutOfTimeInteractor.swift in Sources */, F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */, + 5827B0BD2B14AC9200CCBBA1 /* AccessViewModel+TestingStatus.swift in Sources */, 58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */, + 58EFC7752AFB4CEF00E9F4CB /* AboutViewController.swift in Sources */, 5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */, 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */, 58CCA010224249A1004F3011 /* TunnelViewController.swift in Sources */, + 58CEB30A2AFD584700E6E088 /* CustomCellDisclosureHandling.swift in Sources */, 58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */, 5893716A28817A45004EE76C /* DeviceManagementViewController.swift in Sources */, 7A9CCCB82A96302800DD6A34 /* SetupAccountCompletedCoordinator.swift in Sources */, @@ -4290,32 +4644,42 @@ 58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */, E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */, 7AC8A3AE2ABC6FBB00DC4939 /* SettingsHeaderView.swift in Sources */, + 588D7EDC2AF3A55E005DF40A /* ListAccessMethodInteractorProtocol.swift in Sources */, + 588D7ED62AF3903F005DF40A /* ListAccessMethodViewController.swift in Sources */, 582BB1B1229569620055B6EF /* UINavigationBar+Appearance.swift in Sources */, 7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */, + 586C0D8D2B03D86F00E7CDD7 /* AddAccessMethodSectionIdentifier.swift in Sources */, 58ACF6492655365700ACE4B7 /* PreferencesViewController.swift in Sources */, 7ABE318D2A1CDD4500DF4963 /* UIFont+Weight.swift in Sources */, 58C774BE29A7A249003A1A56 /* CustomNavigationController.swift in Sources */, + 5827B0942B0CACC700CCBBA1 /* ProxyConfigurationSectionIdentifier.swift in Sources */, E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */, 7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */, 0697D6E728F01513007A9E99 /* TransportMonitor.swift in Sources */, 58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */, 7A1A26472A29CF0800B978AA /* RelayFilterDataSource.swift in Sources */, 5864AF0929C78850005B0CD9 /* PreferencesCellFactory.swift in Sources */, + 58CEB30C2AFD586600E6E088 /* DynamicBackgroundConfiguration.swift in Sources */, 587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */, 5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */, 7A9CCCB92A96302800DD6A34 /* SelectLocationCoordinator.swift in Sources */, 58FB865A26EA214400F188BC /* RelayCacheTrackerObserver.swift in Sources */, + 581DFAEC2B1770C1005D6D1C /* AccessMethodViewModel+NavigationItem.swift in Sources */, 58ACF64D26567A5000ACE4B7 /* CustomSwitch.swift in Sources */, F0DA874B2A9CBACB006044F1 /* AccountNumberRow.swift in Sources */, + 586C0D972B04E0AC00E7CDD7 /* PersistentAccessMethod.swift in Sources */, 58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */, 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, + 5827B09D2B0DEF1000CCBBA1 /* AccessMethodActionSheetDelegate.swift in Sources */, F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */, 7A9CCCC12A96302800DD6A34 /* AccountCoordinator.swift in Sources */, 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */, + 58EF87532B161D7C00C098B2 /* AccessMethodActionSheetConfiguration.swift in Sources */, 5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */, F0E3618B2A4ADD2F00AEEF2B /* WelcomeContentView.swift in Sources */, 58F2E146276A2C9900A79513 /* StopTunnelOperation.swift in Sources */, E1187ABC289BBB850024E748 /* OutOfTimeViewController.swift in Sources */, + 586C0D872B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift in Sources */, 7A9CCCBD2A96302800DD6A34 /* LoginCoordinator.swift in Sources */, 58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */, F09A29822A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift in Sources */, @@ -4340,7 +4704,10 @@ 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */, 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */, 5864859929A0D028006C5743 /* FormsheetPresentationController.swift in Sources */, + 58CEB3022AFD365600E6E088 /* SwitchCellContentConfiguration.swift in Sources */, + 586C0D9D2B05F62B00E7CDD7 /* AccessMethodActionSheetContentView.swift in Sources */, 7A9CCCB52A96302800DD6A34 /* AddCreditSucceededCoordinator.swift in Sources */, + 58EF87552B16282D00C098B2 /* AccessMethodActionSheetPresentationView.swift in Sources */, 7A0C0F632A979C4A0058EFCE /* Coordinator+Router.swift in Sources */, 7A6F2FAB2AFD3097006D0856 /* CustomDNSCellFactory.swift in Sources */, 58A99ED3240014A0006599E9 /* TermsOfServiceViewController.swift in Sources */, @@ -4348,6 +4715,7 @@ 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */, 7A9CCCBE2A96302800DD6A34 /* AccountDeletionCoordinator.swift in Sources */, 588527B4276B4F2F00BAA373 /* SetAccountOperation.swift in Sources */, + 58FF9FE02B075ABC00E4C97D /* EditAccessMethodViewController.swift in Sources */, F0DA87472A9CB9A2006044F1 /* AccountExpiryRow.swift in Sources */, 585CA70F25F8C44600B47C62 /* UIMetrics.swift in Sources */, E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */, @@ -4360,11 +4728,18 @@ F0EF50D52A949F8E0031E8DF /* ChangeLogViewModel.swift in Sources */, F0E8E4BB2A56C9F100ED26A3 /* WelcomeInteractor.swift in Sources */, 5878A27729093A4F0096FC88 /* StorePaymentBlockObserver.swift in Sources */, + 58EF87572B16330B00C098B2 /* ProxyConfigurationTester.swift in Sources */, + 5827B0A62B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift in Sources */, + 5827B0AE2B0F4CBE00CCBBA1 /* ProxyConfigurationViewControllerDelegate.swift in Sources */, + 5827B0962B0DB2C100CCBBA1 /* ProxyConfigurationItemIdentifier.swift in Sources */, + 586C0D8B2B03D84400E7CDD7 /* AddAccessMethodItemIdentifier.swift in Sources */, 5868585524054096000B8131 /* AppButton.swift in Sources */, 58E25F812837BBBB002CFB2C /* SceneDelegate.swift in Sources */, 7A1A26492A29D48A00B978AA /* RelayFilterCellFactory.swift in Sources */, 5867771629097C5B006F721F /* ProductState.swift in Sources */, + 586C0D762B03934000E7CDD7 /* AddAccessMethodInteractorProtocol.swift in Sources */, 58C76A082A33850E00100D75 /* ApplicationTarget.swift in Sources */, + 58CEB3042AFD36CE00E6E088 /* SwitchCellContentView.swift in Sources */, F07BF2622A26279100042943 /* RedeemVoucherOperation.swift in Sources */, 585E820327F3285E00939F0E /* SendStoreReceiptOperation.swift in Sources */, 5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */, @@ -4379,7 +4754,10 @@ 58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */, 5864AF0829C78849005B0CD9 /* CellFactoryProtocol.swift in Sources */, F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, + 58CEB2F32AFD0BA100E6E088 /* TextCellContentView.swift in Sources */, + 586C0D7C2B03BDD100E7CDD7 /* AccessMethodViewModel.swift in Sources */, 587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */, + 58EF874F2B16174A00C098B2 /* AccessMethodActionSheetPresentationConfiguration.swift in Sources */, 7A6F2FA72AFBB9AE006D0856 /* AccountExpiry.swift in Sources */, 5819C2172729595500D6EC38 /* SettingsAddDNSEntryCell.swift in Sources */, 7A1A26452A29CEF700B978AA /* RelayFilterViewController.swift in Sources */, @@ -4396,40 +4774,70 @@ 58B26E242943520C00D5980C /* NotificationProviderProtocol.swift in Sources */, 5877F94E2A0A59AA0052D9E9 /* NotificationResponse.swift in Sources */, 58677712290976FB006F721F /* SettingsInteractor.swift in Sources */, + 58EF875D2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift in Sources */, 58CE5E66224146200008646E /* LoginViewController.swift in Sources */, F0C6FA852A6A733700F521F0 /* InAppPurchaseInteractor.swift in Sources */, + 58CEB2F92AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift in Sources */, 5878F50029CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift in Sources */, 583FE01029C0F532006E85F9 /* CustomSplitViewController.swift in Sources */, 7A6F2FA92AFD0842006D0856 /* CustomDNSDataSource.swift in Sources */, + 5827B0AC2B0F4CA500CCBBA1 /* AddAccessMethodViewControllerDelegate.swift in Sources */, 58EF580B25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift in Sources */, 5892A45E265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift in Sources */, 580909D32876D09A0078138D /* RevokedDeviceViewController.swift in Sources */, + 58FF9FF02B07C4D300E4C97D /* PersistentAccessMethod+ViewModel.swift in Sources */, + 58CEB2FD2AFD19D300E6E088 /* UITableView+ReuseIdentifier.swift in Sources */, 5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */, + 588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */, + 5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */, + 588D7EE02AF3A595005DF40A /* ListAccessMethodInteractor.swift in Sources */, 58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */, 58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */, + 58FF9FE82B07650A00E4C97D /* ButtonCellContentConfiguration.swift in Sources */, + 5827B0A82B0F49EF00CCBBA1 /* ProxyConfigurationInteractorProtocol.swift in Sources */, + 586C0D7A2B039CE300E7CDD7 /* ShadowsocksCipherPicker.swift in Sources */, + 58EFC76A2AFAC3B800E9F4CB /* ListAccessMethodHeaderView.swift in Sources */, + 58EFC7732AFB471500E9F4CB /* AddAccessMethodViewController.swift in Sources */, 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */, + 5827B09F2B0E05E600CCBBA1 /* AddAccessMethodInteractor.swift in Sources */, + 58FF9FEC2B07A7CB00E4C97D /* NSDirectionalEdgeInsets+Helpers.swift in Sources */, + 586C0D832B03D2FF00E7CDD7 /* ShadowsocksSectionHandler.swift in Sources */, 58B26E262943522400D5980C /* NotificationProvider.swift in Sources */, 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, F0DA87492A9CBA9F006044F1 /* AccountDeviceRow.swift in Sources */, + 58FF9FE42B075BDD00E4C97D /* EditAccessMethodItemIdentifier.swift in Sources */, 5878A27329091D6D0096FC88 /* TunnelBlockObserver.swift in Sources */, A9E034642ABB302000E59A5A /* UIEdgeInsets+Extensions.swift in Sources */, + 58CEB2E92AFBBA4A00E6E088 /* AddAccessMethodCoordinator.swift in Sources */, + 58DFF7DA2B02862E00F864E0 /* ShadowsocksCipher.swift in Sources */, + 58DFF7D02B02560400F864E0 /* NSAttributedString+Markdown.swift in Sources */, 58E0A98827C8F46300FE6BDD /* Tunnel.swift in Sources */, 7A12D0762B062D5C00E9602D /* URLSessionProtocol.swift in Sources */, 58ACF64F26567A7100ACE4B7 /* CustomSwitchContainer.swift in Sources */, 58EE2E3A272FF814003BFF93 /* SettingsDataSource.swift in Sources */, + 58FF9FEA2B07653800E4C97D /* ButtonCellContentView.swift in Sources */, F0E8E4C12A602CCB00ED26A3 /* AccountDeletionContentView.swift in Sources */, + 58EF87512B16176300C098B2 /* AccessMethodActionSheetContentConfiguration.swift in Sources */, 58B26E1E2943514300D5980C /* InAppNotificationDescriptor.swift in Sources */, 58421032282E42B000F24E46 /* UpdateDeviceDataOperation.swift in Sources */, + 586C0D992B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift in Sources */, + 58EF875B2B16385400C098B2 /* AccessMethodRepositoryProtocol.swift in Sources */, 5878A27D2909657C0096FC88 /* RevokedDeviceInteractor.swift in Sources */, + 5827B09B2B0DEEF800CCBBA1 /* AccessMethodActionSheetPresentationDelegate.swift in Sources */, F0E8E4C32A602E0D00ED26A3 /* AccountDeletionViewModel.swift in Sources */, + 586C0D782B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift in Sources */, 58677710290975E9006F721F /* SettingsInteractorFactory.swift in Sources */, 7A9CCCC02A96302800DD6A34 /* ProfileVoucherCoordinator.swift in Sources */, 7A9CCCBC2A96302800DD6A34 /* ChangeLogCoordinator.swift in Sources */, 58B26E282943527300D5980C /* SystemNotificationProvider.swift in Sources */, + 586C0D932B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift in Sources */, + 58EFC7712AFB45E500E9F4CB /* SettingsChildCoordinator.swift in Sources */, 58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */, F0E8CC0A2A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift in Sources */, 581DA2752A1E283E0046ED47 /* WgKeyRotation.swift in Sources */, + 5827B0BB2B14A28300CCBBA1 /* MethodTestingStatusCellContentView.swift in Sources */, 7A83C4022A57FAA800DFB83A /* SettingsDNSInfoCell.swift in Sources */, + 586C0D952B03D92100E7CDD7 /* SocksItemIdentifier.swift in Sources */, F0C6A8432AB08E54000777A8 /* RedeemVoucherViewConfiguration.swift in Sources */, 7AF10EB42ADE85BC00C090B9 /* RelayFilterCoordinator.swift in Sources */, 58FB865526E8BF3100F188BC /* StorePaymentManagerError.swift in Sources */, @@ -4438,32 +4846,43 @@ 7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */, 587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */, F028A56C2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift in Sources */, + 581DFAEE2B178DEA005D6D1C /* AccessMethodValidationError+Helpers.swift in Sources */, 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */, + 588D7ED82AF3A533005DF40A /* AccessMethodKind+Extensions.swift in Sources */, 58B9EB152489139B00095626 /* RESTError+Display.swift in Sources */, 587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */, 58F2E144276A13F300A79513 /* StartTunnelOperation.swift in Sources */, 58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */, 586E54FB27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift in Sources */, 584592612639B4A200EF967F /* TermsOfServiceContentView.swift in Sources */, - 584EBDBD2747C98F00A0C9FD /* NSAttributedString+Markdown.swift in Sources */, + 5827B0A12B0E064E00CCBBA1 /* AccessMethodRepository.swift in Sources */, 5875960A26F371FC00BF6711 /* Tunnel+Messaging.swift in Sources */, + 586C0D912B03D8A400E7CDD7 /* AccessMethodHeaderFooterReuseIdentifier.swift in Sources */, 7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */, 063687BA28EB234F00BE7161 /* PacketTunnelTransport.swift in Sources */, A9C342C12ACC37E30045F00E /* TunnelStatusBlockObserver.swift in Sources */, 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */, F09D04BD2AEBB7C5003D4F89 /* OutgoingConnectionService.swift in Sources */, + 58FF9FF42B07C61B00E4C97D /* AccessMethodValidationError.swift in Sources */, 5896AE84246D5889005B36CB /* CustomDateComponentsFormatting.swift in Sources */, 5871167F2910035700D41AAC /* PreferencesInteractor.swift in Sources */, 7A9CCCC22A96302800DD6A34 /* SafariCoordinator.swift in Sources */, + 58CEB3082AFD484100E6E088 /* BasicCell.swift in Sources */, + 58CEB2F52AFD0BB500E6E088 /* TextCellContentConfiguration.swift in Sources */, 58E20771274672CA00DE5D77 /* LaunchViewController.swift in Sources */, F0E8CC032A4C753B007ED3B4 /* WelcomeViewController.swift in Sources */, 584D26C4270C855B004EA533 /* PreferencesDataSource.swift in Sources */, F0D8825B2B04F53600D3EF9A /* OutgoingConnectionData.swift in Sources */, 7A6F2FAF2AFE36E7006D0856 /* PreferencesInfoButtonItem.swift in Sources */, + 5827B0BF2B14B37D00CCBBA1 /* Publisher+PreviousValue.swift in Sources */, 7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */, + 5827B0AA2B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift in Sources */, + 586C0D812B03CA8400E7CDD7 /* CurrentValueSubject+UIActionBindings.swift in Sources */, + 581DFAEA2B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift in Sources */, 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */, 58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */, F09A297B2A9F8A9B00EA3B6F /* LogoutDialogueView.swift in Sources */, + 58CEB2FB2AFD13E600E6E088 /* UIListContentConfiguration+Extensions.swift in Sources */, 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, 7A21DACF2A30AA3700A787A9 /* UITextField+Appearance.swift in Sources */, 585B1FF02AB09F97008AD470 /* VPNConnectionProtocol.swift in Sources */, @@ -4472,8 +4891,12 @@ 58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */, 7AF9BE952A40461100DBFEDB /* RelayFilterView.swift in Sources */, 7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */, + 5827B0C52B14D3E800CCBBA1 /* NSDiffableDataSourceSnapshot+Reconfigure.swift in Sources */, 58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */, 58A8EE5A2976BFBB009C0F8D /* SKError+Localized.swift in Sources */, + 586C0D9B2B051E6900E7CDD7 /* AccessMethodActionSheetContainerView.swift in Sources */, + 58EFC76E2AFB3BDA00E9F4CB /* ListAccessMethodCoordinator.swift in Sources */, + 5827B0B92B14A1C700CCBBA1 /* MethodTestingStatusCellContentConfiguration.swift in Sources */, 7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */, 5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */, 586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */, diff --git a/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepository.swift b/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepository.swift new file mode 100644 index 000000000000..c0c0ec86076b --- /dev/null +++ b/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepository.swift @@ -0,0 +1,69 @@ +// +// AccessMethodRepository.swift +// MullvadVPN +// +// Created by pronebird on 22/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation + +class AccessMethodRepository: AccessMethodRepositoryProtocol { + private var memoryStore: [PersistentAccessMethod] { + didSet { + publisher.send(memoryStore) + } + } + + let publisher: PassthroughSubject<[PersistentAccessMethod], Never> = .init() + + static let shared = AccessMethodRepository() + + init() { + memoryStore = [ + PersistentAccessMethod( + id: UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")!, + name: "", + isEnabled: true, + proxyConfiguration: .direct + ), + PersistentAccessMethod( + id: UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")!, + name: "", + isEnabled: true, + proxyConfiguration: .bridges + ), + ] + } + + func add(_ method: PersistentAccessMethod) { + guard !memoryStore.contains(where: { $0.id == method.id }) else { return } + + memoryStore.append(method) + } + + func update(_ method: PersistentAccessMethod) { + guard let index = memoryStore.firstIndex(where: { $0.id == method.id }) else { return } + + memoryStore[index] = method + } + + func delete(id: UUID) { + guard let index = memoryStore.firstIndex(where: { $0.id == id }) else { return } + + // Prevent removing methods that have static UUIDs and always present. + let permanentMethod = memoryStore[index] + if !permanentMethod.kind.isPermanent { + memoryStore.remove(at: index) + } + } + + func fetch(by id: UUID) -> PersistentAccessMethod? { + memoryStore.first { $0.id == id } + } + + func fetchAll() -> [PersistentAccessMethod] { + memoryStore + } +} diff --git a/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepositoryProtocol.swift b/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepositoryProtocol.swift new file mode 100644 index 000000000000..213f524bccef --- /dev/null +++ b/ios/MullvadVPN/AccessMethodRepository/AccessMethodRepositoryProtocol.swift @@ -0,0 +1,36 @@ +// +// AccessMethodRepositoryProtocol.swift +// MullvadVPN +// +// Created by pronebird on 28/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation + +protocol AccessMethodRepositoryProtocol { + /// Publisher that propagates a snapshot of persistent store upon modifications. + var publisher: PassthroughSubject<[PersistentAccessMethod], Never> { get } + + /// Add new access method. + /// - Parameter method: persistent access method model. + func add(_ method: PersistentAccessMethod) + + /// Persist modified access method locating existing entry by id. + /// - Parameter method: persistent access method model. + func update(_ method: PersistentAccessMethod) + + /// Delete access method by id. + /// - Parameter id: an access method id. + func delete(id: UUID) + + /// Fetch access method by id. + /// - Parameter id: an access method id. + /// - Returns: a persistent access method model upon success, otherwise `nil`. + func fetch(by id: UUID) -> PersistentAccessMethod? + + /// Fetch all access method from the persistent store. + /// - Returns: an array of all persistent access method. + func fetchAll() -> [PersistentAccessMethod] +} diff --git a/ios/MullvadVPN/AccessMethodRepository/PersistentAccessMethod.swift b/ios/MullvadVPN/AccessMethodRepository/PersistentAccessMethod.swift new file mode 100644 index 000000000000..b5d5ef2947ea --- /dev/null +++ b/ios/MullvadVPN/AccessMethodRepository/PersistentAccessMethod.swift @@ -0,0 +1,124 @@ +// +// PersistentAccessMethod.swift +// MullvadVPN +// +// Created by pronebird on 15/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes +import Network + +/// Persistent access method model. +struct PersistentAccessMethod: Identifiable, Codable { + /// The unique identifier used for referencing the access method entry in a persistent store. + var id: UUID + + /// The user-defined name for access method. + var name: String + + /// The flag indicating whether configuration is enabled. + var isEnabled: Bool + + /// Proxy configuration. + var proxyConfiguration: PersistentProxyConfiguration +} + +/// Persistent proxy configuration. +enum PersistentProxyConfiguration: Codable { + /// Direct communication without proxy. + case direct + + /// Communication over bridges. + case bridges + + /// Communication over shadowsocks. + case shadowsocks(ShadowsocksConfiguration) + + /// Communication over socks5 proxy. + case socks5(SocksConfiguration) +} + +extension PersistentProxyConfiguration { + /// Socks autentication method. + enum SocksAuthentication: Codable { + case noAuthentication + case usernamePassword(username: String, password: String) + } + + /// Socks v5 proxy configuration. + struct SocksConfiguration: Codable { + /// Proxy server address. + var server: AnyIPAddress + + /// Proxy server port. + var port: UInt16 + + /// Authentication method. + var authentication: SocksAuthentication + } + + /// Shadowsocks configuration. + struct ShadowsocksConfiguration: Codable { + /// Server address. + var server: AnyIPAddress + + /// Server port. + var port: UInt16 + + /// Server password. + var password: String + + /// Server cipher. + var cipher: ShadowsocksCipher + } +} + +extension PersistentAccessMethod { + /// A kind of access method. + var kind: AccessMethodKind { + switch proxyConfiguration { + case .direct: + .direct + case .bridges: + .bridges + case .shadowsocks: + .shadowsocks + case .socks5: + .socks5 + } + } +} + +/// A kind of API access method. +enum AccessMethodKind: Equatable, Hashable, CaseIterable { + /// Direct communication. + case direct + + /// Communication over bridges. + case bridges + + /// Communication over shadowsocks. + case shadowsocks + + /// Communication over socks v5 proxy. + case socks5 +} + +extension AccessMethodKind { + /// Returns `true` if the method is permanent and cannot be deleted. + var isPermanent: Bool { + switch self { + case .direct, .bridges: + true + case .shadowsocks, .socks5: + false + } + } + + /// Returns all access method kinds that can be added by user. + static var allUserDefinedKinds: [AccessMethodKind] { + allCases.filter { !$0.isPermanent } + } +} diff --git a/ios/MullvadVPN/AccessMethodRepository/ProxyConfigurationTester.swift b/ios/MullvadVPN/AccessMethodRepository/ProxyConfigurationTester.swift new file mode 100644 index 000000000000..bf9ad5f03a9a --- /dev/null +++ b/ios/MullvadVPN/AccessMethodRepository/ProxyConfigurationTester.swift @@ -0,0 +1,37 @@ +// +// ProxyConfigurationTester.swift +// MullvadVPN +// +// Created by pronebird on 28/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation + +/// A concrete implementation of an access method proxy configuration. +class ProxyConfigurationTester: ProxyConfigurationTesterProtocol { + private var cancellable: Cancellable? + + static let shared = ProxyConfigurationTester() + + init() {} + + func start(configuration: PersistentProxyConfiguration, completion: @escaping (Error?) -> Void) { + let workItem = DispatchWorkItem { + let randomResult = (0 ... 255).randomElement()?.isMultiple(of: 2) ?? true + + completion(randomResult ? nil : URLError(.timedOut)) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2), execute: workItem) + + cancellable = AnyCancellable { + workItem.cancel() + } + } + + func cancel() { + cancellable = nil + } +} diff --git a/ios/MullvadVPN/AccessMethodRepository/ProxyConfigurationTesterProtocol.swift b/ios/MullvadVPN/AccessMethodRepository/ProxyConfigurationTesterProtocol.swift new file mode 100644 index 000000000000..3ee795cbf9a6 --- /dev/null +++ b/ios/MullvadVPN/AccessMethodRepository/ProxyConfigurationTesterProtocol.swift @@ -0,0 +1,21 @@ +// +// ProxyConfigurationTesterProtocol.swift +// MullvadVPN +// +// Created by pronebird on 28/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Type implementing access method proxy configuration testing. +protocol ProxyConfigurationTesterProtocol { + /// Start testing proxy configuration. + /// - Parameters: + /// - configuration: a proxy configuration. + /// - completion: a completion handler that receives `nil` upon success, otherwise the underlying error. + func start(configuration: PersistentProxyConfiguration, completion: @escaping (Error?) -> Void) + + /// Cancel testing proxy configuration. + func cancel() +} diff --git a/ios/MullvadVPN/AccessMethodRepository/ShadowsocksCipher.swift b/ios/MullvadVPN/AccessMethodRepository/ShadowsocksCipher.swift new file mode 100644 index 000000000000..8610bf33c1a4 --- /dev/null +++ b/ios/MullvadVPN/AccessMethodRepository/ShadowsocksCipher.swift @@ -0,0 +1,48 @@ +// +// ShadowsocksCipher.swift +// MullvadVPN +// +// Created by pronebird on 13/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Type representing a shadowsocks cipher. +struct ShadowsocksCipher: RawRepresentable, CustomStringConvertible, Equatable, Hashable, Codable { + let rawValue: String + + var description: String { + rawValue + } + + /// Default cipher. + static let `default` = ShadowsocksCipher(rawValue: "chacha20") + + /// All supported ciphers. + static let supportedCiphers = supportedCipherIdentifiers.map { ShadowsocksCipher(rawValue: $0) } +} + +private let supportedCipherIdentifiers = [ + // Stream ciphers. + "aes-128-cfb", + "aes-128-cfb1", + "aes-128-cfb8", + "aes-128-cfb128", + "aes-256-cfb", + "aes-256-cfb1", + "aes-256-cfb8", + "aes-256-cfb128", + "rc4", + "rc4-md5", + "chacha20", + "salsa20", + "chacha20-ietf", + // AEAD ciphers. + "aes-128-gcm", + "aes-256-gcm", + "chacha20-ietf-poly1305", + "xchacha20-ietf-poly1305", + "aes-128-pmac-siv", + "aes-256-pmac-siv", +] diff --git a/ios/MullvadVPN/Containers/Navigation/CustomNavigationController.swift b/ios/MullvadVPN/Containers/Navigation/CustomNavigationController.swift index d45fa3c0516d..1b71603a7b72 100644 --- a/ios/MullvadVPN/Containers/Navigation/CustomNavigationController.swift +++ b/ios/MullvadVPN/Containers/Navigation/CustomNavigationController.swift @@ -8,6 +8,7 @@ import UIKit +/// Custom navigation controller that applies the custom appearance to itself. class CustomNavigationController: UINavigationController { override var childForStatusBarHidden: UIViewController? { topViewController @@ -22,4 +23,11 @@ class CustomNavigationController: UINavigationController { navigationBar.configureCustomAppeareance() } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // Navigation bar updates the prompt color on layout so we have to force our own appearance on each layout pass. + navigationBar.overridePromptColor() + } } diff --git a/ios/MullvadVPN/Containers/Navigation/UINavigationBar+Appearance.swift b/ios/MullvadVPN/Containers/Navigation/UINavigationBar+Appearance.swift index 5f69286878f0..b7011599478d 100644 --- a/ios/MullvadVPN/Containers/Navigation/UINavigationBar+Appearance.swift +++ b/ios/MullvadVPN/Containers/Navigation/UINavigationBar+Appearance.swift @@ -9,6 +9,15 @@ import UIKit extension UINavigationBar { + /// Locates the navigation bar prompt label within the view hirarchy and overrides the text color. + /// - Note: Navigation bar does not provide the appearance configuration for the prompt. + func overridePromptColor() { + let promptView = subviews.first { $0.description.contains("Prompt") } + let promptLabel = promptView?.subviews.first { $0 is UILabel } as? UILabel + + promptLabel?.textColor = UIColor.NavigationBar.promptColor + } + func configureCustomAppeareance() { var directionalMargins = directionalLayoutMargins directionalMargins.leading = UIMetrics.contentLayoutMargins.leading diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift new file mode 100644 index 000000000000..60a35bc95b04 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift @@ -0,0 +1,44 @@ +// +// AboutViewController.swift +// MullvadVPN +// +// Created by pronebird on 08/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// View controller used for presenting a detailed information on some topic using markdown in a scrollable text view. +class AboutViewController: UIViewController { + private let textView = UITextView() + private let markdown: String + + init(markdown: String) { + self.markdown = markdown + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacing = 16 + + let stylingOptions = MarkdownStylingOptions( + font: .systemFont(ofSize: 17), + paragraphStyle: paragraphStyle + ) + + textView.attributedText = NSAttributedString(markdownString: markdown, options: stylingOptions) + textView.textContainerInset = UIMetrics.contentInsets + textView.isEditable = false + + view.addConstrainedSubviews([textView]) { + textView.pinEdgesToSuperview() + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodCoordinator.swift new file mode 100644 index 000000000000..1ccf3ec4b141 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodCoordinator.swift @@ -0,0 +1,73 @@ +// +// AddAccessMethodCoordinator.swift +// MullvadVPN +// +// Created by pronebird on 08/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Routing +import UIKit + +class AddAccessMethodCoordinator: Coordinator, Presentable { + private let subject: CurrentValueSubject = .init(AccessMethodViewModel()) + + var presentedViewController: UIViewController { + navigationController + } + + let navigationController: UINavigationController + let accessMethodRepo: AccessMethodRepositoryProtocol + let proxyConfigurationTester: ProxyConfigurationTesterProtocol + + init( + navigationController: UINavigationController, + accessMethodRepo: AccessMethodRepositoryProtocol, + proxyConfigurationTester: ProxyConfigurationTesterProtocol + ) { + self.navigationController = navigationController + self.accessMethodRepo = accessMethodRepo + self.proxyConfigurationTester = proxyConfigurationTester + } + + func start() { + let controller = AddAccessMethodViewController( + subject: subject, + interactor: AddAccessMethodInteractor( + subject: subject, + repo: accessMethodRepo, + proxyConfigurationTester: proxyConfigurationTester + ) + ) + controller.delegate = self + + navigationController.pushViewController(controller, animated: false) + } +} + +extension AddAccessMethodCoordinator: AddAccessMethodViewControllerDelegate { + func controllerDidAdd(_ controller: AddAccessMethodViewController) { + dismiss(animated: true) + } + + func controllerDidCancel(_ controller: AddAccessMethodViewController) { + dismiss(animated: true) + } + + func controllerShouldShowProtocolPicker(_ controller: AddAccessMethodViewController) { + let picker = AccessMethodProtocolPicker(navigationController: navigationController) + + picker.present(currentValue: subject.value.method) { [weak self] newMethod in + self?.subject.value.method = newMethod + } + } + + func controllerShouldShowShadowsocksCipherPicker(_ controller: AddAccessMethodViewController) { + let picker = ShadowsocksCipherPicker(navigationController: navigationController) + + picker.present(currentValue: subject.value.shadowsocks.cipher) { [weak self] selectedCipher in + self?.subject.value.shadowsocks.cipher = selectedCipher + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractor.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractor.swift new file mode 100644 index 000000000000..88143db43b89 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractor.swift @@ -0,0 +1,42 @@ +// +// AddAccessMethodInteractor.swift +// MullvadVPN +// +// Created by pronebird on 22/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation + +struct AddAccessMethodInteractor: AddAccessMethodInteractorProtocol { + let subject: CurrentValueSubject + let repo: AccessMethodRepositoryProtocol + let proxyConfigurationTester: ProxyConfigurationTesterProtocol + + func addMethod() { + guard let persistentMethod = try? subject.value.intoPersistentAccessMethod() else { return } + repo.add(persistentMethod) + } + + func startProxyConfigurationTest(_ completion: ((Bool) -> Void)?) { + guard let config = try? subject.value.intoPersistentProxyConfiguration() else { return } + + let subject = subject + subject.value.testingStatus = .inProgress + + proxyConfigurationTester.start(configuration: config) { error in + let succeeded = error == nil + + subject.value.testingStatus = succeeded ? .succeeded : .failed + + completion?(succeeded) + } + } + + func cancelProxyConfigurationTest() { + subject.value.testingStatus = .initial + + proxyConfigurationTester.cancel() + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractorProtocol.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractorProtocol.swift new file mode 100644 index 000000000000..62e7e6fe017c --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodInteractorProtocol.swift @@ -0,0 +1,18 @@ +// +// AddAccessMethodInteractorProtocol.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// The type implementing the interface for persisting the underlying access method view model in the new entry context. +protocol AddAccessMethodInteractorProtocol: ProxyConfigurationInteractorProtocol { + /// Add new access method to the persistent store. + /// + /// - Calling this method multiple times does nothing as the entry with the same identifier cannot be added more than once. + /// - View controllers should only call this method for valid view models, as this method will do nothing if the view model fails validation. + func addMethod() +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodItemIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodItemIdentifier.swift new file mode 100644 index 000000000000..4eb34385e552 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodItemIdentifier.swift @@ -0,0 +1,61 @@ +// +// AddAccessMethodItemIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +enum AddAccessMethodItemIdentifier: Hashable { + case name + case `protocol` + case proxyConfiguration(ProxyProtocolConfigurationItemIdentifier) + + /// Returns all shadowsocks items wrapped into `ProxyConfigurationItemIdentifier.proxyConfiguration`. + static var allShadowsocksItems: [AddAccessMethodItemIdentifier] { + ShadowsocksItemIdentifier.allCases.map { .proxyConfiguration(.shadowsocks($0)) } + } + + /// Returns all socks items wrapped into `ProxyConfigurationItemIdentifier.proxyConfiguration`. + static func allSocksItems(authenticate: Bool) -> [AddAccessMethodItemIdentifier] { + SocksItemIdentifier.allCases(authenticate: authenticate).map { .proxyConfiguration(.socks($0)) } + } + + /// Cell identifier for the item identifier. + var cellIdentifier: AccessMethodCellReuseIdentifier { + switch self { + case .name: + .textInput + case .protocol: + .textWithDisclosure + case let .proxyConfiguration(item): + item.cellIdentifier + } + } + + /// Whether cell representing the item should be selectable. + var isSelectable: Bool { + switch self { + case .name: + false + case .protocol: + true + case let .proxyConfiguration(item): + item.isSelectable + } + } + + /// The text label for the corresponding cell. + var text: String? { + switch self { + case .name: + NSLocalizedString("NAME", tableName: "APIAccess", value: "Name", comment: "") + case .protocol: + NSLocalizedString("TYPE", tableName: "APIAccess", value: "Type", comment: "") + case .proxyConfiguration: + nil + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodSectionIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodSectionIdentifier.swift new file mode 100644 index 000000000000..9bd0fe9953d3 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodSectionIdentifier.swift @@ -0,0 +1,37 @@ +// +// AddAccessMethodSectionIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum AddAccessMethodSectionIdentifier: Hashable { + case name + case `protocol` + case proxyConfiguration + + /// The section name. + var sectionName: String? { + switch self { + case .name: + nil + case .protocol: + NSLocalizedString( + "PROTOCOL_SECTION_TITLE", + tableName: "APIAccess", + value: "Protocol", + comment: "" + ) + case .proxyConfiguration: + NSLocalizedString( + "HOST_CONFIG_SECTION_TITLE", + tableName: "APIAccess", + value: "Host configuration", + comment: "" + ) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewController.swift new file mode 100644 index 000000000000..8f8dec50b80e --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewController.swift @@ -0,0 +1,374 @@ +// +// AddAccessMethodViewController.swift +// MullvadVPN +// +// Created by pronebird on 08/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import UIKit + +/// The view controller providing the interface for adding new access method. +class AddAccessMethodViewController: UIViewController, UITableViewDelegate { + private let interactor: AddAccessMethodInteractorProtocol + private var validationError: AccessMethodValidationError? + private let viewModelSubject: CurrentValueSubject + private var cancellables = Set() + private var dataSource: UITableViewDiffableDataSource< + AddAccessMethodSectionIdentifier, + AddAccessMethodItemIdentifier + >? + private lazy var cancelBarButton: UIBarButtonItem = { + UIBarButtonItem( + systemItem: .cancel, + primaryAction: UIAction(handler: { [weak self] _ in + self?.onCancel() + }) + ) + }() + + private lazy var addBarButton: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem( + title: NSLocalizedString("ADD_NAVIGATION_BUTTON", tableName: "APIAccess", value: "Add", comment: ""), + primaryAction: UIAction { [weak self] _ in + self?.onAdd() + } + ) + barButtonItem.style = .done + return barButtonItem + }() + + private lazy var sheetPresentation: AccessMethodActionSheetPresentation = { + let sheetPresentation = AccessMethodActionSheetPresentation() + sheetPresentation.delegate = self + return sheetPresentation + }() + + private let contentController = UITableViewController(style: .insetGrouped) + private var tableView: UITableView { contentController.tableView } + + weak var delegate: AddAccessMethodViewControllerDelegate? + + init(subject: CurrentValueSubject, interactor: AddAccessMethodInteractorProtocol) { + self.viewModelSubject = subject + self.interactor = interactor + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.directionalLayoutMargins = UIMetrics.contentLayoutMargins + view.backgroundColor = .secondaryColor + + configureTableView() + configureNavigationItem() + configureDataSource() + } + + // MARK: - UITableViewDelegate + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let sectionIdentifier = dataSource?.snapshot().sectionIdentifiers[section] else { return nil } + + guard let headerView = tableView + .dequeueReusableView(withIdentifier: AccessMethodHeaderFooterReuseIdentifier.primary) + else { return nil } + + var contentConfiguration = UIListContentConfiguration.mullvadGroupedHeader() + contentConfiguration.text = sectionIdentifier.sectionName + + headerView.contentConfiguration = contentConfiguration + + return headerView + } + + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath), + itemIdentifier.isSelectable else { return nil } + + return indexPath + } + + func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) else { return false } + + return itemIdentifier.isSelectable + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) + + switch itemIdentifier { + case .protocol: + showProtocolSelector() + case .proxyConfiguration(.shadowsocks(.cipher)): + showShadowsocksCipher() + default: + break + } + } + + // MARK: - Pickers handling + + private func showProtocolSelector() { + view.endEditing(false) + delegate?.controllerShouldShowProtocolPicker(self) + } + + private func showShadowsocksCipher() { + view.endEditing(false) + delegate?.controllerShouldShowShadowsocksCipherPicker(self) + } + + // MARK: - Cell configuration + + private func dequeueCell(at indexPath: IndexPath, for itemIdentifier: AddAccessMethodItemIdentifier) + -> UITableViewCell { + let cell = tableView.dequeueReusableView(withIdentifier: itemIdentifier.cellIdentifier, for: indexPath) + + if let cell = cell as? DynamicBackgroundConfiguration { + cell.setAutoAdaptingBackgroundConfiguration(.mullvadListGroupedCell(), selectionType: .dimmed) + } + + switch itemIdentifier { + case .name: + configureName(cell, itemIdentifier: itemIdentifier) + case .protocol: + configureProtocol(cell, itemIdentifier: itemIdentifier) + case let .proxyConfiguration(proxyItemIdentifier): + configureProxy(cell, itemIdentifier: proxyItemIdentifier) + } + + return cell + } + + private func configureProxy(_ cell: UITableViewCell, itemIdentifier: ProxyProtocolConfigurationItemIdentifier) { + switch itemIdentifier { + case let .socks(socksItemIdentifier): + let section = SocksSectionHandler(tableStyle: tableView.style, subject: viewModelSubject) + section.configure(cell, itemIdentifier: socksItemIdentifier) + + case let .shadowsocks(shadowsocksItemIdentifier): + let section = ShadowsocksSectionHandler(tableStyle: tableView.style, subject: viewModelSubject) + section.configure(cell, itemIdentifier: shadowsocksItemIdentifier) + } + } + + private func configureName(_ cell: UITableViewCell, itemIdentifier: AddAccessMethodItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .optional) + contentConfiguration.textFieldProperties = .withAutoResignAndDoneReturnKey() + contentConfiguration.inputText = viewModelSubject.value.name + contentConfiguration.editingEvents.onChange = viewModelSubject.bindTextAction(to: \.name) + cell.contentConfiguration = contentConfiguration + } + + private func configureProtocol(_ cell: UITableViewCell, itemIdentifier: AddAccessMethodItemIdentifier) { + var contentConfiguration = UIListContentConfiguration.mullvadValueCell(tableStyle: tableView.style) + contentConfiguration.text = itemIdentifier.text + contentConfiguration.secondaryText = viewModelSubject.value.method.localizedDescription + cell.contentConfiguration = contentConfiguration + + if let cell = cell as? CustomCellDisclosureHandling { + cell.disclosureType = .chevron + } + } + + // MARK: - Data source handling + + private func configureDataSource() { + tableView.registerReusableViews(from: AccessMethodCellReuseIdentifier.self) + tableView.registerReusableViews(from: AccessMethodHeaderFooterReuseIdentifier.self) + + dataSource = UITableViewDiffableDataSource( + tableView: tableView, + cellProvider: { [weak self] _, indexPath, itemIdentifier in + self?.dequeueCell(at: indexPath, for: itemIdentifier) + } + ) + + viewModelSubject.withPreviousValue().sink { [weak self] previousValue, newValue in + self?.viewModelDidChange(previousValue: previousValue, newValue: newValue) + } + .store(in: &cancellables) + } + + private func viewModelDidChange(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel) { + let animated = view.window != nil + let previousValidationError = validationError + + validate() + updateBarButtons(newValue: newValue) + updateSheet(previousValue: previousValue, newValue: newValue, animated: animated) + updateModalPresentation(newValue: newValue) + updateDataSource( + previousValue: previousValue, + newValue: newValue, + previousValidationError: previousValidationError, + newValidationError: validationError, + animated: animated + ) + } + + private func updateSheet(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel, animated: Bool) { + guard previousValue?.testingStatus != newValue.testingStatus else { return } + + switch newValue.testingStatus { + case .initial: + sheetPresentation.hide(animated: animated) + + case .inProgress, .failed, .succeeded: + var presentationConfiguration = AccessMethodActionSheetPresentationConfiguration() + presentationConfiguration.sheetConfiguration.context = .addNew + presentationConfiguration.sheetConfiguration.contentConfiguration.status = newValue.testingStatus + .sheetStatus + sheetPresentation.configuration = presentationConfiguration + + sheetPresentation.show(in: view, animated: animated) + } + } + + private func updateDataSource( + previousValue: AccessMethodViewModel?, + newValue: AccessMethodViewModel, + previousValidationError: AccessMethodValidationError?, + newValidationError: AccessMethodValidationError?, + animated: Bool + ) { + var snapshot = NSDiffableDataSourceSnapshot() + + snapshot.appendSections([.name, .protocol]) + snapshot.appendItems([.name], toSection: .name) + + snapshot.appendItems([.protocol], toSection: .protocol) + // Reconfigure the protocol item on the access method change. + if let previousValue, previousValue.method != newValue.method { + snapshot.reconfigureOrReloadItems([.protocol]) + } + + if newValue.method.hasProxyConfiguration { + snapshot.appendSections([.proxyConfiguration]) + } + + switch newValue.method { + case .direct, .bridges: + break + + case .shadowsocks: + snapshot.appendItems(AddAccessMethodItemIdentifier.allShadowsocksItems, toSection: .proxyConfiguration) + // Reconfigure cipher item on change. + if let previousValue, previousValue.shadowsocks.cipher != newValue.shadowsocks.cipher { + snapshot.reconfigureOrReloadItems([.proxyConfiguration(.shadowsocks(.cipher))]) + } + + case .socks5: + snapshot.appendItems( + AddAccessMethodItemIdentifier.allSocksItems(authenticate: newValue.socks.authenticate), + toSection: .proxyConfiguration + ) + } + + dataSource?.apply(snapshot, animatingDifferences: animated) + } + + // MARK: - Misc + + private func configureTableView() { + tableView.delegate = self + tableView.backgroundColor = .secondaryColor + + view.addConstrainedSubviews([tableView]) { + tableView.pinEdgesToSuperview() + } + + addChild(contentController) + contentController.didMove(toParent: self) + } + + private func configureNavigationItem() { + navigationItem.prompt = NSLocalizedString( + "ADD_METHOD_NAVIGATION_PROMPT", + tableName: "APIAccess", + value: "The app will test the method before adding it.", + comment: "" + ) + navigationItem.title = NSLocalizedString( + "ADD_METHOD_NAVIGATION_TITLE", + tableName: "APIAccess", + value: "Add access method", + comment: "" + ) + navigationItem.leftBarButtonItem = cancelBarButton + navigationItem.rightBarButtonItem = addBarButton + } + + private func validate() { + let validationResult = Result { try viewModelSubject.value.validate() } + validationError = validationResult.error as? AccessMethodValidationError + } + + private func updateBarButtons(newValue: AccessMethodViewModel) { + addBarButton.isEnabled = newValue.testingStatus == .initial && validationError == nil + cancelBarButton.isEnabled = newValue.testingStatus == .initial + } + + private func updateModalPresentation(newValue: AccessMethodViewModel) { + // Prevent swipe gesture when testing or when the sheet offers user actions. + isModalInPresentation = newValue.testingStatus != .initial + } + + private func onAdd() { + view.endEditing(true) + + interactor.startProxyConfigurationTest { [weak self] succeeded in + if succeeded { + self?.addMethodAndNotifyDelegate(afterDelay: true) + } + } + } + + private func onCancel() { + view.endEditing(true) + interactor.cancelProxyConfigurationTest() + + delegate?.controllerDidCancel(self) + } + + /// Tells interactor to add the access method and then notifies the delegate which then dismisses the view controller. + /// - Parameter afterDelay: whether to add a short delay before calling the delegate. + private func addMethodAndNotifyDelegate(afterDelay: Bool) { + interactor.addMethod() + + guard afterDelay else { + sendControllerDidAdd() + return + } + + // Add a short delay to let user see the sheet with successful status before the delegate dismisses the view + // controller. + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in + self?.sendControllerDidAdd() + } + } + + private func sendControllerDidAdd() { + delegate?.controllerDidAdd(self) + } +} + +extension AddAccessMethodViewController: AccessMethodActionSheetPresentationDelegate { + func sheetDidAdd(sheetPresentation: AccessMethodActionSheetPresentation) { + addMethodAndNotifyDelegate(afterDelay: false) + } + + func sheetDidCancel(sheetPresentation: AccessMethodActionSheetPresentation) { + interactor.cancelProxyConfigurationTest() + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewControllerDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewControllerDelegate.swift new file mode 100644 index 000000000000..2dadc4cb71ec --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Add/AddAccessMethodViewControllerDelegate.swift @@ -0,0 +1,35 @@ +// +// AddAccessMethodViewControllerDelegate.swift +// MullvadVPN +// +// Created by pronebird on 23/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +protocol AddAccessMethodViewControllerDelegate: AnyObject { + /// The view controller added the API access method. + /// + /// The delegate should consider dismissing the view controller. + /// + /// - Parameter controller: the calling view controller. + func controllerDidAdd(_ controller: AddAccessMethodViewController) + + /// The user cancelled the view controller. + /// + /// The delegate should consider dismissing the view controller. + /// + /// - Parameter controller: the calling view controller. + func controllerDidCancel(_ controller: AddAccessMethodViewController) + + /// The view controller requests the delegate to present the API access method protocol picker. + /// + /// - Parameter controller: the calling view controller. + func controllerShouldShowProtocolPicker(_ controller: AddAccessMethodViewController) + + /// The view controller requests the delegate to present the cipher picker. + /// + /// - Parameter controller: the calling view controller. + func controllerShouldShowShadowsocksCipherPicker(_ controller: AddAccessMethodViewController) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/BasicCell.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/BasicCell.swift new file mode 100644 index 000000000000..7243b4b2fa70 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/BasicCell.swift @@ -0,0 +1,60 @@ +// +// BasicCell.swift +// MullvadVPN +// +// Created by pronebird on 09/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Basic cell that supports dynamic background configuration and custom cell disclosure. +class BasicCell: UITableViewCell, DynamicBackgroundConfiguration, CustomCellDisclosureHandling { + private lazy var disclosureImageView = UIImageView(image: nil) + + var backgroundConfigurationResolver: BackgroundConfigurationResolver? { + didSet { + backgroundConfiguration = backgroundConfigurationResolver?(configurationState) + } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func updateConfiguration(using state: UICellConfigurationState) { + if let backgroundConfiguration = backgroundConfigurationResolver?(state) { + self.backgroundConfiguration = backgroundConfiguration + } else { + super.updateConfiguration(using: state) + } + } + + var disclosureType: SettingsDisclosureType = .none { + didSet { + accessoryType = .none + + guard let image = disclosureType.image?.withTintColor( + UIColor.Cell.disclosureIndicatorColor, + renderingMode: .alwaysOriginal + ) else { + accessoryView = nil + return + } + + disclosureImageView.image = image + disclosureImageView.sizeToFit() + accessoryView = disclosureImageView + } + } + + override func prepareForReuse() { + super.prepareForReuse() + + disclosureType = .none + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/ButtonCellContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/ButtonCellContentConfiguration.swift new file mode 100644 index 000000000000..2fc7751fbc9b --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/ButtonCellContentConfiguration.swift @@ -0,0 +1,42 @@ +// +// ButtonCellConfiguration.swift +// MullvadVPN +// +// Created by pronebird on 17/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// The content configuration for cells that contain the full-width button. +struct ButtonCellContentConfiguration: UIContentConfiguration, Equatable { + /// Button label. + var text: String? + + /// Button style. + var style: AppButton.Style = .default + + /// Indicates whether button is enabled. + var isEnabled = true + + /// Primary action for button. + var primaryAction: UIAction? + + /// The button content edge insets. + var directionalContentEdgeInsets: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.insetLayoutMargins + + func makeContentView() -> UIView & UIContentView { + return ButtonCellContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> Self { + return self + } +} + +extension ButtonCellContentConfiguration { + struct TextProperties: Equatable { + var font = UIFont.systemFont(ofSize: 17) + var color = UIColor.Cell.titleTextColor + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/ButtonCellContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/ButtonCellContentView.swift new file mode 100644 index 000000000000..66d50d119914 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/ButtonCellContentView.swift @@ -0,0 +1,74 @@ +// +// ButtonCellContentView.swift +// MullvadVPN +// +// Created by pronebird on 17/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Content view presenting a full-width button. +class ButtonCellContentView: UIView, UIContentView { + private let button = AppButton() + + var configuration: UIContentConfiguration { + get { + actualConfiguration + } + set { + guard let newConfiguration = newValue as? ButtonCellContentConfiguration, + actualConfiguration != newConfiguration else { return } + + let previousConfiguration = actualConfiguration + actualConfiguration = newConfiguration + + configureSubviews(previousConfiguration: previousConfiguration) + } + } + + private var actualConfiguration: ButtonCellContentConfiguration + + func supports(_ configuration: UIContentConfiguration) -> Bool { + configuration is ButtonCellContentConfiguration + } + + init(configuration: ButtonCellContentConfiguration) { + actualConfiguration = configuration + + super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + + configureSubviews() + addSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configureSubviews(previousConfiguration: ButtonCellContentConfiguration? = nil) { + guard actualConfiguration != previousConfiguration else { return } + + configureButton() + configureActions(previousConfiguration: previousConfiguration) + } + + private func configureActions(previousConfiguration: ButtonCellContentConfiguration? = nil) { + previousConfiguration?.primaryAction.map { button.removeAction($0, for: .touchUpInside) } + actualConfiguration.primaryAction.map { button.addAction($0, for: .touchUpInside) } + } + + private func configureButton() { + button.setTitle(actualConfiguration.text, for: .normal) + button.isEnabled = actualConfiguration.isEnabled + button.style = actualConfiguration.style + button.overrideContentEdgeInsets = true + button.directionalContentEdgeInsets = actualConfiguration.directionalContentEdgeInsets + } + + private func addSubviews() { + addConstrainedSubviews([button]) { + button.pinEdgesToSuperview() + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/CustomCellDisclosureHandling.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/CustomCellDisclosureHandling.swift new file mode 100644 index 000000000000..a4c7301a6e7f --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/CustomCellDisclosureHandling.swift @@ -0,0 +1,17 @@ +// +// CustomCellDisclosureHandling.swift +// MullvadVPN +// +// Created by pronebird on 09/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Types handling custom disclosure accessory in table view cells. +protocol CustomCellDisclosureHandling: UITableViewCell { + /// Custom disclosure type. + /// + /// Cannot be used together with `accessoryType` property. Automatically resets `accessoryType` upon assignment. + var disclosureType: SettingsDisclosureType { get set } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/DynamicBackgroundConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/DynamicBackgroundConfiguration.swift new file mode 100644 index 000000000000..e16420c8a996 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/DynamicBackgroundConfiguration.swift @@ -0,0 +1,40 @@ +// +// DynamicBackgroundConfiguration.swift +// MullvadVPN +// +// Created by pronebird on 09/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Types providing dynamic background configuration based on cell configuration state. +protocol DynamicBackgroundConfiguration: UITableViewCell { + typealias BackgroundConfigurationResolver = (UICellConfigurationState) -> UIBackgroundConfiguration + + /// Background configuration resolver closure. + /// The closure is called immediately upon assignment, the returned configuration is assigned to `backgroundConfiguration`. + /// All subsequent calls happen on `updateConfiguration(using:)`. + var backgroundConfigurationResolver: BackgroundConfigurationResolver? { get set } +} + +extension DynamicBackgroundConfiguration { + /// Automatically maintains transparent background configuration in any cell state. + func setAutoAdaptingClearBackgroundConfiguration() { + backgroundConfigurationResolver = { _ in .clear() } + } + + /// Automatically adjust background configuration for the cell state based on provided template and type of visual cell selection preference. + /// + /// - Parameters: + /// - backgroundConfiguration: a background configuration template. + /// - selectionType: a cell selection to apply. + func setAutoAdaptingBackgroundConfiguration( + _ backgroundConfiguration: UIBackgroundConfiguration, + selectionType: UIBackgroundConfiguration.CellSelectionType + ) { + backgroundConfigurationResolver = { state in + backgroundConfiguration.adapted(for: state, selectionType: selectionType) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentConfiguration.swift new file mode 100644 index 000000000000..eaa9d07f7df6 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentConfiguration.swift @@ -0,0 +1,26 @@ +// +// MethodTestingStatusCellContentConfiguration.swift +// MullvadVPN +// +// Created by pronebird on 27/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Content configuration for presenting the access method testing progress. +struct MethodTestingStatusCellContentConfiguration: UIContentConfiguration, Equatable { + /// Sheet content configuration. + var sheetConfiguration = AccessMethodActionSheetContentConfiguration() + + /// Layout margins. + var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.insetLayoutMargins + + func makeContentView() -> UIView & UIContentView { + return MethodTestingStatusCellContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> Self { + return self + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentView.swift new file mode 100644 index 000000000000..6c3f1bb68e93 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/MethodTestingStatusCellContentView.swift @@ -0,0 +1,66 @@ +// +// MethodTestingStatusContentCell.swift +// MullvadVPN +// +// Created by pronebird on 27/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Content view presenting the access method testing progress. +class MethodTestingStatusCellContentView: UIView, UIContentView { + var configuration: UIContentConfiguration { + get { + actualConfiguration + } + set { + guard let newConfiguration = newValue as? MethodTestingStatusCellContentConfiguration, + actualConfiguration != newConfiguration else { return } + + let previousConfiguration = actualConfiguration + actualConfiguration = newConfiguration + + configureSubviews(previousConfiguration: previousConfiguration) + } + } + + private var actualConfiguration: MethodTestingStatusCellContentConfiguration + private let sheetContentView = AccessMethodActionSheetContentView() + + func supports(_ configuration: UIContentConfiguration) -> Bool { + configuration is MethodTestingStatusCellContentConfiguration + } + + init(configuration: MethodTestingStatusCellContentConfiguration) { + actualConfiguration = configuration + + super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + + configureSubviews() + addSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func addSubviews() { + addConstrainedSubviews([sheetContentView]) { + sheetContentView.pinEdgesToSuperviewMargins() + } + } + + private func configureSubviews(previousConfiguration: MethodTestingStatusCellContentConfiguration? = nil) { + configureLayoutMargins() + configureSheetContentView() + } + + private func configureLayoutMargins() { + directionalLayoutMargins = actualConfiguration.directionalLayoutMargins + } + + private func configureSheetContentView() { + sheetContentView.configuration = actualConfiguration.sheetConfiguration + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentConfiguration.swift new file mode 100644 index 000000000000..766f9b91db1f --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentConfiguration.swift @@ -0,0 +1,40 @@ +// +// SwitchCellContentConfiguration.swift +// MullvadVPN +// +// Created by pronebird on 09/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Content configuration presenting a label and switch control. +struct SwitchCellContentConfiguration: UIContentConfiguration, Equatable { + struct TextProperties: Equatable { + var font = UIFont.systemFont(ofSize: 17) + var color = UIColor.Cell.titleTextColor + } + + /// Text label. + var text: String? + + /// Whether the toggle is on or off. + var isOn = false + + /// The action dispacthed on toggle change. + var onChange: UIAction? + + /// Text label properties. + var textProperties = TextProperties() + + /// Content view layout margins. + var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.insetLayoutMargins + + func makeContentView() -> UIView & UIContentView { + return SwitchCellContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> Self { + return self + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentView.swift new file mode 100644 index 000000000000..5f50efd035f3 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/SwitchCellContentView.swift @@ -0,0 +1,154 @@ +// +// SwitchCellContentView.swift +// MullvadVPN +// +// Created by pronebird on 09/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Content view presenting a label and switch control. +class SwitchCellContentView: UIView, UIContentView, UITextFieldDelegate { + private var textLabel = UILabel() + private let switchContainer = CustomSwitchContainer() + + var configuration: UIContentConfiguration { + get { + actualConfiguration + } + set { + guard let newConfiguration = newValue as? SwitchCellContentConfiguration, + actualConfiguration != newConfiguration else { return } + + let previousConfiguration = actualConfiguration + actualConfiguration = newConfiguration + + configureSubviews(previousConfiguration: previousConfiguration) + } + } + + private var actualConfiguration: SwitchCellContentConfiguration + + func supports(_ configuration: UIContentConfiguration) -> Bool { + configuration is SwitchCellContentConfiguration + } + + init(configuration: SwitchCellContentConfiguration) { + actualConfiguration = configuration + + super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + + configureSubviews() + addSubviews() + configureAccessibility() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configureSubviews(previousConfiguration: SwitchCellContentConfiguration? = nil) { + configureTextLabel() + configureSwitch() + configureLayoutMargins() + configureActions(previousConfiguration: previousConfiguration) + } + + private func configureActions(previousConfiguration: SwitchCellContentConfiguration? = nil) { + previousConfiguration?.onChange.map { switchContainer.control.removeAction($0, for: .valueChanged) } + actualConfiguration.onChange.map { switchContainer.control.addAction($0, for: .valueChanged) } + } + + private func configureLayoutMargins() { + directionalLayoutMargins = actualConfiguration.directionalLayoutMargins + } + + private func configureTextLabel() { + let textProperties = actualConfiguration.textProperties + + textLabel.font = textProperties.font + textLabel.textColor = textProperties.color + + textLabel.text = actualConfiguration.text + } + + private func configureSwitch() { + switchContainer.control.isOn = actualConfiguration.isOn + } + + private func addSubviews() { + addConstrainedSubviews([textLabel, switchContainer]) { + textLabel.pinEdgesToSuperviewMargins(.all().excluding(.trailing)) + switchContainer.centerYAnchor.constraint(equalTo: centerYAnchor) + switchContainer.pinEdgeToSuperviewMargin(.trailing(0)) + switchContainer.leadingAnchor.constraint( + greaterThanOrEqualToSystemSpacingAfter: textLabel.trailingAnchor, + multiplier: 1 + ) + } + } + + private func configureAccessibility() { + isAccessibilityElement = true + } + + // MARK: - Accessibility + + override var accessibilityTraits: UIAccessibilityTraits { + get { + // Use UISwitch traits to make the entire cell behave as "Switch button" + switchContainer.control.accessibilityTraits + } + set { + super.accessibilityTraits = newValue + } + } + + override var accessibilityLabel: String? { + get { + actualConfiguration.text + } + set { + super.accessibilityLabel = newValue + } + } + + override var accessibilityValue: String? { + get { + self.switchContainer.control.accessibilityValue + } + set { + super.accessibilityValue = newValue + } + } + + override var accessibilityFrame: CGRect { + get { + UIAccessibility.convertToScreenCoordinates(self.bounds, in: self) + } + set { + super.accessibilityFrame = newValue + } + } + + override var accessibilityPath: UIBezierPath? { + get { + UIBezierPath(roundedRect: accessibilityFrame, cornerRadius: 4) + } + set { + super.accessibilityPath = newValue + } + } + + override func accessibilityActivate() -> Bool { + guard switchContainer.isEnabled else { return false } + + let newValue = !switchContainer.control.isOn + + switchContainer.control.setOn(newValue, animated: true) + switchContainer.control.sendActions(for: .valueChanged) + + return true + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentConfiguration+Extensions.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentConfiguration+Extensions.swift new file mode 100644 index 000000000000..9b736666089f --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentConfiguration+Extensions.swift @@ -0,0 +1,53 @@ +// +// TextCellContentConfiguration+Extensions.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension TextCellContentConfiguration.TextFieldProperties { + /// Returns text field properties configured with automatic resign on return key and "done" return key. + static func withAutoResignAndDoneReturnKey() -> Self { + .init(resignOnReturn: true, returnKey: .done) + } + + /// Returns text field properties configured with automatic resign on return key and "done" return key and all auto-correction and smart features disabled. + static func withSmartFeaturesDisabled() -> Self { + withAutoResignAndDoneReturnKey().disabling(features: .all) + } +} + +extension TextCellContentConfiguration { + /// Type of placeholder to set on the text field. + enum PlaceholderType { + case required, optional + + var localizedDescription: String { + switch self { + case .required: + NSLocalizedString( + "REQUIRED_PLACEHOLDER", + tableName: "APIAccess", + value: "Required", + comment: "" + ) + case .optional: + NSLocalizedString( + "OPTIONAL_PLACEHOLDER", + tableName: "APIAccess", + value: "Optional", + comment: "" + ) + } + } + } + + /// Set localized text placeholder using on the given placeholder type. + /// - Parameter type: a placeholder type. + mutating func setPlaceholder(type: PlaceholderType) { + placeholder = type.localizedDescription + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentConfiguration.swift new file mode 100644 index 000000000000..33311ff64fd9 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentConfiguration.swift @@ -0,0 +1,157 @@ +// +// TextCellContentConfiguration.swift +// MullvadVPN +// +// Created by pronebird on 09/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Content configuration presenting a label and text field. +struct TextCellContentConfiguration: UIContentConfiguration, Equatable { + /// The text label. + var text: String? + + /// The input field text. + var inputText: String? + + /// The text input filter that can be used to prevent user from entering illegal characters. + var inputFilter: TextInputFilter = .allowAll + + /// The text field placeholder. + var placeholder: String? + + /// The editing events configuration. + var editingEvents = EditingEvents() + + /// The text properties confgiuration. + var textProperties = TextProperties() + + /// The text field properties configuration. + var textFieldProperties = TextFieldProperties() + + /// The content view layout margins. + var directionalLayoutMargins: NSDirectionalEdgeInsets = UIMetrics.SettingsCell.insetLayoutMargins + + func makeContentView() -> UIView & UIContentView { + return TextCellContentView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> Self { + return self + } +} + +extension TextCellContentConfiguration { + /// The text label properties. + struct TextProperties: Equatable { + var font = UIFont.systemFont(ofSize: 17) + var color = UIColor.Cell.titleTextColor + } + + /// The text input filter. + enum TextInputFilter: Equatable { + /// Allow all input. + case allowAll + + /// Allow digits only. + case digitsOnly + } + + /// Editing events configuration assigned on the text field. + struct EditingEvents: Equatable { + /// The action invoked on text field input change. + var onChange: UIAction? + /// The action invoked when text field begins editing. + var onBegin: UIAction? + /// The action invoked when text field ends editing. + var onEnd: UIAction? + /// The action invoked on the touch ending the editing session. (i.e. the return key) + var onEndOnExit: UIAction? + } + + /// Text field configuration. + struct TextFieldProperties: Equatable { + /// Text font. + var font = UIFont.systemFont(ofSize: 17) + /// Text color. + var textColor = UIColor.Cell.textFieldTextColor + + /// Placeholder color. + var placeholderColor = UIColor.Cell.textFieldPlaceholderColor + + /// Automatically resign keyboard on return key. + var resignOnReturn = false + + /// Text content type. + var textContentType: UITextContentType? + + /// Keyboard type. + var keyboardType: UIKeyboardType = .default + + /// Return key type. + var returnKey: UIReturnKeyType = .default + + /// Indicates whether the text input should be obscured. + /// Set to `true` for password entry. + var isSecureTextEntry = false + + /// Autocorrection type. + var autocorrectionType: UITextAutocorrectionType = .default + + /// Autocapitalization type. + var autocapitalizationType: UITextAutocapitalizationType = .sentences + + /// Spellchecking type. + var spellCheckingType: UITextSpellCheckingType = .default + + var smartInsertDeleteType: UITextSmartInsertDeleteType = .default + var smartDashesType: UITextSmartDashesType = .default + var smartQuotesType: UITextSmartQuotesType = .default + + /// An option set describing a set of text field features to enable or disable in bulk. + struct Features: OptionSet { + /// Autocorrection. + static let autoCorrect = Features(rawValue: 1 << 1) + /// Spellcheck. + static let spellCheck = Features(rawValue: 1 << 2) + /// Autocapitalization. + static let autoCapitalization = Features(rawValue: 1 << 3) + /// Smart features such as automatic hyphenation or insertion of a space at the end of word etc. + static let smart = Features(rawValue: 1 << 4) + /// All of the above. + static let all = Features([.autoCorrect, .spellCheck, .autoCapitalization, .smart]) + + let rawValue: Int + } + + /// Produce text field configuration with the given text field features disabled. + /// - Parameter features: the text field features to disable. + /// - Returns: new text field configuration. + func disabling(features: Features) -> TextFieldProperties { + var mutableProperties = self + mutableProperties.disable(features: features) + return mutableProperties + } + + /// Disable a set of text field features mutating the current configuration in-place. + /// - Parameter features: the text field features to disable. + mutating func disable(features: Features) { + if features.contains(.autoCorrect) { + autocorrectionType = .no + } + if features.contains(.spellCheck) { + spellCheckingType = .no + } + if features.contains(.autoCapitalization) { + autocapitalizationType = .none + } + if features.contains(.smart) { + smartInsertDeleteType = .no + smartDashesType = .no + smartQuotesType = .no + } + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentView.swift new file mode 100644 index 000000000000..9b56cc862750 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Cells/TextCellContentView.swift @@ -0,0 +1,204 @@ +// +// TextCellContentView.swift +// MullvadVPN +// +// Created by pronebird on 09/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Content view presenting a label and text field. +class TextCellContentView: UIView, UIContentView, UIGestureRecognizerDelegate { + private var textLabel = UILabel() + private var textField = CustomTextField() + + var configuration: UIContentConfiguration { + get { + actualConfiguration + } + set { + guard let newConfiguration = newValue as? TextCellContentConfiguration, + actualConfiguration != newConfiguration else { return } + + let previousConfiguration = actualConfiguration + actualConfiguration = newConfiguration + + configureSubviews(previousConfiguration: previousConfiguration) + } + } + + private var actualConfiguration: TextCellContentConfiguration + + func supports(_ configuration: UIContentConfiguration) -> Bool { + configuration is TextCellContentConfiguration + } + + init(configuration: TextCellContentConfiguration) { + actualConfiguration = configuration + + super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + + configureSubviews() + addSubviews() + addTapGestureRecognizer() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureSubviews(previousConfiguration: TextCellContentConfiguration? = nil) { + guard actualConfiguration != previousConfiguration else { return } + + configureTextLabel() + configureTextField() + configureLayoutMargins() + configureActions(previousConfiguration: previousConfiguration) + } + + private func configureActions(previousConfiguration: TextCellContentConfiguration? = nil) { + previousConfiguration?.editingEvents.unregister(from: textField) + actualConfiguration.editingEvents.register(in: textField) + } + + private func configureLayoutMargins() { + directionalLayoutMargins = actualConfiguration.directionalLayoutMargins + } + + private func configureTextLabel() { + let textProperties = actualConfiguration.textProperties + + textLabel.font = textProperties.font + textLabel.textColor = textProperties.color + + textLabel.text = actualConfiguration.text + } + + private func configureTextField() { + textField.text = actualConfiguration.inputText + textField.placeholder = actualConfiguration.placeholder + textField.delegate = self + + actualConfiguration.textFieldProperties.apply(to: textField) + } + + private func addSubviews() { + textField.setContentHuggingPriority(.defaultLow, for: .horizontal) + textField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + textLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + textLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .horizontal) + + addConstrainedSubviews([textLabel, textField]) { + textField.pinEdgesToSuperviewMargins(.all().excluding(.leading)) + textLabel.pinEdgesToSuperviewMargins(.all().excluding(.trailing)) + textField.leadingAnchor.constraint(equalToSystemSpacingAfter: textLabel.trailingAnchor, multiplier: 1) + } + } + + // MARK: - Gesture recognition + + /// Add tap recognizer that activates the text field on tap anywhere within the content view. + private func addTapGestureRecognizer() { + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + tapGestureRecognizer.delegate = self + addGestureRecognizer(tapGestureRecognizer) + } + + @objc private func handleTap(_ gestureRecognizer: UIGestureRecognizer) { + if gestureRecognizer.state == .ended { + textField.selectedTextRange = textField.textRange( + from: textField.endOfDocument, + to: textField.endOfDocument + ) + textField.becomeFirstResponder() + } + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + // Allow our tap recognizer to evaluate only when the text field is not the first responder yet. + super.gestureRecognizerShouldBegin(gestureRecognizer) && !textField.isFirstResponder + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + // Since the text field is right-aligned, a tap in the middle of it puts the caret at the front rather than at + // the end. + // In order to circumvent that, the tap recognizer used by the text field should be forced to fail once + // before the text field becomes the first responder. + // However long tap and other recognizers are unaffected, which makes it possible to tap and hold to grab + // the cursor. + otherGestureRecognizer.view == textField && otherGestureRecognizer.isKind(of: UITapGestureRecognizer.self) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + // Simultaneous recogition is a prerequisite for enabling failure requirements. + true + } +} + +extension TextCellContentView: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if actualConfiguration.textFieldProperties.resignOnReturn { + textField.resignFirstResponder() + } + return true + } + + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + switch actualConfiguration.inputFilter { + case .allowAll: + return true + case .digitsOnly: + return string.allSatisfy { $0.isASCII && $0.isNumber } + } + } +} + +extension TextCellContentConfiguration.TextFieldProperties { + func apply(to textField: CustomTextField) { + textField.font = font + textField.backgroundColor = .clear + textField.textColor = textColor + textField.placeholderTextColor = placeholderColor + textField.textAlignment = .right + textField.textMargins = .zero + textField.cornerRadius = 0 + textField.textContentType = textContentType + textField.keyboardType = keyboardType + textField.returnKeyType = returnKey + textField.isSecureTextEntry = isSecureTextEntry + textField.autocorrectionType = autocorrectionType + textField.smartInsertDeleteType = smartInsertDeleteType + textField.smartDashesType = smartDashesType + textField.smartQuotesType = smartQuotesType + textField.spellCheckingType = spellCheckingType + textField.autocapitalizationType = autocapitalizationType + } +} + +extension TextCellContentConfiguration.EditingEvents { + func register(in textField: UITextField) { + onChange.map { textField.addAction($0, for: .editingChanged) } + onBegin.map { textField.addAction($0, for: .editingDidBegin) } + onEnd.map { textField.addAction($0, for: .editingDidEnd) } + onEndOnExit.map { textField.addAction($0, for: .editingDidEndOnExit) } + } + + func unregister(from textField: UITextField) { + onChange.map { textField.removeAction($0, for: .editingChanged) } + onBegin.map { textField.removeAction($0, for: .editingDidBegin) } + onEnd.map { textField.removeAction($0, for: .editingDidEnd) } + onEndOnExit.map { textField.removeAction($0, for: .editingDidEndOnExit) } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodCellReuseIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodCellReuseIdentifier.swift new file mode 100644 index 000000000000..e6c4427aaeee --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodCellReuseIdentifier.swift @@ -0,0 +1,31 @@ +// +// AccessMethodCellReuseIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Cell reuse identifier used by table view controllers implementing various parts of API access management. +enum AccessMethodCellReuseIdentifier: String, CaseIterable, CellIdentifierProtocol { + /// Cells with static text and disclosure view. + case textWithDisclosure + + /// Cells with a label and text field. + case textInput + + /// Cells with a label and switch control. + case toggle + + /// Cells that contain a button. + case button + + /// Cells that contain the status of API method testing. + case testingStatus + + var cellClass: AnyClass { + BasicCell.self + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodHeaderFooterReuseIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodHeaderFooterReuseIdentifier.swift new file mode 100644 index 000000000000..13622a5a7539 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/AccessMethodHeaderFooterReuseIdentifier.swift @@ -0,0 +1,20 @@ +// +// AccessMethodHeaderFooterReuseIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Header footer view reuse identifier used in view controllers implementing access method management. +enum AccessMethodHeaderFooterReuseIdentifier: String, CaseIterable, HeaderFooterIdentifierProtocol { + case primary + + var headerFooterClass: AnyClass { + switch self { + case .primary: UITableViewHeaderFooterView.self + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ProxyProtocolConfigurationItemIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ProxyProtocolConfigurationItemIdentifier.swift new file mode 100644 index 000000000000..b40cc228259d --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ProxyProtocolConfigurationItemIdentifier.swift @@ -0,0 +1,35 @@ +// +// ProxyProtocolConfigurationItemIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Item identifier used by diffable data sources implementing proxy configuration. +enum ProxyProtocolConfigurationItemIdentifier: Hashable { + case socks(SocksItemIdentifier) + case shadowsocks(ShadowsocksItemIdentifier) + + /// Cell identifier for the item identifier. + var cellIdentifier: AccessMethodCellReuseIdentifier { + switch self { + case let .shadowsocks(itemIdentifier): + itemIdentifier.cellIdentifier + case let .socks(itemIdentifier): + itemIdentifier.cellIdentifier + } + } + + /// Indicates whether cell representing the item should be selectable. + var isSelectable: Bool { + switch self { + case let .shadowsocks(itemIdentifier): + itemIdentifier.isSelectable + case let .socks(itemIdentifier): + itemIdentifier.isSelectable + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ShadowsocksItemIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ShadowsocksItemIdentifier.swift new file mode 100644 index 000000000000..903a5b150de7 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ShadowsocksItemIdentifier.swift @@ -0,0 +1,46 @@ +// +// ShadowsocksItemIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Item identifier used by diffable data sources implementing shadowsocks configuration. +enum ShadowsocksItemIdentifier: Hashable, CaseIterable { + case server + case port + case password + case cipher + + /// Cell identifier for the item identifier. + var cellIdentifier: AccessMethodCellReuseIdentifier { + switch self { + case .server, .port, .password: + .textInput + case .cipher: + .textWithDisclosure + } + } + + /// Indicates whether cell representing the item should be selectable. + var isSelectable: Bool { + self == .cipher + } + + /// The text describing the item identifier and suitable to be used as a field label. + var text: String { + switch self { + case .server: + NSLocalizedString("SHADOWSOCKS_SERVER", tableName: "APIAccess", value: "Server", comment: "") + case .port: + NSLocalizedString("SHADOWSOCKS_PORT", tableName: "APIAccess", value: "Port", comment: "") + case .password: + NSLocalizedString("SHADOWSOCKS_PASSWORD", tableName: "APIAccess", value: "Password", comment: "") + case .cipher: + NSLocalizedString("SHADOWSOCKS_CIPHER", tableName: "APIAccess", value: "Cipher", comment: "") + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ShadowsocksSectionHandler.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ShadowsocksSectionHandler.swift new file mode 100644 index 000000000000..e9afdd51e524 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/ShadowsocksSectionHandler.swift @@ -0,0 +1,76 @@ +// +// ShadowsocksSectionHandler.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import UIKit + +/// Type responsible for handling cells in shadowsocks table view section. +struct ShadowsocksSectionHandler { + let tableStyle: UITableView.Style + let subject: CurrentValueSubject + + func configure(_ cell: UITableViewCell, itemIdentifier: ShadowsocksItemIdentifier) { + switch itemIdentifier { + case .server: + configureServer(cell, itemIdentifier: itemIdentifier) + case .port: + configurePort(cell, itemIdentifier: itemIdentifier) + case .password: + configurePassword(cell, itemIdentifier: itemIdentifier) + case .cipher: + configureCipher(cell, itemIdentifier: itemIdentifier) + } + } + + func configureServer(_ cell: UITableViewCell, itemIdentifier: ShadowsocksItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .required) + contentConfiguration.inputText = subject.value.shadowsocks.server + contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.shadowsocks.server) + contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() + cell.contentConfiguration = contentConfiguration + } + + func configurePort(_ cell: UITableViewCell, itemIdentifier: ShadowsocksItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .required) + contentConfiguration.inputText = subject.value.shadowsocks.port + contentConfiguration.inputFilter = .digitsOnly + contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.shadowsocks.port) + contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() + if case .phone = cell.traitCollection.userInterfaceIdiom { + contentConfiguration.textFieldProperties.keyboardType = .numberPad + } + cell.contentConfiguration = contentConfiguration + } + + func configurePassword(_ cell: UITableViewCell, itemIdentifier: ShadowsocksItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .optional) + contentConfiguration.inputText = subject.value.shadowsocks.password + contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.shadowsocks.password) + contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() + contentConfiguration.textFieldProperties.isSecureTextEntry = true + contentConfiguration.textFieldProperties.textContentType = .password + cell.contentConfiguration = contentConfiguration + } + + func configureCipher(_ cell: UITableViewCell, itemIdentifier: ShadowsocksItemIdentifier) { + var contentConfiguration = UIListContentConfiguration.mullvadValueCell(tableStyle: tableStyle) + contentConfiguration.text = itemIdentifier.text + contentConfiguration.secondaryText = "\(subject.value.shadowsocks.cipher)" + cell.contentConfiguration = contentConfiguration + + if let cell = cell as? CustomCellDisclosureHandling { + cell.disclosureType = .chevron + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/SocksItemIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/SocksItemIdentifier.swift new file mode 100644 index 000000000000..fd8c1bde5e14 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/SocksItemIdentifier.swift @@ -0,0 +1,63 @@ +// +// SocksItemIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Item identifier used by diffable data sources implementing socks configuration. +enum SocksItemIdentifier: Hashable, CaseIterable { + case server + case port + case authentication + case username + case password + + /// Compute item identifiers that should be present in the diffable data source. + /// + /// - Parameter authenticate: whether user opt-in for socks proxy authentication. + /// - Returns: item identifiers to display in the diffable data source. + static func allCases(authenticate: Bool) -> [Self] { + allCases.filter { itemIdentifier in + if authenticate { + return true + } else { + return itemIdentifier != .username && itemIdentifier != .password + } + } + } + + /// Returns cell identifier for the item identiifer. + var cellIdentifier: AccessMethodCellReuseIdentifier { + switch self { + case .server, .username, .password, .port: + .textInput + case .authentication: + .toggle + } + } + + /// Indicates whether cell representing the item should be selectable. + var isSelectable: Bool { + false + } + + /// The text describing the item identifier and suitable to be used as a field label. + var text: String { + switch self { + case .server: + NSLocalizedString("SOCKS_SERVER", tableName: "APIAccess", value: "Server", comment: "") + case .port: + NSLocalizedString("SOCKS_PORT", tableName: "APIAccess", value: "Port", comment: "") + case .authentication: + NSLocalizedString("SOCKS_AUTHENTICATION", tableName: "APIAccess", value: "Authentication", comment: "") + case .username: + NSLocalizedString("SOCKS_USERNAME", tableName: "APIAccess", value: "Username", comment: "") + case .password: + NSLocalizedString("SOCKS_PASSWORD", tableName: "APIAccess", value: "Password", comment: "") + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/SocksSectionHandler.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/SocksSectionHandler.swift new file mode 100644 index 000000000000..7795102e9747 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Common/SocksSectionHandler.swift @@ -0,0 +1,86 @@ +// +// SocksSectionHandler.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import UIKit + +/// Type responsible for handling cells in socks table view section. +struct SocksSectionHandler { + let tableStyle: UITableView.Style + let subject: CurrentValueSubject + + func configure(_ cell: UITableViewCell, itemIdentifier: SocksItemIdentifier) { + switch itemIdentifier { + case .server: + configureServer(cell, itemIdentifier: itemIdentifier) + case .port: + configurePort(cell, itemIdentifier: itemIdentifier) + case .username: + configureUsername(cell, itemIdentifier: itemIdentifier) + case .password: + configurePassword(cell, itemIdentifier: itemIdentifier) + case .authentication: + configureAuthentication(cell, itemIdentifier: itemIdentifier) + } + } + + private func configureServer(_ cell: UITableViewCell, itemIdentifier: SocksItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .required) + contentConfiguration.inputText = subject.value.socks.server + contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() + contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.socks.server) + cell.contentConfiguration = contentConfiguration + } + + private func configurePort(_ cell: UITableViewCell, itemIdentifier: SocksItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .required) + contentConfiguration.inputText = subject.value.socks.port + contentConfiguration.inputFilter = .digitsOnly + contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.socks.port) + contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() + if case .phone = cell.traitCollection.userInterfaceIdiom { + contentConfiguration.textFieldProperties.keyboardType = .numberPad + } + cell.contentConfiguration = contentConfiguration + } + + private func configureAuthentication(_ cell: UITableViewCell, itemIdentifier: SocksItemIdentifier) { + var contentConfiguration = SwitchCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.isOn = subject.value.socks.authenticate + contentConfiguration.onChange = subject.bindSwitchAction(to: \.socks.authenticate) + cell.contentConfiguration = contentConfiguration + } + + private func configureUsername(_ cell: UITableViewCell, itemIdentifier: SocksItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .required) + contentConfiguration.inputText = subject.value.socks.username + contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() + contentConfiguration.textFieldProperties.textContentType = .username + contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.socks.username) + cell.contentConfiguration = contentConfiguration + } + + private func configurePassword(_ cell: UITableViewCell, itemIdentifier: SocksItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .optional) + contentConfiguration.inputText = subject.value.socks.password + contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.socks.password) + contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled() + contentConfiguration.textFieldProperties.isSecureTextEntry = true + contentConfiguration.textFieldProperties.textContentType = .password + cell.contentConfiguration = contentConfiguration + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/CurrentValueSubject+UIActionBindings.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/CurrentValueSubject+UIActionBindings.swift new file mode 100644 index 000000000000..9adb159935b9 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/CurrentValueSubject+UIActionBindings.swift @@ -0,0 +1,36 @@ +// +// Binding.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import UIKit + +extension CurrentValueSubject { + /// Creates `UIAction` that automatically updates the value from text field. + /// + /// - Parameter keyPath: the key path to the field that should be updated. + /// - Returns: an instance of `UIAction`. + func bindTextAction(to keyPath: WritableKeyPath) -> UIAction { + UIAction { action in + guard let textField = action.sender as? UITextField else { return } + + self.value[keyPath: keyPath] = textField.text ?? "" + } + } + + /// Creates `UIAction` that automatically updates the value from input from a switch control. + /// + /// - Parameter keyPath: the key path to the field that should be updated. + /// - Returns: an instance of `UIAction`. + func bindSwitchAction(to keyPath: WritableKeyPath) -> UIAction { + UIAction { action in + guard let toggle = action.sender as? UISwitch else { return } + + self.value[keyPath: keyPath] = toggle.isOn + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodCoordinator.swift new file mode 100644 index 000000000000..92d3a7074b23 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodCoordinator.swift @@ -0,0 +1,89 @@ +// +// EditAccessMethodCoordinator.swift +// MullvadVPN +// +// Created by pronebird on 21/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Routing +import UIKit + +class EditAccessMethodCoordinator: Coordinator { + let navigationController: UINavigationController + let subject: CurrentValueSubject = .init(AccessMethodViewModel()) + let accessMethodRepo: AccessMethodRepositoryProtocol + let proxyConfigurationTester: ProxyConfigurationTester + let methodIdentifier: UUID + + var onFinish: ((EditAccessMethodCoordinator) -> Void)? + + init( + navigationController: UINavigationController, + accessMethodRepo: AccessMethodRepositoryProtocol, + proxyConfigurationTester: ProxyConfigurationTester, + methodIdentifier: UUID + ) { + self.navigationController = navigationController + self.accessMethodRepo = accessMethodRepo + self.proxyConfigurationTester = proxyConfigurationTester + self.methodIdentifier = methodIdentifier + } + + func start() { + guard let persistentMethod = accessMethodRepo.fetch(by: methodIdentifier) else { return } + + subject.value = persistentMethod.toViewModel() + + let interactor = EditAccessMethodInteractor( + subject: subject, + repo: accessMethodRepo, + proxyConfigurationTester: proxyConfigurationTester + ) + let controller = EditAccessMethodViewController(subject: subject, interactor: interactor) + controller.delegate = self + + navigationController.pushViewController(controller, animated: true) + } +} + +extension EditAccessMethodCoordinator: EditAccessMethodViewControllerDelegate { + func controllerDidSaveAccessMethod(_ controller: EditAccessMethodViewController) { + onFinish?(self) + } + + func controllerShouldShowProxyConfiguration(_ controller: EditAccessMethodViewController) { + let interactor = EditAccessMethodInteractor( + subject: subject, + repo: accessMethodRepo, + proxyConfigurationTester: proxyConfigurationTester + ) + let controller = ProxyConfigurationViewController(subject: subject, interactor: interactor) + controller.delegate = self + + navigationController.pushViewController(controller, animated: true) + } + + func controllerDidDeleteAccessMethod(_ controller: EditAccessMethodViewController) { + onFinish?(self) + } +} + +extension EditAccessMethodCoordinator: ProxyConfigurationViewControllerDelegate { + func controllerShouldShowProtocolPicker(_ controller: ProxyConfigurationViewController) { + let picker = AccessMethodProtocolPicker(navigationController: navigationController) + + picker.present(currentValue: subject.value.method) { [weak self] newMethod in + self?.subject.value.method = newMethod + } + } + + func controllerShouldShowShadowsocksCipherPicker(_ controller: ProxyConfigurationViewController) { + let picker = ShadowsocksCipherPicker(navigationController: navigationController) + + picker.present(currentValue: subject.value.shadowsocks.cipher) { [weak self] selectedCipher in + self?.subject.value.shadowsocks.cipher = selectedCipher + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractor.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractor.swift new file mode 100644 index 000000000000..ebecc582c013 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractor.swift @@ -0,0 +1,47 @@ +// +// EditAccessMethodInteractor.swift +// MullvadVPN +// +// Created by pronebird on 23/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation + +struct EditAccessMethodInteractor: EditAccessMethodInteractorProtocol { + let subject: CurrentValueSubject + let repo: AccessMethodRepositoryProtocol + let proxyConfigurationTester: ProxyConfigurationTesterProtocol + + func saveAccessMethod() { + guard let persistentMethod = try? subject.value.intoPersistentAccessMethod() else { return } + + repo.update(persistentMethod) + } + + func deleteAccessMethod() { + repo.delete(id: subject.value.id) + } + + func startProxyConfigurationTest(_ completion: ((Bool) -> Void)?) { + guard let config = try? subject.value.intoPersistentProxyConfiguration() else { return } + + let subject = subject + subject.value.testingStatus = .inProgress + + proxyConfigurationTester.start(configuration: config) { error in + let succeeded = error == nil + + subject.value.testingStatus = succeeded ? .succeeded : .failed + + completion?(succeeded) + } + } + + func cancelProxyConfigurationTest() { + subject.value.testingStatus = .initial + + proxyConfigurationTester.cancel() + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractorProtocol.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractorProtocol.swift new file mode 100644 index 000000000000..57c383c8bc80 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodInteractorProtocol.swift @@ -0,0 +1,26 @@ +// +// EditAccessMethodInteractorProtocol.swift +// MullvadVPN +// +// Created by pronebird on 23/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +/// The type implementing the interface for persisting changes to the underlying access method view model in the editing context. +protocol EditAccessMethodInteractorProtocol: ProxyConfigurationInteractorProtocol { + /// Save changes to persistent store. + /// + /// - Calling this method when the underlying view model fails validation does nothing. + /// - View controllers are responsible to validate the view model before calling this method. + func saveAccessMethod() + + /// Delete the access method from persistent store. + /// + /// - Calling this method multiple times does nothing. + /// - View model does not have to pass validation for this method to work as the identifier field is the only requirement. + /// - View controller presenting the UI for editing the access method must be dismissed after calling this method. + func deleteAccessMethod() +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodItemIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodItemIdentifier.swift new file mode 100644 index 000000000000..a45ebdcb714c --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodItemIdentifier.swift @@ -0,0 +1,72 @@ +// +// EditAccessMethodItemIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 17/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum EditAccessMethodItemIdentifier: Hashable { + case name + case useIfAvailable + case proxyConfiguration + case testMethod + case testingStatus + case deleteMethod + + /// Cell identifier for the item identifier. + var cellIdentifier: AccessMethodCellReuseIdentifier { + switch self { + case .name: + .textInput + case .useIfAvailable: + .toggle + case .proxyConfiguration: + .textWithDisclosure + case .testMethod, .deleteMethod: + .button + case .testingStatus: + .testingStatus + } + } + + /// Returns `true` if the cell background should be made transparent. + var isClearBackground: Bool { + switch self { + case .testMethod, .testingStatus, .deleteMethod: + return true + case .name, .useIfAvailable, .proxyConfiguration: + return false + } + } + + /// Whether cell representing the item should be selectable. + var isSelectable: Bool { + switch self { + case .name, .useIfAvailable, .testMethod, .testingStatus, .deleteMethod: + false + case .proxyConfiguration: + true + } + } + + /// The text label for the corresponding cell. + var text: String? { + switch self { + case .name: + NSLocalizedString("NAME", tableName: "APIAccess", value: "Name", comment: "") + case .useIfAvailable: + NSLocalizedString("USE_IF_AVAILABLE", tableName: "APIAccess", value: "Use if available", comment: "") + case .proxyConfiguration: + NSLocalizedString("PROXY_CONFIGURATION", tableName: "APIAccess", value: "Proxy configuration", comment: "") + case .testMethod: + NSLocalizedString("TEST_METHOD", tableName: "APIAccess", value: "Test method", comment: "") + case .testingStatus: + nil + case .deleteMethod: + NSLocalizedString("DELETE_METHOD", tableName: "APIAccess", value: "Delete method", comment: "") + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodSectionIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodSectionIdentifier.swift new file mode 100644 index 000000000000..0f62f94041f0 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodSectionIdentifier.swift @@ -0,0 +1,49 @@ +// +// EditAccessMethodSectionIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 17/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum EditAccessMethodSectionIdentifier: Hashable { + case name + case testMethod + case useIfAvailable + case proxyConfiguration + case deleteMethod + + /// The section footer text. + var sectionFooter: String? { + switch self { + case .name, .deleteMethod: + nil + + case .testMethod: + NSLocalizedString( + "TEST_METHOD_FOOTER", + tableName: "APIAccess", + value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", + comment: "" + ) + + case .useIfAvailable: + NSLocalizedString( + "USE_IF_AVAILABLE_FOOTER", + tableName: "APIAccess", + value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", + comment: "" + ) + + case .proxyConfiguration: + NSLocalizedString( + "PROXY_CONFIGURATION_FOOTER", + tableName: "APIAccess", + value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", + comment: "" + ) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewController.swift new file mode 100644 index 000000000000..d961906925ed --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewController.swift @@ -0,0 +1,298 @@ +// +// EditAccessMethodViewController.swift +// MullvadVPN +// +// Created by pronebird on 17/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import UIKit + +/// The view controller providing the interface for editing the existing access method. +class EditAccessMethodViewController: UITableViewController { + private let subject: CurrentValueSubject + private var validationError: AccessMethodValidationError? + private let interactor: EditAccessMethodInteractorProtocol + private var cancellables = Set() + private var dataSource: UITableViewDiffableDataSource< + EditAccessMethodSectionIdentifier, + EditAccessMethodItemIdentifier + >? + private lazy var saveBarButton: UIBarButtonItem = { + let barButton = UIBarButtonItem(systemItem: .save, primaryAction: UIAction { [weak self] _ in + self?.onSave() + }) + barButton.style = .done + return barButton + }() + + weak var delegate: EditAccessMethodViewControllerDelegate? + + init(subject: CurrentValueSubject, interactor: EditAccessMethodInteractorProtocol) { + self.subject = subject + self.interactor = interactor + super.init(style: .insetGrouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .secondaryColor + tableView.backgroundColor = .secondaryColor + + configureDataSource() + configureNavigationItem() + } + + override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) else { return false } + + return itemIdentifier.isSelectable + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) else { return } + + if case .proxyConfiguration = itemIdentifier { + delegate?.controllerShouldShowProxyConfiguration(self) + } + } + + override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard let sectionIdentifier = dataSource?.snapshot().sectionIdentifiers[section] else { return nil } + guard let sectionFooterText = sectionIdentifier.sectionFooter else { return nil } + + guard let headerView = tableView + .dequeueReusableView(withIdentifier: AccessMethodHeaderFooterReuseIdentifier.primary) + else { return nil } + + var contentConfiguration = UIListContentConfiguration.mullvadGroupedFooter() + contentConfiguration.text = sectionFooterText + + headerView.contentConfiguration = contentConfiguration + + return headerView + } + + // MARK: - Cell configuration + + private func dequeueCell(at indexPath: IndexPath, for itemIdentifier: EditAccessMethodItemIdentifier) + -> UITableViewCell { + let cell = tableView.dequeueReusableView(withIdentifier: itemIdentifier.cellIdentifier, for: indexPath) + + configureBackground(cell: cell, itemIdentifier: itemIdentifier) + + switch itemIdentifier { + case .name: + configureName(cell, itemIdentifier: itemIdentifier) + case .testMethod: + configureTestMethod(cell, itemIdentifier: itemIdentifier) + case .testingStatus: + configureTestingStatus(cell, itemIdentifier: itemIdentifier) + case .deleteMethod: + configureDeleteMethod(cell, itemIdentifier: itemIdentifier) + case .useIfAvailable: + configureUseIfAvailable(cell, itemIdentifier: itemIdentifier) + case .proxyConfiguration: + configureProxyConfiguration(cell, itemIdentifier: itemIdentifier) + } + + return cell + } + + private func configureBackground(cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + guard let cell = cell as? DynamicBackgroundConfiguration else { return } + + guard !itemIdentifier.isClearBackground else { + cell.setAutoAdaptingClearBackgroundConfiguration() + return + } + + var backgroundConfiguration = UIBackgroundConfiguration.mullvadListGroupedCell() + + if case .proxyConfiguration = itemIdentifier, let validationError, + validationError.containsProxyConfigurationErrors(selectedMethod: subject.value.method) { + backgroundConfiguration.applyValidationErrorStyle() + } + + cell.setAutoAdaptingBackgroundConfiguration(backgroundConfiguration, selectionType: .dimmed) + } + + private func configureName(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + var contentConfiguration = TextCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.setPlaceholder(type: .optional) + contentConfiguration.textFieldProperties = .withAutoResignAndDoneReturnKey() + contentConfiguration.inputText = subject.value.name + contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.name) + cell.contentConfiguration = contentConfiguration + } + + private func configureTestMethod(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + var contentConfiguration = ButtonCellContentConfiguration() + contentConfiguration.style = .tableInsetGroupedSuccess + contentConfiguration.text = itemIdentifier.text + contentConfiguration.isEnabled = subject.value.testingStatus != .inProgress + contentConfiguration.primaryAction = UIAction { [weak self] _ in + self?.onTest() + } + cell.contentConfiguration = contentConfiguration + } + + private func configureTestingStatus(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + var contentConfiguration = MethodTestingStatusCellContentConfiguration() + contentConfiguration.sheetConfiguration = .init(status: subject.value.testingStatus.sheetStatus) + cell.contentConfiguration = contentConfiguration + } + + private func configureUseIfAvailable(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + var contentConfiguration = SwitchCellContentConfiguration() + contentConfiguration.text = itemIdentifier.text + contentConfiguration.isOn = subject.value.isEnabled + contentConfiguration.onChange = subject.bindSwitchAction(to: \.isEnabled) + cell.contentConfiguration = contentConfiguration + } + + private func configureProxyConfiguration(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + var contentConfiguration = UIListContentConfiguration.mullvadCell(tableStyle: tableView.style) + contentConfiguration.text = itemIdentifier.text + cell.contentConfiguration = contentConfiguration + + if let cell = cell as? CustomCellDisclosureHandling { + cell.disclosureType = .chevron + } + } + + private func configureDeleteMethod(_ cell: UITableViewCell, itemIdentifier: EditAccessMethodItemIdentifier) { + var contentConfiguration = ButtonCellContentConfiguration() + contentConfiguration.style = .tableInsetGroupedDanger + contentConfiguration.text = itemIdentifier.text + contentConfiguration.primaryAction = UIAction { [weak self] _ in + self?.onDelete() + } + cell.contentConfiguration = contentConfiguration + } + + // MARK: - Data source handling + + private func configureDataSource() { + tableView.registerReusableViews(from: AccessMethodCellReuseIdentifier.self) + tableView.registerReusableViews(from: AccessMethodHeaderFooterReuseIdentifier.self) + + dataSource = UITableViewDiffableDataSource( + tableView: tableView, + cellProvider: { [weak self] _, indexPath, itemIdentifier in + self?.dequeueCell(at: indexPath, for: itemIdentifier) + } + ) + subject.withPreviousValue() + .sink { [weak self] previousValue, newValue in + self?.viewModelDidChange(previousValue: previousValue, newValue: newValue) + } + .store(in: &cancellables) + } + + private func viewModelDidChange(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel) { + let animated = view.window != nil + let previousValidationError = validationError + + validateViewModel() + updateBarButtons() + updateDataSource( + previousValue: previousValue, + newValue: newValue, + previousValidationError: previousValidationError, + newValidationError: validationError, + animated: animated + ) + } + + private func updateDataSource( + previousValue: AccessMethodViewModel?, + newValue: AccessMethodViewModel, + previousValidationError: AccessMethodValidationError?, + newValidationError: AccessMethodValidationError?, + animated: Bool + ) { + var snapshot = NSDiffableDataSourceSnapshot() + + // Add name field for user-defined access methods. + if !newValue.method.isPermanent { + snapshot.appendSections([.name]) + snapshot.appendItems([.name], toSection: .name) + } + + // Add static sections. + snapshot.appendSections([.testMethod, .useIfAvailable]) + + snapshot.appendItems([.testMethod], toSection: .testMethod) + // Reconfigure the test button on status changes. + if let previousValue, previousValue.testingStatus != newValue.testingStatus { + snapshot.reconfigureOrReloadItems([.testMethod]) + } + + // Add test status below the test button. + if newValue.testingStatus != .initial { + snapshot.appendItems([.testingStatus], toSection: .testMethod) + if let previousValue, previousValue.testingStatus != newValue.testingStatus { + snapshot.reconfigureOrReloadItems([.testingStatus]) + } + } + + snapshot.appendItems([.useIfAvailable], toSection: .useIfAvailable) + + // Add proxy configuration if the access method is configurable. + if newValue.method.hasProxyConfiguration { + snapshot.appendSections([.proxyConfiguration]) + snapshot.appendItems([.proxyConfiguration], toSection: .proxyConfiguration) + + // Reconfigure the proxy configuration cell if validation error changed. + if previousValidationError != newValidationError { + snapshot.reconfigureOrReloadItems([.proxyConfiguration]) + } + } + + // Add delete button for user-defined access methods. + if !newValue.method.isPermanent { + snapshot.appendSections([.deleteMethod]) + snapshot.appendItems([.deleteMethod], toSection: .deleteMethod) + } + + dataSource?.apply(snapshot, animatingDifferences: animated) + } + + // MARK: - Misc + + private func configureNavigationItem() { + navigationItem.title = subject.value.navigationItemTitle + navigationItem.rightBarButtonItem = saveBarButton + } + + private func validateViewModel() { + let validationResult = Result { try subject.value.validate() } + validationError = validationResult.error as? AccessMethodValidationError + } + + private func updateBarButtons() { + saveBarButton.isEnabled = validationError == nil + } + + private func onDelete() { + interactor.deleteAccessMethod() + delegate?.controllerDidDeleteAccessMethod(self) + } + + private func onSave() { + interactor.saveAccessMethod() + delegate?.controllerDidSaveAccessMethod(self) + } + + private func onTest() { + interactor.startProxyConfigurationTest() + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewControllerDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewControllerDelegate.swift new file mode 100644 index 000000000000..29e0dc48687a --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/EditAccessMethodViewControllerDelegate.swift @@ -0,0 +1,29 @@ +// +// EditAccessMethodViewControllerDelegate.swift +// MullvadVPN +// +// Created by pronebird on 23/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +protocol EditAccessMethodViewControllerDelegate: AnyObject { + /// The view controller requests the delegate to present the proxy configuration view controller. + /// - Parameter controller: the calling controller. + func controllerShouldShowProxyConfiguration(_ controller: EditAccessMethodViewController) + + /// The view controller deleted the access method. + /// + /// The delegate should consider dismissing the view controller. + /// + /// - Parameter controller: the calling controller. + func controllerDidDeleteAccessMethod(_ controller: EditAccessMethodViewController) + + /// The view controller saved changes to the access method. + /// + /// The delegate should consider dismissing the view controller. + /// + /// - Parameter controller: the calling controller. + func controllerDidSaveAccessMethod(_ controller: EditAccessMethodViewController) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationItemIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationItemIdentifier.swift new file mode 100644 index 000000000000..68f061584e1d --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationItemIdentifier.swift @@ -0,0 +1,54 @@ +// +// ProxyConfigurationItemIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 22/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum ProxyConfigurationItemIdentifier: Hashable { + case `protocol` + case proxyConfiguration(ProxyProtocolConfigurationItemIdentifier) + + /// Returns all shadowsocks items wrapped into `ProxyConfigurationItemIdentifier.proxyConfiguration`. + static var allShadowsocksItems: [ProxyConfigurationItemIdentifier] { + ShadowsocksItemIdentifier.allCases.map { .proxyConfiguration(.shadowsocks($0)) } + } + + /// Returns all socks items wrapped into `ProxyConfigurationItemIdentifier.proxyConfiguration`. + static func allSocksItems(authenticate: Bool) -> [ProxyConfigurationItemIdentifier] { + SocksItemIdentifier.allCases(authenticate: authenticate).map { .proxyConfiguration(.socks($0)) } + } + + /// Cell identifiers for the item identifier. + var cellIdentifier: AccessMethodCellReuseIdentifier { + switch self { + case .protocol: + .textWithDisclosure + case let .proxyConfiguration(itemIdentifier): + itemIdentifier.cellIdentifier + } + } + + /// Indicates whether cell representing the item should be selectable. + var isSelectable: Bool { + switch self { + case .protocol: + true + case let .proxyConfiguration(itemIdentifier): + itemIdentifier.isSelectable + } + } + + /// The text label for the corresponding cell. + var text: String? { + switch self { + case .protocol: + NSLocalizedString("TYPE", tableName: "APIAccess", value: "Type", comment: "") + case .proxyConfiguration: + nil + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationSectionIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationSectionIdentifier.swift new file mode 100644 index 000000000000..7c02cb33ad7a --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationSectionIdentifier.swift @@ -0,0 +1,33 @@ +// +// ProxyConfigurationSectionIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 21/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum ProxyConfigurationSectionIdentifier: Hashable { + case `protocol` + case proxyConfiguration + + var sectionName: String? { + switch self { + case .protocol: + NSLocalizedString( + "PROTOCOL_SECTION_TITLE", + tableName: "APIAccess", + value: "Protocol", + comment: "" + ) + case .proxyConfiguration: + NSLocalizedString( + "HOST_CONFIG_SECTION_TITLE", + tableName: "APIAccess", + value: "Host configuration", + comment: "" + ) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewController.swift new file mode 100644 index 000000000000..63ce73365e66 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewController.swift @@ -0,0 +1,313 @@ +// +// ProxyConfigurationViewController.swift +// MullvadVPN +// +// Created by pronebird on 21/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import UIKit + +/// The view controller providing the interface for editing and testing the proxy configuration. +class ProxyConfigurationViewController: UIViewController, UITableViewDelegate { + private let subject: CurrentValueSubject + private let interactor: ProxyConfigurationInteractorProtocol + private var cancellables = Set() + + private var dataSource: UITableViewDiffableDataSource< + ProxyConfigurationSectionIdentifier, + ProxyConfigurationItemIdentifier + >? + private lazy var testBarButton: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem( + title: NSLocalizedString("TEST_NAVIGATION_BUTTON", tableName: "APIAccess", value: "Test", comment: ""), + primaryAction: UIAction { [weak self] _ in + self?.onTest() + } + ) + barButtonItem.style = .done + return barButtonItem + }() + + private let contentController = UITableViewController(style: .insetGrouped) + private var tableView: UITableView { + contentController.tableView + } + + private lazy var sheetPresentation: AccessMethodActionSheetPresentation = { + let sheetPresentation = AccessMethodActionSheetPresentation() + sheetPresentation.delegate = self + return sheetPresentation + }() + + weak var delegate: ProxyConfigurationViewControllerDelegate? + + init(subject: CurrentValueSubject, interactor: ProxyConfigurationInteractorProtocol) { + self.subject = subject + self.interactor = interactor + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.directionalLayoutMargins = UIMetrics.contentLayoutMargins + view.backgroundColor = .secondaryColor + + configureTableView() + configureNavigationItem() + configureDataSource() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + updateTableSafeAreaInsets() + } + + // MARK: - UITableViewDelegate + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let sectionIdentifier = dataSource?.snapshot().sectionIdentifiers[section] else { return nil } + + guard let headerView = tableView + .dequeueReusableView(withIdentifier: AccessMethodHeaderFooterReuseIdentifier.primary) + else { return nil } + + var contentConfiguration = UIListContentConfiguration.mullvadGroupedHeader() + contentConfiguration.text = sectionIdentifier.sectionName + + headerView.contentConfiguration = contentConfiguration + + return headerView + } + + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath), + itemIdentifier.isSelectable else { return nil } + + return indexPath + } + + func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + guard let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) else { return false } + + return itemIdentifier.isSelectable + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let itemIdentifier = dataSource?.itemIdentifier(for: indexPath) + + switch itemIdentifier { + case .protocol: + showProtocolSelector() + case .proxyConfiguration(.shadowsocks(.cipher)): + showShadowsocksCipher() + default: + break + } + } + + // MARK: - Pickers handling + + private func showProtocolSelector() { + view.endEditing(false) + delegate?.controllerShouldShowProtocolPicker(self) + } + + private func showShadowsocksCipher() { + view.endEditing(false) + delegate?.controllerShouldShowShadowsocksCipherPicker(self) + } + + // MARK: - Cell configuration + + private func dequeueCell(at indexPath: IndexPath, for itemIdentifier: ProxyConfigurationItemIdentifier) + -> UITableViewCell { + let cell = tableView.dequeueReusableView(withIdentifier: itemIdentifier.cellIdentifier, for: indexPath) + + if let cell = cell as? DynamicBackgroundConfiguration { + cell.setAutoAdaptingBackgroundConfiguration(.mullvadListGroupedCell(), selectionType: .dimmed) + } + + switch itemIdentifier { + case .protocol: + configureProtocol(cell, itemIdentifier: itemIdentifier) + case let .proxyConfiguration(proxyItemIdentifier): + configureProxy(cell, itemIdentifier: proxyItemIdentifier) + } + + return cell + } + + private func configureProxy(_ cell: UITableViewCell, itemIdentifier: ProxyProtocolConfigurationItemIdentifier) { + switch itemIdentifier { + case let .socks(socksItemIdentifier): + let section = SocksSectionHandler(tableStyle: tableView.style, subject: subject) + section.configure(cell, itemIdentifier: socksItemIdentifier) + + case let .shadowsocks(shadowsocksItemIdentifier): + let section = ShadowsocksSectionHandler(tableStyle: tableView.style, subject: subject) + section.configure(cell, itemIdentifier: shadowsocksItemIdentifier) + } + } + + private func configureProtocol(_ cell: UITableViewCell, itemIdentifier: ProxyConfigurationItemIdentifier) { + var contentConfiguration = UIListContentConfiguration.mullvadValueCell(tableStyle: tableView.style) + contentConfiguration.text = itemIdentifier.text + contentConfiguration.secondaryText = subject.value.method.localizedDescription + cell.contentConfiguration = contentConfiguration + + if let cell = cell as? CustomCellDisclosureHandling { + cell.disclosureType = .chevron + } + } + + // MARK: - Data source handling + + private func configureDataSource() { + tableView.registerReusableViews(from: AccessMethodCellReuseIdentifier.self) + tableView.registerReusableViews(from: AccessMethodHeaderFooterReuseIdentifier.self) + + dataSource = UITableViewDiffableDataSource( + tableView: tableView, + cellProvider: { [weak self] _, indexPath, itemIdentifier in + self?.dequeueCell(at: indexPath, for: itemIdentifier) + } + ) + + subject.withPreviousValue() + .sink { [weak self] previousValue, newValue in + self?.viewModelDidChange(previousValue: previousValue, newValue: newValue) + } + .store(in: &cancellables) + } + + private func viewModelDidChange(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel) { + let animated = view.window != nil + + updateDataSource(previousValue: previousValue, newValue: newValue, animated: animated) + updateSheet(previousValue: previousValue, newValue: newValue, animated: animated) + validate() + } + + private func updateSheet(previousValue: AccessMethodViewModel?, newValue: AccessMethodViewModel, animated: Bool) { + guard previousValue?.testingStatus != newValue.testingStatus else { return } + + switch newValue.testingStatus { + case .initial: + sheetPresentation.hide(animated: animated) + + case .inProgress, .failed, .succeeded: + var presentationConfiguration = AccessMethodActionSheetPresentationConfiguration() + presentationConfiguration.dimsBackground = newValue.testingStatus == .inProgress + presentationConfiguration.sheetConfiguration.context = .proxyConfiguration + presentationConfiguration.sheetConfiguration.contentConfiguration.status = newValue.testingStatus + .sheetStatus + sheetPresentation.configuration = presentationConfiguration + + sheetPresentation.show(in: view, animated: animated) + } + } + + private func updateDataSource( + previousValue: AccessMethodViewModel?, + newValue: AccessMethodViewModel, + animated: Bool + ) { + var snapshot = NSDiffableDataSourceSnapshot< + ProxyConfigurationSectionIdentifier, + ProxyConfigurationItemIdentifier + >() + + snapshot.appendSections([.protocol]) + snapshot.appendItems([.protocol], toSection: .protocol) + // Reconfigure protocol cell on change. + if let previousValue, previousValue.method != newValue.method { + snapshot.reconfigureOrReloadItems([.protocol]) + } + + // Add proxy configuration section if the access method is configurable. + if newValue.method.hasProxyConfiguration { + snapshot.appendSections([.proxyConfiguration]) + } + + switch newValue.method { + case .direct, .bridges: + break + + case .shadowsocks: + snapshot.appendItems(ProxyConfigurationItemIdentifier.allShadowsocksItems, toSection: .proxyConfiguration) + // Reconfigure cipher cell on change. + if let previousValue, previousValue.shadowsocks.cipher != newValue.shadowsocks.cipher { + snapshot.reconfigureOrReloadItems([.proxyConfiguration(.shadowsocks(.cipher))]) + } + + case .socks5: + snapshot.appendItems( + ProxyConfigurationItemIdentifier.allSocksItems(authenticate: newValue.socks.authenticate), + toSection: .proxyConfiguration + ) + } + + dataSource?.apply(snapshot, animatingDifferences: animated) + } + + private func validate() { + let validationResult = Result { try subject.value.validate() } + testBarButton.isEnabled = validationResult.isSuccess && subject.value.testingStatus != .inProgress + } + + // MARK: - Misc + + private func configureTableView() { + tableView.delegate = self + tableView.backgroundColor = .secondaryColor + + view.addConstrainedSubviews([tableView]) { + tableView.pinEdgesToSuperview() + } + + addChild(contentController) + contentController.didMove(toParent: self) + } + + private func configureNavigationItem() { + navigationItem.title = NSLocalizedString( + "PROXY_CONFIGURATION_NAVIGATION_TITLE", + tableName: "APIAccess", + value: "Proxy configuration", + comment: "" + ) + navigationItem.rightBarButtonItem = testBarButton + } + + /// Update table view controller safe area to make space for the sheet at the bottom. + private func updateTableSafeAreaInsets() { + let sheetHeight = sheetPresentation.isPresenting ? sheetPresentation.sheetLayoutFrame.height : 0 + var insets = contentController.additionalSafeAreaInsets + // Prevent mutating insets if they haven't changed, in case UIKit doesn't filter duplicates. + if insets.bottom != sheetHeight { + insets.bottom = sheetHeight + contentController.additionalSafeAreaInsets = insets + } + } + + private func onTest() { + view.endEditing(true) + interactor.startProxyConfigurationTest() + } +} + +extension ProxyConfigurationViewController: AccessMethodActionSheetPresentationDelegate { + func sheetDidAdd(sheetPresentation: AccessMethodActionSheetPresentation) {} + + func sheetDidCancel(sheetPresentation: AccessMethodActionSheetPresentation) { + interactor.cancelProxyConfigurationTest() + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewControllerDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewControllerDelegate.swift new file mode 100644 index 000000000000..4948b7d4c351 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfiguration/ProxyConfigurationViewControllerDelegate.swift @@ -0,0 +1,14 @@ +// +// ProxyConfigurationViewControllerDelegate.swift +// MullvadVPN +// +// Created by pronebird on 23/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +protocol ProxyConfigurationViewControllerDelegate: AnyObject { + func controllerShouldShowProtocolPicker(_ controller: ProxyConfigurationViewController) + func controllerShouldShowShadowsocksCipherPicker(_ controller: ProxyConfigurationViewController) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfigurationInteractorProtocol.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfigurationInteractorProtocol.swift new file mode 100644 index 000000000000..cd7d24d2ae96 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfigurationInteractorProtocol.swift @@ -0,0 +1,33 @@ +// +// ProxyConfigurationInteractorProtocol.swift +// MullvadVPN +// +// Created by pronebird on 23/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +/// The type implementing the facilities for testing proxy configuration. +protocol ProxyConfigurationInteractorProtocol { + /// Start testing proxy configuration with data from view model. + /// + /// - It's expected that the completion handler is not called if testing is cancelled. + /// - The interactor should update the underlying view model to indicate the progress of testing. The view controller is expected to keep track of that and update + /// the UI accordingly. + /// + /// - Parameter completion: completion handler receiving `true` if the test succeeded, otherwise `false`. + func startProxyConfigurationTest(_ completion: ((Bool) -> Void)?) + + /// Cancel currently running configuration test. + /// The interactor is expected to reset the testing status to the initial. + func cancelProxyConfigurationTest() +} + +extension ProxyConfigurationInteractorProtocol { + /// Start testing proxy configuration with data from view model. + func startProxyConfigurationTest() { + startProxyConfigurationTest(nil) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfigurationSectionIdentifier.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfigurationSectionIdentifier.swift new file mode 100644 index 000000000000..7c02cb33ad7a --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Edit/ProxyConfigurationSectionIdentifier.swift @@ -0,0 +1,33 @@ +// +// ProxyConfigurationSectionIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 21/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum ProxyConfigurationSectionIdentifier: Hashable { + case `protocol` + case proxyConfiguration + + var sectionName: String? { + switch self { + case .protocol: + NSLocalizedString( + "PROTOCOL_SECTION_TITLE", + tableName: "APIAccess", + value: "Protocol", + comment: "" + ) + case .proxyConfiguration: + NSLocalizedString( + "HOST_CONFIG_SECTION_TITLE", + tableName: "APIAccess", + value: "Host configuration", + comment: "" + ) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift new file mode 100644 index 000000000000..7b58a4ae52ee --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodCoordinator.swift @@ -0,0 +1,109 @@ +// +// ListAccessMethodCoordinator.swift +// MullvadVPN +// +// Created by pronebird on 08/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Routing +import UIKit + +class ListAccessMethodCoordinator: Coordinator, Presenting, SettingsChildCoordinator { + let navigationController: UINavigationController + let accessMethodRepo: AccessMethodRepository = .shared + let proxyConfigurationTester: ProxyConfigurationTester = .shared + + var presentationContext: UIViewController { + navigationController + } + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start(animated: Bool) { + let listController = ListAccessMethodViewController( + interactor: ListAccessMethodInteractor(repo: accessMethodRepo) + ) + listController.delegate = self + navigationController.pushViewController(listController, animated: animated) + } + + private func addNew() { + let coordinator = AddAccessMethodCoordinator( + navigationController: CustomNavigationController(), + accessMethodRepo: accessMethodRepo, + proxyConfigurationTester: proxyConfigurationTester + ) + + coordinator.start() + presentChild(coordinator, animated: true) + } + + private func edit(item: ListAccessMethodItem) { + // Remove previous edit coordinator to prevent accumulation. + childCoordinators.filter { $0 is EditAccessMethodCoordinator }.forEach { $0.removeFromParent() } + + let editCoordinator = EditAccessMethodCoordinator( + navigationController: navigationController, + accessMethodRepo: accessMethodRepo, + proxyConfigurationTester: proxyConfigurationTester, + methodIdentifier: item.id + ) + editCoordinator.onFinish = { [weak self] coordinator in + self?.popToList() + coordinator.removeFromParent() + } + editCoordinator.start() + addChild(editCoordinator) + } + + private func popToList() { + guard let listController = navigationController.viewControllers + .first(where: { $0 is ListAccessMethodViewController }) else { return } + + navigationController.popToViewController(listController, animated: true) + } + + private func about() { + // swiftlint:disable line_length + let aboutMarkdown = """ + **What is Lorem Ipsum?** + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. + """ + // swiftlint:enable line_length + + let aboutController = AboutViewController(markdown: aboutMarkdown) + let aboutNavController = UINavigationController(rootViewController: aboutController) + + aboutController.navigationItem.title = NSLocalizedString( + "ABOUT_API_ACCESS_NAV_TITLE", + value: "About API access", + comment: "" + ) + + aboutController.navigationItem.rightBarButtonItem = UIBarButtonItem( + systemItem: .done, + primaryAction: UIAction { [weak aboutNavController] _ in + aboutNavController?.dismiss(animated: true) + } + ) + + navigationController.present(aboutNavController, animated: true) + } +} + +extension ListAccessMethodCoordinator: ListAccessMethodViewControllerDelegate { + func controllerShouldShowAbout(_ controller: ListAccessMethodViewController) { + about() + } + + func controllerShouldAddNew(_ controller: ListAccessMethodViewController) { + addNew() + } + + func controller(_ controller: ListAccessMethodViewController, shouldEditItem item: ListAccessMethodItem) { + edit(item: item) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodHeaderView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodHeaderView.swift new file mode 100644 index 000000000000..039946bb7fd4 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodHeaderView.swift @@ -0,0 +1,114 @@ +// +// ListAccessMethodHeaderView.swift +// MullvadVPN +// +// Created by pronebird on 07/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Header view pinned at the top of ``AccessMethodListViewController``. +class ListAccessMethodHeaderView: UIView, UITextViewDelegate { + /// Event handler invoked when user taps on the link to learn more about API access. + var onAbout: (() -> Void)? + + private let textView = UITextView() + + override init(frame: CGRect) { + super.init(frame: frame) + + textView.backgroundColor = .clear + textView.dataDetectorTypes = .link + textView.isSelectable = true + textView.isEditable = false + textView.isScrollEnabled = false + textView.contentInset = .zero + textView.textContainerInset = .zero + textView.attributedText = makeAttributedString() + textView.linkTextAttributes = defaultLinkAttributes + textView.delegate = self + + directionalLayoutMargins = UIMetrics.contentHeadingLayoutMargins + + addSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private let defaultTextAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 17), + .foregroundColor: UIColor.ContentHeading.textColor, + ] + + private let defaultLinkAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 17), + .foregroundColor: UIColor.ContentHeading.linkColor, + ] + + private func makeAttributedString() -> NSAttributedString { + let body = NSLocalizedString( + "ACCESS_METHOD_HEADER_BODY", + tableName: "APIAccess", + value: "Manage default and setup custom methods to access the Mullvad API.", + comment: "" + ) + let link = NSLocalizedString( + "ACCESS_METHOD_HEADER_LINK", + tableName: "APIAccess", + value: "About API access...", + comment: "" + ) + + var linkAttributes = defaultLinkAttributes + linkAttributes[.link] = "#" + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + + let attributedString = NSMutableAttributedString() + attributedString.append(NSAttributedString(string: body, attributes: defaultTextAttributes)) + attributedString.append(NSAttributedString(string: " ", attributes: defaultTextAttributes)) + attributedString.append(NSAttributedString(string: link, attributes: linkAttributes)) + attributedString.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: NSRange(location: 0, length: attributedString.length) + ) + return attributedString + } + + private func addSubviews() { + addConstrainedSubviews([textView]) { + textView.pinEdgesToSuperviewMargins() + } + } + + func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + onAbout?() + return false + } + + @available(iOS 17.0, *) + func textView(_ textView: UITextView, menuConfigurationFor textItem: UITextItem, defaultMenu: UIMenu) -> UITextItem + .MenuConfiguration? { + return nil + } + + @available(iOS 17.0, *) + func textView(_ textView: UITextView, primaryActionFor textItem: UITextItem, defaultAction: UIAction) -> UIAction? { + if case .link = textItem.content { + return UIAction { [weak self] _ in + self?.onAbout?() + } + } + return nil + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractor.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractor.swift new file mode 100644 index 000000000000..025e35e0389f --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractor.swift @@ -0,0 +1,47 @@ +// +// ListAccessMethodInteractor.swift +// MullvadVPN +// +// Created by pronebird on 02/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation + +/// A concrete implementation of an API access list interactor. +struct ListAccessMethodInteractor: ListAccessMethodInteractorProtocol { + let repo: AccessMethodRepositoryProtocol + + init(repo: AccessMethodRepositoryProtocol) { + self.repo = repo + } + + var publisher: any Publisher<[ListAccessMethodItem], Never> { + repo.publisher.map { newElements in + newElements.map { $0.toListItem() } + } + } + + func item(by id: UUID) -> ListAccessMethodItem? { + repo.fetch(by: id)?.toListItem() + } + + func fetch() -> [ListAccessMethodItem] { + repo.fetchAll().map { $0.toListItem() } + } +} + +extension PersistentAccessMethod { + func toListItem() -> ListAccessMethodItem { + let sanitizedName = name.trimmingCharacters(in: .whitespaces) + let itemName = sanitizedName.isEmpty ? kind.localizedDescription : sanitizedName + let itemDetail = sanitizedName.isEmpty ? nil : kind.localizedDescription + + return ListAccessMethodItem( + id: id, + name: itemName, + detail: itemDetail + ) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractorProtocol.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractorProtocol.swift new file mode 100644 index 000000000000..67d9553256cd --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodInteractorProtocol.swift @@ -0,0 +1,21 @@ +// +// ListAccessMethodInteractorProtocol.swift +// MullvadVPN +// +// Created by pronebird on 02/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation + +/// Types describing API access list interactor. +protocol ListAccessMethodInteractorProtocol { + /// Returns an item by id. + func item(by id: UUID) -> ListAccessMethodItem? + + /// Fetch all items. + func fetch() -> [ListAccessMethodItem] + + var publisher: any Publisher<[ListAccessMethodItem], Never> { get } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodItem.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodItem.swift new file mode 100644 index 000000000000..44367754ce4d --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodItem.swift @@ -0,0 +1,20 @@ +// +// ListAccessMethodItem.swift +// MullvadVPN +// +// Created by pronebird on 02/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// A concrete implementation of an API access list item. +struct ListAccessMethodItem: Hashable, Identifiable, Equatable { + let id: UUID + + /// The localized name of an API method. + let name: String + + /// The detailed information displayed alongside. + let detail: String? +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodViewController.swift new file mode 100644 index 000000000000..29689a7dcd81 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodViewController.swift @@ -0,0 +1,183 @@ +// +// ListAccessMethodViewController.swift +// MullvadVPN +// +// Created by pronebird on 02/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import UIKit + +enum ListAccessMethodSectionIdentifier: Hashable { + case primary +} + +struct ListAccessMethodItemIdentifier: Hashable { + var id: UUID +} + +/// View controller presenting a list of API access methods. +class ListAccessMethodViewController: UIViewController, UITableViewDelegate { + private let headerView = ListAccessMethodHeaderView() + private let interactor: ListAccessMethodInteractorProtocol + private var cancellables = Set() + + private var dataSource: UITableViewDiffableDataSource< + ListAccessMethodSectionIdentifier, + ListAccessMethodItemIdentifier + >? + private var fetchedItems: [ListAccessMethodItem] = [] + private let contentController = UITableViewController(style: .plain) + private var tableView: UITableView { + contentController.tableView + } + + weak var delegate: ListAccessMethodViewControllerDelegate? + + /// Designated initializer. + /// - Parameter interactor: the object implementing access and manipulation of the API access list. + init(interactor: ListAccessMethodInteractorProtocol) { + self.interactor = interactor + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + headerView.onAbout = { [weak self] in + self?.sendAbout() + } + + view.backgroundColor = .secondaryColor + + tableView.delegate = self + tableView.backgroundColor = .secondaryColor + tableView.separatorColor = .secondaryColor + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 60 + + tableView.registerReusableViews(from: CellReuseIdentifier.self) + + view.addConstrainedSubviews([headerView, tableView]) { + headerView.pinEdgesToSuperview(.all().excluding(.bottom)) + tableView.pinEdgesToSuperview(.all().excluding(.top)) + tableView.topAnchor.constraint(equalTo: headerView.bottomAnchor) + } + + addChild(contentController) + contentController.didMove(toParent: self) + + interactor.publisher.sink { newElements in + self.updateDataSource(animated: true) + } + .store(in: &cancellables) + + configureNavigationItem() + configureDataSource() + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let item = fetchedItems[indexPath.row] + sendEdit(item: item) + } + + private func configureNavigationItem() { + navigationItem.title = NSLocalizedString( + "NAVIGATION_TITLE", + tableName: "Settings", + value: "API access", + comment: "" + ) + navigationItem.rightBarButtonItem = UIBarButtonItem( + systemItem: .add, + primaryAction: UIAction(handler: { [weak self] _ in + self?.sendAddNew() + }) + ) + } + + private func configureDataSource() { + dataSource = UITableViewDiffableDataSource( + tableView: tableView, + cellProvider: { [weak self] tableView, indexPath, itemIdentifier in + self?.dequeueCell(at: indexPath, itemIdentifier: itemIdentifier) + } + ) + updateDataSource(animated: false) + } + + private func updateDataSource(animated: Bool = true) { + let oldFetchedItems = fetchedItems + let newFetchedItems = interactor.fetch() + fetchedItems = newFetchedItems + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.primary]) + + let itemIdentifiers = newFetchedItems.map { item in + ListAccessMethodItemIdentifier(id: item.id) + } + snapshot.appendItems(itemIdentifiers, toSection: .primary) + + for newFetchedItem in newFetchedItems { + for oldFetchedItem in oldFetchedItems { + if newFetchedItem.id == oldFetchedItem.id, + newFetchedItem.name != oldFetchedItem.name || newFetchedItem.detail != oldFetchedItem.detail { + snapshot.reloadItems([ListAccessMethodItemIdentifier(id: newFetchedItem.id)]) + } + } + } + + dataSource?.apply(snapshot, animatingDifferences: animated) + } + + private func dequeueCell( + at indexPath: IndexPath, + itemIdentifier: ListAccessMethodItemIdentifier + ) -> UITableViewCell { + let cell = tableView.dequeueReusableView(withIdentifier: CellReuseIdentifier.default, for: indexPath) + let item = fetchedItems[indexPath.row] + + var contentConfiguration = UIListContentConfiguration.mullvadValueCell(tableStyle: .plain) + contentConfiguration.text = item.name + contentConfiguration.secondaryText = item.detail + cell.contentConfiguration = contentConfiguration + + if let cell = cell as? DynamicBackgroundConfiguration { + cell.setAutoAdaptingBackgroundConfiguration(.mullvadListPlainCell(), selectionType: .dimmed) + } + + if let cell = cell as? CustomCellDisclosureHandling { + cell.disclosureType = .chevron + } + + return cell + } + + private func sendAddNew() { + delegate?.controllerShouldAddNew(self) + } + + private func sendAbout() { + delegate?.controllerShouldShowAbout(self) + } + + private func sendEdit(item: ListAccessMethodItem) { + delegate?.controller(self, shouldEditItem: item) + } +} + +private enum CellReuseIdentifier: String, CaseIterable, CellIdentifierProtocol { + case `default` + + var cellClass: AnyClass { + switch self { + case .default: BasicCell.self + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodViewControllerDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodViewControllerDelegate.swift new file mode 100644 index 000000000000..d9c999f128ce --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/List/ListAccessMethodViewControllerDelegate.swift @@ -0,0 +1,28 @@ +// +// ListAccessMethodViewControllerDelegate.swift +// MullvadVPN +// +// Created by pronebird on 23/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +protocol ListAccessMethodViewControllerDelegate: AnyObject { + /// The view controller requests the delegate to present the about view. + /// + /// - Parameter controller: the calling view controller. + func controllerShouldShowAbout(_ controller: ListAccessMethodViewController) + + /// The view controller requests the delegate to present the add new method controller. + /// + /// - Parameter controller: the calling view controller. + func controllerShouldAddNew(_ controller: ListAccessMethodViewController) + + /// The view controller requests the delegate to present the view controller for editing the existing access method. + /// + /// - Parameters: + /// - controller: the calling view controller + /// - item: the selected item. + func controller(_ controller: ListAccessMethodViewController, shouldEditItem item: ListAccessMethodItem) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind+Extensions.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind+Extensions.swift new file mode 100644 index 000000000000..9d60ea9c5c8d --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodKind+Extensions.swift @@ -0,0 +1,36 @@ +// +// AccessMethodKind.swift +// MullvadVPN +// +// Created by pronebird on 02/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension AccessMethodKind { + /// Returns localized description describing the access method. + var localizedDescription: String { + switch self { + case .direct: + NSLocalizedString("DIRECT", tableName: "APIAccess", value: "Direct", comment: "") + case .bridges: + NSLocalizedString("BRIDGES", tableName: "APIAccess", value: "Bridges", comment: "") + case .shadowsocks: + NSLocalizedString("SHADOWSOCKS", tableName: "APIAccess", value: "Shadowsocks", comment: "") + case .socks5: + NSLocalizedString("SOCKS_V5", tableName: "APIAccess", value: "Socks5", comment: "") + } + } + + /// Returns `true` if access method is configurable. + /// Methods that aren't configurable do not offer any additional configuration. + var hasProxyConfiguration: Bool { + switch self { + case .direct, .bridges: + false + case .shadowsocks, .socks5: + true + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError+Helpers.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError+Helpers.swift new file mode 100644 index 000000000000..9f346f9c92a4 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError+Helpers.swift @@ -0,0 +1,26 @@ +// +// AccessMethodValidationError+Helpers.swift +// MullvadVPN +// +// Created by pronebird on 29/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension AccessMethodValidationError { + /// Checks if any of the fields associated with the given access method have validation errors. + /// + /// - Parameter selectedMethod: the access method specified in view model + /// - Returns: `true` if any of the fields associated with the given access method have validation errors, otherwise false. + func containsProxyConfigurationErrors(selectedMethod: AccessMethodKind) -> Bool { + switch selectedMethod { + case .direct, .bridges: + false + case .shadowsocks: + fieldErrors.contains { $0.context == .shadowsocks } + case .socks5: + fieldErrors.contains { $0.context == .socks } + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift new file mode 100644 index 000000000000..1deef271b389 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodValidationError.swift @@ -0,0 +1,83 @@ +// +// AccessMethodValidationError.swift +// MullvadVPN +// +// Created by pronebird on 17/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Access method validation error that holds an array of individual per-field validation errors. +struct AccessMethodValidationError: LocalizedError, Equatable { + /// The list of per-field errors. + let fieldErrors: [AccessMethodFieldValidationError] + + var errorDescription: String? { + if fieldErrors.count > 1 { + "Multiple validation errors occurred." + } else { + fieldErrors.first?.localizedDescription + } + } +} + +/// Access method field validation error. +struct AccessMethodFieldValidationError: LocalizedError, Equatable { + /// Validated field. + enum Field: String, CustomStringConvertible, Equatable { + case server, port, username + + var description: String { + rawValue + } + } + + /// Validated field context. + enum Context: String, CustomStringConvertible, Equatable { + case socks, shadowsocks + + var description: String { + rawValue + } + } + + /// Validation error kind. + enum Kind: Equatable { + /// The evaluated field is empty. + case emptyValue + + /// Failure to parse IP address. + case parseIPAddress + + /// Failure to parse port value. + case parsePort + + /// Invalid port number, i.e zero. + case invalidPort + } + + /// Kind of validation error. + let kind: Kind + + /// Error field. + let field: Field + + /// Validation field context. + let context: Context + + var errorDescription: String? { + var s = "The \(context) \(field) " + switch kind { + case .emptyValue: + s += "cannot be empty." + case .parseIPAddress: + s += "cannot be parsed as IP address." + case .parsePort: + s += "cannot be parsed as a port number." + case .invalidPort: + s += "contains invalid port number." + } + return s + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel+NavigationItem.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel+NavigationItem.swift new file mode 100644 index 000000000000..ac52ed2e3f6e --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel+NavigationItem.swift @@ -0,0 +1,21 @@ +// +// AccessMethodViewModel+NavigationItem.swift +// MullvadVPN +// +// Created by pronebird on 29/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension AccessMethodViewModel { + /// Title suitable for navigation item. + /// User-defined name is preferred unless it's blank, in which case the name of access method is used instead. + var navigationItemTitle: String { + if name.trimmingCharacters(in: .whitespaces).isEmpty { + method.localizedDescription + } else { + name + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel+Persistent.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel+Persistent.swift new file mode 100644 index 000000000000..7a9c2798c39a --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel+Persistent.swift @@ -0,0 +1,184 @@ +// +// AccessMethodViewModel+Persistent.swift +// MullvadVPN +// +// Created by pronebird on 15/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes +import Network + +extension AccessMethodViewModel { + /// Validate view model. Throws on failure. + /// + /// - Throws: an instance of ``AccessMethodValidationError``. + func validate() throws { + _ = try intoPersistentAccessMethod() + } + + /// Transform view model into persistent model that can be used with ``AccessMethodRepository``. + /// + /// - Throws: an instance of ``AccessMethodValidationError``. + /// - Returns: an instance of ``PersistentAccessMethod``. + func intoPersistentAccessMethod() throws -> PersistentAccessMethod { + return PersistentAccessMethod( + id: id, + name: name, + isEnabled: isEnabled, + proxyConfiguration: try intoPersistentProxyConfiguration() + ) + } + + /// Transform view model's proxy configuration into persistent configuration that can be used with ``AccessMethodRepository``. + /// + /// - Throws: an instance of ``AccessMethodValidationError``. + /// - Returns: an instance of ``PersistentProxyConfiguration``. + func intoPersistentProxyConfiguration() throws -> PersistentProxyConfiguration { + switch method { + case .direct: + .direct + case .bridges: + .bridges + case .socks5: + try socks.intoPersistentProxyConfiguration() + case .shadowsocks: + try shadowsocks.intoPersistentProxyConfiguration() + } + } +} + +extension AccessMethodViewModel.Socks { + /// Transform socks view model into persistent proxy configuration that can be used with ``AccessMethodRepository``. + /// + /// - Throws: an instance of ``AccessMethodValidationError``. + /// - Returns: an instance of ``PersistentProxyConfiguration``. + func intoPersistentProxyConfiguration() throws -> PersistentProxyConfiguration { + var draftConfiguration = PersistentProxyConfiguration.SocksConfiguration( + server: .ipv4(.loopback), + port: 0, + authentication: .noAuthentication + ) + + let context: AccessMethodFieldValidationError.Context = .socks + var fieldErrors: [AccessMethodFieldValidationError] = [] + + switch CommonValidators.parseIPAddress(from: server, context: context) { + case let .success(serverAddress): + draftConfiguration.server = serverAddress + case let .failure(error): + fieldErrors.append(error) + } + + switch CommonValidators.parsePort(from: port, context: context) { + case let .success(port): + draftConfiguration.port = port + case let .failure(error): + fieldErrors.append(error) + } + + if authenticate { + if username.isEmpty { + fieldErrors.append(AccessMethodFieldValidationError( + kind: .emptyValue, + field: .username, + context: context + )) + } else { + draftConfiguration.authentication = .usernamePassword(username: username, password: password) + } + } + + if fieldErrors.isEmpty { + return .socks5(draftConfiguration) + } else { + throw AccessMethodValidationError(fieldErrors: fieldErrors) + } + } +} + +extension AccessMethodViewModel.Shadowsocks { + /// Transform shadowsocks view model into persistent proxy configuration that can be used with ``AccessMethodRepository``. + /// + /// - Throws: an instance of ``AccessMethodValidationError``. + /// - Returns: an instance of ``PersistentProxyConfiguration``. + func intoPersistentProxyConfiguration() throws -> PersistentProxyConfiguration { + var draftConfiguration = PersistentProxyConfiguration.ShadowsocksConfiguration( + server: .ipv4(.loopback), + port: 0, + password: "", + cipher: .default + ) + + let context: AccessMethodFieldValidationError.Context = .shadowsocks + var fieldErrors: [AccessMethodFieldValidationError] = [] + + switch CommonValidators.parseIPAddress(from: server, context: context) { + case let .success(serverAddress): + draftConfiguration.server = serverAddress + case let .failure(error): + fieldErrors.append(error) + } + + switch CommonValidators.parsePort(from: port, context: context) { + case let .success(port): + draftConfiguration.port = port + case let .failure(error): + fieldErrors.append(error) + } + + draftConfiguration.cipher = cipher + draftConfiguration.password = password + + if fieldErrors.isEmpty { + return .shadowsocks(draftConfiguration) + } else { + throw AccessMethodValidationError(fieldErrors: fieldErrors) + } + } +} + +private enum CommonValidators { + /// Parse port from string. + /// + /// - Parameters: + /// - value: a string input. + /// - context: an input context. + /// - Returns: a result containing a parsed port number on success, otherwise an instance of ``AccessMethodFieldValidationError``. + static func parsePort(from value: String, context: AccessMethodFieldValidationError.Context) + -> Result { + guard let portNumber = UInt16(value) else { + return .failure(AccessMethodFieldValidationError(kind: .parsePort, field: .port, context: context)) + } + + guard portNumber > 0 else { + return .failure(AccessMethodFieldValidationError(kind: .invalidPort, field: .port, context: context)) + } + + return .success(portNumber) + } + + /// Parse IP address from string by first running the input via regular expression before parsing it using Apple's facilities which are known to accept all kind of + /// malformed input. + /// + /// - Parameters: + /// - value: a string input. + /// - context: an input context + /// - Returns: a result containing an IP address on success, otherwise an instance of ``AccessMethodFieldValidationError``. + static func parseIPAddress( + from value: String, + context: AccessMethodFieldValidationError.Context + ) -> Result { + let range = NSRange(value.startIndex ..< value.endIndex, in: value) + + let regexMatch = NSRegularExpression.ipv4RegularExpression.firstMatch(in: value, range: range) + ?? NSRegularExpression.ipv6RegularExpression.firstMatch(in: value, range: range) + + if regexMatch?.range == range, let address = AnyIPAddress(value) { + return .success(address) + } else { + return .failure(AccessMethodFieldValidationError(kind: .parseIPAddress, field: .server, context: context)) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel.swift new file mode 100644 index 000000000000..2dc6262dcb14 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessMethodViewModel.swift @@ -0,0 +1,73 @@ +// +// AccessMethodViewModel.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadTypes + +/// The view model used by view controllers editing access method data. +struct AccessMethodViewModel: Identifiable { + /// Socks configuration view model. + struct Socks { + /// Server IP address input. + var server = "" + /// Server port input. + var port = "" + /// Authentication username. + var username = "" + /// Authentication password. + var password = "" + /// Indicates whether authentication is enabled. + var authenticate = false + } + + /// Shadowsocks configuration view model. + struct Shadowsocks { + /// Server IP address input. + var server = "" + /// Server port input. + var port = "" + /// Server password. + var password = "" + /// Shadowsocks cipher. + var cipher = ShadowsocksCipher.default + } + + /// Access method testing status view model. + enum TestingStatus { + /// The default state before the testing began. + case initial + /// Testing is in progress. + case inProgress + /// Testing failed. + case failed + /// Testing succeeded. + case succeeded + } + + /// The unique identifier used for referencing the access method entry in a persistent store. + var id = UUID() + + /// The user-defined name for access method. + var name = "" + + /// The selected access method kind. + /// Determines which subview model is used when presenting proxy configuration in UI. + var method: AccessMethodKind = .socks5 + + /// The flag indicating whether configuration is enabled. + var isEnabled = true + + /// The status of testing the entered proxy configuration. + var testingStatus: TestingStatus = .initial + + /// Socks configuration view model. + var socks = Socks() + + /// Shadowsocks configuration view model. + var shadowsocks = Shadowsocks() +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessViewModel+TestingStatus.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessViewModel+TestingStatus.swift new file mode 100644 index 000000000000..d0544cf8d91a --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/AccessViewModel+TestingStatus.swift @@ -0,0 +1,25 @@ +// +// AccessViewModel+TestingStatus.swift +// MullvadVPN +// +// Created by pronebird on 27/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension AccessMethodViewModel.TestingStatus { + var sheetStatus: AccessMethodActionSheetContentConfiguration.Status { + switch self { + case .initial: + // The sheet is invisible in this state, the return value is not important. + .testing + case .inProgress: + .testing + case .failed: + .unreachable + case .succeeded: + .reachable + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/PersistentAccessMethod+ViewModel.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/PersistentAccessMethod+ViewModel.swift new file mode 100644 index 000000000000..3e3a040b2880 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/PersistentAccessMethod+ViewModel.swift @@ -0,0 +1,24 @@ +// +// PersistentAccessMethod+ViewModel.swift +// MullvadVPN +// +// Created by pronebird on 17/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension PersistentAccessMethod { + /// Convert persistent model into view model. + /// - Returns: an instance of ``AccessMethodViewModel``. + func toViewModel() -> AccessMethodViewModel { + AccessMethodViewModel( + id: id, + name: name, + method: kind, + isEnabled: isEnabled, + socks: proxyConfiguration.socksViewModel, + shadowsocks: proxyConfiguration.shadowsocksViewModel + ) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/PersistentProxyConfiguration+ViewModel.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/PersistentProxyConfiguration+ViewModel.swift new file mode 100644 index 000000000000..3e3d76cb29df --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Models/PersistentProxyConfiguration+ViewModel.swift @@ -0,0 +1,48 @@ +// +// PersistentProxyConfiguration+ViewModel.swift +// MullvadVPN +// +// Created by pronebird on 29/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +extension PersistentProxyConfiguration { + /// View model for socks configuration. + var socksViewModel: AccessMethodViewModel.Socks { + guard case let .socks5(config) = self else { + return AccessMethodViewModel.Socks() + } + + var socks = AccessMethodViewModel.Socks( + server: "\(config.server)", + port: "\(config.port)" + ) + + switch config.authentication { + case let .usernamePassword(username, password): + socks.username = username + socks.password = password + socks.authenticate = true + + case .noAuthentication: + socks.authenticate = false + } + + return socks + } + + /// View model for shadowsocks configuration. + var shadowsocksViewModel: AccessMethodViewModel.Shadowsocks { + guard case let .shadowsocks(config) = self else { + return AccessMethodViewModel.Shadowsocks() + } + return AccessMethodViewModel.Shadowsocks( + server: "\(config.server)", + port: "\(config.port)", + password: config.password, + cipher: config.cipher + ) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/AccessMethodProtocolPicker.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/AccessMethodProtocolPicker.swift new file mode 100644 index 000000000000..0fb922406a8f --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/AccessMethodProtocolPicker.swift @@ -0,0 +1,66 @@ +// +// AccessMethodProtocolPicker.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Type implementing the access method protocol picker. +struct AccessMethodProtocolPicker { + /// The navigation controller used for presenting the picker. + let navigationController: UINavigationController + + /// Push access method protocol picker onto the navigation stack. + /// - Parameters: + /// - currentValue: current selection. + /// - completion: a completion handler. + func present(currentValue: AccessMethodKind, completion: @escaping (AccessMethodKind) -> Void) { + let navigationController = navigationController + + let dataSource = AccessMethodProtocolPickerDataSource() + let controller = ListItemPickerViewController(dataSource: dataSource, selectedItemID: currentValue) + + controller.navigationItem.title = NSLocalizedString( + "SELECT_PROTOCOL_NAV_TITLE", + tableName: "APIAccess", + value: "Type", + comment: "" + ) + + controller.onSelect = { selectedItem in + navigationController.popViewController(animated: true) + completion(selectedItem.method) + } + + navigationController.pushViewController(controller, animated: true) + } +} + +/// Type implementing the data source for the access method protocol picker. +struct AccessMethodProtocolPickerDataSource: ListItemDataSourceProtocol { + struct Item: ListItemDataSourceItem { + let method: AccessMethodKind + + var id: AccessMethodKind { method } + var text: String { method.localizedDescription } + } + + let items: [Item] = AccessMethodKind.allUserDefinedKinds.map { Item(method: $0) } + + var itemCount: Int { + items.count + } + + func item(at indexPath: IndexPath) -> Item { + items[indexPath.row] + } + + func indexPath(for itemID: AccessMethodKind) -> IndexPath? { + guard let index = items.firstIndex(where: { $0.id == itemID }) else { return nil } + + return IndexPath(row: index, section: 0) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/ListItemPickerViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/ListItemPickerViewController.swift new file mode 100644 index 000000000000..f30f75e2c5fe --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/ListItemPickerViewController.swift @@ -0,0 +1,117 @@ +// +// ListItemPickerViewController.swift +// MullvadVPN +// +// Created by pronebird on 13/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// An item type used by list item data source. +protocol ListItemDataSourceItem: Identifiable { + /// Item's text representation for UI presentation. + var text: String { get } +} + +/// A data source type used together with ``ListItemPickerViewController``. +protocol ListItemDataSourceProtocol { + associatedtype Item: ListItemDataSourceItem + + /// Number of items in the data source. + var itemCount: Int { get } + + /// Return item at index path. + /// + /// - Parameter indexPath: an index path. + /// - Returns: the item corresponding to the given index path. + func item(at indexPath: IndexPath) -> Item + + /// Get index path by item ID. + /// + /// - Parameter itemID: an item ID. + /// - Returns: the index path that corresponds to the given item ID upon success, otherwise `nil`. + func indexPath(for itemID: Item.ID) -> IndexPath? +} + +/// A view controller presenting a list of items from which the user can choose one item. +class ListItemPickerViewController: UITableViewController { + typealias Item = DataSource.Item + + private let dataSource: DataSource + private var selectedItemID: Item.ID? + private var scrolledToSelection = false + + var onSelect: ((Item) -> Void)? + + /// Designated initializer. + /// - Parameters: + /// - dataSource: a data source. + /// - selectedValue: the initially selected item ID. + init(dataSource: DataSource, selectedItemID: Item.ID?) { + self.dataSource = dataSource + self.selectedItemID = selectedItemID + + super.init(style: .insetGrouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .secondaryColor + tableView.registerReusableViews(from: CellIdentifier.self) + } + + override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + + guard !scrolledToSelection else { return } + + scrolledToSelection = true + + if let selectedItemID, let indexPath = dataSource.indexPath(for: selectedItemID) { + tableView.scrollToRow(at: indexPath, at: .middle, animated: false) + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let item = dataSource.item(at: indexPath) + var configuration = UIListContentConfiguration.mullvadCell(tableStyle: tableView.style) + configuration.text = item.text + + let cell = tableView.dequeueReusableView(withIdentifier: CellIdentifier.default, for: indexPath) + cell.contentConfiguration = configuration + + if let cell = cell as? CustomCellDisclosureHandling { + cell.disclosureType = item.id == selectedItemID ? .tick : .none + } + + if let cell = cell as? DynamicBackgroundConfiguration { + cell.setAutoAdaptingBackgroundConfiguration(.mullvadListPlainCell(), selectionType: .dimmed) + } + + return cell + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return dataSource.itemCount + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let selectedItem = dataSource.item(at: indexPath) + selectedItemID = selectedItem.id + onSelect?(selectedItem) + } +} + +private enum CellIdentifier: String, CellIdentifierProtocol, CaseIterable { + case `default` + + var cellClass: AnyClass { + BasicCell.self + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/ShadowsocksCipherPicker.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/ShadowsocksCipherPicker.swift new file mode 100644 index 000000000000..2b3ffbd19d7a --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Pickers/ShadowsocksCipherPicker.swift @@ -0,0 +1,66 @@ +// +// ShadowsocksCipherPicker.swift +// MullvadVPN +// +// Created by pronebird on 14/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Type implementing the shadowsocks cipher picker. +struct ShadowsocksCipherPicker { + /// The navigation controller used for presenting the picker. + let navigationController: UINavigationController + + /// Push shadowsocks cipher picker onto the navigation stack. + /// - Parameters: + /// - currentValue: current selection. + /// - completion: a completion handler. + func present(currentValue: ShadowsocksCipher, completion: @escaping (ShadowsocksCipher) -> Void) { + let navigationController = navigationController + + let dataSource = ShadowsocksCipherPickerDataSource() + let controller = ListItemPickerViewController(dataSource: dataSource, selectedItemID: currentValue) + + controller.navigationItem.title = NSLocalizedString( + "SELECT_SHADOWSOCKS_CIPHER_NAV_TITLE", + tableName: "APIAccess", + value: "Cipher", + comment: "" + ) + + controller.onSelect = { selectedItem in + navigationController.popViewController(animated: true) + completion(selectedItem.cipher) + } + + navigationController.pushViewController(controller, animated: true) + } +} + +/// Type implementing the data source for the shadowsocks cipher picker. +struct ShadowsocksCipherPickerDataSource: ListItemDataSourceProtocol { + struct Item: ListItemDataSourceItem { + let cipher: ShadowsocksCipher + + var id: ShadowsocksCipher { cipher } + var text: String { "\(cipher)" } + } + + let items = ShadowsocksCipher.supportedCiphers.map { Item(cipher: $0) } + + var itemCount: Int { + items.count + } + + func item(at indexPath: IndexPath) -> Item { + items[indexPath.row] + } + + func indexPath(for itemID: ShadowsocksCipher) -> IndexPath? { + guard let index = items.firstIndex(where: { $0.id == itemID }) else { return nil } + + return IndexPath(row: index, section: 0) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Publisher+PreviousValue.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Publisher+PreviousValue.swift new file mode 100644 index 000000000000..6cb0a15a0f62 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Publisher+PreviousValue.swift @@ -0,0 +1,19 @@ +// +// Publisher+PreviousValue.swift +// MullvadVPN +// +// Created by pronebird on 27/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation + +extension Publisher { + /// A publisher producing a pair that contains the previous and new value. + /// + /// - Returns: A publisher emitting a tuple containing the previous and new value. + func withPreviousValue() -> some Publisher<(Output?, Output), Failure> { + return scan(nil) { ($0?.1, $1) }.compactMap { $0 } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetConfiguration.swift new file mode 100644 index 000000000000..7704bcc3ee1f --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetConfiguration.swift @@ -0,0 +1,32 @@ +// +// AccessMethodActionSheetConfiguration.swift +// MullvadVPN +// +// Created by pronebird on 28/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// The context in which the sheet is being used. +enum AccessMethodActionSheetContext: Equatable { + /// The variant describing the context when adding a new method. + /// + /// In this context, the sheet offers user to add access method anyway or cancel, once the API tests indicate a failure. + /// (See `contentConfiguration.status`) + case addNew + + /// The variant describing the context when the existing API method is being tested or edited as a part of proxy configuration sub-navigation. + /// + /// In this context, the sheet only offers user to cancel testing the access method. + case proxyConfiguration +} + +/// The sheet configuration. +struct AccessMethodActionSheetConfiguration: Equatable { + /// The sheet presentation context. + var context: AccessMethodActionSheetContext = .addNew + + /// The sheet content configuration. + var contentConfiguration = AccessMethodActionSheetContentConfiguration() +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContainerView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContainerView.swift new file mode 100644 index 000000000000..377e204f22b2 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContainerView.swift @@ -0,0 +1,104 @@ +// +// AccessMethodActionSheetContainerView.swift +// MullvadVPN +// +// Created by pronebird on 15/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// The view implementing a vertical stack layout with the testing progress UI (content view) at the top and action buttons below. +class AccessMethodActionSheetContainerView: UIView { + /// Sheet delegate. + weak var delegate: AccessMethodActionSheetDelegate? + + /// Active configuration. + var configuration = AccessMethodActionSheetConfiguration() { + didSet { + contentView.configuration = configuration.contentConfiguration + updateView() + } + } + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [contentView, addButton, cancelButton]) + stackView.axis = .vertical + stackView.spacing = UIMetrics.padding16 + return stackView + }() + + private let contentView = AccessMethodActionSheetContentView() + private let cancelButton: AppButton = { + let button = AppButton(style: .tableInsetGroupedDefault) + button.setTitle( + NSLocalizedString("SHEET_CANCEL_BUTTON", tableName: "APIAccess", value: "Cancel", comment: ""), + for: .normal + ) + return button + }() + + private let addButton: AppButton = { + let button = AppButton(style: .tableInsetGroupedDanger) + button.setTitle( + NSLocalizedString("SHEET_ADD_ANYWAY_BUTTON", tableName: "APIAccess", value: "Add anyway", comment: ""), + for: .normal + ) + button.isHidden = true + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + addActions() + updateView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + directionalLayoutMargins = UIMetrics.contentLayoutMargins + + addConstrainedSubviews([stackView]) { + stackView.pinEdgesToSuperviewMargins() + } + } + + private func addActions() { + let cancelAction = UIAction { [weak self] _ in + self?.sendSheetDidCancel() + } + + let addAction = UIAction { [weak self] _ in + self?.sendSheetDidAdd() + } + + cancelButton.addAction(cancelAction, for: .touchUpInside) + addButton.addAction(addAction, for: .touchUpInside) + } + + private func updateView() { + let status = configuration.contentConfiguration.status + + switch configuration.context { + case .addNew: + addButton.isHidden = status != .unreachable + cancelButton.isEnabled = status != .reachable + + case .proxyConfiguration: + addButton.isHidden = true + cancelButton.isEnabled = status == .testing + } + } + + private func sendSheetDidAdd() { + delegate?.sheetDidAdd(self) + } + + private func sendSheetDidCancel() { + delegate?.sheetDidCancel(self) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentConfiguration.swift new file mode 100644 index 000000000000..5fb2de4a9abf --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentConfiguration.swift @@ -0,0 +1,56 @@ +// +// AccessMethodActionSheetContentConfiguration.swift +// MullvadVPN +// +// Created by pronebird on 28/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Sheet content view configuration. +struct AccessMethodActionSheetContentConfiguration: Equatable { + /// The status of access method testing. + enum Status: Equatable { + /// API Is reachable. + case reachable + + /// API is unreachable. + case unreachable + + /// API testing is in progress. + case testing + } + + /// The status of testing. + var status: Status = .reachable + + /// Detail text displayed below the status when set. + var detailText: String? +} + +extension AccessMethodActionSheetContentConfiguration.Status { + /// The text label descirbing the status of testing and suitable for user presentation. + var text: String { + switch self { + case .unreachable: + NSLocalizedString("API_UNREACHABLE", tableName: "APIAccess", value: "API unreachable", comment: "") + case .reachable: + NSLocalizedString("API_REACHABLE", tableName: "APIAccess", value: "API reachable", comment: "") + case .testing: + NSLocalizedString("API_TESTING_INPROGRESS", tableName: "APIAccess", value: "Testing...", comment: "") + } + } + + /// The color of a circular status indicator view. + var statusColor: UIColor? { + switch self { + case .unreachable: + .dangerColor + case .reachable: + .successColor + case .testing: + nil + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentView.swift new file mode 100644 index 000000000000..45579d680cd6 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetContentView.swift @@ -0,0 +1,130 @@ +// +// AccessMethodActionSheetContentView.swift +// MullvadVPN +// +// Created by pronebird on 16/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// The sheet content view implementing a layout with an activity indicator or status indicator and primary text label, with detail label below. +class AccessMethodActionSheetContentView: UIView { + var configuration = AccessMethodActionSheetContentConfiguration() { + didSet { + updateView() + } + } + + private let progressView = SpinnerActivityIndicatorView(style: .custom) + private let progressContainer = UIView() + + private let statusIndicator: UIView = { + let view = UIView() + view.layer.cornerRadius = 10 + view.layer.cornerCurve = .circular + view.backgroundColor = .successColor + return view + }() + + private let textLabel: UILabel = { + let textLabel = UILabel() + textLabel.font = UIFont.systemFont(ofSize: 17) + textLabel.textColor = .primaryTextColor + return textLabel + }() + + private let detailLabel: UILabel = { + let textLabel = UILabel() + textLabel.font = UIFont.systemFont(ofSize: 14) + textLabel.textColor = .secondaryTextColor + textLabel.textAlignment = .center + return textLabel + }() + + private let horizontalStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = UIStackView.spacingUseSystem + stackView.alignment = .center + return stackView + }() + + private lazy var verticalStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [containerView, detailLabel]) + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = UIStackView.spacingUseSystem + return stackView + }() + + private let containerView = UIView() + + init() { + super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + + setupView() + updateView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + NSLayoutConstraint.activate([ + progressView.widthAnchor.constraint(equalToConstant: 30), + progressView.heightAnchor.constraint(equalToConstant: 30), + + progressContainer.widthAnchor.constraint(equalToConstant: 30), + progressContainer.heightAnchor.constraint(equalToConstant: 20), + + statusIndicator.widthAnchor.constraint(equalToConstant: 20), + statusIndicator.heightAnchor.constraint(equalToConstant: 20), + ]) + + containerView.addConstrainedSubviews([horizontalStackView]) { + horizontalStackView.pinEdgesToSuperview() + } + + progressContainer.addConstrainedSubviews([progressView]) { + progressView.centerYAnchor.constraint(equalTo: progressContainer.centerYAnchor) + progressView.pinEdgeToSuperview(.leading(0)) + progressView.pinEdgeToSuperview(.trailing(0)) + } + + addConstrainedSubviews([verticalStackView]) { + verticalStackView.pinEdgesToSuperview() + } + } + + private func updateView() { + textLabel.text = configuration.status.text + detailLabel.text = configuration.detailText + statusIndicator.backgroundColor = configuration.status.statusColor + + // Hide detail label when empty to prevent extra margin between subviews in the stack. + detailLabel.isHidden = configuration.detailText?.isEmpty ?? true + + // Remove the first view in the horizontal stack which is either a status indicator or progress. + horizontalStackView.arrangedSubviews.first.map { view in + horizontalStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + + // Reconfigure the horizontal stack by adding the status indicator or progress first. + switch configuration.status { + case .reachable, .unreachable: + horizontalStackView.insertArrangedSubview(statusIndicator, at: 0) + + case .testing: + horizontalStackView.insertArrangedSubview(progressContainer, at: 0) + progressView.startAnimating() + } + + // Text label is always the last one, so only add it into the stack if it's not there yet. + if textLabel.superview == nil { + horizontalStackView.addArrangedSubview(textLabel) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetDelegate.swift new file mode 100644 index 000000000000..0341cd80e01f --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Content view/AccessMethodActionSheetDelegate.swift @@ -0,0 +1,18 @@ +// +// AccessMethodActionSheetDelegate.swift +// MullvadVPN +// +// Created by pronebird on 22/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Sheet container view delegate. +protocol AccessMethodActionSheetDelegate: AnyObject { + /// User tapped the cancel button. + func sheetDidCancel(_ sheet: AccessMethodActionSheetContainerView) + + /// User tapped the add button. + func sheetDidAdd(_ sheet: AccessMethodActionSheetContainerView) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentation.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentation.swift new file mode 100644 index 000000000000..3d801a14f3ef --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentation.swift @@ -0,0 +1,111 @@ +// +// AddAccessMethodActionSheetPresentation.swift +// MullvadVPN +// +// Created by pronebird on 16/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// Class responsible for presentation of access method sheet within the hosting view. +class AccessMethodActionSheetPresentation { + /// The view managed by the sheet presentation. + private let presentationView = AccessMethodActionSheetPresentationView(frame: CGRect( + x: 0, + y: 0, + width: 320, + height: 240 + )) + + /// Indicates whether the sheet is being presented. + private(set) var isPresenting = false + + /// Layout frame of a sheet view. + var sheetLayoutFrame: CGRect { + presentationView.sheetLayoutFrame + } + + /// Delegate. + weak var delegate: AccessMethodActionSheetPresentationDelegate? + + /// Presentation configuration. + var configuration: AccessMethodActionSheetPresentationConfiguration { + get { + presentationView.configuration + } + set { + presentationView.configuration = newValue + } + } + + init() { + presentationView.sheetDelegate = self + presentationView.alpha = 0 + } + + /// Present the sheet within the hosting view. + /// + /// - Parameters: + /// - parent: the hosting view. + /// - animated: whether to animate the transition. + func show(in parent: UIView, animated: Bool = true) { + guard !isPresenting || presentationView.superview != parent else { return } + + isPresenting = true + embed(into: parent) + + UIViewPropertyAnimator.runningPropertyAnimator( + withDuration: animated ? UIMetrics.AccessMethodActionSheetTransition.duration.timeInterval : 0, + delay: 0, + options: UIMetrics.AccessMethodActionSheetTransition.animationOptions + ) { + self.presentationView.alpha = 1 + } + } + + /// Hide the sheet from the hosting view. + /// + /// The sheet is removed from the hosting view after animation. + /// + /// - Parameter animated: whether to animate the transition. + func hide(animated: Bool = true) { + guard isPresenting else { return } + + isPresenting = false + + UIViewPropertyAnimator.runningPropertyAnimator( + withDuration: animated ? UIMetrics.AccessMethodActionSheetTransition.duration.timeInterval : 0, + delay: 0, + options: UIMetrics.AccessMethodActionSheetTransition.animationOptions + ) { + self.presentationView.alpha = 0 + } completion: { position in + guard position == .end else { return } + + self.presentationView.removeFromSuperview() + } + } + + /// Embed the container into the sheet container view into the hosting view. + /// + /// - Parameter parent: the hosting view. + private func embed(into parent: UIView) { + guard presentationView.superview != parent else { return } + + presentationView.removeFromSuperview() + parent.addConstrainedSubviews([presentationView]) { + presentationView.pinEdgesToSuperview() + } + } +} + +extension AccessMethodActionSheetPresentation: AccessMethodActionSheetDelegate { + func sheetDidAdd(_ sheet: AccessMethodActionSheetContainerView) { + delegate?.sheetDidAdd(sheetPresentation: self) + } + + func sheetDidCancel(_ sheet: AccessMethodActionSheetContainerView) { + delegate?.sheetDidCancel(sheetPresentation: self) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationConfiguration.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationConfiguration.swift new file mode 100644 index 000000000000..cc81c831d744 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationConfiguration.swift @@ -0,0 +1,22 @@ +// +// AccessMethodActionSheetPresentationConfiguration.swift +// MullvadVPN +// +// Created by pronebird on 28/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Sheet presentation configuration. +struct AccessMethodActionSheetPresentationConfiguration: Equatable { + /// Whether presentation dims background. + /// When set to `false` the background is made transparent and all touches are passed through enabling interaction with the underlying view. + var dimsBackground = true + + /// Whether presentation blurs the background behind the sheet pinned at the bottom. + var blursSheetBackground = true + + /// Sheet configuration. + var sheetConfiguration = AccessMethodActionSheetConfiguration() +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationDelegate.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationDelegate.swift new file mode 100644 index 000000000000..b32b47ee5e76 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationDelegate.swift @@ -0,0 +1,18 @@ +// +// AccessMethodActionSheetPresentationDelegate.swift +// MullvadVPN +// +// Created by pronebird on 22/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +/// Sheet presentation delegate. +protocol AccessMethodActionSheetPresentationDelegate: AnyObject { + /// User tapped the cancel button. + func sheetDidCancel(sheetPresentation: AccessMethodActionSheetPresentation) + + /// User tapped the add button. + func sheetDidAdd(sheetPresentation: AccessMethodActionSheetPresentation) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationView.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationView.swift new file mode 100644 index 000000000000..d561e5c52321 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/Sheet/Presentation/AccessMethodActionSheetPresentationView.swift @@ -0,0 +1,124 @@ +// +// AccessMethodActionSheetPresentationView.swift +// MullvadVPN +// +// Created by pronebird on 28/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/// The sheet presentation view implementing a layout similar to the one used by system action sheet. +class AccessMethodActionSheetPresentationView: UIView { + /// The dimming background view. + private let backgroundView: UIView = { + let backgroundView = UIView() + backgroundView.backgroundColor = .secondaryColor.withAlphaComponent(0.5) + return backgroundView + }() + + /// The blur view displayed behind the sheet. + private let sheetBlurBackgroundView: UIVisualEffectView = { + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) + blurView.directionalLayoutMargins = .zero + blurView.contentView.directionalLayoutMargins = .zero + return blurView + }() + + /// Sheet container view that contains action buttons and access method testing progress UI. + private let sheetView = AccessMethodActionSheetContainerView(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + + /// Layout frame of a sheet content view. + var sheetLayoutFrame: CGRect { + sheetView.convert(sheetView.bounds, to: self) + } + + /// Sheet delegate. + weak var sheetDelegate: AccessMethodActionSheetDelegate? { + get { + sheetView.delegate + } + set { + sheetView.delegate = newValue + } + } + + /// Presentation configuration. + var configuration = AccessMethodActionSheetPresentationConfiguration() { + didSet { + updateSubviews(previousConfiguration: oldValue, animated: window != nil) + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + addBackgroundView() + updateSubviews(animated: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + if configuration.dimsBackground { + super.point(inside: point, with: event) + } else { + // Accept touches to the content view only when background view is hidden to enable user interaction with + // the view beneath. + sheetView.frame.contains(point) + } + } + + private func addBackgroundView() { + addConstrainedSubviews([backgroundView]) { + backgroundView.pinEdgesToSuperview() + } + } + + private func updateSubviews( + previousConfiguration: AccessMethodActionSheetPresentationConfiguration? = nil, + animated: Bool + ) { + if previousConfiguration?.blursSheetBackground != configuration.blursSheetBackground { + updateSheetBackground() + } + + if previousConfiguration?.dimsBackground != configuration.dimsBackground { + updateBackgroundView(animated: animated) + } + + sheetView.configuration = configuration.sheetConfiguration + } + + private func updateSheetBackground() { + sheetView.removeFromSuperview() + sheetBlurBackgroundView.removeFromSuperview() + + // Embed the sheet view into blur view when configured to blur the sheet's background. + if configuration.blursSheetBackground { + sheetBlurBackgroundView.contentView.addConstrainedSubviews([sheetView]) { + sheetView.pinEdgesToSuperviewMargins() + } + addConstrainedSubviews([sheetBlurBackgroundView]) { + sheetBlurBackgroundView.pinEdgesToSuperview(.all().excluding(.top)) + } + } else { + addConstrainedSubviews([sheetView]) { + sheetView.pinEdgesToSuperviewMargins(.all().excluding(.top)) + } + } + } + + private func updateBackgroundView(animated: Bool) { + UIViewPropertyAnimator.runningPropertyAnimator( + withDuration: animated ? UIMetrics.AccessMethodActionSheetTransition.duration.timeInterval : 0, + delay: 0, + options: UIMetrics.AccessMethodActionSheetTransition.animationOptions, + animations: { + self.backgroundView.alpha = self.configuration.dimsBackground ? 1 : 0 + } + ) + } +} diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsChildCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsChildCoordinator.swift new file mode 100644 index 000000000000..2b6585903214 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsChildCoordinator.swift @@ -0,0 +1,14 @@ +// +// SettingsChildCoordinator.swift +// MullvadVPN +// +// Created by pronebird on 08/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Routing + +/// Types that are child coordinators of ``SettingsCoordinator``. +protocol SettingsChildCoordinator: Coordinator { + func start(animated: Bool) +} diff --git a/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift new file mode 100644 index 000000000000..4d9abb81f868 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/Settings/SettingsCoordinator.swift @@ -0,0 +1,274 @@ +// +// SettingsCoordinator.swift +// MullvadVPN +// +// Created by pronebird on 09/01/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import MullvadLogging +import Operations +import Routing +import UIKit + +/// Settings navigation route. +enum SettingsNavigationRoute: Equatable { + /// The route that's always displayed first upon entering settings. + case root + + /// VPN settings. + case preferences + + /// Problem report. + case problemReport + + /// FAQ section displayed as a modal safari browser. + case faq + + /// API access route. + case apiAccess +} + +/// Top-level settings coordinator. +final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsViewControllerDelegate, + UINavigationControllerDelegate { + private let logger = Logger(label: "SettingsNavigationCoordinator") + + private let interactorFactory: SettingsInteractorFactory + private var currentRoute: SettingsNavigationRoute? + private var modalRoute: SettingsNavigationRoute? + + let navigationController: UINavigationController + + var presentedViewController: UIViewController { + navigationController + } + + /// Event handler invoked when navigating bebtween child routes within the horizontal stack. + var willNavigate: (( + _ coordinator: SettingsCoordinator, + _ from: SettingsNavigationRoute?, + _ to: SettingsNavigationRoute + ) -> Void)? + + /// Event handler invoked when coordinator and its view hierarchy should be dismissed. + var didFinish: ((SettingsCoordinator) -> Void)? + + /// Designated initializer. + /// - Parameters: + /// - navigationController: a navigation controller that the coordinator will be managing. + /// - interactorFactory: an instance of a factory that produces interactors for the child routes. + init( + navigationController: UINavigationController, + interactorFactory: SettingsInteractorFactory + ) { + self.navigationController = navigationController + self.interactorFactory = interactorFactory + } + + /// Start the coordinator fllow. + /// - Parameter initialRoute: the initial route to display. + func start(initialRoute: SettingsNavigationRoute? = nil) { + navigationController.navigationBar.prefersLargeTitles = true + navigationController.delegate = self + + push(from: makeChild(for: .root), animated: false) + if let initialRoute, initialRoute != .root { + push(from: makeChild(for: initialRoute), animated: false) + } + } + + // MARK: - Navigation + + /// Request navigation to the speciifc route. + /// + /// - Parameters: + /// - route: the route to present. + /// - animated: whether transition should be animated. + /// - completion: a completion handler, typically called immediately for horizontal navigation and + func navigate(to route: SettingsNavigationRoute, animated: Bool, completion: (() -> Void)? = nil) { + switch route { + case .root: + popToRoot(animated: animated) + completion?() + + case .faq: + guard modalRoute == nil else { + completion?() + return + } + + modalRoute = route + + logger.debug("Show modal \(route)") + + let safariCoordinator = SafariCoordinator(url: ApplicationConfiguration.faqAndGuidesURL) + + safariCoordinator.didFinish = { [weak self] in + self?.modalRoute = nil + } + + presentChild(safariCoordinator, animated: animated, completion: completion) + + default: + // Ignore navigation if the route is already presented. + guard currentRoute != route else { + completion?() + return + } + + let child = makeChild(for: route) + + // Pop to root first, then push the child. + if navigationController.viewControllers.count > 1 { + popToRoot(animated: animated) + } + push(from: child, animated: animated) + + completion?() + } + } + + // MARK: - UINavigationControllerDelegate + + func navigationController( + _ navigationController: UINavigationController, + willShow viewController: UIViewController, + animated: Bool + ) { + /* + Navigation controller calls this delegate method on `viewWillAppear`, for instance during cancellation + of interactive dismissal of a modally presented settings navigation controller, so it's important that we + ignore repeating routes. + */ + guard let route = route(for: viewController), currentRoute != route else { return } + + logger.debug( + "Navigate from \(currentRoute.map { "\($0)" } ?? "none") -> \(route)" + ) + + willNavigate?(self, currentRoute, route) + + currentRoute = route + + // Release child coordinators when moving to root. + if case .root = route { + releaseChildren() + } + } + + // MARK: - SettingsViewControllerDelegate + + func settingsViewControllerDidFinish(_ controller: SettingsViewController) { + didFinish?(self) + } + + func settingsViewController( + _ controller: SettingsViewController, + didRequestRoutePresentation route: SettingsNavigationRoute + ) { + navigate(to: route, animated: true) + } + + // MARK: - Route handling + + /// Pop to root route. + /// - Parameter animated: whether to animate the transition. + private func popToRoot(animated: Bool) { + navigationController.popToRootViewController(animated: animated) + releaseChildren() + } + + /// Push the child into navigation stack. + /// - Parameters: + /// - result: the result of creating a child representing a route. + /// - animated: whether to animate the transition. + private func push(from result: MakeChildResult, animated: Bool) { + switch result { + case let .viewController(vc): + navigationController.pushViewController(vc, animated: animated) + + case let .childCoordinator(child): + addChild(child) + child.start(animated: animated) + + case .failed: + break + } + } + + /// Release all child coordinators conforming to ``SettingsChildCoordinator`` protocol. + private func releaseChildren() { + childCoordinators.forEach { coordinator in + if coordinator is SettingsChildCoordinator { + coordinator.removeFromParent() + } + } + } + + // MARK: - Route mapping + + /// The result of creating a child representing a route. + private enum MakeChildResult { + /// View controller that should be pushed into navigation stack. + case viewController(UIViewController) + + /// Child coordinator that should be added to the children hierarchy. + /// The child is responsile for presenting itself. + case childCoordinator(SettingsChildCoordinator) + + /// Failure to produce a child. + case failed + } + + /// Produce a view controller or a child coordinator representing the route. + /// - Parameter route: the route for which to request the new view controller or child coordinator. + /// - Returns: a result of creating a child for the route. + private func makeChild(for route: SettingsNavigationRoute) -> MakeChildResult { + switch route { + case .root: + let controller = SettingsViewController( + interactor: interactorFactory.makeSettingsInteractor() + ) + controller.delegate = self + return .viewController(controller) + + case .preferences: + return .viewController(PreferencesViewController( + interactor: interactorFactory.makePreferencesInteractor(), + alertPresenter: AlertPresenter(context: self) + )) + + case .problemReport: + return .viewController(ProblemReportViewController( + interactor: interactorFactory.makeProblemReportInteractor(), + alertPresenter: AlertPresenter(context: self) + )) + + case .apiAccess: + return .childCoordinator(ListAccessMethodCoordinator(navigationController: navigationController)) + + case .faq: + // Handled separately and presented as a modal. + return .failed + } + } + + /// Map the view controller to the individual route. + /// - Parameter viewController: an instance of a view controller within the navigation stack. + /// - Returns: a route upon success, otherwise `nil`. + private func route(for viewController: UIViewController) -> SettingsNavigationRoute? { + switch viewController { + case is SettingsViewController: + return .root + case is PreferencesViewController: + return .preferences + case is ProblemReportViewController: + return .problemReport + case is ListAccessMethodViewController: + return .apiAccess + default: + return nil + } + } +} diff --git a/ios/MullvadVPN/Coordinators/SettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/SettingsCoordinator.swift deleted file mode 100644 index 88a32826b5e1..000000000000 --- a/ios/MullvadVPN/Coordinators/SettingsCoordinator.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// SettingsCoordinator.swift -// MullvadVPN -// -// Created by pronebird on 09/01/2023. -// Copyright © 2023 Mullvad VPN AB. All rights reserved. -// - -import MullvadLogging -import Operations -import Routing -import UIKit - -enum SettingsNavigationRoute: Equatable { - case root - case preferences - case problemReport - case faq -} - -final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsViewControllerDelegate, - UINavigationControllerDelegate { - private let logger = Logger(label: "SettingsNavigationCoordinator") - - private let interactorFactory: SettingsInteractorFactory - private var currentRoute: SettingsNavigationRoute? - private var modalRoute: SettingsNavigationRoute? - - let navigationController: UINavigationController - - var presentedViewController: UIViewController { - navigationController - } - - var willNavigate: (( - _ coordinator: SettingsCoordinator, - _ from: SettingsNavigationRoute?, - _ to: SettingsNavigationRoute - ) -> Void)? - - var didFinish: ((SettingsCoordinator) -> Void)? - - init( - navigationController: UINavigationController, - interactorFactory: SettingsInteractorFactory - ) { - self.navigationController = navigationController - self.interactorFactory = interactorFactory - } - - func start(initialRoute: SettingsNavigationRoute? = nil) { - navigationController.navigationBar.prefersLargeTitles = true - navigationController.delegate = self - - if let rootController = makeViewController(for: .root) { - navigationController.pushViewController(rootController, animated: false) - } - - if let initialRoute, initialRoute != .root, - let nextController = makeViewController(for: initialRoute) { - navigationController.pushViewController(nextController, animated: false) - } - } - - // MARK: - Navigation - - func navigate(to route: SettingsNavigationRoute, animated: Bool, completion: (() -> Void)? = nil) { - switch route { - case .root: - navigationController.popToRootViewController(animated: animated) - completion?() - - case .faq: - guard modalRoute == nil else { - completion?() - return - } - - modalRoute = route - - logger.debug("Show modal \(route)") - - let safariCoordinator = SafariCoordinator(url: ApplicationConfiguration.faqAndGuidesURL) - - safariCoordinator.didFinish = { [weak self] in - self?.modalRoute = nil - } - - presentChild(safariCoordinator, animated: animated, completion: completion) - - default: - let nextViewController = makeViewController(for: route) - let viewControllers = navigationController.viewControllers - - if let rootController = viewControllers.first, viewControllers.count > 1 { - navigationController.setViewControllers( - [rootController, nextViewController].compactMap { $0 }, - animated: animated - ) - } else if let nextViewController { - navigationController.pushViewController(nextViewController, animated: animated) - } - - completion?() - } - } - - // MARK: - UINavigationControllerDelegate - - func navigationController( - _ navigationController: UINavigationController, - willShow viewController: UIViewController, - animated: Bool - ) { - /* - Navigation controller calls this delegate method on `viewWillAppear`, for instance during cancellation - of interactive dismissal of a modally presented settings navigation controller, so it's important that we - ignore repeating routes. - */ - guard let route = route(for: viewController), currentRoute != route else { return } - - logger.debug( - "Navigate from \(currentRoute.map { "\($0)" } ?? "none") -> \(route)" - ) - - willNavigate?(self, currentRoute, route) - - currentRoute = route - } - - // MARK: - SettingsViewControllerDelegate - - func settingsViewControllerDidFinish(_ controller: SettingsViewController) { - didFinish?(self) - } - - func settingsViewController( - _ controller: SettingsViewController, - didRequestRoutePresentation route: SettingsNavigationRoute - ) { - navigate(to: route, animated: true) - } - - // MARK: - Route mapping - - private func makeViewController(for route: SettingsNavigationRoute) -> UIViewController? { - switch route { - case .root: - let controller = SettingsViewController( - interactor: interactorFactory.makeSettingsInteractor() - ) - controller.delegate = self - return controller - - case .preferences: - return PreferencesViewController( - interactor: interactorFactory.makePreferencesInteractor(), - alertPresenter: AlertPresenter(context: self) - ) - - case .problemReport: - return ProblemReportViewController( - interactor: interactorFactory.makeProblemReportInteractor(), - alertPresenter: AlertPresenter(context: self) - ) - - case .faq: - return nil - } - } - - private func route(for viewController: UIViewController) -> SettingsNavigationRoute? { - switch viewController { - case is SettingsViewController: - return .root - case is PreferencesViewController: - return .preferences - case is ProblemReportViewController: - return .problemReport - default: - return nil - } - } -} diff --git a/ios/MullvadVPN/Extensions/NSDiffableDataSourceSnapshot+Reconfigure.swift b/ios/MullvadVPN/Extensions/NSDiffableDataSourceSnapshot+Reconfigure.swift new file mode 100644 index 000000000000..bd0c42f28f06 --- /dev/null +++ b/ios/MullvadVPN/Extensions/NSDiffableDataSourceSnapshot+Reconfigure.swift @@ -0,0 +1,21 @@ +// +// NSDiffableDataSourceSnapshot+Reconfigure.swift +// MullvadVPN +// +// Created by pronebird on 27/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +extension NSDiffableDataSourceSnapshot { + /// Reconfigures cell on iOS 15 or newer, with fallback to reloading cells on earlier iOS. + /// - Parameter itemIdentifiers: item identifiers to reconfigure when possible, otherwise reload. + mutating func reconfigureOrReloadItems(_ itemIdentifiers: [ItemIdentifierType]) { + if #available(iOS 15, *) { + reconfigureItems(itemIdentifiers) + } else { + reloadItems(itemIdentifiers) + } + } +} diff --git a/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift b/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift new file mode 100644 index 000000000000..c799388434e1 --- /dev/null +++ b/ios/MullvadVPN/Extensions/UIBackgroundConfiguration+Extensions.swift @@ -0,0 +1,78 @@ +// +// UIBackgroundConfiguration+Extensions.swift +// MullvadVPN +// +// Created by pronebird on 09/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +extension UIBackgroundConfiguration { + /// Type of cell selection used in Mullvad UI. + enum CellSelectionType { + /// Dimmed blue . + case dimmed + /// Bright green. + case green + } + + /// Returns a plain cell background configuration adapted for Mullvad UI. + /// - Returns: a background configuration + static func mullvadListPlainCell() -> UIBackgroundConfiguration { + var config = listPlainCell() + config.backgroundColor = UIColor.Cell.backgroundColor + return config + } + + /// Returns the corresponding grouped cell background configuration adapted for Mullvad UI. + /// - Returns: a background configuration + static func mullvadListGroupedCell() -> UIBackgroundConfiguration { + var config = listGroupedCell() + config.backgroundColor = UIColor.Cell.backgroundColor + return config + } + + /// Adapt background configuration for the cell state and selection type. + /// + /// - Parameters: + /// - state: a cell state. + /// - selectionType: a desired selecton type. + /// - Returns: new background configuration. + func adapted(for state: UICellConfigurationState, selectionType: CellSelectionType) -> UIBackgroundConfiguration { + var config = self + config.backgroundColor = state.mullvadCellBackgroundColor(selectionType: selectionType) + return config + } + + /// Apply an error outline around the cell indicating an error. + mutating func applyValidationErrorStyle() { + cornerRadius = 10 + strokeWidth = 1 + strokeColor = UIColor.Cell.validationErrorBorderColor + } +} + +extension UICellConfigurationState { + /// Produce background color for the given state and cell selection type. + /// + /// - Parameter selectionType: cell selection type. + /// - Returns: a background color to apply to cell. + func mullvadCellBackgroundColor(selectionType: UIBackgroundConfiguration.CellSelectionType) -> UIColor { + switch selectionType { + case .dimmed: + if isSelected || isHighlighted { + UIColor.Cell.selectedAltBackgroundColor + } else { + UIColor.Cell.backgroundColor + } + + case .green: + if isSelected || isHighlighted { + UIColor.Cell.selectedBackgroundColor + } else { + UIColor.Cell.backgroundColor + } + } + } +} diff --git a/ios/MullvadVPN/Extensions/UIListContentConfiguration+Extensions.swift b/ios/MullvadVPN/Extensions/UIListContentConfiguration+Extensions.swift new file mode 100644 index 000000000000..ad3da9a9ca23 --- /dev/null +++ b/ios/MullvadVPN/Extensions/UIListContentConfiguration+Extensions.swift @@ -0,0 +1,60 @@ +// +// UIListContentConfiguration+Extensions.swift +// MullvadVPN +// +// Created by pronebird on 09/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +extension UIListContentConfiguration { + /// Returns cell configured with default text attribute used in Mullvad UI. + static func mullvadCell(tableStyle: UITableView.Style) -> UIListContentConfiguration { + var configuration = cell() + configuration.textProperties.font = UIFont.systemFont(ofSize: 17) + configuration.textProperties.color = UIColor.Cell.titleTextColor + configuration.directionalLayoutMargins = tableStyle.directionalLayoutMarginsForCell + return configuration + } + + /// Returns value cell configured with default text attribute used in Mullvad UI. + static func mullvadValueCell(tableStyle: UITableView.Style) -> UIListContentConfiguration { + var configuration = valueCell() + configuration.textProperties.font = UIFont.systemFont(ofSize: 17) + configuration.textProperties.color = UIColor.Cell.titleTextColor + configuration.secondaryTextProperties.color = UIColor.Cell.detailTextColor + configuration.secondaryTextProperties.font = UIFont.systemFont(ofSize: 17) + configuration.directionalLayoutMargins = tableStyle.directionalLayoutMarginsForCell + return configuration + } + + /// Returns grouped header configured with default text attribute used in Mullvad UI. + static func mullvadGroupedHeader() -> UIListContentConfiguration { + var configuration = groupedHeader() + configuration.textProperties.color = UIColor.TableSection.headerTextColor + configuration.textProperties.font = UIFont.systemFont(ofSize: 17) + return configuration + } + + /// Returns grouped footer configured with default text attribute used in Mullvad UI. + static func mullvadGroupedFooter() -> UIListContentConfiguration { + var configuration = groupedFooter() + configuration.textProperties.color = UIColor.TableSection.footerTextColor + configuration.textProperties.font = UIFont.systemFont(ofSize: 14) + return configuration + } +} + +extension UITableView.Style { + var directionalLayoutMarginsForCell: NSDirectionalEdgeInsets { + switch self { + case .plain, .grouped: + UIMetrics.SettingsCell.layoutMargins + case .insetGrouped: + UIMetrics.SettingsCell.insetLayoutMargins + @unknown default: + UIMetrics.SettingsCell.layoutMargins + } + } +} diff --git a/ios/MullvadVPN/Extensions/UITableView+ReuseIdentifier.swift b/ios/MullvadVPN/Extensions/UITableView+ReuseIdentifier.swift new file mode 100644 index 000000000000..7ffff229ea77 --- /dev/null +++ b/ios/MullvadVPN/Extensions/UITableView+ReuseIdentifier.swift @@ -0,0 +1,109 @@ +// +// UITableView+ReuseIdentifier.swift +// MullvadVPN +// +// Created by pronebird on 09/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +/** + Type describing cell identifier. + + Example: + + ``` + enum MyCellIdentifier: CaseIterable, String, CellIdentifierProtocol { + case primary, secondary + + var cellClass: AnyClass { + switch self { + case .primary: + return MyPrimaryCell.self + case .secondary: + return MySecondaryCell.self + } + } + } + + // Register all cells + tableView.registerReusableViews(from: MyCellIdentifier.self) + + // Dequeue cell + let cell = tableView.dequeueReusableView(withIdentifier: MyCellIdentifier.primary, for: IndexPath(row: 0, section: 0))) + ``` + */ +protocol CellIdentifierProtocol: CaseIterable, RawRepresentable where RawValue == String { + var cellClass: AnyClass { get } +} + +/** + Type describing header footer view identifier. + + Example: + + ``` + enum MyHeaderFooterIdentifier: CaseIterable, String, HeaderFooterIdentifierProtocol { + case primary, secondary + + var headerFooterClass: AnyClass { + switch self { + case .primary: + return MyPrimaryHeaderFooterView.self + case .secondary: + return MySecondaryHeaderFooterView.self + } + } + } + + // Register all cells + tableView.registerReusableViews(from: MyHeaderFooterIdentifier.self) + + // Dequeue header footer view + let headerFooterView = tableView.dequeueReusableView(withIdentifier: MyHeaderFooterIdentifier.primary) + ``` + */ +protocol HeaderFooterIdentifierProtocol: CaseIterable, RawRepresentable where RawValue == String { + var headerFooterClass: AnyClass { get } +} + +extension UITableView { + /// Register all cell identifiers in the table view. + /// - Parameter cellIdentifierType: a type conforming to the ``CellIdentifierProtocol`` protocol. + func registerReusableViews(from cellIdentifierType: T.Type) { + cellIdentifierType.allCases.forEach { identifier in + register(identifier.cellClass, forCellReuseIdentifier: identifier.rawValue) + } + } + + /// Register header footer view identifiers in the table view. + /// - Parameter headerFooterIdentifierType: a type conforming to the ``HeaderFooterIdentifierProtocol`` protocol. + func registerReusableViews(from headerFooterIdentifierType: T.Type) { + headerFooterIdentifierType.allCases.forEach { identifier in + register(identifier.headerFooterClass, forHeaderFooterViewReuseIdentifier: identifier.rawValue) + } + } +} + +extension UITableView { + /// Convenience method to dequeue a cell by identifier conforming to ``CellIdentifierProtocol``. + /// - Parameters: + /// - identifier: cell identifier. + /// - indexPath: index path + /// - Returns: table cell. + func dequeueReusableView( + withIdentifier identifier: some CellIdentifierProtocol, + for indexPath: IndexPath + ) -> UITableViewCell { + dequeueReusableCell(withIdentifier: identifier.rawValue, for: indexPath) + } + + /// Convenience method to dequeue a header footer view by identifier conforming to ``HeaderFooterIdentifierProtocol``. + /// - Parameter identifier: header footer view identifier. + /// - Returns: table header footer view. + func dequeueReusableView(withIdentifier identifier: some HeaderFooterIdentifierProtocol) + -> UITableViewHeaderFooterView? { + dequeueReusableHeaderFooterView(withIdentifier: identifier.rawValue) + } +} diff --git a/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift b/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift index b32243f76296..d1b05242bd66 100644 --- a/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift +++ b/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift @@ -54,6 +54,24 @@ extension UIView { return pinEdges(edges, to: superview.layoutMarginsGuide) } + + /** + Pin single edge to superview edge. + */ + func pinEdgeToSuperview(_ edge: PinnableEdges.Edge) -> [NSLayoutConstraint] { + guard let superview else { return [] } + + return pinEdges(PinnableEdges([edge]), to: superview) + } + + /** + Pin single edge to superview margin edge. + */ + func pinEdgeToSuperviewMargin(_ edge: PinnableEdges.Edge) -> [NSLayoutConstraint] { + guard let superview else { return [] } + + return pinEdges(PinnableEdges([edge]), to: superview.layoutMarginsGuide) + } } /** diff --git a/ios/MullvadVPN/Notifications/Notification Providers/RegisteredDeviceInAppNotificationProvider.swift b/ios/MullvadVPN/Notifications/Notification Providers/RegisteredDeviceInAppNotificationProvider.swift index 8ac30f74f716..b9b1a9c8e6e2 100644 --- a/ios/MullvadVPN/Notifications/Notification Providers/RegisteredDeviceInAppNotificationProvider.swift +++ b/ios/MullvadVPN/Notifications/Notification Providers/RegisteredDeviceInAppNotificationProvider.swift @@ -36,9 +36,10 @@ final class RegisteredDeviceInAppNotificationProvider: NotificationProvider, let stylingOptions = MarkdownStylingOptions(font: .systemFont(ofSize: 14.0)) return NSAttributedString(markdownString: string, options: stylingOptions) { markdownType, _ in - switch markdownType { - case .bold: + if case .bold = markdownType { return [.foregroundColor: UIColor.InAppNotificationBanner.titleColor] + } else { + return [:] } } } diff --git a/ios/MullvadVPN/UI appearance/NSDirectionalEdgeInsets+Helpers.swift b/ios/MullvadVPN/UI appearance/NSDirectionalEdgeInsets+Helpers.swift new file mode 100644 index 000000000000..e5bda17eb327 --- /dev/null +++ b/ios/MullvadVPN/UI appearance/NSDirectionalEdgeInsets+Helpers.swift @@ -0,0 +1,21 @@ +// +// NSDirectionalEdgeInsets+Helpers.swift +// MullvadVPN +// +// Created by pronebird on 17/11/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +extension NSDirectionalEdgeInsets { + /// Converts directional edge insets to `UIEdgeInsets` based on interface direction. + func toEdgeInsets(_ interfaceDirection: UIUserInterfaceLayoutDirection) -> UIEdgeInsets { + UIEdgeInsets( + top: top, + left: interfaceDirection == .rightToLeft ? trailing : leading, + bottom: bottom, + right: interfaceDirection == .rightToLeft ? leading : trailing + ) + } +} diff --git a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift index cf70f71585ec..21a08cf894e4 100644 --- a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift +++ b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift @@ -75,6 +75,13 @@ extension UIColor { static let backButtonIndicatorColor = UIColor(white: 1.0, alpha: 0.4) static let backButtonTitleColor = UIColor(white: 1.0, alpha: 0.6) static let titleColor = UIColor.white + static let promptColor = UIColor.white + } + + // Heading displayed below the navigation bar. + enum ContentHeading { + static let textColor = UIColor(white: 1.0, alpha: 0.6) + static let linkColor = UIColor.white } // Cells @@ -91,6 +98,15 @@ extension UIColor { static let detailTextColor = UIColor(white: 1.0, alpha: 0.8) static let disclosureIndicatorColor = UIColor(white: 1.0, alpha: 0.8) + static let textFieldTextColor = UIColor.white + static let textFieldPlaceholderColor = UIColor(white: 1.0, alpha: 0.6) + + static let validationErrorBorderColor = UIColor.dangerColor + } + + enum TableSection { + static let headerTextColor = UIColor(white: 1.0, alpha: 0.8) + static let footerTextColor = UIColor(white: 1.0, alpha: 0.6) } enum SubCell { @@ -127,4 +143,7 @@ extension UIColor { static let dangerColor = UIColor(red: 0.89, green: 0.25, blue: 0.22, alpha: 1.0) static let warningColor = UIColor(red: 1.0, green: 0.84, blue: 0.14, alpha: 1.0) static let successColor = UIColor(red: 0.27, green: 0.68, blue: 0.30, alpha: 1.0) + + static let primaryTextColor = UIColor.white + static let secondaryTextColor = UIColor(white: 1.0, alpha: 0.8) } diff --git a/ios/MullvadVPN/UI appearance/UIEdgeInsets+Extensions.swift b/ios/MullvadVPN/UI appearance/UIEdgeInsets+Extensions.swift index 8a89bb99a946..343543b9e7a6 100644 --- a/ios/MullvadVPN/UI appearance/UIEdgeInsets+Extensions.swift +++ b/ios/MullvadVPN/UI appearance/UIEdgeInsets+Extensions.swift @@ -10,6 +10,7 @@ import Foundation import UIKit extension UIEdgeInsets { + /// Returns directional edge insets mapping left edge to leading and right edge to trailing. var toDirectionalInsets: NSDirectionalEdgeInsets { NSDirectionalEdgeInsets( top: top, diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 74086e46f569..33c2e69e926a 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -50,6 +50,11 @@ enum UIMetrics { static let animationOptions: UIView.AnimationOptions = [.curveEaseInOut] } + enum AccessMethodActionSheetTransition { + static let duration: Duration = .milliseconds(250) + static let animationOptions: UIView.AnimationOptions = [.curveEaseInOut] + } + enum SettingsRedeemVoucher { static let cornerRadius = 8.0 static let preferredContentSize = CGSize(width: 280, height: 260) @@ -72,6 +77,9 @@ enum UIMetrics { static let inputCellTextFieldLayoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) static let selectableSettingsCellLeftViewSpacing: CGFloat = 12 static let checkableSettingsCellLeftViewSpacing: CGFloat = 20 + + /// Cell layout margins used in table views that use inset style. + static let insetLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24) } enum InAppBannerNotification { @@ -150,4 +158,7 @@ extension UIMetrics { /// Common layout margins for location cell presentation static let selectLocationCellLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 28, bottom: 16, trailing: 12) + + /// Layout margins used by content heading displayed below the large navigation title. + static let contentHeadingLayoutMargins = NSDirectionalEdgeInsets(top: 8, leading: 24, bottom: 24, trailing: 24) } diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift b/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift index 789462cb9a4d..33dd84f8490d 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsCell.swift @@ -17,18 +17,18 @@ enum SettingsDisclosureType { var image: UIImage? { switch self { case .none: - return nil + nil case .chevron: - return UIImage(named: "IconChevron") + UIImage(resource: .iconChevron) case .externalLink: - return UIImage(named: "IconExtlink") + UIImage(resource: .iconExtlink) case .tick: - return UIImage(named: "IconTickSml") + UIImage(resource: .iconTickSml) } } } -class SettingsCell: UITableViewCell { +class SettingsCell: UITableViewCell, CustomCellDisclosureHandling { typealias InfoButtonHandler = () -> Void let contentContainerSubviewMaxCount = 2 diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift index 3bb4440fa13e..5a7e40b8e639 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift @@ -78,6 +78,19 @@ struct SettingsCellFactory: CellFactoryProtocol { cell.detailTitleLabel.text = nil cell.accessibilityIdentifier = nil cell.disclosureType = .externalLink + + case .apiAccess: + guard let cell = cell as? SettingsCell else { return } + + cell.titleLabel.text = NSLocalizedString( + "API_ACCESS_CELL_LABEL", + tableName: "Settings", + value: "API access", + comment: "" + ) + cell.detailTitleLabel.text = nil + cell.accessibilityIdentifier = nil + cell.disclosureType = .chevron } } } diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift index f4aebdd8e8ee..535871c7429b 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift @@ -9,10 +9,8 @@ import MullvadSettings import UIKit -final class SettingsDataSource: UITableViewDiffableDataSource< - SettingsDataSource.Section, - SettingsDataSource.Item ->, UITableViewDelegate { +final class SettingsDataSource: UITableViewDiffableDataSource, + UITableViewDelegate { enum CellReuseIdentifiers: String, CaseIterable { case basicCell @@ -40,6 +38,7 @@ final class SettingsDataSource: UITableViewDiffableDataSource< case version case problemReport case faq + case apiAccess var reuseIdentifier: CellReuseIdentifiers { .basicCell @@ -134,6 +133,13 @@ final class SettingsDataSource: UITableViewDiffableDataSource< snapshot.appendItems([.preferences], toSection: .main) } + #if DEBUG + if !snapshot.sectionIdentifiers.contains(.main) { + snapshot.appendSections([.main]) + } + snapshot.appendItems([.apiAccess], toSection: .main) + #endif + snapshot.appendSections([.version, .problemReport]) snapshot.appendItems([.version], toSection: .version) snapshot.appendItems([.problemReport, .faq], toSection: .problemReport) diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift b/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift index 3f8e8bf31332..4d072a1e278e 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsViewController.swift @@ -86,6 +86,8 @@ extension SettingsDataSource.Item { return .problemReport case .faq: return .faq + case .apiAccess: + return .apiAccess } } } diff --git a/ios/MullvadVPN/Views/AppButton.swift b/ios/MullvadVPN/Views/AppButton.swift index 2fc3603e2212..c63ac8aaca65 100644 --- a/ios/MullvadVPN/Views/AppButton.swift +++ b/ios/MullvadVPN/Views/AppButton.swift @@ -100,6 +100,7 @@ class LinkButton: CustomButton { /// A subclass that implements action buttons used across the app class AppButton: CustomButton { + /// Default content insets based on current trait collection. var defaultContentInsets: UIEdgeInsets { switch traitCollection.userInterfaceIdiom { case .phone: @@ -112,44 +113,94 @@ class AppButton: CustomButton { } enum Style: Int { + /// Default blue appearance. case `default` + + /// Destructive appearance. case danger + + /// Positive appearance suitable for actions that provide security. case success + + /// Translucent destructive appearance. case translucentDanger + + /// Translucent neutral appearance. case translucentNeutral + + /// Translucent destructive appearance for the left-hand side of a two component split button. case translucentDangerSplitLeft + + /// Translucent destructive appearance for the right-hand side of a two component split button. case translucentDangerSplitRight - var backgroundImage: UIImage? { + /// Default blue rounded button suitable for presentation in table view using `.insetGrouped` style. + case tableInsetGroupedDefault + + /// Positive appearance for presentation in table view using `.insetGrouped` style. + case tableInsetGroupedSuccess + + /// Destructive style suitable for presentation in table view using `.insetGrouped` style. + case tableInsetGroupedDanger + + /// Returns a background image for the button. + var backgroundImage: UIImage { switch self { case .default: - return UIImage(named: "DefaultButton") + UIImage(resource: .defaultButton) case .danger: - return UIImage(named: "DangerButton") + UIImage(resource: .dangerButton) case .success: - return UIImage(named: "SuccessButton") + UIImage(resource: .successButton) case .translucentDanger: - return UIImage(named: "TranslucentDangerButton") + UIImage(resource: .translucentDangerButton) case .translucentNeutral: - return UIImage(named: "TranslucentNeutralButton") + UIImage(resource: .translucentNeutralButton) case .translucentDangerSplitLeft: - return UIImage(named: "TranslucentDangerSplitLeftButton")? - .imageFlippedForRightToLeftLayoutDirection() + UIImage(resource: .translucentDangerSplitLeftButton).imageFlippedForRightToLeftLayoutDirection() case .translucentDangerSplitRight: - return UIImage(named: "TranslucentDangerSplitRightButton")? - .imageFlippedForRightToLeftLayoutDirection() + UIImage(resource: .translucentDangerSplitRightButton).imageFlippedForRightToLeftLayoutDirection() + case .tableInsetGroupedDefault: + DynamicAssets.shared.tableInsetGroupedDefaultBackground + case .tableInsetGroupedSuccess: + DynamicAssets.shared.tableInsetGroupedSuccessBackground + case .tableInsetGroupedDanger: + DynamicAssets.shared.tableInsetGroupedDangerBackground } } } + /// Button style. var style: Style { didSet { updateButtonBackground() } } + /// Prevents updating `contentEdgeInsets` on changes to trait collection. var overrideContentEdgeInsets = false + override var contentEdgeInsets: UIEdgeInsets { + didSet { + // Reset inner directional insets when contentEdgeInsets are set directly. + innerDirectionalContentEdgeInsets = nil + } + } + + /// Directional content edge insets that are automatically translated into `contentEdgeInsets` immeditely upon updating the property and on trait collection + /// changes. + var directionalContentEdgeInsets: NSDirectionalEdgeInsets { + get { + innerDirectionalContentEdgeInsets ?? contentEdgeInsets.toDirectionalInsets + } + set { + innerDirectionalContentEdgeInsets = newValue + updateContentEdgeInsetsFromDirectional() + } + } + + private var innerDirectionalContentEdgeInsets: NSDirectionalEdgeInsets? + init(style: Style) { self.style = style super.init(frame: .zero) @@ -167,25 +218,7 @@ class AppButton: CustomButton { } private func commonInit() { - var contentInsets = contentEdgeInsets - - if contentInsets.top == 0 { - contentInsets.top = defaultContentInsets.top - } - - if contentInsets.bottom == 0 { - contentInsets.bottom = defaultContentInsets.bottom - } - - if contentInsets.right == 0 { - contentInsets.right = defaultContentInsets.right - } - - if contentInsets.left == 0 { - contentInsets.left = defaultContentInsets.left - } - - contentEdgeInsets = contentInsets + super.contentEdgeInsets = defaultContentInsets imageAlignment = .trailingFixed titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .semibold) @@ -203,16 +236,26 @@ class AppButton: CustomButton { } } + /// Set background image based on current style. private func updateButtonBackground() { setBackgroundImage(style.backgroundImage, for: .normal) } + /// Update content edge insets from directional edge insets if set. + private func updateContentEdgeInsetsFromDirectional() { + guard let directionalEdgeInsets = innerDirectionalContentEdgeInsets else { return } + super.contentEdgeInsets = directionalEdgeInsets.toEdgeInsets(effectiveUserInterfaceLayoutDirection) + } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - if traitCollection.userInterfaceIdiom != previousTraitCollection?.userInterfaceIdiom, - !overrideContentEdgeInsets { - contentEdgeInsets = defaultContentInsets + if traitCollection.userInterfaceIdiom != previousTraitCollection?.userInterfaceIdiom { + if overrideContentEdgeInsets { + updateContentEdgeInsetsFromDirectional() + } else { + contentEdgeInsets = defaultContentInsets + } } } } @@ -356,3 +399,37 @@ class CustomButton: UIButton { computeLayout(forContentRect: contentRect).0 } } + +private extension AppButton { + class DynamicAssets { + static let shared = DynamicAssets() + + private init() {} + + /// Default cell corner radius in inset grouped table view + private let tableViewCellCornerRadius: CGFloat = 10 + + lazy var tableInsetGroupedDefaultBackground: UIImage = { + roundedRectImage(fillColor: .primaryColor) + }() + + lazy var tableInsetGroupedSuccessBackground: UIImage = { + roundedRectImage(fillColor: .successColor) + }() + + lazy var tableInsetGroupedDangerBackground: UIImage = { + roundedRectImage(fillColor: .dangerColor) + }() + + private func roundedRectImage(fillColor: UIColor) -> UIImage { + let cornerRadius = tableViewCellCornerRadius + let bounds = CGRect(x: 0, y: 0, width: 44, height: 44) + let image = UIGraphicsImageRenderer(bounds: bounds).image { _ in + fillColor.setFill() + UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).fill() + } + let caps = UIEdgeInsets(top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius) + return image.resizableImage(withCapInsets: caps) + } + } +}