Skip to content

Commit e8dd0dd

Browse files
committed
Merge branch 'pj/fix/tv-city-connection-issues' into 'develop'
Merge-Request: apple/vpn/protonvpn!2836 Approved-by: Chris Janusiewicz <[email protected]> Approved-by: John Biggs <[email protected]>
2 parents 1fa12c4 + c80665b commit e8dd0dd

File tree

4 files changed

+161
-29
lines changed

4 files changed

+161
-29
lines changed

apps/tvos/TestPlans/ProtonVPN-tvOS-Unit-All.xctestplan

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,6 @@
99
}
1010
],
1111
"defaultOptions" : {
12-
"codeCoverage" : {
13-
"targets" : [
14-
{
15-
"containerPath" : "container:..\/..\/libraries\/ConnectionDetails",
16-
"identifier" : "ConnectionDetails",
17-
"name" : "ConnectionDetails"
18-
},
19-
{
20-
"containerPath" : "container:..\/..\/libraries\/tvOS",
21-
"identifier" : "tvOS",
22-
"name" : "tvOS"
23-
}
24-
]
25-
},
2612
"language" : "en",
2713
"region" : "US",
2814
"targetForVariableExpansion" : {

libraries/Features/tvos_app/Sources/tvos_app/Features/Home/CountryList/CountryListView.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
// along with ProtonVPN. If not, see <https://www.gnu.org/licenses/>.
1818

1919
import ComposableArchitecture
20+
import Connection
2021
import Foundation
2122
import ProtonCoreUIFoundations
2223
import SwiftUI
@@ -29,6 +30,8 @@ struct CountryListView: View {
2930
// Watch which item is focused to highlight selected row
3031
@FocusState private var focusedIndex: ItemCoordinate?
3132

33+
@SharedReader(.connectionState) var connectionState: ConnectionState
34+
3235
static let columnCount = 6
3336
private static let gridItemWidth: Double = 210
3437
private static let unfocusedOpacity: Double = 0.5 // "Unfocused" items are half transparent
@@ -86,7 +89,6 @@ struct CountryListView: View {
8689
} primaryAction: {
8790
store.send(.selectItem(.country(code: item.code)))
8891
}
89-
.menuStyle(.borderlessButton)
9092
.buttonStyle(CountryListButtonStyle())
9193
.padding(.top, .themeSpacing8)
9294
.padding(.bottom, .themeSpacing32)
@@ -108,7 +110,13 @@ struct CountryListView: View {
108110
Button {
109111
store.send(.selectItem(.city(name: city, code: item.code)))
110112
} label: {
111-
Text(city)
113+
if case let .connected(intent, _, _, _) = connectionState,
114+
case let .city(name, code) = intent.spec.location,
115+
item.code == code, city == name {
116+
Label(city, systemImage: "lock.fill")
117+
} else {
118+
Text(city)
119+
}
112120
}
113121
}
114122
}

libraries/Features/tvos_app/Sources/tvos_app/Features/Main/MainFeature.swift

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ struct MainFeature {
5353
case homeLoading(HomeLoadingFeature.Action)
5454
case settings(SettingsFeature.Action)
5555

56+
case userSelectedItem(ServerGroupInfo.Kind)
57+
5658
case onAppear
5759
case onLogout
5860
case updateUserLocation
@@ -113,8 +115,11 @@ struct MainFeature {
113115
return .none
114116

115117
case let .homeLoading(.loaded(.countryList(.selectItem(kind)))):
118+
return .send(.userSelectedItem(kind))
119+
120+
case let .userSelectedItem(kind):
116121
let (code, cityName) = kind.selectedItem
117-
switch handleConnectionIntent(to: code, currentConnectionState: state.connectionState) {
122+
switch handleConnectionIntent(to: kind, currentConnectionState: state.connectionState) {
118123
case .connect:
119124
return .send(.connectDisconnectingIfNecessary(countryCode: code, cityName: cityName))
120125
case .disconnect:
@@ -187,31 +192,45 @@ struct MainFeature {
187192
}
188193

189194
private func handleConnectionIntent(
190-
to targetCountryCode: String,
195+
to target: ServerGroupInfo.Kind,
191196
currentConnectionState: ConnectionState
192197
) -> ConnectionStrategy {
193-
guard let activeCountryCode = activeCountryCode(from: currentConnectionState) else {
198+
guard let activeKind = activeKind(from: currentConnectionState) else {
194199
// If we're not already connecting/connected, we can just connect to the selected country
195200
return .connect
196201
}
197-
if targetCountryCode == activeCountryCode {
198-
// If the selected country is the same as the connecting/connected one, disconnect
202+
if target == activeKind {
203+
// If the selected kind is the same as the connecting/connected one, disconnect
199204
return .disconnect
200-
} else {
201-
// Otherwise, proceed with connection to the selected country
202-
return .connect
203205
}
206+
if case let .country(targetCode) = target,
207+
case let .city(_, activeCode) = activeKind {
208+
// if target is country and active kind is city in that country, also disconnect
209+
return targetCode == activeCode ? .disconnect : .connect
210+
}
211+
212+
// Otherwise, proceed with connection to the selected country
213+
return .connect
204214
}
205215

206-
private func activeCountryCode(from connectionState: ConnectionState) -> String? {
207-
if case let .connected(_, server, _, _) = connectionState {
208-
return server.logical.exitCountryCode
216+
private func activeKind(from connectionState: ConnectionState) -> ServerGroupInfo.Kind? {
217+
if case let .connected(intent, server, _, _) = connectionState {
218+
switch intent.spec.location {
219+
case let .city(name, code):
220+
return .city(name: name, code: code)
221+
default:
222+
return .country(code: server.logical.exitCountryCode)
223+
}
209224
}
210225
if case let .connecting(.unresolved(intent)) = connectionState {
211-
return intent.spec.countryCode
226+
if let code = intent.spec.countryCode {
227+
return .country(code: code)
228+
} else {
229+
return nil
230+
}
212231
}
213232
if case let .connecting(.resolved(_, server)) = connectionState {
214-
return server.logical.exitCountryCode
233+
return .country(code: server.logical.exitCountryCode)
215234
}
216235
return nil
217236
}
@@ -227,6 +246,19 @@ struct MainFeature {
227246
}
228247
}
229248

249+
private extension ServerGroupInfo.Kind {
250+
var code: String? {
251+
switch self {
252+
case let .city(_, code):
253+
code
254+
case let .country(code):
255+
code
256+
case let .gateway(name):
257+
name
258+
}
259+
}
260+
}
261+
230262
enum ServerResolutionError: Error {
231263
case noActiveServers(String)
232264
case serverHasNoEndpoints

libraries/Features/tvos_app/Tests/tvos_appTests/MainFeatureTests.swift

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,112 @@ final class MainFeatureTests: XCTestCase {
7474
await clock.advance(by: .seconds(1))
7575
}
7676

77+
@MainActor
78+
func testUserClickedCountryItemShouldConnect() async {
79+
let store = TestStore(initialState: MainFeature.State()) {
80+
MainFeature()
81+
} withDependencies: {
82+
$0.serverRepository = .empty()
83+
}
84+
await store.send(.userSelectedItem(.country(code: "PL")))
85+
await store.receive {
86+
if case .connectDisconnectingIfNecessary("PL", nil) = $0 {
87+
true
88+
} else {
89+
false
90+
}
91+
}
92+
await store.receive(\.connection.input.connect)
93+
await store.receive(\.connection.delegate.intentResolutionFailed)
94+
}
95+
96+
@MainActor
97+
func testUserClickedCityItemShouldConnect() async {
98+
let store = TestStore(initialState: MainFeature.State()) {
99+
MainFeature()
100+
} withDependencies: {
101+
$0.serverRepository = .empty()
102+
}
103+
await store.send(.userSelectedItem(.city(name: "Warsaw", code: "PL")))
104+
await store.receive {
105+
if case .connectDisconnectingIfNecessary("PL", "Warsaw") = $0 {
106+
true
107+
} else {
108+
false
109+
}
110+
}
111+
await store.receive(\.connection.input.connect)
112+
await store.receive(\.connection.delegate.intentResolutionFailed)
113+
}
114+
115+
@MainActor
116+
func testUserClickedCityItemWhileConnectedToThatCityShouldDisconnect() async {
117+
let connectionState = ConnectionState.connected(.mock(withSpecLocation: .city(name: "irrelevant", code: "CA")), .ca, .now, nil)
118+
let store = TestStore(initialState: MainFeature.State(connectionState: connectionState)) {
119+
MainFeature()
120+
} withDependencies: {
121+
$0.serverRepository = .empty()
122+
}
123+
await store.send(.userSelectedItem(.city(name: "irrelevant", code: "CA")))
124+
await store.receive(\.connection.input.disconnect) {
125+
$0.connection.core.shouldDisconnectWhenAllowed = true
126+
}
127+
}
128+
129+
@MainActor
130+
func testUserClickedCityItemWhileConnectedToAnotherCityInTheSameCountryShouldConnect() async {
131+
let connectionState = ConnectionState.connected(.mock(withSpecLocation: .city(name: "irrelevant", code: "CA")), .ca, .now, nil)
132+
let store = TestStore(initialState: MainFeature.State(connectionState: connectionState)) {
133+
MainFeature()
134+
} withDependencies: {
135+
$0.serverRepository = .empty()
136+
}
137+
await store.send(.userSelectedItem(.city(name: "not irrelevant", code: "CA")))
138+
await store.receive {
139+
if case .connectDisconnectingIfNecessary("CA", "not irrelevant") = $0 {
140+
true
141+
} else {
142+
false
143+
}
144+
}
145+
await store.receive(\.connection.input.connect)
146+
await store.receive(\.connection.delegate.intentResolutionFailed)
147+
}
148+
149+
@MainActor
150+
func testUserClickedCountryItemWhileConnectedToCityInAnotherCountryShouldConnect() async {
151+
let connectionState = ConnectionState.connected(.mock(withSpecLocation: .city(name: "irrelevant", code: "CA")), .ca, .now, nil)
152+
let store = TestStore(initialState: MainFeature.State(connectionState: connectionState)) {
153+
MainFeature()
154+
} withDependencies: {
155+
$0.serverRepository = .empty()
156+
}
157+
await store.send(.userSelectedItem(.country(code: "US")))
158+
await store.receive {
159+
if case .connectDisconnectingIfNecessary("US", nil) = $0 {
160+
true
161+
} else {
162+
false
163+
}
164+
}
165+
await store.receive(\.connection.input.connect)
166+
await store.receive(\.connection.delegate.intentResolutionFailed)
167+
}
168+
169+
@MainActor
170+
func testUserClickedCountryItemWhileConnectedToCityInThatCountryShouldDisconnect() async {
171+
let connectionState = ConnectionState.connected(.mock(withSpecLocation: .city(name: "irrelevant", code: "CA")), .ca, .now, nil)
172+
let store = TestStore(initialState: MainFeature.State(connectionState: connectionState)) {
173+
MainFeature()
174+
} withDependencies: {
175+
$0.serverRepository = .empty()
176+
}
177+
await store.send(.userSelectedItem(.country(code: "CA")))
178+
await store.receive(\.connection.input.disconnect) {
179+
$0.connection.core.shouldDisconnectWhenAllowed = true
180+
}
181+
}
182+
77183
@MainActor
78184
func testUserClickedConnect() async {
79185
let clock = TestClock()

0 commit comments

Comments
 (0)