Commit d803b691 authored by Jose Blaya's avatar Jose Blaya
Browse files

Merge branch '178-new-login-receipt-button' into 'release/2.4.0'

Resolve "New login receipt button"

See merge request ios/client-library-apple!234
parents 86cf333c ae0d20c7
Pod::Spec.new do |s|
s.name = "PIALibrary"
s.version = "2.3.0"
s.version = "2.4.0"
s.summary = "PIA client library in Swift."
s.homepage = "https://www.privateinternetaccess.com/"
......
This diff is collapsed.
......@@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>2.3.0</string>
<string>2.4.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSPrincipalClass</key>
......
......@@ -15,6 +15,7 @@
"login.error.validation" = "You must enter a username and password.";
"login.error.unauthorized" = "Your username or password is incorrect.";
"login.error.throttled" = "Too many failed login attempts with this username. Please try again later.";
"login.receipt.button" = "Login using purchase receipt";
"purchase.title" = "Select a VPN plan";
"purchase.subtitle" = "7-day money back guarantee";
......
......@@ -65,6 +65,18 @@ public protocol AccountProvider: class {
*/
func login(with request: LoginRequest, _ callback: LibraryCallback<UserAccount>?)
/**
Logs into system using the purchase receipt. The `isLoggedIn` variable becomes `true` on success.
- Precondition: `isLoggedIn` is `false`.
- Postcondition:
- Sets `currentUser` on success.
- Posts `Notification.Name.PIAAccountDidLogin` on success.
- Parameter request: The login receipt request.
- Parameter callback: Returns an `UserAccount`.
*/
func login(with receiptRequest: LoginReceiptRequest, _ callback: LibraryCallback<UserAccount>?)
/**
Refreshes information associated with the account currently logged in.
......
//
// LoginReceiptRequest.swift
// Pods
//
// Created by Jose Antonio Blaya Garcia on 17/03/2020.
// Copyright © 2020 Private Internet Access, Inc.
//
// This file is part of the Private Internet Access iOS Client.
//
// The Private Internet Access iOS Client is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any later version.
//
// The Private Internet Access iOS Client is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with the Private
// Internet Access iOS Client. If not, see <https://www.gnu.org/licenses/>.
//
import Foundation
/// A login receipt request.
///
/// - Seealso: `AccountProvider.login(...)`
public struct LoginReceiptRequest {
/// The `Data` purchase receipt for the device.
public let receipt: Data
/// :nodoc:
public init(receipt: Data) {
self.receipt = receipt
}
}
......@@ -66,6 +66,8 @@ protocol PlainStore: class {
var isPersistentConnection: Bool? { get set }
var gdprTermsAccepted: Bool? { get set }
var shouldConnectForAllNetworks: Bool? { get set }
var useWiFiProtection: Bool? { get set }
......
......@@ -27,6 +27,9 @@ public struct AccountInfo {
/// The linked email address if any.
public internal(set) var email: String?
/// PIA username
public let username: String
/// The currently subscribed `Plan`.
public let plan: Plan
......
......@@ -32,6 +32,8 @@ protocol WebServices: class {
func token(credentials: Credentials, _ callback: LibraryCallback<String>?)
func token(receipt: Data, _ callback: LibraryCallback<String>?)
func info(token: String, _ callback: LibraryCallback<AccountInfo>?)
func update(credentials: Credentials, email: String, _ callback: SuccessLibraryCallback?)
......
......@@ -26,6 +26,7 @@ import SwiftyBeaver
private let log = SwiftyBeaver.self
class DefaultAccountProvider: AccountProvider, ConfigurationAccess, DatabaseAccess, WebServicesAccess, InAppAccess, WebServicesConsumer {
private let customWebServices: WebServices?
init(webServices: WebServices? = nil) {
......@@ -153,6 +154,44 @@ class DefaultAccountProvider: AccountProvider, ConfigurationAccess, DatabaseAcce
for: first)
}
}
func login(with receiptRequest: LoginReceiptRequest, _ callback: ((UserAccount?, Error?) -> Void)?) {
guard !isLoggedIn else {
preconditionFailure()
}
webServices.token(receipt: receiptRequest.receipt) { (token, error) in
guard let token = token else {
callback?(nil, error)
return
}
self.updateToken(token)
self.webServices.info(token: token) { (accountInfo, error) in
guard let accountInfo = accountInfo else {
callback?(nil, error)
return
}
self.updateDatabaseWith(token,
andUsername: accountInfo.username)
//Save after confirm the login was successful.
self.accessedDatabase.plain.accountInfo = accountInfo
let user = UserAccount(credentials: Credentials(username: "", password: ""), info: accountInfo)
Macros.postNotification(.PIAAccountDidLogin, [
.user: user
])
callback?(user, nil)
}
}
}
func login(with request: LoginRequest, _ callback: ((UserAccount?, Error?) -> Void)?) {
guard !isLoggedIn else {
......
......@@ -30,6 +30,8 @@ private protocol PreferencesStore: class {
var isPersistentConnection: Bool { get set }
var gdprTermsAccepted: Bool { get set }
var mace: Bool { get set }
var useWiFiProtection: Bool { get set }
......@@ -69,6 +71,7 @@ private extension PreferencesStore {
func load(from source: PreferencesStore) {
preferredServer = source.preferredServer
gdprTermsAccepted = source.gdprTermsAccepted
isPersistentConnection = source.isPersistentConnection
mace = source.mace
useWiFiProtection = source.useWiFiProtection
......@@ -118,6 +121,16 @@ extension Client {
}
}
/// GDPR treatment data agreement.
public fileprivate(set) var gdprTermsAccepted: Bool {
get {
return accessedDatabase.plain.gdprTermsAccepted ?? defaults.gdprTermsAccepted
}
set {
accessedDatabase.plain.gdprTermsAccepted = newValue
}
}
/// Enables automatic VPN reconnection.
public fileprivate(set) var isPersistentConnection: Bool {
get {
......@@ -317,6 +330,7 @@ extension Client.Preferences {
fileprivate init() {
preferredServer = nil
isPersistentConnection = true
gdprTermsAccepted = false
mace = false
useWiFiProtection = true
trustCellularData = false
......@@ -361,6 +375,9 @@ extension Client.Preferences {
/// :nodoc:
public var isPersistentConnection: Bool
/// :nodoc:
public var gdprTermsAccepted: Bool
/// :nodoc:
public var mace: Bool
......
......@@ -54,6 +54,8 @@ class UserDefaultsStore: PlainStore, ConfigurationAccess {
static let persistentConnection = "PersistentConnection" // legacy
static let gdprTermsAccepted = "GDPRTermsAccepted"
static let mace = "MACE" // legacy
static let visibleTiles = "VisibleTiles"
......@@ -356,6 +358,18 @@ class UserDefaultsStore: PlainStore, ConfigurationAccess {
}
}
var gdprTermsAccepted: Bool? {
get {
guard let value = backend.object(forKey: Entries.gdprTermsAccepted) as? Bool else {
return nil
}
return value
}
set {
backend.set(newValue, forKey: Entries.gdprTermsAccepted)
}
}
var useWiFiProtection: Bool? {
get {
guard let value = backend.object(forKey: Entries.useWiFiProtection) as? Bool else {
......
......@@ -28,6 +28,7 @@ class GlossAccountInfo: GlossParser {
required init?(json: JSON) {
let email: String? = "email" <~~ json
let username: String = "username" <~~ json ?? ""
let productId: String? = "product_id" <~~ json
let plan: Plan = "plan" <~~ json ?? .other
let canInvite: Bool = "can_invite" <~~ json ?? false
......@@ -52,6 +53,7 @@ class GlossAccountInfo: GlossParser {
parsed = AccountInfo(
email: email,
username: username,
plan: plan,
productId: productId,
isRenewable: isRenewable,
......@@ -69,6 +71,7 @@ extension AccountInfo: JSONEncodable {
public func toJSON() -> JSON? {
return jsonify([
"email" ~~> email,
"username" ~~> username,
"product_id" ~~> productId,
"plan" ~~> plan.rawValue,
"renewable" ~~> isRenewable,
......
......@@ -25,12 +25,16 @@ import Gloss
extension Signup: JSONEncodable {
func toJSON() -> JSON? {
return jsonify([
var json = jsonify([
"email" ~~> email,
"receipt" ~~> receipt.base64EncodedString(),
"marketing" ~~> marketing,
"debug" ~~> debug,
"store" ~~> "apple_app_store"
])
if email == "" {
json?.removeValue(forKey: "email")
}
return json
}
}
......@@ -59,6 +59,37 @@ class PIAWebServices: WebServices, ConfigurationAccess {
})
}
/***
Generates a new auth token for the specific user
*/
func token(receipt: Data, _ callback: ((String?, Error?) -> Void)?) {
let endpoint = ClientEndpoint.token
let status = [200, 401, 429]
let errors: [Int: ClientError] = [
401: .unauthorized,
429: .throttled
]
let parameters = ["store": "apple_app_store",
"receipt": receipt.base64EncodedString()]
req(nil, .post, endpoint, useAuthToken: true, parameters, status, JSONRequestExecutor() { (json, status, error) in
if let knownError = self.knownError(endpoint, status, errors) {
callback?(nil, knownError)
return
}
guard let json = json else {
callback?(nil, error)
return
}
guard let token = GlossToken(json: json)?.parsed else {
callback?(nil, ClientError.malformedResponseData)
return
}
callback?(token, nil)
})
}
func info(token: String, _ callback: ((AccountInfo?, Error?) -> Void)?) {
let endpoint = ClientEndpoint.account
let status = [200, 401, 429]
......
......@@ -25,6 +25,7 @@ import Foundation
/// Simulates account-related operations
public class MockAccountProvider: AccountProvider, WebServicesConsumer {
/// Mocks the outcome of a sign-up operation.
///
/// - Seealso: `AccountProvider.signup(...)`
......@@ -107,6 +108,7 @@ public class MockAccountProvider: AccountProvider, WebServicesConsumer {
webServices.accountInfo = {
return AccountInfo(
email: self.mockEmail,
username: "p0000000",
plan: self.mockPlan,
productId: self.mockProductId,
isRenewable: self.mockIsRenewable,
......@@ -198,6 +200,15 @@ public class MockAccountProvider: AccountProvider, WebServicesConsumer {
delegate.login(with: request, callback)
}
public func login(with receiptRequest: LoginReceiptRequest, _ callback: LibraryCallback<UserAccount>?) {
guard !mockIsUnauthorized else {
callback?(nil, ClientError.unauthorized)
return
}
delegate.login(with: receiptRequest, callback)
}
/// :nodoc:
public func refreshAccountInfo(force: Bool, _ callback: ((AccountInfo?, Error?) -> Void)?) {
guard !mockIsUnauthorized else {
......
......@@ -43,6 +43,12 @@ class MockWebServices: WebServices {
callback?(result, error)
}
func token(receipt: Data, _ callback: ((String?, Error?) -> Void)?) {
let result = "AUTH_TOKEN"
let error: ClientError? = (result == nil) ? .unsupported : nil
callback?(result, error)
}
func info(token: String, _ callback: ((AccountInfo?, Error?) -> Void)?) {
let result = accountInfo?()
let error: ClientError? = (result == nil) ? .unsupported : nil
......
......@@ -62,7 +62,7 @@ internal enum L10n {
internal static let anonymous = L10n.tr("Signup", "purchase.trials.anonymous")
/// Support 10 devices at once
internal static let devices = L10n.tr("Signup", "purchase.trials.devices")
/// Start your 7-days free trial
/// Start your 7-day free trial
internal static let intro = L10n.tr("Signup", "purchase.trials.intro")
/// Connect to any region easily
internal static let region = L10n.tr("Signup", "purchase.trials.region")
......@@ -287,6 +287,10 @@ internal enum L10n {
/// Password
internal static let placeholder = L10n.tr("Welcome", "login.password.placeholder")
}
internal enum Receipt {
/// Login using purchase receipt
internal static let button = L10n.tr("Welcome", "login.receipt.button")
}
internal enum Restore {
/// Didn't receive account details?
internal static let button = L10n.tr("Welcome", "login.restore.button")
......
......@@ -37,7 +37,6 @@ public class ConfirmVPNPlanViewController: AutolayoutViewController, BrandableNa
private var signupTransaction: InAppTransaction?
weak var completionDelegate: WelcomeCompletionDelegate?
var omitsSiblingLink = false
var termsAndConditionsAgreed = false
var preset: Preset?
private var allPlans: [PurchasePlan] = [.dummy, .dummy]
......@@ -110,13 +109,6 @@ public class ConfirmVPNPlanViewController: AutolayoutViewController, BrandableNa
return
}
guard termsAndConditionsAgreed else {
//present term and conditions
self.performSegue(withIdentifier: StoryboardSegue.Welcome.presentGDPRTermsSegue.rawValue,
sender: nil)
return
}
self.status = .restore(element: textEmail)
let plan = allPlans[planIndex]
......@@ -180,7 +172,7 @@ public class ConfirmVPNPlanViewController: AutolayoutViewController, BrandableNa
guard let transaction = transaction else {
if let error = error {
var message = error.localizedDescription
let message = error.localizedDescription
log.error("Purchase failed (error: \(error))")
Macros.displayImageNote(withImage: Asset.iconWarning.image,
message: message)
......@@ -214,11 +206,6 @@ public class ConfirmVPNPlanViewController: AutolayoutViewController, BrandableNa
vc.signupRequest = SignupRequest(email: email, transaction: signupTransaction)
vc.preset = preset
vc.completionDelegate = completionDelegate
} else if (segue.identifier == StoryboardSegue.Welcome.presentGDPRTermsSegue.rawValue) {
let gdprViewController = segue.destination as! GDPRViewController
gdprViewController.delegate = self
}
}
......@@ -242,16 +229,3 @@ public class ConfirmVPNPlanViewController: AutolayoutViewController, BrandableNa
}
}
extension ConfirmVPNPlanViewController: GDPRDelegate {
public func gdprViewWasAccepted() {
self.termsAndConditionsAgreed = true
self.signUp(nil)
}
public func gdprViewWasRejected() {
self.termsAndConditionsAgreed = false
}
}
......@@ -22,7 +22,7 @@
import UIKit
public class GetStartedViewController: AutolayoutViewController, ConfigurationAccess {
public class GetStartedViewController: PIAWelcomeViewController {
private static let smallDeviceMaxViewHeight: CGFloat = 520
private static let maxViewHeight: CGFloat = 500
......@@ -48,15 +48,17 @@ public class GetStartedViewController: AutolayoutViewController, ConfigurationAc
@IBOutlet private weak var textAgreement: UITextView!
@IBOutlet weak var visualEffectView: UIVisualEffectView!
private var allPlans: [PurchasePlan] = [.dummy, .dummy]
private var selectedPlanIndex: Int?
private var signupEmail: String?
private var signupTransaction: InAppTransaction?
private var isPurchasing = false
var preset = Preset()
private weak var delegate: PIAWelcomeViewControllerDelegate?
weak var completionDelegate: WelcomeCompletionDelegate?
@IBOutlet private weak var buttonViewConstraintHeight: NSLayoutConstraint!
@IBOutlet private weak var hiddenButtonsConstraintHeight: NSLayoutConstraint!
var termsWerePresentedOnce = false
private var buttonViewIsExpanded = false {
didSet {
self.updateButtonView()
......@@ -95,6 +97,8 @@ public class GetStartedViewController: AutolayoutViewController, ConfigurationAc
override public func viewDidLoad() {
allPlans = [.dummy, .dummy]
completionDelegate = self
subscribeNowTitle.text = L10n.Signup.Purchase.Trials.intro
subscribeNowDescription.text = L10n.Signup.Purchase.Trials.Price.after("")
......@@ -156,25 +160,63 @@ public class GetStartedViewController: AutolayoutViewController, ConfigurationAc
}
// MARK: Actions
@IBAction private func scrollPage(_ sender: UIPageControl) {
scrollToPage(sender.currentPage, animated: true)
@IBAction func confirmPlan() {
if let index = selectedPlanIndex,
let plans = allPlans {
let plan = plans[index]
self.startPurchaseProcessWithEmail("", andPlan: plan)
}
}
/**
Creates a wrapped `GetStartedViewController` ready for presentation.
- Parameter preset: The optional `Preset` to configure this controller with
- Parameter delegate: The `PIAWelcomeViewControllerDelegate` to handle raised events
*/
public static func with(preset: Preset? = nil, delegate: PIAWelcomeViewControllerDelegate? = nil) -> UIViewController {
let nav = StoryboardScene.Welcome.initialScene.instantiate()
let vc = nav.topViewController as! GetStartedViewController
if let customPreset = preset {
vc.preset = customPreset
private func startPurchaseProcessWithEmail(_ email: String,
andPlan plan: PurchasePlan) {
guard !Client.store.hasUncreditedTransactions else {
let alert = Macros.alert(
nil,
L10n.Signup.Purchase.Uncredited.Alert.message
)
alert.addCancelAction(L10n.Signup.Purchase.Uncredited.Alert.Button.cancel)
alert.addActionWithTitle(L10n.Signup.Purchase.Uncredited.Alert.Button.recover) {
self.navigationController?.popToRootViewController(animated: true)
Macros.postNotification(.PIARecoverAccount)
}
present(alert, animated: true, completion: nil)
return
}
//textEmail.text = email
isPurchasing = true
disableInteractions(fully: true)
self.showLoadingAnimation()
preset.accountProvider.purchase(plan: plan.plan) { (transaction, error) in
self.isPurchasing = false
self.enableInteractions()
self.hideLoadingAnimation()
guard let transaction = transaction else {
if let error = error {
let message = error.localizedDescription
Macros.displayImageNote(withImage: Asset.iconWarning.image,
message: message)
}
return
}
self.signupEmail = email
self.signupTransaction = transaction
self.perform(segue: StoryboardSegue.Welcome.signupViaPurchaseSegue)
}
vc.delegate = delegate
return nav
}
@IBAction private func scrollPage(_ sender: UIPageControl) {
scrollToPage(sender.currentPage, animated: true)
}
public static func withPurchase(preset: Preset? = nil, delegate: PIAWelcomeViewControllerDelegate? = nil) -> UIViewController {
......@@ -191,6 +233,25 @@ public class GetStartedViewController: AutolayoutViewController, ConfigurationAc
public override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == StoryboardSegue.Welcome.presentGDPRTermsSegue.rawValue {