From 84934278114a426ddcec95f202a3bd230a2a5750 Mon Sep 17 00:00:00 2001 From: kp-laura-sempere <46663952+kp-laura-sempere@users.noreply.github.com> Date: Wed, 14 Feb 2024 14:54:56 +0100 Subject: [PATCH] PIA-1298: Home screen design (#79) * PIA-1298: Add publisher to retrieve the selected location * PIA-1298: Add single source of truth for connectivity state to show in UI * PIA-1298: Update dashboard selected location section * PIA-1298: Quick connect section and update regions list styles * PIA-1298: Add Connection state monitor tests --- .../Assets.xcassets/Dashboard/Contents.json | 6 + .../Contents.json | 16 ++ .../connect-inner-button.imageset/connect.pdf | Bin 0 -> 1773 bytes .../Assets.xcassets/Regions/Contents.json | 6 + .../Contents.json | 15 ++ .../icon-smart-location-highlighted.pdf | Bin 0 -> 4236 bytes .../Contents.json | 15 ++ .../icon-smart-location.pdf | Bin 0 -> 4236 bytes .../CompositionRoot/DashboardFactory.swift | 27 +-- .../Presentation/DashboardViewModel.swift | 4 - .../PIAConnectionButtonViewModel.swift | 96 +++++----- .../QuickConnectButtonViewModel.swift | 15 +- .../Presentation/QuickConnectViewModel.swift | 14 +- .../SelectedServerViewModel.swift | 51 +++++- PIA VPN-tvOS/Dashboard/UI/DashboardView.swift | 52 ++---- .../Dashboard/UI/PIAConnectionButton.swift | 79 ++++++--- .../Dashboard/UI/QuickConnectButton.swift | 25 ++- .../Dashboard/UI/QuickConnectView.swift | 14 +- .../Dashboard/UI/SelectedServerView.swift | 52 ++++-- .../UseCases/SelectedServerUseCase.swift | 9 +- .../UseCases/VpnConnectionUseCase.swift | 67 ++++--- .../RegionsSelectionFactory.swift | 7 +- .../UI/RegionsContainerView.swift | 21 ++- .../UI/RegionsListItemButton.swift | 2 +- .../RegionsSelection/UI/RegionsListView.swift | 6 +- .../UseCases/RegionsListUseCase.swift | 18 +- .../RootContainerFactory.swift | 1 + .../Presentation/RootContainerViewModel.swift | 6 +- .../RootContainer/UI/RootContainerView.swift | 10 +- .../VpnConnectionFactory.swift | 35 ++++ .../ConnectionStateMonitor.swift | 116 ++++++++++++ .../StateMonitors/StateMonitorsFactory.swift | 6 + .../UI/Modifiers/ButtonStyleModifier.swift | 18 ++ .../Shared/UI/PIAImages+SwiftUI.swift | 8 + PIA VPN-tvOS/Shared/UI/PIAStyles.swift | 8 + .../Shared/UI/Views/View+Extensions.swift | 18 ++ .../ClientPreferences+Protocols.swift | 27 ++- .../PIALibrary+Protocols.swift | 22 ++- .../Common/ConnectionStateMonitorTests.swift | 165 ++++++++++++++++++ .../Mocks/ConnectionStateMonitorMock.swift | 19 ++ .../Mocks/SelectedServerUseCaseMock.swift | 12 +- .../Common/Mocks/ServerMock.swift | 4 +- .../Mocks/VpnConnectionUseCaseMock.swift | 20 +-- .../Common/VpnConnectionUseCaseTests.swift | 129 ++++++++++++++ .../Dashboard/DashboardViewModelTests.swift | 22 --- .../PIAConnectionButtonViewModelTests.swift | 54 +++--- .../QuickConnectViewModelTests.swift | 19 +- .../RootContainerViewModelTests.swift | 6 +- .../Shared/Mocks/ServerProviderMock.swift | 30 ++++ .../Shared/Mocks/VPNStatusProviderMock.swift | 22 +++ PIA VPN.xcodeproj/project.pbxproj | 34 +++- PIA VPN/AppPreferences.swift | 1 + PIA VPN/Server+Automatic.swift | 1 + PIA VPN/ServerProvider+UI.swift | 4 + PIA VPN/SwiftGen+Strings.swift | 24 +++ PIA VPN/en.lproj/Localizable.strings | 10 ++ 56 files changed, 1155 insertions(+), 313 deletions(-) create mode 100644 PIA VPN-tvOS/Assets.xcassets/Dashboard/Contents.json create mode 100644 PIA VPN-tvOS/Assets.xcassets/Dashboard/connect-inner-button.imageset/Contents.json create mode 100644 PIA VPN-tvOS/Assets.xcassets/Dashboard/connect-inner-button.imageset/connect.pdf create mode 100644 PIA VPN-tvOS/Assets.xcassets/Regions/Contents.json create mode 100644 PIA VPN-tvOS/Assets.xcassets/Regions/icon-smart-location-highlighted.imageset/Contents.json create mode 100644 PIA VPN-tvOS/Assets.xcassets/Regions/icon-smart-location-highlighted.imageset/icon-smart-location-highlighted.pdf create mode 100644 PIA VPN-tvOS/Assets.xcassets/Regions/icon-smart-location.imageset/Contents.json create mode 100644 PIA VPN-tvOS/Assets.xcassets/Regions/icon-smart-location.imageset/icon-smart-location.pdf create mode 100644 PIA VPN-tvOS/Shared/CompositionRoot/VpnConnectionFactory.swift create mode 100644 PIA VPN-tvOS/Shared/StateMonitors/ConnectionStateMonitor.swift create mode 100644 PIA VPN-tvOS/Shared/UI/Modifiers/ButtonStyleModifier.swift create mode 100644 PIA VPN-tvOS/Shared/UI/Views/View+Extensions.swift create mode 100644 PIA VPN-tvOSTests/Common/ConnectionStateMonitorTests.swift create mode 100644 PIA VPN-tvOSTests/Common/Mocks/ConnectionStateMonitorMock.swift create mode 100644 PIA VPN-tvOSTests/Common/VpnConnectionUseCaseTests.swift create mode 100644 PIA VPN-tvOSTests/Shared/Mocks/ServerProviderMock.swift diff --git a/PIA VPN-tvOS/Assets.xcassets/Dashboard/Contents.json b/PIA VPN-tvOS/Assets.xcassets/Dashboard/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/PIA VPN-tvOS/Assets.xcassets/Dashboard/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PIA VPN-tvOS/Assets.xcassets/Dashboard/connect-inner-button.imageset/Contents.json b/PIA VPN-tvOS/Assets.xcassets/Dashboard/connect-inner-button.imageset/Contents.json new file mode 100644 index 000000000..f96e2b4b7 --- /dev/null +++ b/PIA VPN-tvOS/Assets.xcassets/Dashboard/connect-inner-button.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "connect.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/PIA VPN-tvOS/Assets.xcassets/Dashboard/connect-inner-button.imageset/connect.pdf b/PIA VPN-tvOS/Assets.xcassets/Dashboard/connect-inner-button.imageset/connect.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8678c23d09dd6023310fd707bb08366b064ca862 GIT binary patch literal 1773 zcmZuy!EPHz486}+@M0h-;1P!$4rhQMKw~#W(H3=;-hv)fdE=Fo zy;Bf$h0m6uMNmbTu34ZR{7{Uy!l z9+-I+FH~w&FSQ_JiB(;^QnA=tWn^3ik7f>-3X1QME~By&hI-0o>1ni=h?KBntfR=;2(DHZ6XemTsb=BcL2gap zZz++b;x%gH?A9{$>F?RtjfzKctByRVokdisv{+0E4D6a!VeZiopQTxXk_JPP_ZYA& zP``8+V21YD`pT3o!^e;XJ}Eq-#0+~dRgCe=edwx#^a0KoOUe!g5Y(yFn55g=F_>#m zv02QXVH#L@x5Gy?H`hc8o(;SUp<-jNs18h_nK@aCR`@Wk9YwU~10(3y8Ff^l zZynj^TEXauCeBLDqvIBWF{*p249yQRNyET@Rno5Yhi}3<{2KaRIxd~lhU&}cu(?uF zo)%Z0U@WxRx9jz0zoYlRW5MB>{r2x)1I=z0cf%3*dAMCI-fTY8`^OD4t(qiI|FFZ( zj;CDnVYm6TT@E|yOLTrrc(qyYhc#;U4SE7^7F%G1Ju*UPd$vQ<{+<1}x&>M4JjQWS|y2tt4e6Lt}crl*n_)DM{MpupF2uI;+lGsyz4 zYEIktaqi2WCvRT8{@R+Xl+}|y{r-p2`uTJH;)Pm{H}t1+Bfj}@Iexg@Y7g+5cAYQx z%l4o)ua>{>cFXm5FZI>i@vq%k{iUp#>eAEt@il*a{u}?aJE+OnIZe6Xq_Z}sNFT8j z7xZ~|RE75j{yGe(&1RlTvQ{T&rrG*3iw$yw&+b$|F%A3pSCPh$$k@Tw?q%0Cdo9ww zWT{{~CH4s_Um{VkQT45Qua4@M|4X%FQ=K!>PIF1Rl0AD`py=@jk?oSHKRfNCaUsOX z>x1&%%ob}JP(PcQR?@DR&BL*49KiI+3YxDg+0p z?{nFd22Cc%NO^>#*@UVCG~r_oQSXVHDlhl+idnm83YknPONr4S+qCjg$P25ARmX&$9 z)AxauszYbvT6~byos~>M$u7bafD{7nu&;xF60sK^K*TmujCbLvHHiqF1>7ryJ`j6J zK*X^j$f>55k(nrLk!1{G!6wluvaDl_l?nz^Ld$&Q0Pln?VWrPD!jX{ps?klFe#Jtj z0jrsTq>5x~Mwi+t5YxgT;9eyPA_YXi$WU4j3k15zL6oTyu++*!9f>p)8GKF|tgTuZ zf_tu2Q4N3J2=^F8Um(g7DsRN7$e`r`){AD8*%GgKP~z6=LVuZaEA`=oeb%UfqtkU9;z)KtIUB(wRJ_|Oq zQbF-m+aWR%1x=@GKsyHOXRnuPEXT50Q)CMtu2m5?z+0$~Xm77nDvBQFaOqnvg zQ%D5Gz3T|sL%28qy9eQ-=HKW+xL|uDQc#Sf&Lg#Tq6kSM7<9G_ti!ACM38F!BE&2a zZJ`ewu}QGn`6*X1kOYOteJ~9Y8AI4vx9k)vF-y8c@kVqD_J|L^CMEc&iBByXveu!B z=GZa@ouGDYjA6l0NL0BrKn9eDBT1wI5@=lYdxY2|7)~QDbxw^tF}f%TF+4@0V54q9 z#Y&bqc*Er^81casMZsvviHlJ!5r5MyQR~$Q8kGnd<)IF4IJfP*+LQaovXsZq%_b+z5I}vQB|$PcI^e6@?GXk};z zQ>q_O!5}9Tpe{|IL92lHYLzL{*fG({5K-{{tNI3^hhu*gu1`pMEw-nnk8|XgWndUD z&doQ+D>A+9R&E59p>63ofZ`8;(&RKaE$=53BdO4py z-tU&PrZ?_&7k70!UX~*?(gSw|-)!%JY24W>G(3X|;^*<^hr1h?s>y?Ez3RC-eZ*P& znf+%VgQWC85;=Hgh2r?t_OjidK0G}7>3(^yCY@ESwN`KmmyW0b_k{~g`{BqCu0{_b zp9=h;rPp0PV+cQj3Z~a12v-M&F8ABJ{c^7;df&eS9Z%1v!}3f&y}NnVNZDMUP8UrM eSAsWh@BUq2ef__|_Wn}c36l-#$&+t?e*GUQUsUh_ literal 0 HcmV?d00001 diff --git a/PIA VPN-tvOS/Assets.xcassets/Regions/icon-smart-location.imageset/Contents.json b/PIA VPN-tvOS/Assets.xcassets/Regions/icon-smart-location.imageset/Contents.json new file mode 100644 index 000000000..1bef05539 --- /dev/null +++ b/PIA VPN-tvOS/Assets.xcassets/Regions/icon-smart-location.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "icon-smart-location.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/PIA VPN-tvOS/Assets.xcassets/Regions/icon-smart-location.imageset/icon-smart-location.pdf b/PIA VPN-tvOS/Assets.xcassets/Regions/icon-smart-location.imageset/icon-smart-location.pdf new file mode 100644 index 0000000000000000000000000000000000000000..779182e4b757d2453c63fff070debeaca3e714a2 GIT binary patch literal 4236 zcma)A%Z?m16y5tPyor<`;V!>jQWS|y2tt4e6Lt}crl*n_)DM{MpupF2uI;+lGsyz4 zYEIktaqi2WCvRT8{@R+Xl+}|y{r-p2`uTJH;)Pm{H}t1+Bfj}@Iexg@Y7g+5cAYQx z%l4o)ua>{>cFXm5FZI>i@vq%k{iUp#>eAEt@il*a{u}?aJE+Obo)%6%{y6WBs_@>x zUxxv;+00W(*6QTUG+SS0u|baT*`4YqrePodD$*Dd89Uh8z3jSXuSMFIEER00#6Cgg zOC$<5s=ih4)lvQOf2nqCs&gjVX)Z}uvhAFvTtJbtHm67*u@o0Hk?oSHv2fZ)<3fm& z*9YajnJv~dpnf(pt)yKs$vIc>t6&p!TAOCQQ-a!P68MWn`)neeF4|6}&}ploCD95x z8CS|23LSRFljfp>$uJFsdz_$^wXep`R=e2*T3#as=xD}K*p*4gtgVT3bRtvXR0s}G z-{-O^4Vp}jk@5&fvk6rP*ulpfqTUlXRbKAt6|;8H6f&7uq{L{DZCd##HEM+)uFR-Ej~!<&Pt}BWEWuyKnj6(*w;ZoiP#GdAYvQ!%)4;ZnnZ-o0`3(;ABepq zAmZ2%5x~M&axfh-u*vaIX>tkpd!MWGJnN1p-~f?y z!9CZisD{69gnNvlFA!x3l{aEkWYF>e>qRrlY>8JqC~<3bp})*I7oUk7R23Fwx25Fr zaHh1}AHh8st8qs6;GKz~xE>Y=eTj4s*#bgHOIMMGylIeVWvIoTysw?0Zv;7+n;s~k z<1nlB43D;iM%dy^&5;6%7Q`3|^b0L>uWm=&uGw=?pdVymY0s!dYzcx^d{Leup9LFQ zsi63(?GPD>f~M0opdEwtGg%rnXrL}$o0>PasX=!m(A6f?0+v?F(b8xW42)(%S3p}I zeGIKL`PL8X*_}ixy685EFQ?&xe*hEw@^^`NSAIEEvd?DZ@L3 zL{Qwjj*vZsivzHG5H4!|jUI#xwl^XL#YpNrQd=jAkR*aZXUo7ky!uWAspc<2%o5QS z`oIyJ1go8&auowfPeH0##O&Zh)sgwG~!a{)VLF)i;@t-QzQyD>K0V2 zWQl_}T+V_KA6!usjFz0Z7}XMS7$pq$s}h2SKGhOIprOSJh-(TFlrYODVWL&7XazZI zA>;l-%@h5WLB8xEExI&;2CV|Jbs~*@5UmUm1-KKyp{Wkr+EwUoID#}pvPGHqn+QgB z>zt|933y_J+IWpz5e%29?mzA_P1Fc=l{6jKeW>6@9Sg&apqC`;6o>|OH%&lsfTalp za)@;rW4Eeb;&2n=+&{pWo|FRiOJS{%IvFE+X}?LJ1h{1hh*sM=j!45-yLgILhITNe z`T-RTazX*>(gYf`3W%>(nIerH6Rivp1@FJAZxDJo_E+KhgrwJEds_N9M}AobhVkOu zd~-aWE@%DjH@rZxHoyM!=b|@nwjY)b{IuNMZC{=~>397`jcntbm3NVRt&*;n^XcRL zZaHgu<6d`hSEu7;IYJ{na98ln_8yqVoxMWCGngQL9&diQyMd{iJh;}Yo~zSGoVB0X ze+Dv0N)IHFgJ)JKj$dsr+x_Xo!=s<>m-lMYS=Cx=1($H?h#GKTxWKd@jtt>y^bqo? zz#m$A-Q_ce@FS>TdOd=0bztanzrEWp_llzT{VUM%^n5xj&-Bx~n`e!b&GqSY(d2L? dc=Pt|-v!p!{~K)YFV&qe*`S_0`S$16{{b5vRPX=* literal 0 HcmV?d00001 diff --git a/PIA VPN-tvOS/Dashboard/CompositionRoot/DashboardFactory.swift b/PIA VPN-tvOS/Dashboard/CompositionRoot/DashboardFactory.swift index 7d79e5d90..0e09ffdac 100644 --- a/PIA VPN-tvOS/Dashboard/CompositionRoot/DashboardFactory.swift +++ b/PIA VPN-tvOS/Dashboard/CompositionRoot/DashboardFactory.swift @@ -15,10 +15,8 @@ class DashboardFactory { return DashboardViewModel(accountProvider: defaultAccountProvider, appRouter: AppRouterFactory.makeAppRouter(), navigationDestination: RegionsDestinations.serversList) } - static func makePIAConnectionButton(size: CGFloat = 160, lineWidth: CGFloat = 6) -> PIAConnectionButton { + static func makePIAConnectionButton() -> PIAConnectionButton { return PIAConnectionButton( - size: size, - lineWidth: lineWidth, viewModel: makePIAConnectionButtonViewModel() ) } @@ -29,20 +27,17 @@ class DashboardFactory { extension DashboardFactory { private static func makePIAConnectionButtonViewModel() -> PIAConnectionButtonViewModel { - return PIAConnectionButtonViewModel(useCase: makeVpnConnectionUseCase(), - vpnStatusMonitor: StateMonitorsFactory.makeVPNStatusMonitor()) + return PIAConnectionButtonViewModel(useCase: VpnConnectionFactory.makeVpnConnectionUseCase, connectionStateMonitor: StateMonitorsFactory.makeConnectionStateMonitor) } - private static func makeVpnConnectionUseCase() -> VpnConnectionUseCaseType { - return VpnConnectionUseCase(serverProvider: makeServerProvider()) - } + private static func makeSelectedServerUserCase() -> SelectedServerUseCaseType { - return SelectedServerUseCase(serverProvider: makeServerProvider(), clientPreferences: Client.preferences) + return SelectedServerUseCase(serverProvider: VpnConnectionFactory.makeServerProvider(), clientPreferences: RegionsSelectionFactory.makeClientPreferences) } private static func makeSelectedServerViewModel() -> SelectedServerViewModel { - return SelectedServerViewModel(useCase: makeSelectedServerUserCase()) + return SelectedServerViewModel(useCase: makeSelectedServerUserCase(), routerAction: .navigate(router: AppRouterFactory.makeAppRouter(), destination: RegionsDestinations.serversList)) } internal static func makeSelectedServerView() -> SelectedServerView { @@ -56,15 +51,7 @@ extension DashboardFactory { extension DashboardFactory { - static func makeServerProvider() -> ServerProviderType { - guard let defaultServerProvider: DefaultServerProvider = - Client.providers.serverProvider as? DefaultServerProvider else { - fatalError("Incorrect server provider type") - } - - return defaultServerProvider - - } + static internal func makeQuickConnectButtonViewModel(for server: ServerType, delegate: QuickConnectButtonViewModelDelegate?) -> QuickConnectButtonViewModel { QuickConnectButtonViewModel(server: server, delegate: delegate) @@ -75,7 +62,7 @@ extension DashboardFactory { } static internal func makeQuickConnectViewModel() -> QuickConnectViewModel { - QuickConnectViewModel(connectUseCase: makeVpnConnectionUseCase(), selectedServerUseCase: makeSelectedServerUserCase()) + QuickConnectViewModel(selectedServerUseCase: makeSelectedServerUserCase(), regionsUseCase: RegionsSelectionFactory.makeRegionsListUseCase()) } static func makeQuickConnectView() -> QuickConnectView { diff --git a/PIA VPN-tvOS/Dashboard/Presentation/DashboardViewModel.swift b/PIA VPN-tvOS/Dashboard/Presentation/DashboardViewModel.swift index eb2ee2acd..63c662189 100644 --- a/PIA VPN-tvOS/Dashboard/Presentation/DashboardViewModel.swift +++ b/PIA VPN-tvOS/Dashboard/Presentation/DashboardViewModel.swift @@ -14,10 +14,6 @@ class DashboardViewModel: ObservableObject { self.navigationDestination = navigationDestination } - func regionSelectionSectionWasTapped() { - appRouter.navigate(to: navigationDestination) - } - // TODO: Remove this functionality from Dashboard when we have it on the settings menu func logOut() { diff --git a/PIA VPN-tvOS/Dashboard/Presentation/PIAConnectionButtonViewModel.swift b/PIA VPN-tvOS/Dashboard/Presentation/PIAConnectionButtonViewModel.swift index 064b08cd2..635fb8c0d 100644 --- a/PIA VPN-tvOS/Dashboard/Presentation/PIAConnectionButtonViewModel.swift +++ b/PIA VPN-tvOS/Dashboard/Presentation/PIAConnectionButtonViewModel.swift @@ -5,64 +5,69 @@ import SwiftUI import PIALibrary import Combine -extension VPNStatus { - func toConnectionButtonState() -> PIAConnectionButtonViewModel.State { - switch self { - case .connected: - return .connected - case .connecting: - return .connecting - case .disconnecting: - return .disconnecting - default: - return .disconnected - } - } -} + class PIAConnectionButtonViewModel: ObservableObject { - enum State { - case disconnected - case connecting - case connected - case disconnecting + + + @Published var state: ConnectionState = .disconnected + var animating: Bool { + state == .connecting || state == .disconnecting } - @Published var state: State = .disconnected + @Published var isShowingErrorAlert: Bool = false private let vpnConnectionUseCase: VpnConnectionUseCaseType - private let vpnStatusMonitor: VPNStatusMonitorType + private let connectionStateMonitor: ConnectionStateMonitorType private var cancellables = Set() - init(useCase: VpnConnectionUseCaseType, vpnStatusMonitor: VPNStatusMonitorType) { + init(useCase: VpnConnectionUseCaseType, connectionStateMonitor: ConnectionStateMonitorType) { self.vpnConnectionUseCase = useCase - self.vpnStatusMonitor = vpnStatusMonitor + self.connectionStateMonitor = connectionStateMonitor addObservers() } + var errorAlertTitle: String { + L10n.Localizable.ErrorAlert.ConnectionError.NoNetwork.title + } + + var errorAlertMessage: String { + L10n.Localizable.ErrorAlert.ConnectionError.NoNetwork.message + } + + var errorAlertRetryActionTitle: String { + L10n.Localizable.ErrorAlert.ConnectionError.NoNetwork.RetryAction.title + } + + var errorAlertCloseActionTitle: String { + L10n.Localizable.Global.close + } + + private func addObservers() { - vpnStatusMonitor.getStatus().sink { [weak self] vpnStatus in - self?.state = vpnStatus.toConnectionButtonState() - }.store(in: &cancellables) + connectionStateMonitor.connectionStatePublisher + .sink { newConnectionState in + self.state = newConnectionState + }.store(in: &cancellables) + } - // Inner ring color and outer ring color - var tintColor: (Color, Color) { + var tintColor: Color { switch state { case .disconnected: - return (.pia_red_dark, .pia_red_dark) + return .pia_yellow_dark case .connecting, .disconnecting: - return (.pia_yellow_dark, .pia_surface_container_secondary) + return .pia_yellow_dark case .connected: - return (.pia_primary, .pia_primary) + return .pia_primary + case .error: + return .pia_red + default: + return .pia_yellow_dark } } - var animating: Bool { - state == .connecting || - state == .disconnecting - } } // MARK: Connection @@ -74,20 +79,33 @@ extension PIAConnectionButtonViewModel { case .disconnected: connect() case .connecting: - break + disconnect() case .connected: disconnect() - case .disconnecting: + case .disconnecting, .unkown: break + case .error: + connect() } } private func connect() { - vpnConnectionUseCase.connect() + Task { + do { + try await vpnConnectionUseCase.connect() + } catch { + DispatchQueue.main.async { + self.isShowingErrorAlert = true + } + } + } } private func disconnect() { - vpnConnectionUseCase.disconnect() + Task { + try await vpnConnectionUseCase.disconnect() + } + } } diff --git a/PIA VPN-tvOS/Dashboard/Presentation/QuickConnectButtonViewModel.swift b/PIA VPN-tvOS/Dashboard/Presentation/QuickConnectButtonViewModel.swift index d0583327a..071d1645d 100644 --- a/PIA VPN-tvOS/Dashboard/Presentation/QuickConnectButtonViewModel.swift +++ b/PIA VPN-tvOS/Dashboard/Presentation/QuickConnectButtonViewModel.swift @@ -9,23 +9,28 @@ class QuickConnectButtonViewModel: ObservableObject { private let server: ServerType - @Published var flagName = "" + + var flagName: String { + "flag-\(server.country.lowercased())" + } + + var titleText: String { + server.country.uppercased() + } weak var delegate: QuickConnectButtonViewModelDelegate? init(server: ServerType, delegate: QuickConnectButtonViewModelDelegate?) { self.server = server self.delegate = delegate - updateStatus() + } func connectButtonDidTap() { delegate?.quickConnectButtonViewModel(didSelect: server) } - private func updateStatus() { - flagName = "flag-\(server.country.lowercased())" - } + } diff --git a/PIA VPN-tvOS/Dashboard/Presentation/QuickConnectViewModel.swift b/PIA VPN-tvOS/Dashboard/Presentation/QuickConnectViewModel.swift index 14bc2eb15..9c4fc2b94 100644 --- a/PIA VPN-tvOS/Dashboard/Presentation/QuickConnectViewModel.swift +++ b/PIA VPN-tvOS/Dashboard/Presentation/QuickConnectViewModel.swift @@ -5,18 +5,19 @@ class QuickConnectViewModel: ObservableObject { @Published private (set) var servers: [ServerType] = [] - private let connectUseCase: VpnConnectionUseCaseType private let selectedServerUseCase: SelectedServerUseCaseType + private let regionsUseCase: RegionsListUseCaseType - init(connectUseCase: VpnConnectionUseCaseType, - selectedServerUseCase: SelectedServerUseCaseType) { - self.connectUseCase = connectUseCase + init(selectedServerUseCase: SelectedServerUseCaseType, + regionsUseCase: RegionsListUseCaseType) { self.selectedServerUseCase = selectedServerUseCase + self.regionsUseCase = regionsUseCase } func updateStatus() { - servers = selectedServerUseCase.getHistoricalServers().reversed() + let allHistoricalServers = selectedServerUseCase.getHistoricalServers().reversed().dropFirst() + servers = Array(allHistoricalServers.prefix(4)) } } @@ -24,7 +25,8 @@ class QuickConnectViewModel: ObservableObject { extension QuickConnectViewModel: QuickConnectButtonViewModelDelegate { func quickConnectButtonViewModel(didSelect server: ServerType) { - connectUseCase.connect(to: server) + regionsUseCase.select(server: server) + updateStatus() } } diff --git a/PIA VPN-tvOS/Dashboard/Presentation/SelectedServerViewModel.swift b/PIA VPN-tvOS/Dashboard/Presentation/SelectedServerViewModel.swift index 7c7d93cc0..e8cad5d72 100644 --- a/PIA VPN-tvOS/Dashboard/Presentation/SelectedServerViewModel.swift +++ b/PIA VPN-tvOS/Dashboard/Presentation/SelectedServerViewModel.swift @@ -3,18 +3,61 @@ import Foundation class SelectedServerViewModel: ObservableObject { - let selectedSeverSectionTitle = L10n.Localizable.Tiles.Region.title.uppercased() - @Published var serverName: String = "" + let useCase: SelectedServerUseCaseType + let routerAction: AppRouter.Actions + @Published var selectedServer: ServerType? + + var selectedSeverTitle: String { + let genericTitle = L10n.Localizable.LocationSelection.AnyOtherLocation.title + guard let selectedServer else { + return genericTitle + } + if selectedServer.isAutomatic { + return L10n.Localizable.LocationSelection.OptimalLocation.title + } else { + return genericTitle + } + } - init(useCase: SelectedServerUseCaseType) { + var selectedServerSubtitle: String { + guard let selectedServer else { return "" } + return selectedServer.name + } + + init(useCase: SelectedServerUseCaseType, routerAction: AppRouter.Actions) { self.useCase = useCase + self.routerAction = routerAction updateState() } + private var focusedAutomaticServerIconName: String { + .smart_location_icon_highlighted_name + } + + private var unfocusedAutomaticServerIconName: String { + .smart_location_icon_name + } + + func iconImageNameFor(focused: Bool) -> String { + guard let currentServer = selectedServer else { return "" } + if currentServer.isAutomatic { + let autoIcon = focused ? focusedAutomaticServerIconName : unfocusedAutomaticServerIconName + return autoIcon + } else { + return "flag-\(currentServer.country.lowercased())" + } + } + + func selectedServerSectionWasTapped() { + routerAction.callAsFunction() + } + private func updateState() { - serverName = useCase.getSelectedServer().name + useCase.getSelectedServer() + .map{ $0 } + .assign(to: &$selectedServer) } } diff --git a/PIA VPN-tvOS/Dashboard/UI/DashboardView.swift b/PIA VPN-tvOS/Dashboard/UI/DashboardView.swift index 84e18a75f..4e1b188d6 100644 --- a/PIA VPN-tvOS/Dashboard/UI/DashboardView.swift +++ b/PIA VPN-tvOS/Dashboard/UI/DashboardView.swift @@ -2,43 +2,22 @@ import SwiftUI struct DashboardView: View { - let viewWidth = UIScreen.main.bounds.width - let viewHeight = UIScreen.main.bounds.height @ObservedObject var viewModel: DashboardViewModel var body: some View { - VStack { - VStack(alignment: .leading) { - DashboardConnectionButtonSection() - .padding() - - Divider() - - SelectedServerSection(onRegionSelectionSectionTapped: viewModel.regionSelectionSectionWasTapped) - .padding() - - Divider() - - QuickConnectSection() - .frame(width: (viewWidth/2)) - - Divider() - - // TODO: Remove logout button from the Dashboard - // when we have it in the settings screen - Button { - viewModel.logOut() - } label: { - Text(L10n.Localizable.Menu.Logout.confirm) - } - - } - .frame(maxWidth: (viewWidth/2)) - .padding() + VStack(alignment: .leading) { + DashboardConnectionButtonSection() + .padding(.bottom, 80) + + SelectedServerSection() + .padding(.bottom, 40) + + QuickConnectSection() } - .frame(width: viewWidth, height: viewHeight) + .frame(width: Spacing.dashboardViewWidth) + } } @@ -55,17 +34,8 @@ fileprivate struct DashboardConnectionButtonSection: View { } fileprivate struct SelectedServerSection: View { - - var onRegionSelectionSectionTapped: () -> Void - var body: some View { - Button { - onRegionSelectionSectionTapped() - } label: { - DashboardFactory.makeSelectedServerView() - } - .buttonStyle(.plain) - .buttonBorderShape(.roundedRectangle(radius: 4)) + DashboardFactory.makeSelectedServerView() } } diff --git a/PIA VPN-tvOS/Dashboard/UI/PIAConnectionButton.swift b/PIA VPN-tvOS/Dashboard/UI/PIAConnectionButton.swift index 1f7aa9f87..13968c4f9 100644 --- a/PIA VPN-tvOS/Dashboard/UI/PIAConnectionButton.swift +++ b/PIA VPN-tvOS/Dashboard/UI/PIAConnectionButton.swift @@ -2,10 +2,16 @@ import SwiftUI + struct PIAConnectionButton: View { - var size: CGFloat = 160 + var size: CGFloat = 256 var lineWidth: CGFloat = 6 + @FocusState var isFocused: Bool + var animation: Animation { + Animation.linear + } + @ObservedObject var viewModel:PIAConnectionButtonViewModel var body: some View { @@ -13,37 +19,64 @@ struct PIAConnectionButton: View { viewModel.toggleConnection() } label: { ZStack { - connectingRing(for: viewModel.tintColor.0) - .frame(width: size) - .opacity(viewModel.animating ? 1 : 0) - .animation(Animation - .easeOut(duration: 1) - .repeat(while: viewModel.animating), value: viewModel.animating) + animatedRing(with: viewModel.tintColor) Circle() - .stroke(style: StrokeStyle(lineWidth: lineWidth)) - .foregroundStyle(viewModel.animating ? .tertiary : .primary) - .foregroundColor(viewModel.tintColor.1) - .frame(width: size) - - Image("vpn-button") - .resizable() - .renderingMode(.template) - .foregroundColor(viewModel.tintColor.0) - .frame(width: (size-100), height: (size-100)) + .fill(Color.pia_background) + .frame(width: size - lineWidth) + + if !viewModel.animating { + connectionStatusOuterRing() + } + connectionStatusInnerImage() } - + .frame(width: size + 40, height: size + 40) + .scaleEffect(isFocused ? 1.15 : 1) + .animation(.easeOut, value: isFocused) + + } + .focused($isFocused) + .buttonStyle(BasicButtonStyle()) + .buttonBorderShape(ButtonBorderShape.circle) + .alert(viewModel.errorAlertTitle, isPresented: $viewModel.isShowingErrorAlert) { + Button(viewModel.errorAlertCloseActionTitle, role: .cancel) { + + } + Button("Retry", role: .none) { + viewModel.toggleConnection() + } + } message: { + Text("Please check your internet connection and try again") } - .buttonStyle(.card) - .buttonBorderShape(ButtonBorderShape.capsule) - } - - func connectingRing(for color: Color) -> some View { + func connectionStatusInnerImage() -> some View { + Image.connect_inner_button + .foregroundColor(viewModel.animating ? viewModel.tintColor : isFocused ? Color.pia_background : viewModel.tintColor) + .frame(width: 128, height: 128) + } + + func connectionStatusOuterRing() -> some View { + Circle() + .fill(isFocused ? viewModel.tintColor : Color.pia_background) + .stroke(viewModel.tintColor, lineWidth: lineWidth) + .frame(width: size) + } + + func animatedRing(with color: Color) -> some View { Circle() .trim(from: viewModel.animating ? 0 : 1, to: viewModel.animating ? 1.5 : 1) .stroke(color.gradient, style: StrokeStyle(lineWidth: lineWidth, lineCap: .square)) .rotationEffect(.degrees(-95)) + .glow(color: viewModel.tintColor, radius: 36, opacity: 0.6) + .frame(width: size) + .opacity(viewModel.animating ? 1 : 0) + .animation(Animation + .easeInOut(duration: 0.8) + .repeat(while: viewModel.animating), value: viewModel.animating) + } } + + + diff --git a/PIA VPN-tvOS/Dashboard/UI/QuickConnectButton.swift b/PIA VPN-tvOS/Dashboard/UI/QuickConnectButton.swift index df57bc107..b73d02027 100644 --- a/PIA VPN-tvOS/Dashboard/UI/QuickConnectButton.swift +++ b/PIA VPN-tvOS/Dashboard/UI/QuickConnectButton.swift @@ -10,19 +10,32 @@ import SwiftUI struct QuickConnectButton: View { @ObservedObject var viewModel: QuickConnectButtonViewModel + @FocusState var isButtonFocused: Bool - var size: CGFloat = 65 + var size: CGFloat = 80 var body: some View { Button { viewModel.connectButtonDidTap() } label: { - Image(viewModel.flagName) - .resizable() - .frame(width: size, height: size*0.75) + VStack(spacing: 8) { + Image(viewModel.flagName) + .resizable() + .frame(width: size, height: size) + Text(viewModel.titleText) + .font(.system(size: 29, weight: .medium)) + .foregroundColor(isButtonFocused ? .pia_on_primary : .pia_on_surface) + .lineLimit(nil) + } + .padding(24) + } - .buttonStyle(.card) - .buttonBorderShape(.roundedRectangle(radius: 2)) + .frame(width: 160) + .background(isButtonFocused ? Color.pia_primary : Color.pia_surface_container_primary) + .clipShape(RoundedRectangle(cornerSize: Spacing.tileCornerSize)) + .focused($isButtonFocused) + .buttonStyle(BasicButtonStyle()) + .buttonBorderShape(.roundedRectangle(radius: Spacing.tileBorderRadius)) } } diff --git a/PIA VPN-tvOS/Dashboard/UI/QuickConnectView.swift b/PIA VPN-tvOS/Dashboard/UI/QuickConnectView.swift index 2ce0b5cfe..557b59955 100644 --- a/PIA VPN-tvOS/Dashboard/UI/QuickConnectView.swift +++ b/PIA VPN-tvOS/Dashboard/UI/QuickConnectView.swift @@ -4,15 +4,15 @@ import SwiftUI struct QuickConnectView: View { @ObservedObject var viewModel: QuickConnectViewModel - let rows = [ GridItem(.adaptive(minimum: 80)) ] + let rows = [ GridItem(.adaptive(minimum: 160, maximum: 160)) ] var body: some View { - ScrollView(.horizontal) { - LazyHGrid(rows: rows) { - ForEach(viewModel.servers, id: \.regionIdentifier) { item in - DashboardFactory.makeQuickConnectButton(for: item, delegate: viewModel) - } + + HStack(alignment: .top, spacing: 44) { + ForEach(viewModel.servers, id: \.regionIdentifier) { item in + DashboardFactory.makeQuickConnectButton(for: item, delegate: viewModel) } - }.onAppear { + } + .onAppear { viewModel.updateStatus() } diff --git a/PIA VPN-tvOS/Dashboard/UI/SelectedServerView.swift b/PIA VPN-tvOS/Dashboard/UI/SelectedServerView.swift index b3843f16d..9dd0c5edc 100644 --- a/PIA VPN-tvOS/Dashboard/UI/SelectedServerView.swift +++ b/PIA VPN-tvOS/Dashboard/UI/SelectedServerView.swift @@ -3,36 +3,50 @@ import SwiftUI struct SelectedServerView: View { @Environment(\.colorScheme) var colorScheme + @FocusState var isButtonFocused: Bool @ObservedObject var viewModel: SelectedServerViewModel - var body: some View { - - HStack(alignment: .top) { + private func buttonView() -> some View { + HStack(alignment: .center, spacing: 10) { + Image(viewModel.iconImageNameFor(focused: isButtonFocused)) + .resizable() + .frame(width: 80, height: 80) VStack(alignment: .leading) { - Text(viewModel.selectedSeverSectionTitle) - .font(.callout) - Text(viewModel.serverName) - .font(.caption) + Text(viewModel.selectedSeverTitle) + .font(.system(size: 29, weight: .medium)) + .foregroundColor(isButtonFocused ? .pia_on_primary : .pia_on_surface_container_secondary) + Text(viewModel.selectedServerSubtitle) + .font(.system(size: 31, weight: .bold)) + .foregroundColor(isButtonFocused ? .pia_on_primary : .pia_on_surface) + .lineLimit(nil) } + .padding(.leading, 22) Spacer() - HStack(alignment: .center) { - if colorScheme == .light { - Image.map - } else { - Image.map - .blendMode(.hardLight) - } - - Image(systemName: "chevron.forward") - .foregroundStyle(Color.gray) - .frame(width: 50) - + Image(systemName: "ellipsis") + .foregroundColor(isButtonFocused ? .pia_on_primary : .pia_on_surface) + .frame(width: 52) + } + .padding(.leading, 30) + } + + var body: some View { + VStack { + Button { + viewModel.selectedServerSectionWasTapped() + } label: { + buttonView() } + .background(isButtonFocused ? Color.pia_primary : Color.pia_surface_container_primary) + .clipShape(RoundedRectangle(cornerSize: Spacing.tileCornerSize)) + .buttonStyle(BasicButtonStyle()) + .focused($isButtonFocused) + .buttonBorderShape(.roundedRectangle(radius: Spacing.tileBorderRadius)) } + } } diff --git a/PIA VPN-tvOS/Dashboard/UseCases/SelectedServerUseCase.swift b/PIA VPN-tvOS/Dashboard/UseCases/SelectedServerUseCase.swift index 2dc2a3c8d..60dfca762 100644 --- a/PIA VPN-tvOS/Dashboard/UseCases/SelectedServerUseCase.swift +++ b/PIA VPN-tvOS/Dashboard/UseCases/SelectedServerUseCase.swift @@ -1,9 +1,10 @@ import Foundation import PIALibrary +import Combine protocol SelectedServerUseCaseType { - func getSelectedServer() -> ServerType + func getSelectedServer() -> AnyPublisher func getHistoricalServers() -> [ServerType] } @@ -17,13 +18,13 @@ class SelectedServerUseCase: SelectedServerUseCaseType { self.clientPreferences = clientPreferences } - func getSelectedServer() -> ServerType { - return clientPreferences.selectedServer + func getSelectedServer() -> AnyPublisher { + return clientPreferences.getSelectedServer() } func getHistoricalServers() -> [ServerType] { - return serverProvider.historicalServers + return serverProvider.historicalServersType } private func automaticServer() -> ServerType { diff --git a/PIA VPN-tvOS/Dashboard/UseCases/VpnConnectionUseCase.swift b/PIA VPN-tvOS/Dashboard/UseCases/VpnConnectionUseCase.swift index 21bc03c39..28f1187ff 100644 --- a/PIA VPN-tvOS/Dashboard/UseCases/VpnConnectionUseCase.swift +++ b/PIA VPN-tvOS/Dashboard/UseCases/VpnConnectionUseCase.swift @@ -1,43 +1,68 @@ import Foundation import PIALibrary +import Combine protocol VpnConnectionUseCaseType { - func connect() - func connect(to server: ServerType) - func disconnect() + func connect() async throws + func disconnect() async throws + func getConnectionIntent() -> AnyPublisher +} + +enum VpnConnectionIntent: Equatable { + case none + case connect + case disconnect } class VpnConnectionUseCase: VpnConnectionUseCaseType { + internal var connectionIntent: CurrentValueSubject + let serverProvider: ServerProviderType + let vpnProvider: VPNStatusProviderType - init(serverProvider: ServerProviderType) { + init(serverProvider: ServerProviderType, vpnProvider: VPNStatusProviderType) { self.serverProvider = serverProvider + self.vpnProvider = vpnProvider + self.connectionIntent = CurrentValueSubject(.none) } - func connect() { - // TODO: Inject VPNProvider object - let vpnProvider = Client.providers.vpnProvider - vpnProvider.connect { error in - NSLog("Connection error: \(error)") + func connect() async throws { + + connectionIntent.send(.connect) + + return try await withCheckedThrowingContinuation { continuation in + vpnProvider.connect { error in + if let error = error { + self.connectionIntent.send(completion: .failure(error)) + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } } } - func disconnect() { - // TODO: Inject VPNProvider object - let vpnProvider = Client.providers.vpnProvider - vpnProvider.disconnect { error in - NSLog("Disconnect error: \(error)") - } + + func disconnect() async throws { + + connectionIntent.send(.disconnect) + return try await withCheckedThrowingContinuation { continuation in + vpnProvider.disconnect { error in + if let error = error { + self.connectionIntent.send(completion: .failure(error)) + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } } - - func connect(to server: ServerType) { - // TODO: Implement me - print("VpnConnectionUseCase: connect to: \(server.name)") + + func getConnectionIntent() -> AnyPublisher { + return connectionIntent.eraseToAnyPublisher() } - - } diff --git a/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift b/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift index 0f09e7216..0851de21c 100644 --- a/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift +++ b/PIA VPN-tvOS/RegionsSelection/CompositionRoot/RegionsSelectionFactory.swift @@ -59,11 +59,14 @@ class RegionsSelectionFactory { return RegionsListView(viewModel: makeRegionsListViewModel(with: .previouslySearched)) } - static func makeRegionsListUseCase() -> RegionsListUseCaseType { - return RegionsListUseCase(serverProvider: DashboardFactory.makeServerProvider(), clientPreferences: Client.preferences) + return RegionsListUseCase(serverProvider: VpnConnectionFactory.makeServerProvider(), clientPreferences: makeClientPreferences, vpnConnectionUseCase: VpnConnectionFactory.makeVpnConnectionUseCase) } + static var makeClientPreferences: ClientPreferencesType = { + return ClientPreferences(clientPrefs: Client.preferences) + }() + /// FavoritesUseCase is the same instance across the whole app /// in order to be able to publish updates to the favorites collection static var makeFavoriteRegionUseCase: FavoriteRegionUseCaseType = { diff --git a/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift b/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift index 162532e8a..ea79b6ab8 100644 --- a/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift +++ b/PIA VPN-tvOS/RegionsSelection/UI/RegionsContainerView.swift @@ -9,8 +9,6 @@ import SwiftUI struct RegionsContainerView: View { - let viewWidth = UIScreen.main.bounds.width - let viewHeight = UIScreen.main.bounds.height @ObservedObject var viewModel: RegionsContainerViewModel @FocusState var focusedFilter: RegionsContainerViewModel.RegionsNavigationItems? @@ -24,29 +22,30 @@ struct RegionsContainerView: View { HStack { Text(menuItem.text) .font(.system(size: 38, weight: .medium)) - .foregroundColor(.pia_on_surface) + .foregroundColor(focusedFilter == menuItem ? .pia_on_primary : .pia_on_surface) .padding(20) Spacer() } - .background(focusedFilter == menuItem ? Color.pia_surface_container_primary : Color.clear) + .background(focusedFilter == menuItem ? Color.pia_primary : Color.clear) .cornerRadius(12) } .cornerRadius(4) - .buttonStyle(.borderless) + .buttonStyle(BasicButtonStyle()) .focused($focusedFilter, equals: menuItem) } - }.listStyle(.plain) + } + .listStyle(.plain) } var body: some View { - VStack(alignment: .trailing) { - HStack(alignment: .top) { + VStack(alignment: .leading) { + HStack(alignment: .top, spacing: 40) { regionsFilterButtons - .frame(width: viewWidth * 0.23) - VStack { + .frame(width: Spacing.regionsFilterSectionWidth) + VStack(alignment: .trailing) { switch viewModel.selectedSection { case .favorites: RegionsSelectionFactory.makeFavoriteRegionsListView() @@ -65,7 +64,7 @@ struct RegionsContainerView: View { } } - } + }.frame(minWidth: 1208) } .onChange(of: focusedFilter) { _, newValue in guard let focusedMenuItem = newValue else { return } diff --git a/PIA VPN-tvOS/RegionsSelection/UI/RegionsListItemButton.swift b/PIA VPN-tvOS/RegionsSelection/UI/RegionsListItemButton.swift index 605ea1a8b..197299301 100644 --- a/PIA VPN-tvOS/RegionsSelection/UI/RegionsListItemButton.swift +++ b/PIA VPN-tvOS/RegionsSelection/UI/RegionsListItemButton.swift @@ -40,7 +40,7 @@ struct RegionsListItemButton: View { .background(buttonFocused ? Color.pia_primary : Color.pia_surface_container_secondary) .clipShape(RoundedRectangle(cornerRadius: 20)) .focused($buttonFocused) - .buttonStyle(.borderless) + .buttonStyle(BasicButtonStyle()) .buttonBorderShape(.roundedRectangle(radius: 20)) .contextMenu(menuItems: { diff --git a/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift b/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift index ae057b3f3..90d06033a 100644 --- a/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift +++ b/PIA VPN-tvOS/RegionsSelection/UI/RegionsListView.swift @@ -13,7 +13,7 @@ struct RegionsListView: View { @ObservedObject var viewModel: RegionsListViewModel let columns = [ - GridItem(.adaptive(minimum: 376, maximum: 376)) + GridItem(.adaptive(minimum: 376, maximum: 376), spacing: 40) ] private func contextMenuItem(for server: ServerType) -> RegionsListItemButton.ContextMenuItem { @@ -32,7 +32,8 @@ struct RegionsListView: View { .fontWeight(.regular) .foregroundColor(Color.pia_on_surface_container_secondary) } - ScrollView { + ScrollView(.vertical) { + LazyVGrid(columns: columns, alignment: .leading, spacing: 40) { ForEach(viewModel.servers, id: \.identifier) { server in RegionsListItemButton( @@ -40,7 +41,6 @@ struct RegionsListView: View { viewModel.didSelectRegionServer(server) }, iconName: "flag-\(server.country.lowercased())", - // TODO: substitute here title: viewModel.getDisplayName(for: server).title, subtitle: viewModel.getDisplayName(for: server).subtitle, favoriteIconName: viewModel.favoriteIconName(for: server), diff --git a/PIA VPN-tvOS/RegionsSelection/UseCases/RegionsListUseCase.swift b/PIA VPN-tvOS/RegionsSelection/UseCases/RegionsListUseCase.swift index 9411a866a..c55f5696c 100644 --- a/PIA VPN-tvOS/RegionsSelection/UseCases/RegionsListUseCase.swift +++ b/PIA VPN-tvOS/RegionsSelection/UseCases/RegionsListUseCase.swift @@ -19,19 +19,31 @@ class RegionsListUseCase: RegionsListUseCaseType { private let serverProvider: ServerProviderType private var clientPreferences: ClientPreferencesType + private let vpnConnectionUseCase: VpnConnectionUseCaseType - init(serverProvider: ServerProviderType, clientPreferences: ClientPreferencesType) { + init(serverProvider: ServerProviderType, clientPreferences: ClientPreferencesType, vpnConnectionUseCase: VpnConnectionUseCaseType) { self.serverProvider = serverProvider self.clientPreferences = clientPreferences + self.vpnConnectionUseCase = vpnConnectionUseCase } func getCurrentServers() -> [ServerType] { - return serverProvider.currentServers + return serverProvider.currentServersType } func select(server: ServerType) { - // This triggers a connection clientPreferences.selectedServer = server + Task { + do { + try await vpnConnectionUseCase.connect() + } catch { + // TODO: Handle error + NSLog("Connection error after selecting server: \(error)") + } + + + } + } } diff --git a/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift b/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift index 888ef83c6..5d9a1e7cf 100644 --- a/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift +++ b/PIA VPN-tvOS/RootContainer/CompositionRoot/RootContainerFactory.swift @@ -16,6 +16,7 @@ class RootContainerFactory { connectionStatsPermissonType: ConnectionStatsPermisson(), bootstrap: BootstraperFactory.makeBootstrapper(), userAuthenticationStatusMonitor: StateMonitorsFactory.makeUserAuthenticationStatusMonitor(), + connectionStateMonitor: StateMonitorsFactory.makeConnectionStateMonitor, appRouter: AppRouterFactory.makeAppRouter()) } } diff --git a/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift b/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift index 0e264ffe0..3a8408f24 100644 --- a/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift +++ b/PIA VPN-tvOS/RootContainer/Presentation/RootContainerViewModel.swift @@ -21,10 +21,13 @@ class RootContainerViewModel: ObservableObject { private let connectionStatsPermissonType: ConnectionStatsPermissonType private let bootstrap: BootstraperType private let userAuthenticationStatusMonitor: UserAuthenticationStatusMonitorType + + /// Inject here the connectionStateMonitor instance so we start monitoring the vpn status before creating the Dashboard view + private let conenctionStateMonitor: ConnectionStateMonitorType private let appRouter: AppRouterType private var cancellables = Set() - init(accountProvider: AccountProviderType, notificationCenter: NotificationCenterType = NotificationCenter.default, vpnConfigurationAvailability: VPNConfigurationAvailabilityType, connectionStatsPermissonType: ConnectionStatsPermissonType, bootstrap: BootstraperType, userAuthenticationStatusMonitor: UserAuthenticationStatusMonitorType, appRouter: AppRouterType) { + init(accountProvider: AccountProviderType, notificationCenter: NotificationCenterType = NotificationCenter.default, vpnConfigurationAvailability: VPNConfigurationAvailabilityType, connectionStatsPermissonType: ConnectionStatsPermissonType, bootstrap: BootstraperType, userAuthenticationStatusMonitor: UserAuthenticationStatusMonitorType, connectionStateMonitor: ConnectionStateMonitorType, appRouter: AppRouterType) { self.accountProvider = accountProvider self.notificationCenter = notificationCenter @@ -32,6 +35,7 @@ class RootContainerViewModel: ObservableObject { self.connectionStatsPermissonType = connectionStatsPermissonType self.bootstrap = bootstrap self.userAuthenticationStatusMonitor = userAuthenticationStatusMonitor + self.conenctionStateMonitor = connectionStateMonitor self.appRouter = appRouter subscribeToAccountUpdates() diff --git a/PIA VPN-tvOS/RootContainer/UI/RootContainerView.swift b/PIA VPN-tvOS/RootContainer/UI/RootContainerView.swift index 59a38be6d..30b17d7b3 100644 --- a/PIA VPN-tvOS/RootContainer/UI/RootContainerView.swift +++ b/PIA VPN-tvOS/RootContainer/UI/RootContainerView.swift @@ -7,7 +7,7 @@ struct RootContainerView: View { @Environment(\.scenePhase) var scenePhase @ObservedObject private var appRouter: AppRouter - + // TODO: inject in this VM the ConnectionStateMonitor so that we start monitoring asap init(viewModel: RootContainerViewModel, appRouter: AppRouter) { self.viewModel = viewModel self.appRouter = appRouter @@ -28,13 +28,7 @@ struct RootContainerView: View { .withOnboardingRoutes() } }.onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { - NSLog(">>> Active") - } else if newPhase == .inactive { - NSLog(">>> Inactive") - } else if newPhase == .background { - NSLog(">>> Background") - } + } } } diff --git a/PIA VPN-tvOS/Shared/CompositionRoot/VpnConnectionFactory.swift b/PIA VPN-tvOS/Shared/CompositionRoot/VpnConnectionFactory.swift new file mode 100644 index 000000000..c2a9fd7b5 --- /dev/null +++ b/PIA VPN-tvOS/Shared/CompositionRoot/VpnConnectionFactory.swift @@ -0,0 +1,35 @@ +// +// VpnConnectionFactory.swift +// PIA VPN-tvOS +// +// Created by Laura S on 2/12/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import PIALibrary + +class VpnConnectionFactory { + static var makeVpnConnectionUseCase: VpnConnectionUseCaseType = { + return VpnConnectionUseCase(serverProvider: makeServerProvider(), vpnProvider: makeVpnProvider()) + }() + + static func makeServerProvider() -> ServerProviderType { + guard let defaultServerProvider: DefaultServerProvider = + Client.providers.serverProvider as? DefaultServerProvider else { + fatalError("Incorrect server provider type") + } + + return defaultServerProvider + + } + + static func makeVpnProvider() -> VPNStatusProviderType { + guard let defaultVpnProvider = Client.providers.vpnProvider as? DefaultVPNProvider else { + fatalError("Incorrect VPNProvider type") + } + + return defaultVpnProvider + + } +} diff --git a/PIA VPN-tvOS/Shared/StateMonitors/ConnectionStateMonitor.swift b/PIA VPN-tvOS/Shared/StateMonitors/ConnectionStateMonitor.swift new file mode 100644 index 000000000..646663409 --- /dev/null +++ b/PIA VPN-tvOS/Shared/StateMonitors/ConnectionStateMonitor.swift @@ -0,0 +1,116 @@ +// +// ConnectionStatusMonitor.swift +// PIA VPN-tvOS +// +// Created by Laura S on 2/12/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import Combine +import PIALibrary + + +enum ConnectionState: Equatable { + case unkown + case disconnected + case connecting + case connected + case disconnecting + case error(Error) + + static func == (lhs: ConnectionState, rhs: ConnectionState) -> Bool { + switch (lhs, rhs) { + case (.unkown, .unkown): + return true + case (.disconnected, .disconnected): + return true + case (.connecting, .connecting): + return true + case (.connected, .connected): + return true + case (.disconnecting, .disconnecting): + return true + case (.error, .error): + return true + default: + return false + } + } +} + +protocol ConnectionStateMonitorType { + var connectionStatePublisher: Published.Publisher { get } +} + +class ConnectionStateMonitor: ConnectionStateMonitorType { + private var cancellable: AnyCancellable? + + private let vpnStatusMonitor: VPNStatusMonitorType + private let vpnConnectionUseCase: VpnConnectionUseCaseType + + @Published private var connectionState: ConnectionState = .unkown + var connectionStatePublisher: Published.Publisher { + $connectionState + } + + init(vpnStatusMonitor: VPNStatusMonitorType, vpnConnectionUseCase: VpnConnectionUseCaseType) { + self.vpnStatusMonitor = vpnStatusMonitor + self.vpnConnectionUseCase = vpnConnectionUseCase + + addObservers() + + } + + private func addObservers() { + cancellable = vpnStatusMonitor.getStatus() + .setFailureType(to: Error.self) + .combineLatest(vpnConnectionUseCase.getConnectionIntent()) { vpnStatus, connectionIntent in + return (status: vpnStatus, intent: connectionIntent) + } + .receive(on: RunLoop.main) + .sink(receiveCompletion: { completion in + switch completion { + case .finished: + break + case .failure(let failure): + self.connectionState = .error(failure) + } + }, receiveValue: { result in + self.calculateState(for: result.intent, vpnStatus: result.status) + + }) + } + + private func calculateState(for connectionIntent: VpnConnectionIntent, vpnStatus: VPNStatus) { + switch (connectionIntent, vpnStatus) { + case (_, .connected): + self.connectionState = .connected + case (.connect, _): + self.connectionState = .connecting + case (_, .disconnected): + self.connectionState = .disconnected + case (.disconnect, _): + self.connectionState = .disconnecting + default: + self.connectionState = vpnStatus.toConnectionState() + } + } + +} + + +extension VPNStatus { + func toConnectionState() -> ConnectionState { + switch self { + case .connected: + return .connected + case .connecting: + return .connecting + case .disconnecting: + return .disconnecting + default: + return .disconnected + } + } +} diff --git a/PIA VPN-tvOS/Shared/StateMonitors/StateMonitorsFactory.swift b/PIA VPN-tvOS/Shared/StateMonitors/StateMonitorsFactory.swift index 8bfb79788..569ec390e 100644 --- a/PIA VPN-tvOS/Shared/StateMonitors/StateMonitorsFactory.swift +++ b/PIA VPN-tvOS/Shared/StateMonitors/StateMonitorsFactory.swift @@ -23,4 +23,10 @@ class StateMonitorsFactory { return VPNStatusMonitor(vpnStatusProvider: defaultVPNProvider, notificationCenter: NotificationCenter.default) } + + static var makeConnectionStateMonitor: ConnectionStateMonitorType = { + return ConnectionStateMonitor(vpnStatusMonitor: makeVPNStatusMonitor(), vpnConnectionUseCase: VpnConnectionFactory.makeVpnConnectionUseCase) + }() + } + diff --git a/PIA VPN-tvOS/Shared/UI/Modifiers/ButtonStyleModifier.swift b/PIA VPN-tvOS/Shared/UI/Modifiers/ButtonStyleModifier.swift new file mode 100644 index 000000000..95df249ca --- /dev/null +++ b/PIA VPN-tvOS/Shared/UI/Modifiers/ButtonStyleModifier.swift @@ -0,0 +1,18 @@ +// +// ButtonStyleModifier.swift +// PIA VPN-tvOS +// +// Created by Laura S on 2/12/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import SwiftUI + + +struct BasicButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding() + } +} diff --git a/PIA VPN-tvOS/Shared/UI/PIAImages+SwiftUI.swift b/PIA VPN-tvOS/Shared/UI/PIAImages+SwiftUI.swift index 472b476dc..4c3151065 100644 --- a/PIA VPN-tvOS/Shared/UI/PIAImages+SwiftUI.swift +++ b/PIA VPN-tvOS/Shared/UI/PIAImages+SwiftUI.swift @@ -9,4 +9,12 @@ extension Image { static let onboarding_configure_robots = Image("configure-robots") static let onboarding_stats_tv = Image("stats-tv") static let loading_pia_brand = Image("loading-pia-brand") + static let smart_location_icon = Image(.smart_location_icon_name) + static let smart_location_icon_highlighted = Image(.smart_location_icon_highlighted_name) + static let connect_inner_button = Image("connect-inner-button") +} + +extension String { + static let smart_location_icon_name = "icon-smart-location" + static let smart_location_icon_highlighted_name = "icon-smart-location-highlighted" } diff --git a/PIA VPN-tvOS/Shared/UI/PIAStyles.swift b/PIA VPN-tvOS/Shared/UI/PIAStyles.swift index 9c8d3a308..d736d9ee2 100644 --- a/PIA VPN-tvOS/Shared/UI/PIAStyles.swift +++ b/PIA VPN-tvOS/Shared/UI/PIAStyles.swift @@ -7,7 +7,15 @@ // import Foundation +import SwiftUI struct Spacing { + static let screenWidth = UIScreen.main.bounds.width + static let screenHeight = UIScreen.main.bounds.height static let screenTopPadding: CGFloat = 100 + static let dashboardViewWidth: CGFloat = 780 + static let tileBorderRadius: CGFloat = 20 + static let tileCornerSize: CGSize = CGSize(width: 20, height: 20) + static let regionsFilterSectionWidth: CGFloat = 470 } + diff --git a/PIA VPN-tvOS/Shared/UI/Views/View+Extensions.swift b/PIA VPN-tvOS/Shared/UI/Views/View+Extensions.swift new file mode 100644 index 000000000..2049ee318 --- /dev/null +++ b/PIA VPN-tvOS/Shared/UI/Views/View+Extensions.swift @@ -0,0 +1,18 @@ +// +// View+Extensions.swift +// PIA VPN-tvOS +// +// Created by Laura S on 2/12/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import SwiftUI + +extension View { + func glow(color: Color = .red, radius: CGFloat = 20, opacity: CGFloat = 0.4) -> some View { + self + .shadow(color: color.opacity(opacity), radius: radius / 3) + .shadow(color: color.opacity(opacity), radius: radius) + + } +} diff --git a/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols/ClientPreferences+Protocols.swift b/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols/ClientPreferences+Protocols.swift index f8d87e970..e75585bb8 100644 --- a/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols/ClientPreferences+Protocols.swift +++ b/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols/ClientPreferences+Protocols.swift @@ -8,23 +8,42 @@ import Foundation import PIALibrary +import Combine protocol ClientPreferencesType { var selectedServer: ServerType { get set } + func getSelectedServer() -> AnyPublisher } -extension Client.Preferences: ClientPreferencesType { +class ClientPreferences: ClientPreferencesType { + private let clientPrefs: Client.Preferences + var selectedServer: ServerType { get { - return displayedServer + return clientPrefs.displayedServer } set { guard let newServer = newValue as? Server else { return } - displayedServer = newServer + clientPrefs.displayedServer = newServer + + selectedServerPublisher.send(newServer) + // TODO: Verify whether this is necessary - let pendingPreferences = Client.preferences.editable() + let pendingPreferences = clientPrefs.editable() pendingPreferences.commit() } } + private var selectedServerPublisher: CurrentValueSubject + + func getSelectedServer() -> AnyPublisher { + return selectedServerPublisher.eraseToAnyPublisher() + } + + init(clientPrefs: Client.Preferences) { + self.clientPrefs = clientPrefs + self.selectedServerPublisher = CurrentValueSubject(clientPrefs.displayedServer) + } + } + diff --git a/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols/PIALibrary+Protocols.swift b/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols/PIALibrary+Protocols.swift index 240d36474..505dd167a 100644 --- a/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols/PIALibrary+Protocols.swift +++ b/PIA VPN-tvOS/Shared/Utils/PIALibrary+Protocols/PIALibrary+Protocols.swift @@ -17,23 +17,39 @@ protocol ServerType { var country: String { get } var geo: Bool { get } var pingTime: Int? { get } + var isAutomatic: Bool { get } } extension Server: ServerType {} protocol ServerProviderType { - var historicalServers: [Server] { get } - var targetServer: Server { get } - var currentServers: [Server] { get } + var historicalServersType: [ServerType] { get } + var targetServerType: ServerType { get } + var currentServersType: [ServerType] { get } // Add methods from ServerProvider to this protocol as needed } extension DefaultServerProvider: ServerProviderType { + var historicalServersType: [ServerType] { + return self.historicalServers + } + + var targetServerType: ServerType { + return self.targetServer + } + + var currentServersType: [ServerType] { + return self.currentServers + } + } protocol VPNStatusProviderType { var vpnStatus: VPNStatus { get } + func connect(_ callback: SuccessLibraryCallback?) + func disconnect(_ callback: SuccessLibraryCallback?) + } extension DefaultVPNProvider: VPNStatusProviderType {} diff --git a/PIA VPN-tvOSTests/Common/ConnectionStateMonitorTests.swift b/PIA VPN-tvOSTests/Common/ConnectionStateMonitorTests.swift new file mode 100644 index 000000000..fcd2e9bca --- /dev/null +++ b/PIA VPN-tvOSTests/Common/ConnectionStateMonitorTests.swift @@ -0,0 +1,165 @@ +// +// ConnectionStateMonitorTests.swift +// PIA VPN-tvOSTests +// +// Created by Laura S on 2/13/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + + + +import Combine +import XCTest +@testable import PIA_VPN_tvOS +import PIALibrary + +class ConnectionStateMonitorTests: XCTestCase { + class Fixture { + let vpnStatusMonitorMock = VPNStatusMonitorMock() + let vpnConnectionUseCaseMock = VpnConnectionUseCaseMock() + } + + var fixture: Fixture! + var sut: ConnectionStateMonitor! + var capturedConnectionStates = [ConnectionState]() + var cancellables = Set() + + private func instantiateSut() { + sut = ConnectionStateMonitor(vpnStatusMonitor: fixture.vpnStatusMonitorMock, vpnConnectionUseCase: fixture.vpnConnectionUseCaseMock) + } + + override func setUp() { + fixture = Fixture() + capturedConnectionStates = [] + cancellables = Set() + } + + override func tearDown() { + fixture = nil + sut = nil + } + + private func fulfillOnConnectionState(_ expectedState: ConnectionState, expectation: XCTestExpectation) { + sut.connectionStatePublisher.sink { [weak self] newConnectionState in + self?.capturedConnectionStates.append(newConnectionState) + if newConnectionState == expectedState { + expectation.fulfill() + } + }.store(in: &cancellables) + } + + func test_connectionStateWhen_statusDisconnected_intentConnect() { + instantiateSut() + let connectionStateExp = expectation(description: "Wait for expected conn state") + fulfillOnConnectionState(.connecting, expectation: connectionStateExp) + + // GIVEN that the conection intent is 'connect' + fixture.vpnConnectionUseCaseMock.getConnectionIntentResult.send(.connect) + + // AND GIVEN that the vpn status is disconnected + fixture.vpnStatusMonitorMock.status.send(.disconnected) + + wait(for: [connectionStateExp], timeout: 3) + // THEN the connection state is 'connecting' + XCTAssertEqual(capturedConnectionStates.last!, .connecting) + + } + + func test_connectionStateWhen_statusConnecting_intentDisconnect() { + fixture.vpnStatusMonitorMock.status.send(.disconnecting) + instantiateSut() + let connectionStateExp = expectation(description: "Wait for expected conn state") + fulfillOnConnectionState(.disconnecting, expectation: connectionStateExp) + + // GIVEN that the conection intent is 'disconnect' + fixture.vpnConnectionUseCaseMock.getConnectionIntentResult.send(.disconnect) + + // AND GIVEN that the vpn status is connecting + fixture.vpnStatusMonitorMock.status.send(.connecting) + + + wait(for: [connectionStateExp], timeout: 3) + // THEN the connection state is 'disconnecting' + XCTAssertEqual(capturedConnectionStates.last!, .disconnecting ) + + } + + func test_connectionStateWhen_statusConnected_intentNone() { + fixture.vpnStatusMonitorMock.status.send(.connecting) + instantiateSut() + let connectionStateExp = expectation(description: "Wait for expected conn state") + fulfillOnConnectionState(.connected, expectation: connectionStateExp) + + // GIVEN that the conection intent is 'none' + fixture.vpnConnectionUseCaseMock.getConnectionIntentResult.send(.none) + + // AND GIVEN that the vpn status is connected + fixture.vpnStatusMonitorMock.status.send(.connected) + + + wait(for: [connectionStateExp], timeout: 3) + // THEN the connection state is 'connected' + XCTAssertEqual(capturedConnectionStates.last!, .connected ) + + } + + func test_connectionStateWhen_statusConnected_intentConnect() { + fixture.vpnStatusMonitorMock.status.send(.connecting) + instantiateSut() + let connectionStateExp = expectation(description: "Wait for expected conn state") + fulfillOnConnectionState(.connected, expectation: connectionStateExp) + + // GIVEN that the conection intent is 'connect' + fixture.vpnConnectionUseCaseMock.getConnectionIntentResult.send(.connect) + + // AND GIVEN that the vpn status is connected + fixture.vpnStatusMonitorMock.status.send(.connected) + + + wait(for: [connectionStateExp], timeout: 3) + // THEN the connection state is 'connected' + XCTAssertEqual(capturedConnectionStates.last!, .connected ) + + } + + func test_connectionStateWhen_statusConnected_intentDisconnect() { + fixture.vpnStatusMonitorMock.status.send(.connecting) + instantiateSut() + let connectionStateExp = expectation(description: "Wait for expected conn state") + fulfillOnConnectionState(.connected, expectation: connectionStateExp) + + // GIVEN that the conection intent is 'disconnect' + fixture.vpnConnectionUseCaseMock.getConnectionIntentResult.send(.disconnect) + + // AND GIVEN that the vpn status is connected + fixture.vpnStatusMonitorMock.status.send(.connected) + + + wait(for: [connectionStateExp], timeout: 3) + // THEN the connection state is 'connected' + XCTAssertEqual(capturedConnectionStates.last!, .connected ) + + } + + + func test_connectionStateWhen_errorInConnectionIntent() { + fixture.vpnStatusMonitorMock.status.send(.connecting) + instantiateSut() + let connectionError: Error = NSError(domain: "test.connection_error", code: 1) as Error + let connectionStateExp = expectation(description: "Wait for expected conn state") + fulfillOnConnectionState(.error(connectionError), expectation: connectionStateExp) + + // AND GIVEN that the vpn status is connected + fixture.vpnStatusMonitorMock.status.send(.connected) + + // AND GIVEN that the conection intent stops with an error + fixture.vpnConnectionUseCaseMock.getConnectionIntentResult.send(completion: .failure(connectionError)) + + wait(for: [connectionStateExp], timeout: 3) + + // THEN the connection state becomes 'error' + XCTAssertEqual(capturedConnectionStates.last!, .error(connectionError) ) + + } + +} diff --git a/PIA VPN-tvOSTests/Common/Mocks/ConnectionStateMonitorMock.swift b/PIA VPN-tvOSTests/Common/Mocks/ConnectionStateMonitorMock.swift new file mode 100644 index 000000000..9d2a195a7 --- /dev/null +++ b/PIA VPN-tvOSTests/Common/Mocks/ConnectionStateMonitorMock.swift @@ -0,0 +1,19 @@ +// +// ConnectionStateMonitorMock.swift +// PIA VPN-tvOSTests +// +// Created by Laura S on 2/13/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +@testable import PIA_VPN_tvOS + +class ConnectionStateMonitorMock: ConnectionStateMonitorType { + @Published internal var connectionState: ConnectionState = .unkown + var connectionStatePublisher: Published.Publisher { + $connectionState + } + + +} diff --git a/PIA VPN-tvOSTests/Common/Mocks/SelectedServerUseCaseMock.swift b/PIA VPN-tvOSTests/Common/Mocks/SelectedServerUseCaseMock.swift index 9576255ee..b1fa69749 100644 --- a/PIA VPN-tvOSTests/Common/Mocks/SelectedServerUseCaseMock.swift +++ b/PIA VPN-tvOSTests/Common/Mocks/SelectedServerUseCaseMock.swift @@ -1,18 +1,20 @@ import Foundation +import Combine @testable import PIA_VPN_tvOS class SelectedServerUseCaseMock: SelectedServerUseCaseType { + var getSelectedServerCalled = false var getSelectedServerAttempt = 0 - var getSelectedServerResult: ServerType = ServerMock() - - func getSelectedServer() -> ServerType { + var getSelectedServerResult: CurrentValueSubject = CurrentValueSubject(ServerMock()) + func getSelectedServer() -> AnyPublisher { getSelectedServerCalled = true getSelectedServerAttempt += 1 - return getSelectedServerResult + return getSelectedServerResult.eraseToAnyPublisher() } - + + var getHistoricalServersCalled = false var getHistoricalServersttempt = 0 var getHistoricalServersResult: [ServerType] = [] diff --git a/PIA VPN-tvOSTests/Common/Mocks/ServerMock.swift b/PIA VPN-tvOSTests/Common/Mocks/ServerMock.swift index 3a41d7ab7..098ffbe9d 100644 --- a/PIA VPN-tvOSTests/Common/Mocks/ServerMock.swift +++ b/PIA VPN-tvOSTests/Common/Mocks/ServerMock.swift @@ -3,6 +3,7 @@ import Foundation @testable import PIA_VPN_tvOS class ServerMock: ServerType { + var isAutomatic: Bool var pingTime: Int? @@ -16,13 +17,14 @@ class ServerMock: ServerType { var geo: Bool - init(name: String, identifier: String, regionIdentifier: String, country: String, geo: Bool, pingTime: Int? = nil) { + init(name: String, identifier: String, regionIdentifier: String, country: String, geo: Bool, pingTime: Int? = nil, isAutomatic: Bool = false) { self.name = name self.identifier = identifier self.regionIdentifier = regionIdentifier self.country = country self.geo = geo self.pingTime = pingTime + self.isAutomatic = isAutomatic } convenience init() { diff --git a/PIA VPN-tvOSTests/Common/Mocks/VpnConnectionUseCaseMock.swift b/PIA VPN-tvOSTests/Common/Mocks/VpnConnectionUseCaseMock.swift index bc4678749..178e1b3fd 100644 --- a/PIA VPN-tvOSTests/Common/Mocks/VpnConnectionUseCaseMock.swift +++ b/PIA VPN-tvOSTests/Common/Mocks/VpnConnectionUseCaseMock.swift @@ -8,23 +8,18 @@ import Foundation @testable import PIA_VPN_tvOS +import Combine class VpnConnectionUseCaseMock: VpnConnectionUseCaseType { - var connectToServerCalled: Bool = false - var connectCalledToServerAttempt: Int = 0 - var connectToServerCalledWithArgument: ServerType? - - var connectionAction: (() -> Void)? - var disconnectionAction: (() -> Void)? - - func connect(to server: ServerType) { - connectToServerCalled = true - connectCalledToServerAttempt += 1 - connectToServerCalledWithArgument = server - connectionAction?() + var getConnectionIntentCalled = false + var getConnectionIntentCalledAttempt = 0 + var getConnectionIntentResult = CurrentValueSubject(VpnConnectionIntent.none) + func getConnectionIntent() -> AnyPublisher { + return getConnectionIntentResult.eraseToAnyPublisher() } + var connectionAction: (() -> Void)? var connectCalled: Bool = false var connectCalledAttempt: Int = 0 @@ -36,6 +31,7 @@ class VpnConnectionUseCaseMock: VpnConnectionUseCaseType { var disconnectCalled: Bool = false var disconnectCalledAttempt: Int = 0 + var disconnectionAction: (() -> Void)? func disconnect() { disconnectCalled = true diff --git a/PIA VPN-tvOSTests/Common/VpnConnectionUseCaseTests.swift b/PIA VPN-tvOSTests/Common/VpnConnectionUseCaseTests.swift new file mode 100644 index 000000000..85d55989c --- /dev/null +++ b/PIA VPN-tvOSTests/Common/VpnConnectionUseCaseTests.swift @@ -0,0 +1,129 @@ +// +// VpnConnectionUseCaseTests.swift +// PIA VPN-tvOSTests +// +// Created by Laura S on 2/13/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +import XCTest +import Combine + +@testable import PIA_VPN_tvOS + +class VpnConnectionUseCaseTests: XCTestCase { + class Fixture { + let serverProviderMock = ServerProviderMock() + let vpnProviderMock = VPNStatusProviderMock(vpnStatus: .disconnected) + } + private var subscriptions = Set() + var fixture: Fixture! + var sut: VpnConnectionUseCase! + + override func setUp() { + fixture = Fixture() + subscriptions = Set() + } + + override func tearDown() { + fixture = nil + sut = nil + } + + private func instantiateSut() { + sut = VpnConnectionUseCase(serverProvider: fixture.serverProviderMock , vpnProvider: fixture.vpnProviderMock) + } + + func test_connect() async throws { + // GIVEN that there is no error connecting the vpn provider + fixture.vpnProviderMock.connectCalledWithCallbackError = nil + + instantiateSut() + + // The initial state of the connection intent is 'none' + XCTAssertEqual(sut.connectionIntent.value, .none) + + // WHEN trying to connect + try await sut.connect() + + // THEN the connection intent becomes 'connect' + XCTAssertEqual(sut.connectionIntent.value, .connect) + + // AND the vpn provider is called to connect once + XCTAssertTrue(fixture.vpnProviderMock.connectCalled) + XCTAssertEqual(fixture.vpnProviderMock.connectCalledAttempt, 1) + } + + func test_connect_when_vpnProvider_sendsError() async throws { + // GIVEN that there is an error connecting the vpn provider + fixture.vpnProviderMock.connectCalledWithCallbackError = NSError(domain: "com.piavpn.tests", code: 1) + + instantiateSut() + // The initial state of the connection intent is 'none' + XCTAssertEqual(sut.connectionIntent.value, .none) + + var connectionIntentFinishedError: Error? + sut.getConnectionIntent() + .sink { completion in + switch completion { + case .failure(let error): + connectionIntentFinishedError = error + default: + break + } + } receiveValue: { newValue in + }.store(in: &subscriptions) + + // WHEN trying to connect + try? await sut.connect() + // THEN an error is thown + XCTAssertNotNil(connectionIntentFinishedError) + } + + func test_disconnect() async throws { + // GIVEN that there is no error disconnecting the vpn provider + fixture.vpnProviderMock.disconnectCalledWithCallbackError = nil + + instantiateSut() + // The initial state of the connection intent is 'none' + XCTAssertEqual(sut.connectionIntent.value, .none) + + // WHEN trying to disconnect + try await sut.disconnect() + + // THEN the connection intent becomes 'disconnect' + XCTAssertEqual(sut.connectionIntent.value, .disconnect) + + // AND the vpn provider is called to disconnect once + XCTAssertTrue(fixture.vpnProviderMock.disconnectCalled) + XCTAssertEqual(fixture.vpnProviderMock.disconnectCalledAttempt, 1) + } + + func test_disconnect_when_vpnProvider_sendsError() async throws { + // GIVEN that there is an error disconnecting the vpn provider + fixture.vpnProviderMock.disconnectCalledWithCallbackError = NSError(domain: "com.piavpn.tests", code: 1) + + instantiateSut() + // The initial state of the connection intent is 'none' + XCTAssertEqual(sut.connectionIntent.value, .none) + + var disconnectionIntentFinishedError: Error? + sut.getConnectionIntent() + .sink { completion in + switch completion { + case .failure(let error): + disconnectionIntentFinishedError = error + default: + break + } + } receiveValue: { newValue in + }.store(in: &subscriptions) + + // WHEN trying to disconnect + try? await sut.disconnect() + // THEN an error is thown + XCTAssertNotNil(disconnectionIntentFinishedError) + } + +} diff --git a/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift b/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift index 5d9e6b1f0..bea278bfb 100644 --- a/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift +++ b/PIA VPN-tvOSTests/Dashboard/DashboardViewModelTests.swift @@ -32,27 +32,5 @@ class DashboardViewModelTests: XCTestCase { sut = DashboardViewModel(accountProvider: fixture.accountProviderMock, appRouter: fixture.appRouter, navigationDestination: RegionsDestinations.serversList) } - func test_navigateToRegionsList() { - // GIVEN that the Dashboard view is visible - initializeSut() - - let emptyNavigationPath: NavigationPath = NavigationPath() - let regionsListNavigationPath: NavigationPath = NavigationPath([RegionsDestinations.serversList]) - - // AND GIVEN that the path of the navigation router is empty - XCTAssertEqual(fixture.appRouter.path, emptyNavigationPath) - XCTAssertTrue(fixture.appRouter.path.isEmpty) - - // WHEN the regions selection section is tapped - sut.regionSelectionSectionWasTapped() - - // THEN the app router navigates to the Regions list - XCTAssertFalse(fixture.appRouter.path.isEmpty) - XCTAssertEqual(fixture.appRouter.stackCount, 1) - XCTAssertEqual(fixture.appRouter.pathDestinations.count, 1) - XCTAssertTrue(fixture.appRouter.pathDestinations.last! is RegionsDestinations) - XCTAssertEqual(fixture.appRouter.pathDestinations.last! as! RegionsDestinations, .serversList) - - } } diff --git a/PIA VPN-tvOSTests/Dashboard/PIAConnectionButtonViewModelTests.swift b/PIA VPN-tvOSTests/Dashboard/PIAConnectionButtonViewModelTests.swift index d5998faf6..7c6d72ef8 100644 --- a/PIA VPN-tvOSTests/Dashboard/PIAConnectionButtonViewModelTests.swift +++ b/PIA VPN-tvOSTests/Dashboard/PIAConnectionButtonViewModelTests.swift @@ -14,20 +14,18 @@ final class PIAConnectionButtonViewModelTests: XCTestCase { final class Fixture { let vpnConnectionUseCaseMock = VpnConnectionUseCaseMock() - let vpnStatusMonitor = VPNStatusMonitorMock() + let connectionStateMonitorMock = ConnectionStateMonitorMock() } var fixture: Fixture! var sut: PIAConnectionButtonViewModel! var cancellables: Set! - var capturedState: [PIAConnectionButtonViewModel.State]! + var capturedState: [ConnectionState]! override func setUp() { fixture = Fixture() - sut = PIAConnectionButtonViewModel(useCase: fixture.vpnConnectionUseCaseMock, - vpnStatusMonitor: fixture.vpnStatusMonitor) cancellables = Set() - capturedState = [PIAConnectionButtonViewModel.State]() + capturedState = [ConnectionState]() } override func tearDown() { @@ -37,31 +35,34 @@ final class PIAConnectionButtonViewModelTests: XCTestCase { capturedState = nil } + private func instantiateSut() { + sut = PIAConnectionButtonViewModel(useCase: fixture.vpnConnectionUseCaseMock, + connectionStateMonitor: fixture.connectionStateMonitorMock) + } + func test_toggleConnection_when_disconnected() { - // GIVEN that the vpn state is disconnected - XCTAssertTrue(sut.state == .disconnected) + // GIVEN that the connection state is disconnected + fixture.connectionStateMonitorMock.connectionState = .disconnected + let connectExpectation = XCTestExpectation(description: "connection is called") fixture.vpnConnectionUseCaseMock.connectionAction = { [weak self] in - self?.fixture.vpnStatusMonitor.status.send(.connecting) - self?.fixture.vpnStatusMonitor.status.send(.connected) - } - - fixture.vpnConnectionUseCaseMock.disconnectionAction = { [weak self] in - self?.fixture.vpnStatusMonitor.status.send(.disconnecting) - self?.fixture.vpnStatusMonitor.status.send(.disconnected) + self?.fixture.connectionStateMonitorMock.connectionState = .connecting + self?.fixture.connectionStateMonitorMock.connectionState = .connected + connectExpectation.fulfill() } - capturedState = [PIAConnectionButtonViewModel.State]() + instantiateSut() + XCTAssertTrue(sut.state == .disconnected) sut.$state.sink { _ in } receiveValue: { state in self.capturedState.append(state) }.store(in: &cancellables) - // WHEN calling the toggle connection method sut.toggleConnection() + wait(for: [connectExpectation], timeout: 3) // THEN the vpnConnectionUseCase is called once to connect the vpn XCTAssertTrue(fixture.vpnConnectionUseCaseMock.connectCalled) XCTAssertTrue(fixture.vpnConnectionUseCaseMock.connectCalledAttempt == 1) @@ -71,21 +72,20 @@ final class PIAConnectionButtonViewModelTests: XCTestCase { } func test_toggleConnection_when_connected() { - // GIVEN that the vpn state is connected - sut.state = .connected - XCTAssertTrue(sut.state == .connected) - fixture.vpnConnectionUseCaseMock.connectionAction = { [weak self] in - self?.fixture.vpnStatusMonitor.status.send(.connecting) - self?.fixture.vpnStatusMonitor.status.send(.connected) - } + // GIVEN that the vpn state is connected + fixture.connectionStateMonitorMock.connectionState = .connected + let disconnectExpectation = XCTestExpectation(description: "Disconnect is called") fixture.vpnConnectionUseCaseMock.disconnectionAction = { [weak self] in - self?.fixture.vpnStatusMonitor.status.send(.disconnecting) - self?.fixture.vpnStatusMonitor.status.send(.disconnected) + self?.fixture.connectionStateMonitorMock.connectionState = .disconnecting + self?.fixture.connectionStateMonitorMock.connectionState = .disconnected + disconnectExpectation.fulfill() } - capturedState = [PIAConnectionButtonViewModel.State]() + instantiateSut() + XCTAssertTrue(sut.state == .connected) + sut.$state.sink { _ in } receiveValue: { state in @@ -96,6 +96,8 @@ final class PIAConnectionButtonViewModelTests: XCTestCase { // WHEN calling the toggle connection method sut.toggleConnection() + wait(for: [disconnectExpectation], timeout: 3) + // THEN the vpnConnectionUseCase is called once to disconnect the vpn XCTAssertTrue(fixture.vpnConnectionUseCaseMock.disconnectCalled) XCTAssertTrue(fixture.vpnConnectionUseCaseMock.disconnectCalledAttempt == 1) diff --git a/PIA VPN-tvOSTests/Dashboard/QuickConnectViewModelTests.swift b/PIA VPN-tvOSTests/Dashboard/QuickConnectViewModelTests.swift index 79382111c..1b42ef087 100644 --- a/PIA VPN-tvOSTests/Dashboard/QuickConnectViewModelTests.swift +++ b/PIA VPN-tvOSTests/Dashboard/QuickConnectViewModelTests.swift @@ -4,8 +4,8 @@ import XCTest final class QuickConnectViewModelTests: XCTestCase { class Fixture { - let connectUseCaseMock = VpnConnectionUseCaseMock() let selectedServerUseCaseMock = SelectedServerUseCaseMock() + let regionsUseCaseMock = RegionsListUseCaseMock() let serverMock = ServerMock() } @@ -21,19 +21,19 @@ final class QuickConnectViewModelTests: XCTestCase { } private func initilizeSut() { - sut = QuickConnectViewModel(connectUseCase: fixture.connectUseCaseMock, selectedServerUseCase: fixture.selectedServerUseCaseMock) + sut = QuickConnectViewModel(selectedServerUseCase: fixture.selectedServerUseCaseMock, regionsUseCase: fixture.regionsUseCaseMock) } func test_quickConnectServers_on_launch() { - // GIVEN that there are 2 historical servers + // GIVEN that there are 2 historical servers (the first one is the current selected server) fixture.selectedServerUseCaseMock.getHistoricalServersResult = [ServerMock(), ServerMock()] // WHEN showing the Quick Connect section initilizeSut() sut.updateStatus() - // THEN there are 2 Quick Connect buttons displayed - XCTAssertEqual(sut.servers.count, 2) + // THEN there is 1 Quick Connect button displayed + XCTAssertEqual(sut.servers.count, 1) } @@ -47,10 +47,11 @@ final class QuickConnectViewModelTests: XCTestCase { // WHEN the sut is informed via `QuickConnectButtonViewModelDelegate` to connect sut.quickConnectButtonViewModel(didSelect: fixture.serverMock) - // THEN the vpn connect use case is called to connect to "Italy-Milano" - XCTAssertTrue(fixture.connectUseCaseMock.connectToServerCalled) - XCTAssertEqual(fixture.connectUseCaseMock.connectCalledToServerAttempt, 1) - XCTAssertEqual(fixture.connectUseCaseMock.connectToServerCalledWithArgument?.name, "Italy-Milano") + // THEN the regions use case is called to select the "Italy-Milano" server + XCTAssertTrue(fixture.regionsUseCaseMock.selectServerCalled) + XCTAssertEqual(fixture.regionsUseCaseMock.selectServerCalledWithArgument!.name, "Italy-Milano") + + } } diff --git a/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift b/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift index 1bea29ddb..3a0a67d9e 100644 --- a/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift +++ b/PIA VPN-tvOSTests/RootContainer/RootContainerViewModelTests.swift @@ -20,6 +20,7 @@ final class RootContainerViewModelTests: XCTestCase { var connectionStatsPermissonMock = ConnectionStatsPermissonMock(value: nil) let appRouterSpy = AppRouterSpy() let bootstrapMock = BootstraperMock() + let connectionStatusMonitorMock = ConnectionStateMonitorMock() func makeUserAuthenticationStatusMonitorMock(status: UserAuthenticationStatus) -> UserAuthenticationStatusMonitorMock { return UserAuthenticationStatusMonitorMock(status: status) @@ -46,6 +47,7 @@ final class RootContainerViewModelTests: XCTestCase { connectionStatsPermissonType: fixture.connectionStatsPermissonMock, bootstrap: fixture.bootstrapMock, userAuthenticationStatusMonitor: fixture.makeUserAuthenticationStatusMonitorMock(status: .loggedOut), + connectionStateMonitor: fixture.connectionStatusMonitorMock, appRouter: fixture.appRouterSpy) sut.isBootstrapped = bootStrapped } @@ -138,7 +140,8 @@ final class RootContainerViewModelTests: XCTestCase { vpnConfigurationAvailability: fixture.vpnConfigurationAvailabilityMock, connectionStatsPermissonType: fixture.connectionStatsPermissonMock, bootstrap: fixture.bootstrapMock, - userAuthenticationStatusMonitor: userAuthenticationStatusMonitor, + userAuthenticationStatusMonitor: userAuthenticationStatusMonitor, + connectionStateMonitor: fixture.connectionStatusMonitorMock, appRouter: fixture.appRouterSpy) XCTAssertEqual(sut.state, .notActivated) @@ -165,6 +168,7 @@ final class RootContainerViewModelTests: XCTestCase { connectionStatsPermissonType: fixture.connectionStatsPermissonMock, bootstrap: fixture.bootstrapMock, userAuthenticationStatusMonitor: userAuthenticationStatusMonitor, + connectionStateMonitor: fixture.connectionStatusMonitorMock, appRouter: fixture.appRouterSpy) XCTAssertEqual(sut.state, .activated) diff --git a/PIA VPN-tvOSTests/Shared/Mocks/ServerProviderMock.swift b/PIA VPN-tvOSTests/Shared/Mocks/ServerProviderMock.swift new file mode 100644 index 000000000..beedf44f1 --- /dev/null +++ b/PIA VPN-tvOSTests/Shared/Mocks/ServerProviderMock.swift @@ -0,0 +1,30 @@ +// +// ServerProviderMock.swift +// PIA VPN-tvOSTests +// +// Created by Laura S on 2/13/24. +// Copyright © 2024 Private Internet Access Inc. All rights reserved. +// + +import Foundation +@testable import PIA_VPN_tvOS + +class ServerProviderMock: ServerProviderType { + + var historicalServersTypeResult:[ServerType] = [] + var historicalServersType: [ServerType] { + historicalServersTypeResult + } + + var targetServerTypeResult: ServerType = ServerMock() + var targetServerType: ServerType { + targetServerTypeResult + } + + var currentServersTypeResult: [ServerType] = [] + var currentServersType: [ServerType] { + currentServersTypeResult + } + + +} diff --git a/PIA VPN-tvOSTests/Shared/Mocks/VPNStatusProviderMock.swift b/PIA VPN-tvOSTests/Shared/Mocks/VPNStatusProviderMock.swift index c295403a9..5ebb3de27 100644 --- a/PIA VPN-tvOSTests/Shared/Mocks/VPNStatusProviderMock.swift +++ b/PIA VPN-tvOSTests/Shared/Mocks/VPNStatusProviderMock.swift @@ -11,6 +11,7 @@ import PIALibrary @testable import PIA_VPN_tvOS class VPNStatusProviderMock: VPNStatusProviderType { + var vpnStatus: VPNStatus init(vpnStatus: VPNStatus) { @@ -20,4 +21,25 @@ class VPNStatusProviderMock: VPNStatusProviderType { func changeStatus(vpnStatus: VPNStatus) { self.vpnStatus = vpnStatus } + + var connectCalled = false + var connectCalledAttempt = 0 + var connectCalledWithCallbackError: Error? + func connect(_ callback: SuccessLibraryCallback?) { + + connectCalled = true + connectCalledAttempt += 1 + callback?(connectCalledWithCallbackError) + } + + var disconnectCalled = false + var disconnectCalledAttempt = 0 + var disconnectCalledWithCallbackError: Error? + func disconnect(_ callback: SuccessLibraryCallback?) { + + disconnectCalled = true + disconnectCalledAttempt += 1 + callback?(disconnectCalledWithCallbackError) + } + } diff --git a/PIA VPN.xcodeproj/project.pbxproj b/PIA VPN.xcodeproj/project.pbxproj index 57c95b1f9..bb5e6848f 100644 --- a/PIA VPN.xcodeproj/project.pbxproj +++ b/PIA VPN.xcodeproj/project.pbxproj @@ -173,6 +173,9 @@ 690FC4882B3C60F500F6DCC8 /* QuickConnectViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FC4872B3C60F500F6DCC8 /* QuickConnectViewModelTests.swift */; }; 6913179F2B32E4D4009B4E85 /* PIALibrary+Protocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6913179E2B32E4D4009B4E85 /* PIALibrary+Protocols.swift */; }; 691317A12B32ED76009B4E85 /* AccountProviderTypeMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 691317A02B32ED76009B4E85 /* AccountProviderTypeMock.swift */; }; + 69194BBB2B7BE59600691775 /* VpnConnectionUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69194BBA2B7BE59600691775 /* VpnConnectionUseCaseTests.swift */; }; + 69194BBD2B7BEDF000691775 /* ServerProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69194BBC2B7BEDF000691775 /* ServerProviderMock.swift */; }; + 69194BBF2B7BFB2A00691775 /* ConnectionStateMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69194BBE2B7BFB2A00691775 /* ConnectionStateMonitorTests.swift */; }; 6924831A2AB045A5002A0407 /* PIAWidgetAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692483192AB045A5002A0407 /* PIAWidgetAttributes.swift */; }; 6924831B2AB045A5002A0407 /* PIAWidgetAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692483192AB045A5002A0407 /* PIAWidgetAttributes.swift */; }; 6924831C2AB045A5002A0407 /* PIAWidgetAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692483192AB045A5002A0407 /* PIAWidgetAttributes.swift */; }; @@ -202,9 +205,13 @@ 693CA5BA2B3F268400D38378 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693CA5B92B3F268400D38378 /* PacketTunnelProvider.swift */; }; 693CA5BF2B3F268400D38378 /* PIA-VPN-tvOS-NetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 693CA5B62B3F268300D38378 /* PIA-VPN-tvOS-NetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 694AC74E2B17AB9C007E7B56 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 694AC74D2B17AB9C007E7B56 /* DashboardView.swift */; }; + 6953E54B2B7A8FDC00D685B4 /* ButtonStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6953E54A2B7A8FDC00D685B4 /* ButtonStyleModifier.swift */; }; + 6953E54D2B7A907A00D685B4 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6953E54C2B7A907A00D685B4 /* View+Extensions.swift */; }; + 6953E54F2B7AA69800D685B4 /* ConnectionStateMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6953E54E2B7AA69800D685B4 /* ConnectionStateMonitor.swift */; }; + 6953E5512B7AB15100D685B4 /* VpnConnectionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6953E5502B7AB15100D685B4 /* VpnConnectionFactory.swift */; }; + 6953E5532B7B748800D685B4 /* ConnectionStateMonitorMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6953E5522B7B748800D685B4 /* ConnectionStateMonitorMock.swift */; }; 695BF81D2AC30EFB00D1139C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0E7EC043209326E30029811E /* Localizable.strings */; }; 695BF81F2AC410E000D1139C /* (null) in Sources */ = {isa = PBXBuildFile; }; - 696346552B6CFF520051F8BC /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 291C6397183EBC210039EC03 /* Images.xcassets */; }; 696346572B6D0EDC0051F8BC /* RegionsFilterUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 696346562B6D0EDC0051F8BC /* RegionsFilterUseCase.swift */; }; 6963465B2B70F6A80051F8BC /* RegionsFilterUseCaseMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6963465A2B70F6A80051F8BC /* RegionsFilterUseCaseMock.swift */; }; 6963465D2B7109DA0051F8BC /* RegionsFilterUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6963465C2B7109DA0051F8BC /* RegionsFilterUseCaseTests.swift */; }; @@ -1021,6 +1028,9 @@ 690FC4872B3C60F500F6DCC8 /* QuickConnectViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectViewModelTests.swift; sourceTree = ""; }; 6913179E2B32E4D4009B4E85 /* PIALibrary+Protocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PIALibrary+Protocols.swift"; sourceTree = ""; }; 691317A02B32ED76009B4E85 /* AccountProviderTypeMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountProviderTypeMock.swift; sourceTree = ""; }; + 69194BBA2B7BE59600691775 /* VpnConnectionUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VpnConnectionUseCaseTests.swift; sourceTree = ""; }; + 69194BBC2B7BEDF000691775 /* ServerProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProviderMock.swift; sourceTree = ""; }; + 69194BBE2B7BFB2A00691775 /* ConnectionStateMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStateMonitorTests.swift; sourceTree = ""; }; 692483192AB045A5002A0407 /* PIAWidgetAttributes.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = PIAWidgetAttributes.swift; sourceTree = ""; tabWidth = 4; }; 6924831D2AB04FFD002A0407 /* PIAWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PIAWidgetBundle.swift; sourceTree = ""; }; 6924831F2AB05F18002A0407 /* PIAConnectionView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = PIAConnectionView.swift; sourceTree = ""; tabWidth = 4; }; @@ -1045,6 +1055,11 @@ 693CA5BC2B3F268400D38378 /* PIA_VPN_tvOS_NetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PIA_VPN_tvOS_NetworkExtension.entitlements; sourceTree = ""; }; 6947AADB2ACDC8AE001BCC66 /* PIA-VPN-e2e-simulator.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "PIA-VPN-e2e-simulator.xctestplan"; sourceTree = ""; }; 694AC74D2B17AB9C007E7B56 /* DashboardView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; tabWidth = 4; }; + 6953E54A2B7A8FDC00D685B4 /* ButtonStyleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyleModifier.swift; sourceTree = ""; }; + 6953E54C2B7A907A00D685B4 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; + 6953E54E2B7AA69800D685B4 /* ConnectionStateMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStateMonitor.swift; sourceTree = ""; }; + 6953E5502B7AB15100D685B4 /* VpnConnectionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VpnConnectionFactory.swift; sourceTree = ""; }; + 6953E5522B7B748800D685B4 /* ConnectionStateMonitorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStateMonitorMock.swift; sourceTree = ""; }; 696346562B6D0EDC0051F8BC /* RegionsFilterUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsFilterUseCase.swift; sourceTree = ""; }; 6963465A2B70F6A80051F8BC /* RegionsFilterUseCaseMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsFilterUseCaseMock.swift; sourceTree = ""; }; 6963465C2B7109DA0051F8BC /* RegionsFilterUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionsFilterUseCaseTests.swift; sourceTree = ""; }; @@ -2032,6 +2047,8 @@ isa = PBXGroup; children = ( 696E8F142B31AF200080BB31 /* Mocks */, + 69194BBA2B7BE59600691775 /* VpnConnectionUseCaseTests.swift */, + 69194BBE2B7BFB2A00691775 /* ConnectionStateMonitorTests.swift */, ); path = Common; sourceTree = ""; @@ -2045,6 +2062,7 @@ 690FC4812B3C5A4300F6DCC8 /* ServerMock.swift */, 690FC4852B3C5F7300F6DCC8 /* SelectedServerUseCaseMock.swift */, E52E68FF2B56ABE400471913 /* AppRouterSpy.swift */, + 6953E5522B7B748800D685B4 /* ConnectionStateMonitorMock.swift */, ); path = Mocks; sourceTree = ""; @@ -2205,6 +2223,7 @@ isa = PBXGroup; children = ( 69AB49312B63D22400C780CD /* KeychainFactory.swift */, + 6953E5502B7AB15100D685B4 /* VpnConnectionFactory.swift */, ); path = CompositionRoot; sourceTree = ""; @@ -2720,6 +2739,7 @@ E52E69092B5BC0ED00471913 /* VPNStatusMonitor.swift */, E52E690B2B5BC19300471913 /* UserAuthenticationStatusMonitor.swift */, E52E691C2B5DC2B200471913 /* StateMonitorsFactory.swift */, + 6953E54E2B7AA69800D685B4 /* ConnectionStateMonitor.swift */, ); path = StateMonitors; sourceTree = ""; @@ -2730,6 +2750,7 @@ E52E69212B5DCF1F00471913 /* VPNStatusProviderMock.swift */, E52E69242B5E92CC00471913 /* VPNStatusMonitorMock.swift */, 693474C72B6B8BDD0061F788 /* KeychainTypeMock.swift */, + 69194BBC2B7BEDF000691775 /* ServerProviderMock.swift */, ); path = Mocks; sourceTree = ""; @@ -2738,6 +2759,7 @@ isa = PBXGroup; children = ( E55EDCED2B760E40007010DB /* ActionButton.swift */, + 6953E54C2B7A907A00D685B4 /* View+Extensions.swift */, ); path = Views; sourceTree = ""; @@ -2798,6 +2820,7 @@ isa = PBXGroup; children = ( E55EDCE92B760D3E007010DB /* TextFieldStyleModifier.swift */, + 6953E54A2B7A8FDC00D685B4 /* ButtonStyleModifier.swift */, ); path = Modifiers; sourceTree = ""; @@ -3703,7 +3726,6 @@ buildActionMask = 2147483647; files = ( E52E69032B56E22D00471913 /* Welcome.strings in Resources */, - 696346552B6CFF520051F8BC /* Images.xcassets in Resources */, 69F0534A2B1F1D2800AE0665 /* Colors.xcassets in Resources */, E5C507812B0E145000200A6A /* Preview Assets.xcassets in Resources */, E5C5077E2B0E145000200A6A /* Assets.xcassets in Resources */, @@ -4241,6 +4263,7 @@ 6913179F2B32E4D4009B4E85 /* PIALibrary+Protocols.swift in Sources */, E52E690A2B5BC0ED00471913 /* VPNStatusMonitor.swift in Sources */, 69FF0B082B3AD3E60074AA04 /* SelectedServerViewModel.swift in Sources */, + 6953E54F2B7AA69800D685B4 /* ConnectionStateMonitor.swift in Sources */, 69FF0B0B2B3AD4C70074AA04 /* SwiftGen+Strings.swift in Sources */, 69F675AA2B592ED6000E31D5 /* ClientPreferences+Protocols.swift in Sources */, 69FF0B062B3AD2860074AA04 /* QuickConnectView.swift in Sources */, @@ -4249,6 +4272,7 @@ E5AB288C2B2A487A00744E5F /* LoginViewModelErrorHandler.swift in Sources */, 69E61DEF2B556FDA00085648 /* RegionsListUseCase.swift in Sources */, E5AB28732B279B7000744E5F /* LoginProvider.swift in Sources */, + 6953E54B2B7A8FDC00D685B4 /* ButtonStyleModifier.swift in Sources */, E5C507A52B153A2F00200A6A /* LoginViewModel.swift in Sources */, E55EDCF32B762A75007010DB /* ConnectionStatsPermisson.swift in Sources */, 693CA5AE2B3ED75500D38378 /* Flags.swift in Sources */, @@ -4309,6 +4333,7 @@ E5AB28712B279B4000744E5F /* LoginProviderType.swift in Sources */, 693B9A3E2B75592D00757A41 /* RegionsDisplayNameUseCase.swift in Sources */, 693CA5B02B3EDB4900D38378 /* RegionFilter.swift in Sources */, + 6953E5512B7AB15100D685B4 /* VpnConnectionFactory.swift in Sources */, E5AB287B2B28B4C400744E5F /* Credentials.swift in Sources */, E52E68FE2B55E47600471913 /* Routes.swift in Sources */, E5C5077C2B0E144E00200A6A /* ContentView.swift in Sources */, @@ -4319,6 +4344,7 @@ E5AB28B62B361E6C00744E5F /* VPNConfigurationInstallingErrorMapper.swift in Sources */, E55EDCD22B755DB1007010DB /* OnboardingComponentView.swift in Sources */, 69793DA42B5A9B27000CB845 /* RegionsContainerView.swift in Sources */, + 6953E54D2B7A907A00D685B4 /* View+Extensions.swift in Sources */, E55EDCF02B760EEB007010DB /* FormTextFieldsView.swift in Sources */, E5C5077A2B0E144E00200A6A /* PIA_VPN_tvOSApp.swift in Sources */, 69F1C2952B2B216100E924AE /* PIA+Animation.swift in Sources */, @@ -4379,15 +4405,19 @@ 690FC4862B3C5F7300F6DCC8 /* SelectedServerUseCaseMock.swift in Sources */, E5AB28E02B4C107F00744E5F /* VPNConfigurationAvailabilityMock.swift in Sources */, 690FC4822B3C5A4300F6DCC8 /* ServerMock.swift in Sources */, + 69194BBB2B7BE59600691775 /* VpnConnectionUseCaseTests.swift in Sources */, + 6953E5532B7B748800D685B4 /* ConnectionStateMonitorMock.swift in Sources */, 690FC4842B3C5B0500F6DCC8 /* QuickConnectButtonViewModelDelegateMock.swift in Sources */, 693474C82B6B8BDD0061F788 /* KeychainTypeMock.swift in Sources */, 693B9A442B7622B600757A41 /* RegionsDisplayNameUseCaseTests.swift in Sources */, E5AB287F2B28F6EF00744E5F /* Stubs.swift in Sources */, 69E61DF12B56990E00085648 /* DashboardViewModelTests.swift in Sources */, + 69194BBD2B7BEDF000691775 /* ServerProviderMock.swift in Sources */, E5AB28972B2C782C00744E5F /* Stubs+PIALibrary.swift in Sources */, 690FC4882B3C60F500F6DCC8 /* QuickConnectViewModelTests.swift in Sources */, E5AB28992B2C783600744E5F /* LoginProviderTests.swift in Sources */, E5C507C22B1F702700200A6A /* LoginWithCredentialsUseCaseMock.swift in Sources */, + 69194BBF2B7BFB2A00691775 /* ConnectionStateMonitorTests.swift in Sources */, E52E69002B56ABE400471913 /* AppRouterSpy.swift in Sources */, 693474C62B6B8ABA0061F788 /* FavoriteRegionUseCaseMock.swift in Sources */, E5AB28E12B4C107F00744E5F /* VpnConfigurationProviderTypeMock.swift in Sources */, diff --git a/PIA VPN/AppPreferences.swift b/PIA VPN/AppPreferences.swift index 318309ffb..ed0e7a400 100644 --- a/PIA VPN/AppPreferences.swift +++ b/PIA VPN/AppPreferences.swift @@ -602,6 +602,7 @@ class AppPreferences { self.defaults = defaults #if os(iOS) defaults.register(defaults: [ + Entries.version: AppPreferences.currentVersion, Entries.appVersion: "", Entries.launched: false, diff --git a/PIA VPN/Server+Automatic.swift b/PIA VPN/Server+Automatic.swift index 7b3180990..ff59f3848 100644 --- a/PIA VPN/Server+Automatic.swift +++ b/PIA VPN/Server+Automatic.swift @@ -37,3 +37,4 @@ extension Server { return (identifier == Server.automatic.identifier) } } + diff --git a/PIA VPN/ServerProvider+UI.swift b/PIA VPN/ServerProvider+UI.swift index 228b24891..2cd9aaa7b 100644 --- a/PIA VPN/ServerProvider+UI.swift +++ b/PIA VPN/ServerProvider+UI.swift @@ -42,16 +42,19 @@ extension Client.Preferences { } else { ed.preferredServer = newValue } + let action = ed.requiredVPNAction() ed.commit() action?.execute { [weak self] (error) in self?.connectToSelectedServerIfNeeded(shouldReconnect: true) } + } } private func connectToSelectedServerIfNeeded(shouldReconnect: Bool = false) { + #if os(iOS) let vpn = Client.providers.vpnProvider switch vpn.vpnStatus { @@ -62,5 +65,6 @@ extension Client.Preferences { vpn.reconnect(after: nil, forceDisconnect: true, nil) } } + #endif } } diff --git a/PIA VPN/SwiftGen+Strings.swift b/PIA VPN/SwiftGen+Strings.swift index 7fb88c4c4..0bf06241f 100644 --- a/PIA VPN/SwiftGen+Strings.swift +++ b/PIA VPN/SwiftGen+Strings.swift @@ -310,6 +310,20 @@ internal enum L10n { } } } + internal enum ErrorAlert { + internal enum ConnectionError { + internal enum NoNetwork { + /// Please check your internet connection and try again + internal static let message = L10n.tr("Localizable", "error_alert.connection_error.no_network.message", fallback: "Please check your internet connection and try again") + /// Unable to connect + internal static let title = L10n.tr("Localizable", "error_alert.connection_error.no_network.title", fallback: "Unable to connect") + internal enum RetryAction { + /// Retry + internal static let title = L10n.tr("Localizable", "error_alert.connection_error.no_network.retry_action.title", fallback: "Retry") + } + } + } + } internal enum Expiration { /// Your subscription expires soon. Renew to stay protected. internal static let message = L10n.tr("Localizable", "expiration.message", fallback: "Your subscription expires soon. Renew to stay protected.") @@ -530,6 +544,16 @@ internal enum L10n { } } } + internal enum LocationSelection { + internal enum AnyOtherLocation { + /// Selected Location + internal static let title = L10n.tr("Localizable", "location_selection.any_other_location.title", fallback: "Selected Location") + } + internal enum OptimalLocation { + /// Optimal Location + internal static let title = L10n.tr("Localizable", "location_selection.optimal_location.title", fallback: "Optimal Location") + } + } internal enum Menu { internal enum Accessibility { /// Menu diff --git a/PIA VPN/en.lproj/Localizable.strings b/PIA VPN/en.lproj/Localizable.strings index a1280ad5e..b395e69cb 100644 --- a/PIA VPN/en.lproj/Localizable.strings +++ b/PIA VPN/en.lproj/Localizable.strings @@ -474,3 +474,13 @@ "tvos.login.title" = "Enter your PIA VPN account details"; "tvos.login.placeholder.username" = "Enter Username"; "tvos.login.placeholder.password" = "Enter Password"; + +//Connection error(tvOS) +"error_alert.connection_error.no_network.title" = "Unable to connect"; +"error_alert.connection_error.no_network.message" = "Please check your internet connection and try again"; +"error_alert.connection_error.no_network.retry_action.title" = "Retry"; + +//Dashboard screen (tvOS) +"location_selection.optimal_location.title" = "Optimal Location"; +"location_selection.any_other_location.title" = "Selected Location"; +