Skip to content

Commit

Permalink
14. Implemented Error handlers for all subscription scenarios (#2508)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/414235014887631/1206707680638882/f

Description:
This adds different error-handling 'handles' to attach pixels and events and streamlines the overall error-handling flow in the UI
  • Loading branch information
afterxleep authored Feb 29, 2024
1 parent 180362f commit 8b901e8
Show file tree
Hide file tree
Showing 12 changed files with 338 additions and 88 deletions.
2 changes: 1 addition & 1 deletion DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -9921,7 +9921,7 @@
repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit";
requirement = {
kind = exactVersion;
version = 113.0.0;
version = 113.1.0;
};
};
B6F997C22B8F374300476735 /* XCRemoteSwiftPackageReference "apple-toolbox" */ = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/DuckDuckGo/BrowserServicesKit",
"state" : {
"revision" : "f903ffcbc51e85ac262c355b56726e3387957a80",
"version" : "113.0.0"
"revision" : "7f5c89edfdf38cec173f125f46bacad43960d2d4",
"version" : "113.1.0"
}
},
{
Expand Down Expand Up @@ -165,7 +165,7 @@
{
"identity" : "trackerradarkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/duckduckgo/TrackerRadarKit.git",
"location" : "https://github.com/duckduckgo/TrackerRadarKit",
"state" : {
"revision" : "a6b7ba151d9dc6684484f3785293875ec01cc1ff",
"version" : "1.2.2"
Expand Down
4 changes: 3 additions & 1 deletion DuckDuckGo/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

#if SUBSCRIPTION
private func setupSubscriptionsEnvironment() {
Task { SubscriptionPurchaseEnvironment.current = .appStore
Task {
SubscriptionPurchaseEnvironment.currentServiceEnvironment = .staging
SubscriptionPurchaseEnvironment.current = .appStore
await AccountManager().checkSubscriptionState()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,29 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec
struct FeatureSelection: Codable {
let feature: String
}

enum UseSubscriptionError: Error {
case purchaseFailed,
missingEntitlements,
failedToGetSubscriptionOptions,
failedToSetSubscription,
failedToRestoreFromEmail,
failedToRestoreFromEmailSubscriptionInactive,
failedToRestorePastPurchase,
subscriptionNotFound,
subscriptionExpired,
hasActiveSubscription,
cancelledByUser,
generalError
}

@Published var transactionStatus: SubscriptionTransactionStatus = .idle
@Published var hasActiveSubscription = false
@Published var purchaseError: AppStorePurchaseFlow.Error?
@Published var activateSubscription: Bool = false
@Published var emailActivationComplete: Bool = false
// Transaction Status and erros are observed from ViewModels to handle errors in the UI
@Published private(set) var transactionStatus: SubscriptionTransactionStatus = .idle
@Published private(set) var transactionError: UseSubscriptionError?

@Published private(set) var activateSubscription: Bool = false
@Published var selectedFeature: FeatureSelection?
@Published var emailActivationComplete: Bool = false

weak var broker: UserScriptMessageBroker?

Expand Down Expand Up @@ -115,48 +131,58 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec
}
// swiftlint:enable nesting

// Manage transation in progress flag
private func withTransactionInProgress<T>(_ work: () async throws -> T) async rethrows -> T {
transactionStatus = transactionStatus
// Manage transaction in progress flag
private func withTransactionInProgress<T>(_ work: () async -> T) async -> T {
setTransactionStatus(transactionStatus)
defer {
transactionStatus = .idle
setTransactionStatus(.idle)
}
return try await work()
return await work()
}

private func resetSubscriptionFlow() {
hasActiveSubscription = false
purchaseError = nil
setTransactionError(nil)
}

private func setTransactionError(_ error: UseSubscriptionError?) {
transactionError = error
}

private func setTransactionStatus(_ status: SubscriptionTransactionStatus) {
transactionStatus = status
}

func getSubscription(params: Any, original: WKScriptMessage) async throws -> Encodable? {

// MARK: Broker Methods (Called from WebView via UserScripts)
func getSubscription(params: Any, original: WKScriptMessage) async -> Encodable? {
let authToken = AccountManager().authToken ?? Constants.empty
return Subscription(token: authToken)
}

func getSubscriptionOptions(params: Any, original: WKScriptMessage) async throws -> Encodable? {
func getSubscriptionOptions(params: Any, original: WKScriptMessage) async -> Encodable? {

await withTransactionInProgress {

transactionStatus = .purchasing
setTransactionStatus(.purchasing)
resetSubscriptionFlow()

switch await AppStorePurchaseFlow.subscriptionOptions() {
case .success(let subscriptionOptions):
return subscriptionOptions
case .failure:
os_log(.info, log: .subscription, "Failed to obtain subscription options")
os_log(.error, log: .subscription, "Failed to obtain subscription options")
setTransactionError(.failedToGetSubscriptionOptions)
return nil
}

}
}

func subscriptionSelected(params: Any, original: WKScriptMessage) async throws -> Encodable? {
func subscriptionSelected(params: Any, original: WKScriptMessage) async -> Encodable? {

await withTransactionInProgress {

transactionStatus = .purchasing
setTransactionError(nil)
setTransactionStatus(.purchasing)
resetSubscriptionFlow()

struct SubscriptionSelection: Decodable {
Expand All @@ -171,7 +197,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec

// Check for active subscriptions
if await PurchaseManager.hasActiveSubscription() {
hasActiveSubscription = true
setTransactionError(.hasActiveSubscription)
return nil
}

Expand All @@ -180,27 +206,35 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec
switch await AppStorePurchaseFlow.purchaseSubscription(with: subscriptionSelection.id, emailAccessToken: emailAccessToken) {
case .success:
break
case .failure:
purchaseError = .purchaseFailed
case .failure(let error):

switch error {
case .cancelledByUser:
setTransactionError(.cancelledByUser)
default:
setTransactionError(.purchaseFailed)
}
originalMessage = original
setTransactionStatus(.idle)
return nil
}

transactionStatus = .polling
setTransactionStatus(.polling)
switch await AppStorePurchaseFlow.completeSubscriptionPurchase() {
case .success(let purchaseUpdate):
await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: purchaseUpdate)
case .failure:
purchaseError = .missingEntitlements
setTransactionError(.missingEntitlements)
await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: PurchaseUpdate(type: "completed"))
}
return nil
}
}

func setSubscription(params: Any, original: WKScriptMessage) async throws -> Encodable? {
func setSubscription(params: Any, original: WKScriptMessage) async -> Encodable? {
guard let subscriptionValues: SubscriptionValues = DecodableHelper.decode(from: params) else {
assertionFailure("SubscriptionPagesUserScript: expected JSON representation of SubscriptionValues")
setTransactionError(.generalError)
return nil
}

Expand All @@ -211,41 +245,61 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec
accountManager.storeAuthToken(token: authToken)
accountManager.storeAccount(token: accessToken, email: accountDetails.email, externalID: accountDetails.externalID)
} else {
os_log(.info, log: .subscription, "Failed to obtain subscription options")
os_log(.error, log: .subscription, "Failed to obtain subscription options")
setTransactionError(.failedToSetSubscription)
}

return nil
}

func backToSettings(params: Any, original: WKScriptMessage) async throws -> Encodable? {
func backToSettings(params: Any, original: WKScriptMessage) async -> Encodable? {
let accountManager = AccountManager()
if let accessToken = accountManager.accessToken,
case let .success(accountDetails) = await accountManager.fetchAccountDetails(with: accessToken) {
accountManager.storeAccount(token: accessToken, email: accountDetails.email, externalID: accountDetails.externalID)
emailActivationComplete = true
switch await SubscriptionService.getSubscriptionDetails(token: accessToken) {

// If the account is not active, display an error and logout
case .success(let response) where !response.isSubscriptionActive:
setTransactionError(.failedToRestoreFromEmailSubscriptionInactive)
accountManager.signOut()
return nil

case .success:

// Store the account data and mark as active
accountManager.storeAccount(token: accessToken,
email: accountDetails.email,
externalID: accountDetails.externalID)
emailActivationComplete = true

case .failure:
os_log(.error, log: .subscription, "Failed to restore subscription from Email")
setTransactionError(.failedToRestoreFromEmail)
}
} else {
os_log(.info, log: .subscription, "Failed to restore subscription from Email")
os_log(.error, log: .subscription, "General error. Could not get account Details")
setTransactionError(.generalError)
}
return nil
}

func activateSubscription(params: Any, original: WKScriptMessage) async throws -> Encodable? {
func activateSubscription(params: Any, original: WKScriptMessage) async -> Encodable? {
activateSubscription = true
return nil
}

func featureSelected(params: Any, original: WKScriptMessage) async throws -> Encodable? {
func featureSelected(params: Any, original: WKScriptMessage) async -> Encodable? {
guard let featureSelection: FeatureSelection = DecodableHelper.decode(from: params) else {
assertionFailure("SubscriptionPagesUserScript: expected JSON representation of FeatureSelection")
setTransactionError(.generalError)
return nil
}
selectedFeature = featureSelection

return nil
}

// MARK: Push actions

// MARK: Push actions (Push Data back to WebViews)
enum SubscribeActionName: String {
case onPurchaseUpdate
}
Expand All @@ -257,33 +311,46 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec

func pushAction(method: SubscribeActionName, webView: WKWebView, params: Encodable) {
let broker = UserScriptMessageBroker(context: SubscriptionPagesUserScript.context, requiresRunInPageContentWorld: true )

broker.push(method: method.rawValue, params: params, for: self, into: webView)
}

func restoreAccountFromAppStorePurchase() async -> Bool {

// MARK: Native methods - Called from ViewModels
func restoreAccountFromAppStorePurchase() async throws {
setTransactionStatus(.restoring)

await withTransactionInProgress {
transactionStatus = .restoring
switch await AppStoreRestoreFlow.restoreAccountFromPastPurchase() {
case .success:
return true
case .failure:
return false
}
let result = await AppStoreRestoreFlow.restoreAccountFromPastPurchase()
switch result {
case .success:
setTransactionStatus(.idle)
case .failure(let error):
let mappedError = mapAppStoreRestoreErrorToTransactionError(error)
setTransactionStatus(.idle)
throw mappedError
}
}

// MARK: Utility Methods
func mapAppStoreRestoreErrorToTransactionError(_ error: AppStoreRestoreFlow.Error) -> UseSubscriptionError {
switch error {
case .subscriptionExpired:
return .subscriptionExpired
case .missingAccountOrTransactions:
return .subscriptionNotFound
default:
return .failedToRestorePastPurchase
}

}

func cleanup() {
transactionStatus = .idle
hasActiveSubscription = false
purchaseError = nil
setTransactionStatus(.idle)
setTransactionError(nil)
activateSubscription = false
emailActivationComplete = false
selectedFeature = nil
broker = nil
}

}

#endif
30 changes: 30 additions & 0 deletions DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ final class SubscriptionEmailViewModel: ObservableObject {
@Published var shouldReloadWebView = false
@Published var activateSubscription = false
@Published var managingSubscriptionEmail = false
@Published var transactionError: SubscriptionRestoreError?
@Published var shouldDisplayInactiveError: Bool = false
var webViewModel: AsyncHeadlessWebViewViewModel

private static let allowedDomains = [
Expand All @@ -45,6 +47,12 @@ final class SubscriptionEmailViewModel: ObservableObject {
"duosecurity.com",
]

enum SubscriptionRestoreError: Error {
case failedToRestoreFromEmail,
subscriptionExpired,
generalError
}

private var cancellables = Set<AnyCancellable>()

init(userScript: SubscriptionPagesUserScript = SubscriptionPagesUserScript(),
Expand Down Expand Up @@ -82,6 +90,28 @@ final class SubscriptionEmailViewModel: ObservableObject {
}
}
.store(in: &cancellables)

subFeature.$transactionError
.receive(on: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] value in
guard let strongSelf = self else { return }
if let value {
strongSelf.handleTransactionError(error: value)
}
}
.store(in: &cancellables)
}

private func handleTransactionError(error: SubscriptionPagesUseSubscriptionFeature.UseSubscriptionError) {
switch error {

case .subscriptionExpired:
transactionError = .subscriptionExpired
default:
transactionError = .generalError
}
shouldDisplayInactiveError = true
}

private func completeActivation() {
Expand Down
Loading

0 comments on commit 8b901e8

Please sign in to comment.