Skip to content

Commit

Permalink
PIA-884: Add proper error handling for Login tvOS (#47)
Browse files Browse the repository at this point in the history
* PIA-884: Add new class to handle errors instead of VM

* PIA-884: Move Equatable extensions to tvOS target

* PIA-884: Add proper error handling to login feature

* PIA-884: Add missing files
  • Loading branch information
kp-said-rehouni authored Dec 18, 2023
1 parent 38d8906 commit 92a1d7a
Show file tree
Hide file tree
Showing 17 changed files with 635 additions and 151 deletions.
7 changes: 6 additions & 1 deletion PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ class LoginFactory {
LoginViewModel(loginWithCredentialsUseCase: makeLoginWithCredentialsUseCase(),
checkLoginAvailability: CheckLoginAvailability(),
validateLoginCredentials: ValidateCredentialsFormat(),
errorMapper: LoginPresentableErrorMapper())
errorHandler: makeLoginViewModelErrorHandler())

}

private static func makeLoginWithCredentialsUseCase() -> LoginWithCredentialsUseCaseType {
Expand All @@ -30,4 +31,8 @@ class LoginFactory {
LoginProvider(accountProvider: Client.providers.accountProvider,
userAccountMapper: UserAccountMapper())
}

private static func makeLoginViewModelErrorHandler() -> LoginViewModelErrorHandlerType {
LoginViewModelErrorHandler(errorMapper: LoginPresentableErrorMapper())
}
}
16 changes: 16 additions & 0 deletions PIA VPN-tvOS/Login/Domain/Entities/AccountInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,19 @@ struct AccountInfo {
return dateFormatter.string(from: self.expirationDate)
}
}

extension AccountInfo: Equatable {
public static func == (lhs: AccountInfo, rhs: AccountInfo) -> Bool {
lhs.email == rhs.email
&& lhs.username == rhs.username
&& lhs.plan == rhs.plan
&& lhs.productId == rhs.productId
&& lhs.isRenewable == rhs.isRenewable
&& lhs.isRecurring == rhs.isRecurring
&& lhs.expirationDate == rhs.expirationDate
&& lhs.canInvite == rhs.canInvite
&& lhs.isExpired == rhs.isExpired
&& lhs.shouldPresentExpirationAlert == rhs.shouldPresentExpirationAlert
&& lhs.renewUrl == rhs.renewUrl
}
}
6 changes: 6 additions & 0 deletions PIA VPN-tvOS/Login/Domain/Entities/Credentials.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ struct Credentials {
self.password = password
}
}

extension Credentials: Equatable {
public static func == (lhs: Credentials, rhs: Credentials) -> Bool {
lhs.username == rhs.username && lhs.password == rhs.password
}
}
15 changes: 15 additions & 0 deletions PIA VPN-tvOS/Login/Domain/Entities/LoginError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,18 @@ enum LoginError: Error {
case passwordWrongFormat
case generic(message: String?)
}

extension LoginError: Equatable {
public static func == (lhs: LoginError, rhs: LoginError) -> Bool {
switch (lhs, rhs) {
case (.unauthorized, .unauthorized), (.expired, .expired), (.usernameWrongFormat, .usernameWrongFormat), (.passwordWrongFormat, .passwordWrongFormat):
return true
case let (.throttled(lhsDelay), .throttled(rhsDelay)):
return lhsDelay == rhsDelay
case let (.generic(lhsMessage), .generic(rhsMessage)):
return lhsMessage == rhsMessage
default:
return false
}
}
}
7 changes: 7 additions & 0 deletions PIA VPN-tvOS/Login/Domain/Entities/UserAccount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,10 @@ struct UserAccount {
self.info = info
}
}

