From 5759a55d6afc7a84aa6c79cb3eba2c050c27c03d Mon Sep 17 00:00:00 2001 From: brave-builds Date: Thu, 14 Nov 2024 23:50:47 +0000 Subject: [PATCH] Uplift of #26560 (squashed) to beta --- .../Sources/AIChat/AIChatStrings.swift | 7 + .../Settings/AIChatAdvancedSettingsView.swift | 11 + .../Paged/BraveSkusScriptHandler.swift | 1 - .../Debug/StoreKitReceiptView.swift | 11 +- .../Views/StoreKitReceiptSimpleView.swift | 219 ++++++++++++++++++ .../Sources/BraveStrings/BraveStrings.swift | 150 ++++++++++++ .../BraveVPNSettingsViewController.swift | 21 ++ .../BraveVPNInAppPurchaseObserver.swift | 38 +++ .../BraveVPN/Resources/BraveVPNStrings.swift | 8 + 9 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 ios/brave-ios/Sources/BraveStore/Views/StoreKitReceiptSimpleView.swift diff --git a/ios/brave-ios/Sources/AIChat/AIChatStrings.swift b/ios/brave-ios/Sources/AIChat/AIChatStrings.swift index 64e79fe7e793..15d6dcb00784 100644 --- a/ios/brave-ios/Sources/AIChat/AIChatStrings.swift +++ b/ios/brave-ios/Sources/AIChat/AIChatStrings.swift @@ -601,6 +601,13 @@ extension Strings { value: "Subscription", comment: "The title for the header for subscription details" ) + public static let advancedSettingsViewReceiptTitle = NSLocalizedString( + "aichat.advancedSettingsViewReceiptTitle", + tableName: "BraveLeo", + bundle: .module, + value: "View AppStore Receipt", + comment: "The title for the button that allows the user to view the AppStore Receipt" + ) public static let appStoreErrorTitle = NSLocalizedString( "aichat.appStoreErrorTitle", tableName: "BraveLeo", diff --git a/ios/brave-ios/Sources/AIChat/Components/Settings/AIChatAdvancedSettingsView.swift b/ios/brave-ios/Sources/AIChat/Components/Settings/AIChatAdvancedSettingsView.swift index f4fec9c6dc3e..6623b79c9077 100644 --- a/ios/brave-ios/Sources/AIChat/Components/Settings/AIChatAdvancedSettingsView.swift +++ b/ios/brave-ios/Sources/AIChat/Components/Settings/AIChatAdvancedSettingsView.swift @@ -4,6 +4,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import BraveCore +import BraveStore import BraveUI import DesignSystem import Preferences @@ -328,6 +329,16 @@ public struct AIChatAdvancedSettingsView: View { ) } } + + // Check if there's an AppStore receipt and subscriptions have been loaded + if !viewModel.isSubscriptionStatusLoading && viewModel.inAppPurchaseSubscriptionState != nil + { + NavigationLink { + StoreKitReceiptSimpleView() + } label: { + LabelView(title: Strings.AIChat.advancedSettingsViewReceiptTitle) + }.listRowBackground(Color(.secondaryBraveGroupedBackground)) + } } header: { Text(Strings.AIChat.advancedSettingsSubscriptionHeaderTitle.uppercased()) } diff --git a/ios/brave-ios/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/BraveSkusScriptHandler.swift b/ios/brave-ios/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/BraveSkusScriptHandler.swift index e5e2143f8f43..fb10e0da2595 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/BraveSkusScriptHandler.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/BraveSkusScriptHandler.swift @@ -94,7 +94,6 @@ class BraveSkusScriptHandler: TabContentScript { switch method { case .refreshOrder: let order = try OrderMessage.from(message: message) - // Serialize to jsonObject??? return await skusManager.refreshOrder(for: order.orderId, domain: skusDomain) case .fetchOrderCredentials: diff --git a/ios/brave-ios/Sources/BraveStore/Debug/StoreKitReceiptView.swift b/ios/brave-ios/Sources/BraveStore/Debug/StoreKitReceiptView.swift index d8983c8a10b7..33908fccb89d 100644 --- a/ios/brave-ios/Sources/BraveStore/Debug/StoreKitReceiptView.swift +++ b/ios/brave-ios/Sources/BraveStore/Debug/StoreKitReceiptView.swift @@ -63,7 +63,16 @@ public struct StoreKitReceiptView: View { NavigationLink( destination: { - groupedProductsView(for: receipt.inAppPurchaseReceipts) + let purchaseDate = Date.now + let expirationDate = Date.distantFuture + + groupedProductsView( + for: receipt.inAppPurchaseReceipts.sorted(by: { + $0.purchaseDate ?? purchaseDate > $1.purchaseDate ?? purchaseDate + || $0.subscriptionExpirationDate ?? expirationDate > $1.subscriptionExpirationDate + ?? expirationDate + }) + ) }, label: { Text("In-App Purchases") diff --git a/ios/brave-ios/Sources/BraveStore/Views/StoreKitReceiptSimpleView.swift b/ios/brave-ios/Sources/BraveStore/Views/StoreKitReceiptSimpleView.swift new file mode 100644 index 000000000000..d53da3a3a8f6 --- /dev/null +++ b/ios/brave-ios/Sources/BraveStore/Views/StoreKitReceiptSimpleView.swift @@ -0,0 +1,219 @@ +// Copyright 2024 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 https://mozilla.org/MPL/2.0/. + +import BraveCore +import BraveStrings +import Introspect +import SwiftUI + +private struct StoreKitReceiptSimpleLineView: View { + var title: String + var value: String + + var body: some View { + HStack { + Text("\(title):") + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + .frame(alignment: .leading) + .padding(.trailing, 16.0) + + Text(value) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding() + } +} + +public struct StoreKitReceiptSimpleView: View { + @State + private var loading: Bool = true + + @State + private var base64EncodedReceipt: String? + + @State + private var receipt: BraveStoreKitReceipt? + + public init() { + + } + + public var body: some View { + VStack { + if let receipt = receipt { + List { + StoreKitReceiptSimpleLineView( + title: Strings.ReceiptViewer.applicationVersionTitle, + value: receipt.appVersion + ) + + StoreKitReceiptSimpleLineView( + title: Strings.ReceiptViewer.receiptDateTitle, + value: formatDate(receipt.receiptCreationDate) + ) + + productsView(for: groupProducts(receipt: receipt)) + } + } else if loading { + ProgressView(Strings.ReceiptViewer.receiptViewerLoadingTitle) + .task { + if let receipt = try? AppStoreReceipt.receipt { + self.base64EncodedReceipt = receipt + + if let data = Data(base64Encoded: receipt) { + self.receipt = BraveStoreKitReceipt(data: data) + loading = false + } + } + } + } else { + VStack { + Text( + base64EncodedReceipt == nil + ? Strings.ReceiptViewer.noReceiptFoundTitle + : Strings.ReceiptViewer.receiptLoadingErrorTitle + ) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + .padding(30.0) + } + } + } + .navigationTitle(Strings.ReceiptViewer.receiptViewerTitle) + .navigationViewStyle(.stack) + .introspectNavigationController { controller in + controller.navigationBar.topItem?.backButtonDisplayMode = .minimal + } + .toolbar { + if let base64EncodedReceipt { + ToolbarItem(placement: .navigationBarTrailing) { + ShareLink(item: base64EncodedReceipt) { + Label(Strings.ReceiptViewer.shareReceiptTitle, systemImage: "square.and.arrow.up") + } + .padding() + } + } + } + } + + private func groupProducts(receipt: BraveStoreKitReceipt) -> [Purchase] { + let purchaseDate = Date.now + let expirationDate = Date.distantFuture + let purchases = receipt.inAppPurchaseReceipts.sorted(by: { + $0.purchaseDate ?? purchaseDate > $1.purchaseDate ?? purchaseDate + || $0.subscriptionExpirationDate ?? expirationDate > $1.subscriptionExpirationDate + ?? expirationDate + }) + + return Dictionary(grouping: purchases, by: { $0.productId }).compactMap { + (key, purchases) -> Purchase? in + guard let latestPurchase = purchases.first else { + return nil + } + return Purchase(productId: key, purchase: latestPurchase) + } + .sorted(by: { $0.productId < $1.productId }) + } + + @ViewBuilder + private func productsView(for products: [Purchase]) -> some View { + ForEach(products) { product in + NavigationLink( + destination: { + List { + purchaseView(for: product.purchase) + } + .navigationTitle(productName(from: product.productId)) + .introspectNavigationController { controller in + controller.navigationBar.topItem?.backButtonDisplayMode = .minimal + } + }, + label: { + Text(productName(from: product.productId)) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + ) + .navigationBarTitleDisplayMode(.inline) + .buttonStyle(PlainButtonStyle()) + .padding() + } + } + + @ViewBuilder + private func purchaseView(for purchase: BraveStoreKitPurchase) -> some View { + VStack { + StoreKitReceiptSimpleLineView( + title: Strings.ReceiptViewer.receiptOrderIDTitle, + value: "\(purchase.webOrderLineItemId)" + ) + + StoreKitReceiptSimpleLineView( + title: Strings.ReceiptViewer.receiptTransactionIDTitle, + value: purchase.transactionId + ) + + StoreKitReceiptSimpleLineView( + title: Strings.ReceiptViewer.receiptOriginalPurchaseDateTitle, + value: formatDate(purchase.originalPurchaseDate) + ) + + StoreKitReceiptSimpleLineView( + title: Strings.ReceiptViewer.receiptPurchaseDate, + value: formatDate(purchase.purchaseDate) + ) + + StoreKitReceiptSimpleLineView( + title: Strings.ReceiptViewer.receiptExpirationDate, + value: formatDate(purchase.subscriptionExpirationDate) + ) + + if let cancellationDate = purchase.cancellationDate { + StoreKitReceiptSimpleLineView( + title: Strings.ReceiptViewer.receiptCancellationDate, + value: formatDate(cancellationDate) + ) + } + } + } + + private func productName(from bundleId: String) -> String { + switch bundleId { + case BraveStoreProduct.vpnMonthly.rawValue: + return Strings.ReceiptViewer.vpnMonthlySubscriptionName + case BraveStoreProduct.vpnYearly.rawValue: + return Strings.ReceiptViewer.vpnYearlySubscriptionName + case BraveStoreProduct.leoMonthly.rawValue: + return Strings.ReceiptViewer.leoMonthlySubscriptionName + case BraveStoreProduct.leoYearly.rawValue: + return Strings.ReceiptViewer.leoYearlySubscriptionName + default: return bundleId + } + } + + private func formatDate(_ date: Date?) -> String { + guard let date = date else { + return Strings.ReceiptViewer.receiptInvalidDate + } + + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .long + return formatter.string(from: date) + } + + private struct Purchase: Identifiable { + let productId: String + let purchase: BraveStoreKitPurchase + + var id: String { + productId + } + } +} diff --git a/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift b/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift index 548b190f5e31..4555fd9c0520 100644 --- a/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift +++ b/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift @@ -6124,6 +6124,156 @@ extension Strings { } } +// MARK: - StoreKit Receipt Viewer + +extension Strings { + public struct ReceiptViewer { + public static let vpnMonthlySubscriptionName = + NSLocalizedString( + "storekitReceiptViewer.vpnMonthlySubscriptionName", + bundle: .module, + value: "Brave VPN Monthly", + comment: "The title of the product subscription the user purchased (Monthly subscription)" + ) + + public static let vpnYearlySubscriptionName = + NSLocalizedString( + "storekitReceiptViewer.vpnYearlySubscriptionName", + bundle: .module, + value: "Brave VPN Yearly", + comment: "The title of the product subscription the user purchased (Yearly subscription)" + ) + + public static let leoMonthlySubscriptionName = + NSLocalizedString( + "storekitReceiptViewer.leoMonthlySubscriptionName", + bundle: .module, + value: "Brave Leo Monthly", + comment: "The title of the product subscription the user purchased (Monthly subscription)" + ) + + public static let leoYearlySubscriptionName = + NSLocalizedString( + "storekitReceiptViewer.leoYearlySubscriptionName", + bundle: .module, + value: "Brave Leo Yearly", + comment: "The title of the product subscription the user purchased (Yearly subscription)" + ) + + public static let receiptViewerTitle = + NSLocalizedString( + "storekitReceiptViewer.receiptViewerTitle", + bundle: .module, + value: "App Store Receipt", + comment: "The title of the screen that shows the App Store Receipt" + ) + + public static let noReceiptFoundTitle = + NSLocalizedString( + "storekitReceiptViewer.noReceiptFoundTitle", + bundle: .module, + value: "Sorry, no App Store receipts were found", + comment: "The error message when the App Store Receipt was not found in the Application Bundle" + ) + + public static let receiptLoadingErrorTitle = + NSLocalizedString( + "storekitReceiptViewer.receiptLoadingErrorTitle", + bundle: .module, + value: "Sorry, the App Store receipt could not be loaded", + comment: "The error message when the App Store Receipt was found in the Application Bundle, but could not be loaded" + ) + + public static let applicationVersionTitle = + NSLocalizedString( + "storekitReceiptViewer.applicationVersionTitle", + bundle: .module, + value: "Application Version", + comment: "The title of the label that shows the Application Version" + ) + + public static let receiptDateTitle = + NSLocalizedString( + "storekitReceiptViewer.receiptDateTitle", + bundle: .module, + value: "Receipt Date", + comment: "The title of the label that shows the date the receipt was created" + ) + + public static let receiptOrderIDTitle = + NSLocalizedString( + "storekitReceiptViewer.receiptOrderIDTitle", + bundle: .module, + value: "Order ID", + comment: "The title of the label that shows the Order ID of the user's purchase" + ) + + public static let receiptTransactionIDTitle = + NSLocalizedString( + "storekitReceiptViewer.receiptTransactionIDTitle", + bundle: .module, + value: "Transaction ID", + comment: "The title of the label that shows the Transaction ID of the user's purchase" + ) + + public static let receiptOriginalPurchaseDateTitle = + NSLocalizedString( + "storekitReceiptViewer.receiptOriginalPurchaseDateTitle", + bundle: .module, + value: "Original Purchase Date", + comment: "The title of the label that shows the date when the product subscription was first purchased" + ) + + public static let receiptPurchaseDate = + NSLocalizedString( + "storekitReceiptViewer.receiptPurchaseDate", + bundle: .module, + value: "Purchase Date", + comment: "The title of the label that shows the date when the product subscription was recently purchased or renewed" + ) + + public static let receiptExpirationDate = + NSLocalizedString( + "storekitReceiptViewer.receiptExpirationDate", + bundle: .module, + value: "Expiration Date", + comment: "The title of the label that shows the date when the product subscription expired" + ) + + public static let receiptCancellationDate = + NSLocalizedString( + "storekitReceiptViewer.receiptCancellationDate", + bundle: .module, + value: "Cancellation Date", + comment: "The title of the label that shows the date when the product subscription was cancelled" + ) + + public static let receiptInvalidDate = + NSLocalizedString( + "storekitReceiptViewer.receiptInvalidDate", + bundle: .module, + value: "N/A", + comment: "The title of the label that shows any invalid date - Not Available or N/A for short is displayed" + ) + + public static let receiptViewerLoadingTitle = + NSLocalizedString( + "storekitReceiptViewer.receiptViewerLoadingTitle", + bundle: .module, + value: "Loading Please Wait...", + comment: "The title of the label that shows when the Receipt Viewer is still loading resources and subscription information" + ) + + public static let shareReceiptTitle = + NSLocalizedString( + "storekitReceiptViewer.shareReceiptTitle", + bundle: .module, + value: "Share Receipt", + comment: "The title of the button that shows a system action sheet, to share the receipt" + ) + } +} + // MARK: - Shortcuts extension Strings { diff --git a/ios/brave-ios/Sources/BraveVPN/Components/Settings/BraveVPNSettingsViewController.swift b/ios/brave-ios/Sources/BraveVPN/Components/Settings/BraveVPNSettingsViewController.swift index 9416aa292ee2..164c1059c407 100644 --- a/ios/brave-ios/Sources/BraveVPN/Components/Settings/BraveVPNSettingsViewController.swift +++ b/ios/brave-ios/Sources/BraveVPN/Components/Settings/BraveVPNSettingsViewController.swift @@ -4,6 +4,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import BraveShared +import BraveStore import BraveUI import GuardianConnect import Preferences @@ -80,6 +81,9 @@ public class BraveVPNSettingsViewController: TableViewController { Row( text: Strings.VPN.settingsLinkReceipt, selection: { [unowned self] in + Task { + try await BraveVPNInAppPurchaseObserver.refreshReceipt() + } openURL?(.brave.braveVPNLinkReceiptProd) }, cellClass: ButtonCell.self @@ -91,6 +95,9 @@ public class BraveVPNSettingsViewController: TableViewController { Row( text: "[Staging] Link Receipt", selection: { [unowned self] in + Task { + try await BraveVPNInAppPurchaseObserver.refreshReceipt() + } openURL?(.brave.braveVPNLinkReceiptStaging) }, cellClass: ButtonCell.self @@ -98,6 +105,9 @@ public class BraveVPNSettingsViewController: TableViewController { Row( text: "[Dev] Link Receipt", selection: { [unowned self] in + Task { + try await BraveVPNInAppPurchaseObserver.refreshReceipt() + } openURL?(.brave.braveVPNLinkReceiptDev) }, cellClass: ButtonCell.self @@ -105,6 +115,17 @@ public class BraveVPNSettingsViewController: TableViewController { ] } + rows.append( + Row( + text: Strings.VPN.settingsViewReceipt, + selection: { [unowned self] in + let controller = UIHostingController(rootView: StoreKitReceiptSimpleView()) + self.navigationController?.pushViewController(controller, animated: true) + }, + cellClass: ButtonCell.self + ) + ) + return rows } diff --git a/ios/brave-ios/Sources/BraveVPN/Components/Subscription/BraveVPNInAppPurchaseObserver.swift b/ios/brave-ios/Sources/BraveVPN/Components/Subscription/BraveVPNInAppPurchaseObserver.swift index 013b799b2089..58cdcc6ac66f 100644 --- a/ios/brave-ios/Sources/BraveVPN/Components/Subscription/BraveVPNInAppPurchaseObserver.swift +++ b/ios/brave-ios/Sources/BraveVPN/Components/Subscription/BraveVPNInAppPurchaseObserver.swift @@ -172,3 +172,41 @@ public class BraveVPNInAppPurchaseObserver: NSObject, SKPaymentTransactionObserv return true } } + +extension BraveVPNInAppPurchaseObserver { + @MainActor + static func refreshReceipt() async throws { + let request = SKReceiptRefreshRequest() + let delegate = ReceiptRefreshDelegate() + request.delegate = delegate + + try await withCheckedThrowingContinuation { continuation in + delegate.completion = { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + + request.start() + } + } + + private class ReceiptRefreshDelegate: NSObject, SKRequestDelegate { + var completion: ((Result) -> Void)? + + func requestDidFinish(_ request: SKRequest) { + completion?(.success(())) + completion = nil + request.delegate = nil + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + completion?(.failure(error)) + completion = nil + request.delegate = nil + } + } +} diff --git a/ios/brave-ios/Sources/BraveVPN/Resources/BraveVPNStrings.swift b/ios/brave-ios/Sources/BraveVPN/Resources/BraveVPNStrings.swift index 5d0b76e12508..f9eb56b475b0 100644 --- a/ios/brave-ios/Sources/BraveVPN/Resources/BraveVPNStrings.swift +++ b/ios/brave-ios/Sources/BraveVPN/Resources/BraveVPNStrings.swift @@ -240,6 +240,14 @@ extension Strings { comment: "Footer text to link your VPN receipt to other devices." ) + public static let settingsViewReceipt = + NSLocalizedString( + "vpn.settingsViewReceipt", + bundle: .module, + value: "View AppStore Receipt", + comment: "Button to allow the user to view the app-store receipt." + ) + public static let settingsServerHost = NSLocalizedString( "vpn.settingsServerHost",