WIP: Paywall

This commit is contained in:
Kylmakalle 2025-02-07 21:24:59 +02:00
parent 02eebc19ca
commit f57e82072f
12 changed files with 299 additions and 64 deletions

View File

@ -30,6 +30,8 @@ swift_library(
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGStrings:SGStrings",
"//Swiftgram/SGSwiftUI:SGSwiftUI",
"//Swiftgram/SGIAP:SGIAP",
"//Swiftgram/SGPayWall:SGPayWall",
"//submodules/LegacyUI:LegacyUI",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Postbox:Postbox",
@ -40,7 +42,7 @@ swift_library(
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/OverlayStatusController:OverlayStatusController",
"//submodules/AccountContext:AccountContext",
"//submodules/UndoUI:UndoUI",
"//submodules/UndoUI:UndoUI"
] + flex_dependency,
visibility = [
"//visibility:public",

View File

@ -14,6 +14,7 @@ import PresentationDataUtils
// Optional
import SGSimpleSettings
import SGLogging
import SGPayWall
import OverlayStatusController
#if DEBUG
import FLEX
@ -265,12 +266,14 @@ struct SessionBackupManagerView: View {
wrapperController?.present(controller, in: .window(.root), with: nil)
Task {
let (view, accountsWithInfo) = await combineLatest(signal, signal2).awaitable()
backupSessionsFromView(view, accountsWithInfo: accountsWithInfo.1)
withAnimation {
sessions = getBackedSessions()
if let result = try? await combineLatest(signal, signal2).awaitable() {
let (view, accountsWithInfo) = result
backupSessionsFromView(view, accountsWithInfo: accountsWithInfo.1)
withAnimation {
sessions = getBackedSessions()
}
controller.dismiss()
}
controller.dismiss()
}
}
@ -833,13 +836,13 @@ private enum SGDebugControllerSection: Int32, SGItemListSection {
private enum SGDebugDisclosureLink: String {
case sessionBackupManager
case messageFilter
case debugIAP
}
private enum SGDebugActions: String {
case flexing
case fileManager
case clearRegDateCache
case debugIAP
}
private enum SGDebugToggles: String {
@ -863,7 +866,7 @@ private func SGDebugControllerEntries(presentationData: PresentationData) -> [SG
#if DEBUG
entries.append(.action(id: id.count, section: .base, actionType: .flexing, text: "FLEX", kind: .generic))
entries.append(.action(id: id.count, section: .base, actionType: .fileManager, text: "FileManager", kind: .generic))
entries.append(.action(id: id.count, section: .base, actionType: .debugIAP, text: "Buy", kind: .generic))
entries.append(.disclosure(id: id.count, section: .base, link: .debugIAP, text: "Pro"))
#endif
if SGSimpleSettings.shared.b {
@ -977,6 +980,22 @@ public func sgDebugController(context: AccountContext) -> ViewController {
action: { _ in return false }
), nil)
}
case .debugIAP:
#if DEBUG
if #available(iOS 13.0, *) {
if let sgIAPManager = context.sharedContext.SGIAP {
presentControllerImpl?(sgPayWallController(presentationData: presentationData, SGIAPManager: sgIAPManager), ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
}
} else {
presentControllerImpl?(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: "Update OS to access this feature", timeout: nil, customUndoText: nil),
elevatedLayout: false,
action: { _ in return false }
), nil)
}
#endif
}
}, action: { actionType in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
@ -1022,10 +1041,6 @@ public func sgDebugController(context: AccountContext) -> ViewController {
nil)
}
#endif
case .debugIAP:
#if DEBUG
preconditionFailure("IAP")
#endif
}
})

View File

