diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 6f6083f838..292c5878d8 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -8891,3 +8891,5 @@ Sorry for the inconvenience."; "Settings.RaiseToListenInfo" = "Raise to Listen allows you to quickly listen and reply to incoming audio messages by raising the phone to your ear."; "Login.CodeSentCallText" = "Calling **%@** to dictate the code."; + +"Premium.Purchase.OnlyOneSubscriptionAllowed" = "You have already purchased Telegram Premium for another account. You can only have one Telegram Premium subscription on one Apple ID."; diff --git a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift index 99d606b32d..adc0ba55c4 100644 --- a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift +++ b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift @@ -308,11 +308,17 @@ public final class InAppPurchaseManager: NSObject { return signal } - public func getValidTransactionIds() -> [String] { + public struct ReceiptPurchase: Equatable { + public let productId: String + public let transactionId: String + public let expirationDate: Date + } + + public func getReceiptPurchases() -> [ReceiptPurchase] { guard let data = getReceiptData(), let receipt = parseReceipt(data) else { return [] } - return receipt.purchases.map { $0.transactionId } + return receipt.purchases.map { ReceiptPurchase(productId: $0.productId, transactionId: $0.transactionId, expirationDate: $0.expirationDate) } } } @@ -359,7 +365,6 @@ extension InAppPurchaseManager: SKPaymentTransactionObserver { switch transaction.transactionState { case .purchased: Logger.shared.log("InAppPurchaseManager", "Account \(accountPeerId), transaction \(transaction.transactionIdentifier ?? ""), original transaction \(transaction.original?.transactionIdentifier ?? "none") purchased") - transactionState = .purchased(transactionId: transaction.transactionIdentifier) transactionsToAssign.append(transaction) case .restored: diff --git a/submodules/InAppPurchaseManager/Sources/Receipt.swift b/submodules/InAppPurchaseManager/Sources/Receipt.swift index 194e96c7b1..fbb5d1b9d4 100644 --- a/submodules/InAppPurchaseManager/Sources/Receipt.swift +++ b/submodules/InAppPurchaseManager/Sources/Receipt.swift @@ -7,6 +7,7 @@ private struct Asn1Tag { static let sequence: Int32 = 0x10 static let set: Int32 = 0x11 static let utf8String: Int32 = 0x0c + static let date: Int32 = 0x16 } private struct Asn1Entry { @@ -124,10 +125,12 @@ struct Receipt { fileprivate struct Tag { static let productIdentifier: Int32 = 1702 static let transactionIdentifier: Int32 = 1703 + static let expirationDate: Int32 = 1708 } let productId: String let transactionId: String + let expirationDate: Date } let purchases: [Purchase] @@ -192,6 +195,27 @@ func parseReceipt(_ data: Data) -> Receipt? { return Receipt(purchases: purchases) } +private func parseRfc3339Date(_ str: String) -> Date? { + let posixLocale = Locale(identifier: "en_US_POSIX") + + let formatter1 = DateFormatter() + formatter1.locale = posixLocale + formatter1.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ssX5" + formatter1.timeZone = TimeZone(secondsFromGMT: 0) + + let result = formatter1.date(from: str) + if result != nil { + return result + } + + let formatter2 = DateFormatter() + formatter2.locale = posixLocale + formatter2.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.SSSSSSX5" + formatter2.timeZone = TimeZone(secondsFromGMT: 0) + + return formatter2.date(from: str) +} + private func parsePurchaseAttributes(_ data: Data) -> Receipt.Purchase? { let root = parse(data) guard root.tag == Asn1Tag.set else { @@ -200,6 +224,7 @@ private func parsePurchaseAttributes(_ data: Data) -> Receipt.Purchase? { var productId: String? var transactionId: String? + var expirationDate: Date? let receiptAttributes = parseSequence(root.data) for attribute in receiptAttributes { @@ -219,12 +244,16 @@ private func parsePurchaseAttributes(_ data: Data) -> Receipt.Purchase? { let valEntry = parse(value) guard valEntry.tag == Asn1Tag.utf8String else { return nil } transactionId = String(bytes: valEntry.data, encoding: .utf8) + case Receipt.Purchase.Tag.expirationDate: + let valEntry = parse(value) + guard valEntry.tag == Asn1Tag.date else { return nil } + expirationDate = parseRfc3339Date(String(bytes: valEntry.data, encoding: .utf8) ?? "") default: break } } - guard let productId, let transactionId else { + guard let productId, let transactionId, let expirationDate else { return nil } - return Receipt.Purchase(productId: productId, transactionId: transactionId) + return Receipt.Purchase(productId: productId, transactionId: transactionId, expirationDate: expirationDate) } diff --git a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift index eac07cf23e..207ee5f4cc 100644 --- a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift @@ -1196,14 +1196,28 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { let otherPeerName: String? let products: [PremiumProduct]? let selectedProductId: String? - let validTransactionIds: [String] + let validPurchases: [InAppPurchaseManager.ReceiptPurchase] let promoConfiguration: PremiumPromoConfiguration? let present: (ViewController) -> Void let selectProduct: (String) -> Void let buy: () -> Void let updateIsFocused: (Bool) -> Void - init(context: AccountContext, source: PremiumSource, isPremium: Bool?, justBought: Bool, otherPeerName: String?, products: [PremiumProduct]?, selectedProductId: String?, validTransactionIds: [String], promoConfiguration: PremiumPromoConfiguration?, present: @escaping (ViewController) -> Void, selectProduct: @escaping (String) -> Void, buy: @escaping () -> Void, updateIsFocused: @escaping (Bool) -> Void) { + init( + context: AccountContext, + source: PremiumSource, + isPremium: Bool?, + justBought: Bool, + otherPeerName: String?, + products: [PremiumProduct]?, + selectedProductId: String?, + validPurchases: [InAppPurchaseManager.ReceiptPurchase], + promoConfiguration: PremiumPromoConfiguration?, + present: @escaping (ViewController) -> Void, + selectProduct: @escaping (String) -> Void, + buy: @escaping () -> Void, + updateIsFocused: @escaping (Bool) -> Void + ) { self.context = context self.source = source self.isPremium = isPremium @@ -1211,7 +1225,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { self.otherPeerName = otherPeerName self.products = products self.selectedProductId = selectedProductId - self.validTransactionIds = validTransactionIds + self.validPurchases = validPurchases self.promoConfiguration = promoConfiguration self.present = present self.selectProduct = selectProduct @@ -1241,7 +1255,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { if lhs.selectedProductId != rhs.selectedProductId { return false } - if lhs.validTransactionIds != rhs.validTransactionIds { + if lhs.validPurchases != rhs.validPurchases { return false } if lhs.promoConfiguration != rhs.promoConfiguration { @@ -1256,7 +1270,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { var products: [PremiumProduct]? var selectedProductId: String? - var validTransactionIds: [String] = [] + var validPurchases: [InAppPurchaseManager.ReceiptPurchase] = [] var isPremium: Bool? @@ -1276,7 +1290,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { var canUpgrade: Bool { if let products = self.products, let current = products.first(where: { $0.isCurrent }), let transactionId = current.transactionId { - if self.validTransactionIds.contains(transactionId) { + if self.validPurchases.contains(where: { $0.transactionId == transactionId }) { return products.first(where: { $0.months > current.months }) != nil } else { return false @@ -1373,7 +1387,7 @@ private final class PremiumIntroScreenContentComponent: CombinedComponent { let state = context.state state.products = context.component.products state.selectedProductId = context.component.selectedProductId - state.validTransactionIds = context.component.validTransactionIds + state.validPurchases = context.component.validPurchases state.isPremium = context.component.isPremium let theme = environment.theme @@ -1986,7 +2000,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { private(set) var products: [PremiumProduct]? private(set) var selectedProductId: String? - fileprivate var validTransactionIds: [String] = [] + fileprivate var validPurchases: [InAppPurchaseManager.ReceiptPurchase] = [] var isPremium: Bool? var otherPeerName: String? @@ -2015,7 +2029,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { var canUpgrade: Bool { if let products = self.products, let current = products.first(where: { $0.isCurrent }), let transactionId = current.transactionId { - if self.validTransactionIds.contains(transactionId) { + if self.validPurchases.contains(where: { $0.transactionId == transactionId }) { return products.first(where: { $0.months > current.months }) != nil } else { return false @@ -2036,7 +2050,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { super.init() - self.validTransactionIds = context.inAppPurchaseManager?.getValidTransactionIds() ?? [] + self.validPurchases = context.inAppPurchaseManager?.getReceiptPurchases() ?? [] let availableProducts: Signal<[InAppPurchaseManager.Product], NoError> if let inAppPurchaseManager = context.inAppPurchaseManager { @@ -2136,7 +2150,28 @@ private final class PremiumIntroScreenComponent: CombinedComponent { let premiumProduct = self.products?.first(where: { $0.id == self.selectedProductId }), !self.inProgress else { return } + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + let isUpgrade = self.products?.first(where: { $0.isCurrent }) != nil + + var hasActiveSubsciption = false + if let data = self.context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_receipt_check"] { + + } else if !self.validPurchases.isEmpty && !isUpgrade { + let now = Date() + for purchase in self.validPurchases.reversed() { + if (purchase.productId.hasSuffix(".monthly") || purchase.productId.hasSuffix(".annual")) && purchase.expirationDate > now { + hasActiveSubsciption = true + } + } + } + + if hasActiveSubsciption { + let errorText = presentationData.strings.Premium_Purchase_OnlyOneSubscriptionAllowed + let alertController = textAlertController(context: self.context, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]) + self.present(alertController) + return + } addAppLogEvent(postbox: self.context.account.postbox, type: "premium.promo_screen_accept") @@ -2173,7 +2208,6 @@ private final class PremiumIntroScreenComponent: CombinedComponent { addAppLogEvent(postbox: strongSelf.context.account.postbox, type: "premium.promo_screen_fail") - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let errorText = presentationData.strings.Premium_Purchase_ErrorUnknown let alertController = textAlertController(context: strongSelf.context, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]) strongSelf.present(alertController) @@ -2198,7 +2232,6 @@ private final class PremiumIntroScreenComponent: CombinedComponent { strongSelf.updateInProgress(false) strongSelf.updated(transition: .immediate) - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } var errorText: String? switch error { case .generic: @@ -2475,7 +2508,7 @@ private final class PremiumIntroScreenComponent: CombinedComponent { otherPeerName: state.otherPeerName, products: state.products, selectedProductId: state.selectedProductId, - validTransactionIds: state.validTransactionIds, + validPurchases: state.validPurchases, promoConfiguration: state.promoConfiguration, present: context.component.present, selectProduct: { [weak state] productId in diff --git a/submodules/TelegramBaseController/Sources/MediaNavigationAccessoryHeaderNode.swift b/submodules/TelegramBaseController/Sources/MediaNavigationAccessoryHeaderNode.swift index fcdf028f53..4905cd1adf 100644 --- a/submodules/TelegramBaseController/Sources/MediaNavigationAccessoryHeaderNode.swift +++ b/submodules/TelegramBaseController/Sources/MediaNavigationAccessoryHeaderNode.swift @@ -188,7 +188,7 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi case .x0_5: self.rateButton.setContent(.image(optionsRateImage(rate: "0.5X", color: self.theme.rootController.navigationBar.accentTextColor))) case .x1: - self.rateButton.setContent(.image(optionsRateImage(rate: "2X", color: self.theme.rootController.navigationBar.controlColor))) + self.rateButton.setContent(.image(optionsRateImage(rate: "1X", color: self.theme.rootController.navigationBar.controlColor))) self.rateButton.accessibilityLabel = self.strings.VoiceOver_Media_PlaybackRate self.rateButton.accessibilityValue = self.strings.VoiceOver_Media_PlaybackRateNormal self.rateButton.accessibilityHint = self.strings.VoiceOver_Media_PlaybackRateChange @@ -378,7 +378,7 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi case .x0_5: self.rateButton.setContent(.image(optionsRateImage(rate: "0.5X", color: self.theme.rootController.navigationBar.accentTextColor))) case .x1: - self.rateButton.setContent(.image(optionsRateImage(rate: "2X", color: self.theme.rootController.navigationBar.controlColor))) + self.rateButton.setContent(.image(optionsRateImage(rate: "1X", color: self.theme.rootController.navigationBar.controlColor))) case .x1_5: self.rateButton.setContent(.image(optionsRateImage(rate: "1.5X", color: self.theme.rootController.navigationBar.accentTextColor))) case .x2: @@ -511,6 +511,8 @@ public final class MediaNavigationAccessoryHeaderNode: ASDisplayNode, UIScrollVi if let rate = self.playbackBaseRate { switch rate { case .x1: + nextRate = .x1_5 + case .x1_5: nextRate = .x2 default: nextRate = .x1 diff --git a/submodules/TelegramBaseController/Sources/TelegramBaseController.swift b/submodules/TelegramBaseController/Sources/TelegramBaseController.swift index 349cbeaff8..9551940eec 100644 --- a/submodules/TelegramBaseController/Sources/TelegramBaseController.swift +++ b/submodules/TelegramBaseController/Sources/TelegramBaseController.swift @@ -687,41 +687,41 @@ open class TelegramBaseController: ViewController, KeyShortcutResponder { } strongSelf.context.sharedContext.mediaManager.playlistControl(.setBaseRate(baseRate), type: type) - var hasTooltip = false - strongSelf.forEachController({ controller in - if let controller = controller as? UndoOverlayController { - hasTooltip = true - controller.dismissWithCommitAction() - } - return true - }) - - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - let slowdown: Bool? - if baseRate == .x1 { - slowdown = true - } else if baseRate == .x2 { - slowdown = false - } else { - slowdown = nil - } - if let slowdown = slowdown { - strongSelf.present( - UndoOverlayController( - presentationData: presentationData, - content: .audioRate( - slowdown: slowdown, - text: slowdown ? presentationData.strings.Conversation_AudioRateTooltipNormal : presentationData.strings.Conversation_AudioRateTooltipSpeedUp - ), - elevatedLayout: false, - animateInAsReplacement: hasTooltip, - action: { action in - return true - } - ), - in: .current - ) - } +// var hasTooltip = false +// strongSelf.forEachController({ controller in +// if let controller = controller as? UndoOverlayController { +// hasTooltip = true +// controller.dismissWithCommitAction() +// } +// return true +// }) +// +// let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } +// let slowdown: Bool? +// if baseRate == .x1 { +// slowdown = true +// } else if baseRate == .x2 { +// slowdown = false +// } else { +// slowdown = nil +// } +// if let slowdown = slowdown { +// strongSelf.present( +// UndoOverlayController( +// presentationData: presentationData, +// content: .audioRate( +// slowdown: slowdown, +// text: slowdown ? presentationData.strings.Conversation_AudioRateTooltipNormal : presentationData.strings.Conversation_AudioRateTooltipSpeedUp +// ), +// elevatedLayout: false, +// animateInAsReplacement: hasTooltip, +// action: { action in +// return true +// } +// ), +// in: .current +// ) +// } }) } mediaAccessoryPanel.togglePlayPause = { [weak self] in diff --git a/submodules/TelegramUI/Sources/OverlayPlayerControlsNode.swift b/submodules/TelegramUI/Sources/OverlayPlayerControlsNode.swift index cc3314ab97..41df961a91 100644 --- a/submodules/TelegramUI/Sources/OverlayPlayerControlsNode.swift +++ b/submodules/TelegramUI/Sources/OverlayPlayerControlsNode.swift @@ -35,6 +35,47 @@ private func generateCollapseIcon(theme: PresentationTheme) -> UIImage? { }) } +private func optionsRateImage(rate: String, color: UIColor = .white) -> UIImage? { + return generateImage(CGSize(width: 30.0, height: 16.0), rotatedContext: { size, context in + UIGraphicsPushContext(context) + + context.clear(CGRect(origin: CGPoint(), size: size)) + + let lineWidth = 1.0 + UIScreenPixel + context.setLineWidth(lineWidth) + context.setStrokeColor(color.cgColor) + + + let string = NSMutableAttributedString(string: rate, font: Font.with(size: 11.0, design: .round, weight: .bold), textColor: color) + + var offset = CGPoint(x: 1.0, y: 0.0) + var width: CGFloat + if rate.count >= 3 { + if rate == "0.5X" { + string.addAttribute(.kern, value: -0.8 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string)) + offset.x += -0.5 + } else { + string.addAttribute(.kern, value: -0.5 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string)) + offset.x += -0.3 + } + width = 29.0 + } else { + string.addAttribute(.kern, value: -0.5 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string)) + width = 19.0 + offset.x += -0.3 + } + + let path = UIBezierPath(roundedRect: CGRect(x: floorToScreenPixels((size.width - width) / 2.0), y: 0.0, width: width, height: 16.0).insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0), byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 2.0, height: 2.0)) + context.addPath(path.cgPath) + context.strokePath() + + let boundingRect = string.boundingRect(with: size, options: [], context: nil) + string.draw(at: CGPoint(x: offset.x + floor((size.width - boundingRect.width) / 2.0), y: offset.y + UIScreenPixel + floor((size.height - boundingRect.height) / 2.0))) + + UIGraphicsPopContext() + }) +} + private let digitsSet = CharacterSet(charactersIn: "0123456789") private func timestampLabelWidthForDuration(_ timestamp: Double) -> CGFloat { let text: String @@ -375,8 +416,10 @@ final class OverlayPlayerControlsNode: ASDisplayNode { } let baseRate: AudioPlaybackRate - if !value.status.baseRate.isEqual(to: 1.0) { + if value.status.baseRate.isEqual(to: 2.0) { baseRate = .x2 + } else if value.status.baseRate.isEqual(to: 1.5) { + baseRate = .x1_5 } else { baseRate = .x1 } @@ -715,10 +758,9 @@ final class OverlayPlayerControlsNode: ASDisplayNode { } private func updateOrderButton(_ order: MusicPlaybackSettingsOrder) { - let baseColor = self.presentationData.theme.list.itemSecondaryTextColor switch order { case .regular: - self.orderButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/OrderReverse"), color: baseColor) + self.orderButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/OrderReverse"), color: self.presentationData.theme.list.itemSecondaryTextColor) case .reversed: self.orderButton.icon = generateTintedImage(image: UIImage(bundleImageName: "GlobalMusicPlayer/OrderReverse"), color: self.presentationData.theme.list.itemAccentColor) case .random: @@ -740,10 +782,12 @@ final class OverlayPlayerControlsNode: ASDisplayNode { private func updateRateButton(_ baseRate: AudioPlaybackRate) { switch baseRate { - case .x2: - self.rateButton.setImage(PresentationResourcesRootController.navigationPlayerMaximizedRateActiveIcon(self.presentationData.theme), for: []) - default: - self.rateButton.setImage(PresentationResourcesRootController.navigationPlayerMaximizedRateInactiveIcon(self.presentationData.theme), for: []) + case .x2: + self.rateButton.setImage(optionsRateImage(rate: "2X", color: self.presentationData.theme.list.itemAccentColor), for: []) + case .x1_5: + self.rateButton.setImage(optionsRateImage(rate: "1.5X", color: self.presentationData.theme.list.itemAccentColor), for: []) + default: + self.rateButton.setImage(optionsRateImage(rate: "1X", color: self.presentationData.theme.list.itemSecondaryTextColor), for: []) } } @@ -959,12 +1003,14 @@ final class OverlayPlayerControlsNode: ASDisplayNode { if let currentRate = self.currentRate { switch currentRate { case .x1: + nextRate = .x1_5 + case .x1_5: nextRate = .x2 default: nextRate = .x1 } } else { - nextRate = .x2 + nextRate = .x1_5 } self.control?(.setBaseRate(nextRate)) }