Skip to content

Commit

Permalink
PIA-882: Login feature for tvOS (#44)
Browse files Browse the repository at this point in the history
* PIA-882: Add domain-app logic components for Login

* PIA-882: Add presentation components for Login

* PIA-882: Add data components for Login

* PIA-882: Add LoginView

* PIA-882: Add Login factory to create Login feature

* PIA-882: Add unit and integration tests for Login feature on tvOs

* PIA-882: Decoupled PIALibrary from presentation and domain layers
  • Loading branch information
kp-said-rehouni authored Dec 13, 2023
1 parent c0fc9b5 commit d9f39a2
Show file tree
Hide file tree
Showing 29 changed files with 1,315 additions and 1 deletion.
33 changes: 33 additions & 0 deletions PIA VPN-tvOS/Login/CompositionRoot/LoginFactory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// LoginFactory.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 4/12/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation
import PIALibrary

class LoginFactory {
static func makeLoginView() -> LoginView {
LoginView(viewModel: makeLoginViewModel())
}

private static func makeLoginViewModel() -> LoginViewModel {
LoginViewModel(loginWithCredentialsUseCase: makeLoginWithCredentialsUseCase(),
checkLoginAvailability: CheckLoginAvailability(),
validateLoginCredentials: ValidateCredentialsFormat(),
errorMapper: LoginPresentableErrorMapper())
}

private static func makeLoginWithCredentialsUseCase() -> LoginWithCredentialsUseCaseType {
LoginWithCredentialsUseCase(loginProvider: makeLoginProvider(),
errorMapper: LoginDomainErrorMapper())
}

private static func makeLoginProvider() -> LoginProviderType {
LoginProvider(accountProvider: Client.providers.accountProvider,
userAccountMapper: UserAccountMapper())
}
}
31 changes: 31 additions & 0 deletions PIA VPN-tvOS/Login/Data/LoginDomainErrorMapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// LoginDomainErrorMapper.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 4/12/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation
import PIALibrary

class LoginDomainErrorMapper: LoginDomainErrorMapperType {
func map(error: Error?) -> LoginError {
guard let clientError = error as? ClientError else {
return .generic(message: error?.localizedDescription)
}

switch clientError {
case .unauthorized:
return .unauthorized

case .throttled(retryAfter: let retryAfter):
return .throttled(retryAfter: Double(retryAfter))

case .expired:
return .expired
default:
return .generic(message: error?.localizedDescription)
}
}
}
41 changes: 41 additions & 0 deletions PIA VPN-tvOS/Login/Data/LoginProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// LoginProvider.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 4/12/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation
import PIALibrary

class LoginProvider: LoginProviderType {
private let accountProvider: AccountProvider
private let userAccountMapper: UserAccountMapper

init(accountProvider: AccountProvider, userAccountMapper: UserAccountMapper) {
self.accountProvider = accountProvider
self.userAccountMapper = userAccountMapper
}

func login(with credentials: Credentials, completion: @escaping (Result<UserAccount, Error>) -> Void) {
let pialibraryCredentials = PIALibrary.Credentials(username: credentials.username, password: credentials.password)
let request = LoginRequest(credentials: pialibraryCredentials)

accountProvider.login(with: request) { [weak self] userAccount, error in
guard let self = self else { return }

if let error = error {
completion(.failure(error))
return
}

guard let userAccount = userAccount else {
completion(.failure(ClientError.unexpectedReply))
return
}

completion(.success(userAccountMapper.map(userAccount: userAccount)))
}
}
}
49 changes: 49 additions & 0 deletions PIA VPN-tvOS/Login/Data/UserAccountMapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// UserAccountMapper.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 12/12/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation
import PIALibrary

class UserAccountMapper {
func map(userAccount: PIALibrary.UserAccount) -> UserAccount {
let credentials = Credentials(username: userAccount.credentials.username,
password: userAccount.credentials.password)

guard let info = userAccount.info else {
return UserAccount(credentials: credentials, info: nil)
}

let accountInfo = AccountInfo(email: info.email,
username: info.username,
plan: Plan.map(plan: info.plan),
productId: info.productId,
isRenewable: info.isRenewable,
isRecurring: info.isRecurring,
expirationDate: info.expirationDate,
canInvite: info.canInvite,
shouldPresentExpirationAlert: info.shouldPresentExpirationAlert,
renewUrl: info.renewUrl)

return UserAccount(credentials: credentials, info: accountInfo)
}
}

extension Plan {
static func map(plan: PIALibrary.Plan) -> Plan {
switch plan {
case .monthly:
return .monthly
case .yearly:
return .yearly
case .trial:
return .trial
case .other:
return .other
}
}
}
46 changes: 46 additions & 0 deletions PIA VPN-tvOS/Login/Domain/Entities/AccountInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// AccountInfo.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 12/12/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

enum Plan: String {
case monthly
case yearly
case trial
case other
}

struct AccountInfo {
let email: String?
let username: String
let plan: Plan
let productId: String?
let isRenewable: Bool
let isRecurring: Bool
let expirationDate: Date
let canInvite: Bool

public var isExpired: Bool {
return (expirationDate.timeIntervalSinceNow < 0)
}

public var dateComponentsBeforeExpiration: DateComponents {
return Calendar.current.dateComponents([.day, .hour], from: Date(), to: expirationDate)
}

public let shouldPresentExpirationAlert: Bool
public let renewUrl: URL?

public func humanReadableExpirationDate(usingLocale locale: Locale = Locale.current) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .long
dateFormatter.timeStyle = .none
dateFormatter.locale = locale
return dateFormatter.string(from: self.expirationDate)
}
}
19 changes: 19 additions & 0 deletions PIA VPN-tvOS/Login/Domain/Entities/Credentials.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// Credentials.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 12/12/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