@ -12,7 +12,8 @@ swift_library(
deps = [
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGConfig:SGConfig",
"//submodules/AppBundle:AppBundle"
"//submodules/AppBundle:AppBundle",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
],
visibility = [
"//visibility:public",

View File

@ -2,6 +2,7 @@ import StoreKit
import SGConfig
import SGLogging
import AppBundle
import Combine
private final class CurrencyFormatterEntry {
public let symbol: String
@ -89,13 +90,16 @@ private func fractionalValueToCurrencyAmount(value: Double, currency: String) ->
public extension Notification.Name {
static let SGIAPHelperPurchaseNotification = Notification.Name("SGIAPPurchaseNotification")
static let SGIAPHelperErrorNotification = Notification.Name("SGIAPErrorNotification")
static let SGIAPHelperProductsUpdatedNotification = Notification.Name("SGIAPProductsUpdatedNotification")
}
public final class SGIAP: NSObject {
public final class SGIAPManager: NSObject {
private var productRequest: SKProductsRequest?
private var productsRequestCompletion: (([SKProduct]) -> Void)?
private var purchaseCompletion: ((Bool, Error?) -> Void)?
public private(set) var availableProducts: [SGProduct] = []
private var finishedSuccessfulTransactions = Set<String>()
public final class SGProduct: Equatable {
private lazy var numberFormatter: NumberFormatter = {
@ -105,7 +109,7 @@ public final class SGIAP: NSObject {
return numberFormatter
}()
let skProduct: SKProduct
public let skProduct: SKProduct
init(skProduct: SKProduct) {
self.skProduct = skProduct
@ -198,7 +202,7 @@ public final class SGIAP: NSObject {
}
public init(foo: Bool = false) {
public init(foo: Bool = false) { // I don't want to override init, idk why
super.init()
SKPaymentQueue.default().add(self)
@ -220,7 +224,7 @@ public final class SGIAP: NSObject {
}
private func requestProducts() {
SGLogger.shared.log("SGIAP", "Requesting products...")
SGLogger.shared.log("SGIAP", "Requesting products for \(SG_CONFIG.iaps.count) ids...")
let productRequest = SKProductsRequest(productIdentifiers: Set(SG_CONFIG.iaps))
productRequest.delegate = self
@ -242,13 +246,18 @@ public final class SGIAP: NSObject {
}
}
extension SGIAP: SKProductsRequestDelegate {
extension SGIAPManager: SKProductsRequestDelegate {
public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
self.productRequest = nil
DispatchQueue.main.async {
let products = response.products
SGLogger.shared.log("SGIAP", "Received products \(products.map({ $0.productIdentifier }).joined(separator: ", "))")
SGLogger.shared.log("SGIAP", "Received products (\(products.count)): \(products.map({ $0.productIdentifier }).joined(separator: ", "))")
let currentlyAvailableProducts = self.availableProducts
self.availableProducts = products.map({ SGProduct(skProduct: $0) })
if currentlyAvailableProducts != self.availableProducts {
NotificationCenter.default.post(name: .SGIAPHelperProductsUpdatedNotification, object: nil)
}
}
}
@ -258,13 +267,14 @@ extension SGIAP: SKProductsRequestDelegate {
}
}
extension SGIAP: SKPaymentTransactionObserver {
extension SGIAPManager: SKPaymentTransactionObserver {
public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
SGLogger.shared.log("SGIAP", "paymentQueue transactions \(transactions.count)")
for transaction in transactions {
SGLogger.shared.log("SGIAP", "Transaction state for \(transaction.payment.productIdentifier): \(transaction.transactionState)")
SGLogger.shared.log("SGIAP", "Transaction \(transaction.transactionIdentifier ?? "nil") state for product \(transaction.payment.productIdentifier): \(transaction.transactionState.description)")
switch transaction.transactionState {
case .purchased, .restored:
SGLogger.shared.log("SGIAP", "Sending SGIAPHelperPurchaseNotification for \(transaction.transactionIdentifier ?? "nil")")
NotificationCenter.default.post(name: .SGIAPHelperPurchaseNotification, object: transaction)
case .purchasing, .deferred:
break
@ -272,11 +282,12 @@ extension SGIAP: SKPaymentTransactionObserver {
if let transactionError = transaction.error as NSError?,
let localizedDescription = transaction.error?.localizedDescription,
transactionError.code != SKError.paymentCancelled.rawValue {
SGLogger.shared.log("SGIAP", "Transaction Error: \(localizedDescription)")
SGLogger.shared.log("SGIAP", "Transaction Error []: \(localizedDescription)")
}
SGLogger.shared.log("SGIAP", "Sending SGIAPHelperErrorNotification for \(transaction.transactionIdentifier ?? "nil")")
NotificationCenter.default.post(name: .SGIAPHelperErrorNotification, object: transaction)
default:
SGLogger.shared.log("SGIAP", "Unknown transaction state \(transaction.transactionState). Finishing transaction.")
SGLogger.shared.log("SGIAP", "Unknown transaction \(transaction.transactionIdentifier ?? "nil") state \(transaction.transactionState). Finishing transaction.")
SKPaymentQueue.default().finishTransaction(transaction)
}
}
@ -326,3 +337,24 @@ public func getPurchaceReceiptData() -> Data? {
}
return receiptData
}
extension SKPaymentTransactionState {
var description: String {
switch self {
case .purchasing:
return "Purchasing"
case .purchased:
return "Purchased"
case .failed:
return "Failed"
case .restored:
return "Restored"
case .deferred:
return "Deferred"
@unknown default:
return "Unknown"
}
}
}

23
Swiftgram/SGPayWall/BUILD Normal file
View File

@ -0,0 +1,23 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGPayWall",
module_name = "SGPayWall",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGIAP:SGIAP",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGSwiftUI:SGSwiftUI",
"//Swiftgram/SGStrings:SGStrings",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,150 @@
import Foundation
import SwiftUI
import SGSwiftUI
import SGIAP
import TelegramPresentationData
import LegacyUI
import Display
import SGConfig
// import SGStrings
struct SGPerk: Identifiable {
let id = UUID()
let title: String
let description: String
let icon: String
}
@available(iOS 13.0, *)
struct SGPayWallView: View {
weak var wrapperController: LegacyController?
let SGIAP: SGIAPManager
let perks = [
SGPerk(title: "Premium Features", description: "Access all premium features and tools", icon: "star.fill"),
SGPerk(title: "No Ads", description: "Enjoy an ad-free experience", icon: "banner.slash"),
SGPerk(title: "Cloud Sync", description: "Sync your data across all devices", icon: "cloud.fill"),
SGPerk(title: "Priority Support", description: "Get priority customer support", icon: "questionmark.circle.fill"),
SGPerk(title: "Advanced Stats", description: "Access detailed analytics and insights", icon: "chart.bar.fill"),
SGPerk(title: "Premium Features", description: "Access all premium features and tools", icon: "star.fill"),
SGPerk(title: "No Ads", description: "Enjoy an ad-free experience", icon: "banner.slash"),
SGPerk(title: "Cloud Sync", description: "Sync your data across all devices", icon: "cloud.fill"),
SGPerk(title: "Priority Support", description: "Get priority customer support", icon: "questionmark.circle.fill"),
SGPerk(title: "Advanced Stats", description: "Access detailed analytics and insights", icon: "chart.bar.fill"),
SGPerk(title: "Advanced Stats", description: "Access detailed analytics and insights", icon: "chart.bar.fill"),
SGPerk(title: "Premium Features", description: "Access all premium features and tools", icon: "star.fill"),
SGPerk(title: "No Ads", description: "Enjoy an ad-free experience", icon: "banner.slash"),
SGPerk(title: "Cloud Sync", description: "Sync your data across all devices", icon: "cloud.fill"),
SGPerk(title: "Priority Support", description: "Get priority customer support", icon: "questionmark.circle.fill"),
SGPerk(title: "Advanced Stats", description: "Access detailed analytics and insights", icon: "chart.bar.fill"),
]
var body: some View {
NavigationView {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 24) {
ForEach(perks) { perk in
HStack(spacing: 16) {
Image(systemName: perk.icon)
.font(.title)
.foregroundColor(.blue)
.frame(width: 32)
VStack(alignment: .leading, spacing: 4) {
Text(perk.title)
.font(.headline)
Text(perk.description)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal)
}
}
.padding(.vertical, 24)
}
VStack(spacing: 12) {
Button(action: {
for availableProduct in SGIAP.availableProducts {
if SG_CONFIG.iaps.contains(availableProduct.skProduct.productIdentifier ) {
SGIAP.purchaseProduct(availableProduct, completion: { _ in })
}
}
SGIAP.buyProduct(product: product)
}) {
Text("Unlock Premium - $9.99")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.cornerRadius(16)
}
Button(action: {
SGIAP.restorePurchases(completion: { _ in })
}) {
Text("Restore Purchases")
.font(.subheadline)
.foregroundColor(.blue)
}
}
.padding(24)
.background(
Rectangle()
.fill(Color(UIColor.systemBackground))
.shadow(radius: 8, y: -4)
)
}.navigationBarItems(trailing: closeButtonView)
}
.colorScheme(.dark)
}
private var closeButtonView: some View {
Button(action: {
wrapperController?.dismiss(animated: true)
}) {
if #available(iOS 15.0, *) {
Image(systemName: "xmark.circle.fill")
.font(.headline)
.symbolRenderingMode(.hierarchical)
.foregroundColor(.secondary)
} else {
Image(systemName: "xmark.circle.fill")
.font(.headline)
.foregroundColor(.secondary)
}
}
}
}
@available(iOS 13.0, *)
public func sgPayWallController(presentationData: PresentationData? = nil, SGIAPManager: SGIAPManager) -> ViewController {
// let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
let theme = defaultDarkColorPresentationTheme
let strings = presentationData?.strings ?? defaultPresentationStrings
let legacyController = LegacySwiftUIController(
presentation: .modal(animateIn: true),
theme: theme,
strings: strings
)
// legacyController.displayNavigationBar = false
legacyController.statusBar.statusBarStyle = .White
legacyController.attemptNavigation = { _ in return false }
let swiftUIView = SGPayWallView(wrapperController: legacyController, SGIAP: SGIAPManager)
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
legacyController.bind(controller: controller)
return legacyController
}

View File

@ -36,7 +36,11 @@ public struct SignalError<E>: Error {
}
}
// Extension for Signals with Error types
public struct SignalCompleted: Error {}
// Extension for Signals
// NoError can be marked a
// try? await signal.awaitable()
extension Signal {
@available(iOS 13.0, *)
public func awaitable(file: String = #file, line: Int = #line) async throws -> T {
@ -57,46 +61,26 @@ extension Signal {
disposable?.dispose()
},
error: { error in
if let error = error as? Error {
continuation.resume(throwing: error)
} else {
continuation.resume(throwing: SignalError(error))
}
disposable?.dispose()
},
completed: {
disposable?.dispose()
}
)
}
}
}
// Extension for Signals with NoError
extension Signal where E == NoError {
@available(iOS 13.0, *)
public func awaitable(file: String = #file, line: Int = #line) async -> T {
return await withCheckedContinuation { continuation in
var disposable: Disposable?
let hasResumed = Atomic<Bool>(value: false)
disposable = self.start(
next: { value in
if !hasResumed.with({ $0 }) {
let _ = hasResumed.swap(true)
continuation.resume(returning: value)
if let error = error as? Error {
continuation.resume(throwing: error)
} else {
continuation.resume(throwing: SignalError(error))
}
} else {
#if DEBUG
// Consider using awaitableStream() or |> take(1)
assertionFailure("awaitable Signal emitted more than one value. \(file):\(line)")
// I don't even know what we should consider here. awaitableStream?
assertionFailure("awaitable Signal emitted an error after a value. \(file):\(line)")
#endif
}
disposable?.dispose()
},
error: { _ in
// This will never be called for NoError
disposable?.dispose()
},
completed: {
if !hasResumed.with({ $0 }) {
let _ = hasResumed.swap(true)
continuation.resume(throwing: SignalCompleted())
}
disposable?.dispose()
}
)

View File

@ -1,7 +1,8 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
sgdeps = [
"//Swiftgram/SGSimpleSettings:SGSimpleSettings"
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGIAP:SGIAP"
]
swift_library(

View File

@ -1,4 +1,5 @@
import SGSimpleSettings
import SGIAP
import Foundation
import UIKit
import AsyncDisplayKit
@ -935,6 +936,7 @@ public protocol SharedAccountContext: AnyObject {
var immediateExperimentalUISettings: ExperimentalUISettings { get }
// MARK: Swiftgram
var immediateSGStatus: SGStatus { get }
var SGIAP: SGIAPManager? { get }
var currentInAppNotificationSettings: Atomic<InAppNotificationSettings> { get }
var currentMediaInputSettings: Atomic<MediaInputSettings> { get }
var currentStickerSettings: Atomic<StickerSettings> { get }

View File

@ -19,6 +19,7 @@ sgdeps = [
"//Swiftgram/SGDebugUI:SGDebugUI",
"//Swiftgram/SGInputToolbar:SGInputToolbar",
"//Swiftgram/SGIAP:SGIAP",
"//Swiftgram/SGPayWall:SGPayWall"
# "//Swiftgram/SGContentAnalysis:SGContentAnalysis"
]

View File

@ -1348,6 +1348,12 @@ private func extractAccountManagerState(records: AccountRecordsView<TelegramAcco
authContextReadyDisposable.set(nil)
}
}))
// MARK: Swiftgram
if #available(iOS 13.0, *) {
self.setupIAP()
}
let logoutDataSignal: Signal<(AccountManager, Set<PeerId>), NoError> = self.sharedContextPromise.get()
@ -3067,22 +3073,24 @@ extension AppDelegate {
func setupIAP() {
NotificationCenter.default.addObserver(forName: .SGIAPHelperPurchaseNotification, object: nil, queue: nil) { [weak self] notification in
SGLogger.shared.log("SGIAP", "Got SGIAPHelperPurchaseNotification")
guard let strongSelf = self else { return }
if let transaction = notification.object as? SKPaymentTransaction {
let _ = (strongSelf.context.get()
|> take(1)
|> deliverOnMainQueue).start(next: { context in
SGLogger.shared.log("SGIAP", "Got context for SGIAPHelperPurchaseNotification")
guard let context = context else {
SGLogger.shared.log("SGIAP", "Empty app context (how?)")
SGLogger.shared.log("SGIAP", "Finishing transaction")
SGLogger.shared.log("SGIAP", "Finishing transaction \(transaction.transactionIdentifier ?? "nil")")
SKPaymentQueue.default().finishTransaction(transaction)
return
}
let _ = Task {
await strongSelf.sendReceiptForVerification(primaryContext: context.context)
await strongSelf.fetchSGStatus(primaryContext: context.context)
SGLogger.shared.log("SGIAP", "Finishing transaction")
SGLogger.shared.log("SGIAP", "Finishing transaction \(transaction.transactionIdentifier ?? "nil")")
SKPaymentQueue.default().finishTransaction(transaction)
}
})
@ -3100,7 +3108,7 @@ extension AppDelegate {
SGSimpleSettings.shared.primaryUserId = String(primaryUserId)
}
var primaryContext = await getContextForUserId(context: context, userId: primaryUserId).awaitable()
var primaryContext = try? await getContextForUserId(context: context, userId: primaryUserId).awaitable()
if let primaryContext = primaryContext {
SGLogger.shared.log("SGIAP", "Got primary context for user id: \(primaryContext.account.peerId.id._internalGetInt64Value())")
return primaryContext
@ -3136,8 +3144,10 @@ extension AppDelegate {
if let deviceToken, let apiToken {
do {
let _ = try await postSGReceipt(token: apiToken,
deviceToken: deviceToken,
encodedReceiptData: encodedReceiptData).awaitable()
deviceToken: deviceToken,
encodedReceiptData: encodedReceiptData).awaitable()
} catch let error as SignalCompleted {
let _ = error
} catch {
SGLogger.shared.log("SGIAP", "Error: \(error)")
}
@ -3155,7 +3165,7 @@ extension AppDelegate {
SGLogger.shared.log("SGIAP", "Asking user id \(userId) to keep connection: true")
primaryContext.account.network.shouldKeepConnection.set(.single(true))
}
let iqtpResponse = await sgIqtpQuery(engine: primaryContext.engine, query: makeIqtpQuery(0, "s")).awaitable()
let iqtpResponse = try? await sgIqtpQuery(engine: primaryContext.engine, query: makeIqtpQuery(0, "s")).awaitable()
guard let iqtpResponse = iqtpResponse else {
SGLogger.shared.log("SGIAP", "IQTP response is nil!")
// if !currentShouldKeepConnection {
@ -3165,7 +3175,7 @@ extension AppDelegate {
return
}
SGLogger.shared.log("SGIAP", "Got IQTP response: \(iqtpResponse)")
let _ = await updateSGStatusInteractively(accountManager: primaryContext.sharedContext.accountManager, { value in
let _ = try? await updateSGStatusInteractively(accountManager: primaryContext.sharedContext.accountManager, { value in
var value = value
let newStatus: Int64

View File

@ -243,12 +243,13 @@ public final class SharedAccountContextImpl: SharedAccountContext {
}
private var experimentalUISettingsDisposable: Disposable?
// MARK: Swiftgram
private var immediateSGStatusValue = Atomic<SGStatus>(value: SGStatus.default)
public var immediateSGStatus: SGStatus {
return self.immediateSGStatusValue.with { $0 }
}
private var sgStatusDisposable: Disposable?
public var SGIAP: SGIAPManager?
public var presentGlobalController: (ViewController, Any?) -> Void = { _, _ in }
public var presentCrossfadeController: () -> Void = {}
@ -485,6 +486,8 @@ public final class SharedAccountContextImpl: SharedAccountContext {
let _ = immediateSGStatusValue.swap(settings)
}
})
self.initSGIAP(isMainApp: applicationBindings.isMainApp)
//
let _ = self.contactDataManager?.personNameDisplayOrder().start(next: { order in
let _ = updateContactSettingsInteractively(accountManager: accountManager, { settings in
@ -3037,3 +3040,14 @@ private func peerInfoControllerImpl(context: AccountContext, updatedPresentation
}
return nil
}
// MARK: Swiftgram
extension SharedAccountContextImpl {
func initSGIAP(isMainApp: Bool) {
if isMainApp {
self.SGIAP = SGIAPManager()
} else {
self.SGIAP = nil
}
}
}