extension UserAccount: Equatable {
public static func == (lhs: UserAccount, rhs: UserAccount) -> Bool {
lhs.credentials == rhs.credentials
&& lhs.info == rhs.info
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import Foundation

class LoginPresentableErrorMapper {
func map(error: LoginError) -> String? {
return nil
switch error {
case .unauthorized:
return "Your username or password is incorrect."
case .throttled(retryAfter: let retryAfter):
return "Too many failed login attempts with this username. Please try again after \(retryAfter) second(s)."
case .generic(message: let message):
return message
case .usernameWrongFormat, .passwordWrongFormat:
return "You must enter a username and password."
default:
return nil
}
}
}
37 changes: 37 additions & 0 deletions PIA VPN-tvOS/Login/Presentation/LoginStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// LoginStatus.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 13/12/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

enum LoginCredentialsError {
case none
case username
case password
}

enum LoginStatus {
case none
case isLogging
case failed(errorMessage: String?, field: LoginCredentialsError)
case succeeded(userAccount: UserAccount)
}

extension LoginStatus: Equatable {
public static func == (lhs: LoginStatus, rhs: LoginStatus) -> Bool {
switch (lhs, rhs) {
case (.none, .none), (.isLogging, .isLogging):
return true
case let (.failed(lhsErrorMessage, lhsField), .failed(rhsErrorMessage, rhsField)):
return lhsErrorMessage == rhsErrorMessage && lhsField == rhsField
case let (.succeeded(lhsAccount), .succeeded(rhsAccount)):
return lhsAccount == rhsAccount
default:
return false
}
}
}
61 changes: 33 additions & 28 deletions PIA VPN-tvOS/Login/Presentation/LoginViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,68 +8,73 @@

import Foundation

enum LoginStatus {
case none
case isLogging
case failed(error: LoginError)
case succeeded(userAccount: UserAccount)
}

class LoginViewModel: ObservableObject {
private let loginWithCredentialsUseCase: LoginWithCredentialsUseCaseType
private let checkLoginAvailability: CheckLoginAvailabilityType
private let validateLoginCredentials: ValidateCredentialsFormatType
private let errorMapper: LoginPresentableErrorMapper
private let errorHandler: LoginViewModelErrorHandlerType

var loginStatus: LoginStatus = .none
@Published var isAccountExpired = false
@Published var didLoginSuccessfully = false
@Published var shouldShowErrorMessage = false
@Published var loginStatus: LoginStatus = .none

init(loginWithCredentialsUseCase: LoginWithCredentialsUseCaseType, checkLoginAvailability: CheckLoginAvailabilityType, validateLoginCredentials: ValidateCredentialsFormatType, errorMapper: LoginPresentableErrorMapper) {
init(loginWithCredentialsUseCase: LoginWithCredentialsUseCaseType, checkLoginAvailability: CheckLoginAvailabilityType, validateLoginCredentials: ValidateCredentialsFormatType, errorHandler: LoginViewModelErrorHandlerType) {
self.loginWithCredentialsUseCase = loginWithCredentialsUseCase
self.checkLoginAvailability = checkLoginAvailability
self.validateLoginCredentials = validateLoginCredentials
self.errorMapper = errorMapper
self.errorHandler = errorHandler
}

func login(username: String, password: String) async {
func login(username: String, password: String) {
if case .isLogging = loginStatus {
return
}

if case .failure(let error) = checkLoginAvailability() {
handleError(error: error)
handleError(error)
return
}

if case .failure(let error) = validateLoginCredentials(username: username, password: password) {
// Handle credentials wrong format
loginStatus = .failed(error: error)
handleError(error)
return
}

loginStatus = .isLogging

await withCheckedContinuation { continuation in
loginWithCredentialsUseCase.execute(username: username, password: password) { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let userAccount):
loginWithCredentialsUseCase.execute(username: username, password: password) { [weak self] result in
guard let self = self else { return }

switch result {
case .success(let userAccount):
Task { @MainActor in
self.loginStatus = .succeeded(userAccount: userAccount)
self.didLoginSuccessfully = true
}

case .failure(let error):
self.handleError(error: error)
}
continuation.resume()
case .failure(let error):
handleError(error)
}
}
}

private func handleError(error: LoginError) {
private func handleError(_ error: LoginError) {
guard error != .expired else {
Task { @MainActor in
loginStatus = .failed(errorMessage: nil, field: .none)
isAccountExpired = true
}
return
}

if case .throttled(let delay) = error {
checkLoginAvailability.disableLoginFor(delay)
}

let errorMessage = errorMapper.map(error: error)
loginStatus = .failed(error: error)
Task { @MainActor in
loginStatus = errorHandler(error: error)
shouldShowErrorMessage = true
}
}
}
37 changes: 37 additions & 0 deletions PIA VPN-tvOS/Login/Presentation/LoginViewModelErrorHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// LoginViewModelErrorHandler.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 13/12/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

protocol LoginViewModelErrorHandlerType {
func callAsFunction(error: LoginError) -> LoginStatus
}

class LoginViewModelErrorHandler: LoginViewModelErrorHandlerType {
private let errorMapper: LoginPresentableErrorMapper

init(errorMapper: LoginPresentableErrorMapper) {
self.errorMapper = errorMapper
}

func callAsFunction(error: LoginError) -> LoginStatus {
guard error != .usernameWrongFormat && error != .passwordWrongFormat else {
return handleCredentialsError(error)
}

let message = errorMapper.map(error: error)
return .failed(errorMessage: message, field: .none)
}

private func handleCredentialsError(_ error: LoginError) -> LoginStatus {
let message = errorMapper.map(error: error)
let field: LoginCredentialsError = error == .usernameWrongFormat ? .username : .password

return .failed(errorMessage: message, field: field)
}
}
34 changes: 23 additions & 11 deletions PIA VPN-tvOS/Login/UI/LoginView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,29 @@ struct LoginView: View {

var body: some View {
VStack {
Text("Sign in to your account")
.font(.system(size: 36))
TextField("Username(p1234567)", text: $userName)
TextField("Password", text: $password)
Button(action: {
Task {
await viewModel.login(username: userName, password: password)
ZStack {
if viewModel.loginStatus == .isLogging {
ProgressView().progressViewStyle(.circular)
}
}, label: {
Text("LOGIN")
})
}

VStack {
Text("Sign in to your account")
.font(.system(size: 36))
TextField("Username(p1234567)", text: $userName)
SecureField("Password", text: $password)
Button(action: {
viewModel.login(username: userName, password: password)
}, label: {
Text("LOGIN")
})
}
}
}.alert("Error", isPresented: $viewModel.shouldShowErrorMessage, actions: {
Button("OK") {}
}, message: {
if case .failed(let errorMessage, _) = viewModel.loginStatus, let errorMessage = errorMessage {
Text(errorMessage)
}
})
}
}
39 changes: 0 additions & 39 deletions PIA VPN-tvOSTests/Login/Helpers/Equatable.swift

This file was deleted.

Loading

0 comments on commit 92a1d7a

Please sign in to comment.