struct Credentials {
let username: String
let password: String

init(username: String, password: String) {
self.username = username
self.password = password
}
}
18 changes: 18 additions & 0 deletions PIA VPN-tvOS/Login/Domain/Entities/LoginError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// LoginError.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 29/11/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

enum LoginError: Error {
case unauthorized
case throttled(retryAfter: Double)
case expired
case usernameWrongFormat
case passwordWrongFormat
case generic(message: String?)
}
23 changes: 23 additions & 0 deletions PIA VPN-tvOS/Login/Domain/Entities/UserAccount.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// UserAccount.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 12/12/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

struct UserAccount {
let credentials: Credentials
let info: AccountInfo?

var isRenewable: Bool {
return info?.isRenewable ?? false
}

init(credentials: Credentials, info: AccountInfo?) {
self.credentials = credentials
self.info = info
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// LoginDomainErrorMapperType.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 4/12/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

protocol LoginDomainErrorMapperType {
func map(error: Error?) -> LoginError
}
13 changes: 13 additions & 0 deletions PIA VPN-tvOS/Login/Domain/Interfaces/LoginProviderType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// LoginProviderType.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 27/11/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

protocol LoginProviderType {
func login(with credentials: Credentials, completion: @escaping (Result<UserAccount, Error>) -> Void)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// LoginWithCredentialsUseCase.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 27/11/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

protocol LoginWithCredentialsUseCaseType {
func execute(username: String, password: String, completion: @escaping (Result<UserAccount, LoginError>) -> Void)
}

class LoginWithCredentialsUseCase: LoginWithCredentialsUseCaseType {
private let loginProvider: LoginProviderType
private let errorMapper: LoginDomainErrorMapperType

init(loginProvider: LoginProviderType, errorMapper: LoginDomainErrorMapperType) {
self.loginProvider = loginProvider
self.errorMapper = errorMapper
}

func execute(username: String, password: String, completion: @escaping (Result<UserAccount, LoginError>) -> Void) {
let credentials = Credentials(username: username,
password: password)

loginProvider.login(with: credentials) { [weak self] result in
guard let self = self else { return }

switch result {
case .success(let userAccount):
completion(.success(userAccount))
case .failure(let error):
completion(.failure(errorMapper.map(error: error)))
}
}
}
}




30 changes: 30 additions & 0 deletions PIA VPN-tvOS/Login/Presentation/CheckLoginAvailability.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// CheckLoginAvailability.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 29/11/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

protocol CheckLoginAvailabilityType {
func disableLoginFor(_ delay: Double)
func callAsFunction() -> Result<Void, LoginError>
}

class CheckLoginAvailability: CheckLoginAvailabilityType {
private var timeToRetryCredentials: TimeInterval? = nil

func disableLoginFor(_ delay: Double) {
timeToRetryCredentials = delay
}

func callAsFunction() -> Result<Void, LoginError> {
if let timeUntilNextTry = timeToRetryCredentials?.timeSinceNow() {
return .failure(.throttled(retryAfter: timeUntilNextTry))
}

return .success(())
}
}
15 changes: 15 additions & 0 deletions PIA VPN-tvOS/Login/Presentation/LoginPresentableErrorMapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// LoginPresentableErrorMapper.swift
// PIA VPN-tvOS
//
// Created by Said Rehouni on 28/11/23.
// Copyright © 2023 Private Internet Access Inc. All rights reserved.
//

import Foundation

class LoginPresentableErrorMapper {
func map(error: LoginError) -> String? {
return nil
}
}
Loading

0 comments on commit d9f39a2

Please sign in to comment.