From d0e8bbea837ff9a176d93f3fdb42219f5e0f98ee Mon Sep 17 00:00:00 2001 From: Soner YUKSEL Date: Wed, 2 Aug 2023 17:01:24 -0400 Subject: [PATCH] Ref #4879: VPN churn improvements - VerifyReceiptData fields with isBillingRetry (#7803) --- App/Client.xcodeproj/Brave.xctestplan | 7 + Package.swift | 4 + .../BrowserViewController+Callout.swift | 34 +++- ...onPreferencesDebugMenuViewController.swift | 6 +- Sources/BraveVPN/BraveVPN.swift | 103 +++++++++++- .../Onboarding/OnboardingPreferences.swift | 9 +- .../FullScreenCalloutManager.swift | 36 ++-- Tests/BraveVPNTests/BraveVPNTests.swift | 157 ++++++++++++++++++ 8 files changed, 334 insertions(+), 22 deletions(-) create mode 100644 Tests/BraveVPNTests/BraveVPNTests.swift diff --git a/App/Client.xcodeproj/Brave.xctestplan b/App/Client.xcodeproj/Brave.xctestplan index f8655922f69..dae73e8e64a 100644 --- a/App/Client.xcodeproj/Brave.xctestplan +++ b/App/Client.xcodeproj/Brave.xctestplan @@ -116,6 +116,13 @@ "identifier" : "PrivateCDNTests", "name" : "PrivateCDNTests" } + }, + { + "target" : { + "containerPath" : "container:..", + "identifier" : "BraveVPNTests", + "name" : "BraveVPNTests" + } } ], "version" : 1 diff --git a/Package.swift b/Package.swift index 8e36b084da0..506adb611b8 100644 --- a/Package.swift +++ b/Package.swift @@ -290,6 +290,10 @@ var package = Package( name: "BraveSharedTests", dependencies: ["BraveShared", "Preferences"] ), + .testTarget( + name: "BraveVPNTests", + dependencies: ["BraveVPN", "BraveShared", "GuardianConnect"] + ), .testTarget( name: "BraveWalletTests", dependencies: [ diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Callout.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Callout.swift index 7d22adf7c5d..9517232dbdd 100644 --- a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Callout.swift +++ b/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+Callout.swift @@ -17,8 +17,19 @@ import SafariServices // MARK: - Callouts extension BrowserViewController { - - // Priority: P3A - Bottom Bar - VPN - Default Browser - Rewards - Cookie Notification - Link Receipt + /* + Check FullScreenCalloutType to make alterations to priority of pop-over variation + + Priority: + - P3A + - VPN Update Billing + - Bottom Bar + - VPN Promotion + - Default Browser + - Rewards + - Cookie Notification + - VPN Link Receipt + */ func presentFullScreenCallouts() { for type in FullScreenCalloutType.allCases { presentScreenCallout(for: type) @@ -34,6 +45,8 @@ extension BrowserViewController { switch type { case .p3a: presentP3AScreenCallout() + case .vpnUpdateBilling: + presentVPNUpdateBillingCallout(skipSafeGuards: skipSafeGuards) case .bottomBar: presentBottomBarCallout(skipSafeGuards: skipSafeGuards) case .defaultBrowser: @@ -244,11 +257,24 @@ extension BrowserViewController { present(popup, animated: false) } - private func presentVPNChurnPromoCallout(for type: VPNChurnPromoType) { + private func presentVPNUpdateBillingCallout(skipSafeGuards: Bool = false) { + if !skipSafeGuards { + // TODO: Condition + return + } + + presentVPNChurnPromoCallout(for: .updateBillingExpired) { + // TODO: Action + } + } + + // MARK: Helper Methods for Presentation + + private func presentVPNChurnPromoCallout(for type: VPNChurnPromoType, completion: @escaping () -> Void) { var vpnChurnPromoView = VPNChurnPromoView(churnPromoType: type) vpnChurnPromoView.renewAction = { - // TODO: Action + completion() } let popup = PopupViewController(rootView: vpnChurnPromoView, isDismissable: true) diff --git a/Sources/Brave/Frontend/Settings/RetentionPreferencesDebugMenuViewController.swift b/Sources/Brave/Frontend/Settings/RetentionPreferencesDebugMenuViewController.swift index 6e8a9cb80fe..1b1992a35ab 100644 --- a/Sources/Brave/Frontend/Settings/RetentionPreferencesDebugMenuViewController.swift +++ b/Sources/Brave/Frontend/Settings/RetentionPreferencesDebugMenuViewController.swift @@ -129,18 +129,18 @@ class RetentionPreferencesDebugMenuViewController: TableViewController { .boolRow( title: "VPN Callout Shown", detailText: "Flag determining if VPN callout is shown to user.", - toggleValue: Preferences.FullScreenCallout.vpnCalloutCompleted.value, + toggleValue: Preferences.FullScreenCallout.vpnPromotionCalloutCompleted.value, valueChange: { if $0 { let status = $0 - Preferences.FullScreenCallout.vpnCalloutCompleted.value = status + Preferences.FullScreenCallout.vpnPromotionCalloutCompleted.value = status } }, cellReuseId: "VPNCalloutCell"), .boolRow( title: "Rewards Callout Shown", detailText: "Flag determining if Rewards callout is shown to user.", - toggleValue: Preferences.FullScreenCallout.vpnCalloutCompleted.value, + toggleValue: Preferences.FullScreenCallout.vpnPromotionCalloutCompleted.value, valueChange: { if $0 { let status = $0 diff --git a/Sources/BraveVPN/BraveVPN.swift b/Sources/BraveVPN/BraveVPN.swift index c52aca6a77d..60e10daec4d 100644 --- a/Sources/BraveVPN/BraveVPN.swift +++ b/Sources/BraveVPN/BraveVPN.swift @@ -13,6 +13,24 @@ import os.log /// A static class to handle all things related to the Brave VPN service. public class BraveVPN { + + public struct ReceiptResponse { + enum Status { + case active, expired, retryPeriod + } + + enum ExpirationIntent: Int { + case none, cancelled, billingError, priceIncreaseConsent, notAvailable, unknown + } + + var status: Status + var expiryReason: ExpirationIntent = .none + var expiryDate: Date? + + var isInTrialPeriod: Bool = false + var autoRenewEnabled: Bool = false + } + private static let housekeepingApi = GRDHousekeepingAPI() private static let helper = GRDVPNHelper.sharedInstance() private static let serverManager = GRDServerManager() @@ -80,9 +98,8 @@ public class BraveVPN { return } - // We validate the current receipt at the start to know if the subscription has expirerd. - BraveVPN.validateReceipt() { expired in - if expired == true { + BraveVPN.validateReceiptData() { receiptResponse in + if receiptResponse?.status == .expired { clearConfiguration() logAndStoreError("Receipt expired") return @@ -157,6 +174,86 @@ public class BraveVPN { receiptHasExpired?(false) } } + + /// Connects to Guardian's server to validate locally stored receipt. + /// Returns ReceiptResponse whoich hold information about status of receipt expiration etc + public static func validateReceiptData(receiptResponse: ((ReceiptResponse?) -> Void)? = nil) { + guard let receipt = receipt, + let bundleId = Bundle.main.bundleIdentifier else { + receiptResponse?(nil) + return + } + + if Preferences.VPN.skusCredential.value != nil { + // Receipt verification applies to Apple's IAP only, + // if we detect Brave's SKU token we should not look at Apple's receipt. + return + } + + housekeepingApi.verifyReceiptData(receipt, bundleId: bundleId) { response, error in + if let error = error { + // Error while fetching receipt response, the variations of error can be listed + // No App Store receipt data present + // Failed to retrieve receipt data from server + // Failed to decode JSON response data + receiptResponse?(nil) + logAndStoreError("Call for receipt verification failed: \(error.localizedDescription)") + return + } + + guard let response = response else { + receiptResponse?(nil) + logAndStoreError("Receipt verification response is empty") + return + } + + let receiptResponseItem = GRDIAPReceiptResponse(withReceiptResponse: response) + let processedReceiptDetail = BraveVPN.processReceiptResponse(receiptResponseItem: receiptResponseItem) + + switch processedReceiptDetail.status { + case .expired: + Preferences.VPN.expirationDate.value = Date(timeIntervalSince1970: 1) + logAndStoreError("VPN Subscription LineItems are empty subscription expired", printToConsole: false) + case .active, .retryPeriod: + if let expirationDate = processedReceiptDetail.expiryDate { + Preferences.VPN.expirationDate.value = expirationDate + } + + Preferences.VPN.freeTrialUsed.value = !processedReceiptDetail.isInTrialPeriod + + populateRegionDataIfNecessary() + GRDSubscriptionManager.setIsPayingUser(true) + } + + receiptResponse?(processedReceiptDetail) + } + } + + public static func processReceiptResponse(receiptResponseItem: GRDIAPReceiptResponse) -> ReceiptResponse { + guard let newestReceiptLineItem = receiptResponseItem.lineItems.sorted(by: { $0.expiresDate > $1.expiresDate }).first else { + return ReceiptResponse(status: .expired) + } + + let lineItemMetaData = receiptResponseItem.lineItemsMetadata.first( + where: { Int($0.originalTransactionId) ?? 00 == newestReceiptLineItem.originalTransactionId }) + + guard let metadata = lineItemMetaData else { + return ReceiptResponse(status: .active) + } + + let receiptStatus: ReceiptResponse.Status = lineItemMetaData?.isInBillingRetryPeriod == true ? .retryPeriod : .active + // Expiration Intent is unsigned + let expirationIntent = ReceiptResponse.ExpirationIntent(rawValue: Int(metadata.expirationIntent)) ?? .none + // 0 is for turned off renewal, 1 is subscription renewal + let autoRenewEnabled = metadata.autoRenewStatus == 1 + + return ReceiptResponse( + status: receiptStatus, + expiryReason: expirationIntent, + expiryDate: newestReceiptLineItem.expiresDate, + isInTrialPeriod: newestReceiptLineItem.isTrialPeriod, + autoRenewEnabled: autoRenewEnabled) + } // MARK: - STATE diff --git a/Sources/Onboarding/OnboardingPreferences.swift b/Sources/Onboarding/OnboardingPreferences.swift index c76d068d66f..69b27e9741e 100644 --- a/Sources/Onboarding/OnboardingPreferences.swift +++ b/Sources/Onboarding/OnboardingPreferences.swift @@ -49,8 +49,8 @@ extension Preferences { key: "fullScreenCallout.full-screen-bottom-bar-callout-completed", default: false) - /// Whether the vpn callout is shown. - public static let vpnCalloutCompleted = Option( + /// Whether the vpn promotion callout is shown. + public static let vpnPromotionCalloutCompleted = Option( key: "fullScreenCallout.full-screen-vpn-callout-completed", default: false) @@ -68,6 +68,11 @@ extension Preferences { public static let omniboxCalloutCompleted = Option( key: "fullScreenCallout.full-screen-omnibox-callout-completed", default: false) + + /// Whether the vpn promotion callout is shown. + public static let vpnUpdateBillingCalloutCompleted = Option( + key: "fullScreenCallout.full-screen-vpn-billing-callout-completed", + default: false) } } diff --git a/Sources/Onboarding/ProductNotifications/FullScreenCalloutManager.swift b/Sources/Onboarding/ProductNotifications/FullScreenCalloutManager.swift index 28161bbb5b5..d2b84f5cfbc 100644 --- a/Sources/Onboarding/ProductNotifications/FullScreenCalloutManager.swift +++ b/Sources/Onboarding/ProductNotifications/FullScreenCalloutManager.swift @@ -9,17 +9,31 @@ import Preferences import Growth public enum FullScreenCalloutType: CaseIterable { - case bottomBar, p3a, rewards, defaultBrowser, blockCookieConsentNotices, vpnPromotion, vpnLinkReceipt + /* + The order will effect the priority + + Priority: + - P3A + - VPN Update Billing + - Bottom Bar + - VPN Promotion + - Default Browser + - Rewards + - Cookie Notification + - VPN Link Receipt + */ + case p3a, vpnUpdateBilling, bottomBar, vpnPromotion, defaultBrowser, rewards, blockCookieConsentNotices, vpnLinkReceipt /// The number of days passed to show certain type of callout var period: Int { switch self { - case .bottomBar: return 0 case .p3a: return 0 - case .rewards: return 8 + case .vpnUpdateBilling: return 0 + case .bottomBar: return 0 + case .vpnPromotion: return 4 case .defaultBrowser: return 10 + case .rewards: return 8 case .blockCookieConsentNotices: return 0 - case .vpnPromotion: return 4 case .vpnLinkReceipt: return 0 } } @@ -27,18 +41,20 @@ public enum FullScreenCalloutType: CaseIterable { /// The preference value stored for complete state public var preferenceValue: Preferences.Option { switch self { - case .bottomBar: - return Preferences.FullScreenCallout.bottomBarCalloutCompleted case .p3a: return Preferences.Onboarding.p3aOnboardingShown - case .rewards: - return Preferences.FullScreenCallout.rewardsCalloutCompleted + case .vpnUpdateBilling: + return Preferences.FullScreenCallout.vpnUpdateBillingCalloutCompleted + case .bottomBar: + return Preferences.FullScreenCallout.bottomBarCalloutCompleted + case .vpnPromotion: + return Preferences.FullScreenCallout.vpnPromotionCalloutCompleted case .defaultBrowser: return Preferences.DefaultBrowserIntro.completed + case .rewards: + return Preferences.FullScreenCallout.rewardsCalloutCompleted case .blockCookieConsentNotices: return Preferences.FullScreenCallout.blockCookieConsentNoticesCalloutCompleted - case .vpnPromotion: - return Preferences.FullScreenCallout.vpnCalloutCompleted case .vpnLinkReceipt: return Preferences.Onboarding.vpnLinkReceiptShown } diff --git a/Tests/BraveVPNTests/BraveVPNTests.swift b/Tests/BraveVPNTests/BraveVPNTests.swift new file mode 100644 index 00000000000..3e639bf123b --- /dev/null +++ b/Tests/BraveVPNTests/BraveVPNTests.swift @@ -0,0 +1,157 @@ +// Copyright 2021 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +@testable import BraveVPN +import BraveShared +import XCTest +import GuardianConnect + +class BraveVPNTests: XCTestCase { + + override func setUp() { + + let lineItemPurchasedNotInTrial: NSDictionary = [ + "quantity": "1", + "expires_date": "2024-07-27 22:19:43 Etc/GMT", + "expires_date_pst": "2024-07-27 15:19:43 America/Los_Angeles", + "is_in_intro_offer_period": "false", + "purchase_date_ms": "1690492783000", + "transaction_id": "2000000377681042", + "is_trial_period": "false", + "original_transaction_id": "2000000159090000", + "in_app_ownership_type": "PURCHASED", + "original_purchase_date_pst": "2022-09-20 08:12:56 America/Los_Angeles", + "product_id": "bravevpn.yearly", + "purchase_date": "2024-07-27 22:19:43 Etc/GMT", + "subscription_group_identifier": "20621968", + "original_purchase_date_ms": "1663686776000", + "expires_date_ms": "1690496383000", + "purchase_date_pst": "2023-07-27 14:19:43 America/Los_Angeles", + "original_purchase_date": "2022-09-20 15:12:56 Etc/GMT"] + + let lineItemPurchasedInTrial: NSDictionary = [ + "quantity": "1", + "expires_date": "2024-07-27 22:19:43 Etc/GMT", + "expires_date_pst": "2024-07-27 15:19:43 America/Los_Angeles", + "is_in_intro_offer_period": "false", + "purchase_date_ms": "1690492783000", + "transaction_id": "2000000377681042", + "is_trial_period": "true", + "original_transaction_id": "2000000159090000", + "in_app_ownership_type": "PURCHASED", + "original_purchase_date_pst": "2022-09-20 08:12:56 America/Los_Angeles", + "product_id": "bravevpn.yearly", + "purchase_date": "2024-07-27 22:19:43 Etc/GMT", + "subscription_group_identifier": "20621968", + "original_purchase_date_ms": "1663686776000", + "web_order_line_item_id": "2000000032896443", + "expires_date_ms": "1690496383000", + "purchase_date_pst": "2023-07-27 14:19:43 America/Los_Angeles", + "original_purchase_date": "2022-09-20 15:12:56 Etc/GMT"] + + let lineItemMetaDataAutoRenewEnabled: NSDictionary = [ + "product_id": "bravevpn.yearly", + "original_transaction_id": "2000000159090000", + "auto_renew_product_id": "bravevpn.yearly", + "auto_renew_status": "1"] + + let lineItemMetaDataAutoRenewCanceled: NSDictionary = [ + "product_id": "bravevpn.yearly", + "original_transaction_id": "2000000159090000", + "auto_renew_product_id": "bravevpn.yearly", + "auto_renew_status": "0"] + + let lineItemMetaDataIsInRetryPeriod: NSDictionary = [ + "product_id": "bravevpn.yearly", + "original_transaction_id": "2000000159090000", + "auto_renew_product_id": "bravevpn.yearly", + "auto_renew_status": "0", + "is_in_billing_retry_period": "true"] + + + subjectActivePeriodRenewableNotInTrial = generateReceiptResponse(using: lineItemPurchasedNotInTrial, metaData: lineItemMetaDataAutoRenewEnabled) + subjectActivePeriodRenewableInTrial = generateReceiptResponse(using: lineItemPurchasedInTrial, metaData: lineItemMetaDataAutoRenewEnabled) + subjectActivePeriodNotRenewable = generateReceiptResponse(using: lineItemPurchasedNotInTrial, metaData: lineItemMetaDataAutoRenewCanceled) + subjectRetryPeriod = generateReceiptResponse(using: lineItemPurchasedNotInTrial, metaData: lineItemMetaDataIsInRetryPeriod) + subjectExpiredPeriod = generateReceiptResponse(using: nil, metaData: lineItemMetaDataAutoRenewCanceled) + } + + override func tearDown() { + subjectActivePeriodRenewableNotInTrial = nil + subjectActivePeriodRenewableInTrial = nil + subjectActivePeriodNotRenewable = nil + subjectRetryPeriod = nil + subjectExpiredPeriod = nil + + super.tearDown() + } + + func testSubscriptionActiveAutoRenewEnabledNotInTrial() { + let processedLineItem = BraveVPN.processReceiptResponse(receiptResponseItem: subjectActivePeriodRenewableNotInTrial) + + XCTAssertTrue(processedLineItem.status == .active) + XCTAssertTrue(processedLineItem.autoRenewEnabled) + XCTAssertFalse(processedLineItem.isInTrialPeriod) + } + + func testSubscriptionActiveAutoRenewEnabledInTrial() { + let processedLineItem = BraveVPN.processReceiptResponse(receiptResponseItem: subjectActivePeriodRenewableInTrial) + + XCTAssertTrue(processedLineItem.status == .active) + XCTAssertTrue(processedLineItem.autoRenewEnabled) + XCTAssertTrue(processedLineItem.isInTrialPeriod) + } + + func testSubscriptionActiveAutoRenewDisabled() { + let processedLineItem = BraveVPN.processReceiptResponse(receiptResponseItem: subjectActivePeriodNotRenewable) + + XCTAssertTrue(processedLineItem.status == .active) + XCTAssertFalse(processedLineItem.autoRenewEnabled) + } + + func testSubscriptionIsInRetryPeriod() { + let processedLineItem = BraveVPN.processReceiptResponse(receiptResponseItem: subjectRetryPeriod) + + XCTAssertTrue(processedLineItem.status == .retryPeriod) + XCTAssertFalse(processedLineItem.autoRenewEnabled) + XCTAssertFalse(processedLineItem.isInTrialPeriod) + } + + func testSubscriptionExpiredPeriod() { + let processedLineItem = BraveVPN.processReceiptResponse(receiptResponseItem: subjectExpiredPeriod) + + XCTAssertTrue(processedLineItem.status == .expired) + XCTAssertFalse(processedLineItem.autoRenewEnabled) + XCTAssertFalse(processedLineItem.isInTrialPeriod) + } + + private func generateReceiptResponse(using lineItem: NSDictionary?, metaData: NSDictionary)-> GRDIAPReceiptResponse { + var receiptLineItem: GRDReceiptLineItem? + + if let lineItem = lineItem { + receiptLineItem = GRDReceiptLineItem( + dictionary: lineItem as! [AnyHashable: Any]) + } + let receiptLineItemMetaData: GRDReceiptLineItemMetadata = GRDReceiptLineItemMetadata( + dictionary: metaData as! [AnyHashable: Any]) + + let receiptResponse = GRDIAPReceiptResponse(withReceiptResponse: ["":""]) + if let receiptLineItem = receiptLineItem { + receiptResponse.lineItems = [receiptLineItem] + } else { + receiptResponse.lineItems = [] + } + receiptResponse.lineItemsMetadata = [receiptLineItemMetaData] + + return receiptResponse + } + + private var subjectActivePeriodRenewableNotInTrial: GRDIAPReceiptResponse! + private var subjectActivePeriodRenewableInTrial: GRDIAPReceiptResponse! + private var subjectActivePeriodNotRenewable: GRDIAPReceiptResponse! + private var subjectRetryPeriod: GRDIAPReceiptResponse! + private var subjectExpiredPeriod: GRDIAPReceiptResponse! +}