diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index bae497e383..6ba3c87638 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -476,7 +476,7 @@ FD0F85662EA82FCC004E0B98 /* PaymentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */; }; FD0F85682EA83385004E0B98 /* SessionProEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85672EA83382004E0B98 /* SessionProEndpoint.swift */; }; FD0F85732EA83C44004E0B98 /* AnyCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85722EA83C41004E0B98 /* AnyCodable.swift */; }; - FD0F85752EA83D5D004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85742EA83D49004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift */; }; + FD0F85752EA83D5D004E0B98 /* GenerateProProofResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85742EA83D49004E0B98 /* GenerateProProofResponse.swift */; }; FD0F85772EA83D92004E0B98 /* ProProof.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85762EA83D8F004E0B98 /* ProProof.swift */; }; FD0F85792EA83EAD004E0B98 /* ResponseHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */; }; FD0F857B2EA85FAB004E0B98 /* Request+SessionProAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */; }; @@ -513,7 +513,6 @@ FD1DD8B92EF3ACE5009F2C1B /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; FD1DD8BA2EF3ACF5009F2C1B /* ThemeMessagePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9DA28A244E9003AE748 /* ThemeMessagePreviewView.swift */; }; FD1DD8BB2EF3AD04009F2C1B /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; }; - FD1F3CED2ED5728600E536D5 /* SetPaymentRefundRequestedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */; }; FD1F3CEF2ED6509900E536D5 /* SessionProUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */; }; FD1F3CF32ED657AC00E536D5 /* Constants+LibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */; }; FD1F3CF62ED69B6600E536D5 /* SessionProState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF52ED69B6200E536D5 /* SessionProState.swift */; }; @@ -2256,7 +2255,7 @@ FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProvider.swift; sourceTree = ""; }; FD0F85672EA83382004E0B98 /* SessionProEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProEndpoint.swift; sourceTree = ""; }; FD0F85722EA83C41004E0B98 /* AnyCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCodable.swift; sourceTree = ""; }; - FD0F85742EA83D49004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProPaymentOrGenerateProProofResponse.swift; sourceTree = ""; }; + FD0F85742EA83D49004E0B98 /* GenerateProProofResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateProProofResponse.swift; sourceTree = ""; }; FD0F85762EA83D8F004E0B98 /* ProProof.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProProof.swift; sourceTree = ""; }; FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseHeader.swift; sourceTree = ""; }; FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Request+SessionProAPI.swift"; sourceTree = ""; }; @@ -2290,7 +2289,6 @@ FD1BDB9A2E6123D6008EF998 /* JobQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobQueue.swift; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; FD1D732D2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _029_BlockCommunityMessageRequests.swift; sourceTree = ""; }; - FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPaymentRefundRequestedResponse.swift; sourceTree = ""; }; FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProUI.swift; sourceTree = ""; }; FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+LibSession.swift"; sourceTree = ""; }; FD1F3CF52ED69B6200E536D5 /* SessionProState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProState.swift; sourceTree = ""; }; @@ -4697,9 +4695,8 @@ FD0F85692EA83518004E0B98 /* Requests */ = { isa = PBXGroup; children = ( - FD0F85742EA83D49004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift */, + FD0F85742EA83D49004E0B98 /* GenerateProProofResponse.swift */, FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */, - FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */, 25F58B94BEC98FEA256CE42F /* GetProStatusResponse.swift */, ); path = Requests; @@ -7489,7 +7486,6 @@ FD2272B02C33E337004D8A6C /* NetworkError.swift in Sources */, FD306BD62EB0323000ADB003 /* BackendUserProStatus.swift in Sources */, FD0F85772EA83D92004E0B98 /* ProProof.swift in Sources */, - FD1F3CED2ED5728600E536D5 /* SetPaymentRefundRequestedResponse.swift in Sources */, FD6B92AB2E77A920004463B5 /* SOGS.swift in Sources */, FD6B92E92E77C5D1004463B5 /* SubscribeResponse.swift in Sources */, FDAE2DBF2F5A5CB200E6FB80 /* StorageServerError.swift in Sources */, @@ -7502,7 +7498,7 @@ FD6B92F02E77C5D1004463B5 /* SubscribeRequest.swift in Sources */, FD6B92F22E77C5D1004463B5 /* UnsubscribeResponse.swift in Sources */, 947D7FD62D509FC900E8E413 /* SessionNetworkAPI.swift in Sources */, - FD0F85752EA83D5D004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift in Sources */, + FD0F85752EA83D5D004E0B98 /* GenerateProProofResponse.swift in Sources */, FDAE2DBC2F5A5C5E00E6FB80 /* Crypto+FileServer.swift in Sources */, FD6B92B42E77AA11004463B5 /* PinnedMessage.swift in Sources */, FD6B92B52E77AA11004463B5 /* SendDirectMessageResponse.swift in Sources */, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 9eb918363b..b2dc090e90 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -971,14 +971,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold switch result.status { case .success: return result.features - case .utfDecodingError: - Log.warn(.messageSender, "Failed to extract features for message, falling back to manual handling") - guard (text ?? "").utf16.count > SessionPro.CharacterLimit else { - return .none - } - - return .largerCharacterLimit - case .exceedsCharacterLimit: throw MessageError.messageTooLarge } }() diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift deleted file mode 100644 index ea9248e386..0000000000 --- a/Session/Conversations/Input View/InputView.swift +++ /dev/null @@ -1,695 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import Combine -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit -import SignalUtilitiesKit - -final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { - // MARK: - Variables - - private static let linkPreviewViewInset: CGFloat = 6 - private static let thresholdForCharacterLimit: Int = 200 - - private var disposables: Set = Set() - private let dependencies: Dependencies - private let threadVariant: SessionThread.Variant - private weak var delegate: InputViewDelegate? - private var sessionProState: SessionProManagerType? - - var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } - var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? - private var linkPreviewLoadTask: Task? - private var voiceMessageRecordingView: VoiceMessageRecordingView? - private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) - - private lazy var linkPreviewView: LinkPreviewView = { - let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset) - - return LinkPreviewView(maxWidth: maxWidth, using: dependencies) { [weak self] in - self?.linkPreviewInfo = nil - self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - } - }() - - var text: String { - get { inputTextView.text ?? "" } - set { inputTextView.text = newValue } - } - - var selectedRange: NSRange { - get { inputTextView.selectedRange } - set { inputTextView.selectedRange = newValue } - } - - var inputState: SessionThreadViewModel.MessageInputState = .all { - didSet { - setMessageInputState(inputState) - } - } - - override var intrinsicContentSize: CGSize { CGSize.zero } - var lastSearchedText: String? { nil } - - // MARK: - UI - - private lazy var tapGestureRecognizer: UITapGestureRecognizer = { - let result: UITapGestureRecognizer = UITapGestureRecognizer() - result.addTarget(self, action: #selector(disabledInputTapped)) - result.isEnabled = false - - return result - }() - - private lazy var swipeGestureRecognizer: UISwipeGestureRecognizer = { - let result: UISwipeGestureRecognizer = UISwipeGestureRecognizer() - result.direction = .down - result.addTarget(self, action: #selector(didSwipeDown)) - result.cancelsTouchesInView = false - - return result - }() - - private var bottomStackView: UIStackView? - private lazy var attachmentsButton: ExpandingAttachmentsButton = { - let result = ExpandingAttachmentsButton(delegate: delegate) - result.accessibilityLabel = "Attachments button" - result.accessibilityIdentifier = "Attachments button" - result.isAccessibilityElement = true - - return result - }() - - private lazy var voiceMessageButton: InputViewButton = { - let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self) - result.accessibilityLabel = "New voice message" - result.accessibilityIdentifier = "New voice message" - result.isAccessibilityElement = true - - return result - }() - - private lazy var sendButton: InputViewButton = { - let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self) - result.isHidden = true - result.accessibilityIdentifier = "Send message button" - result.accessibilityLabel = "Send message button" - result.isAccessibilityElement = true - - return result - }() - private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton) - - private lazy var mentionsView: MentionSelectionView = { - let result: MentionSelectionView = MentionSelectionView(using: dependencies) - result.delegate = self - - return result - }() - - private lazy var mentionsViewContainer: UIView = { - let result: UIView = UIView() - result.accessibilityLabel = "Mentions list" - result.accessibilityIdentifier = "Mentions list" - result.alpha = 0 - - let backgroundView = UIView() - backgroundView.themeBackgroundColor = .backgroundSecondary - backgroundView.alpha = Values.lowOpacity - result.addSubview(backgroundView) - backgroundView.pin(to: result) - - let blurView: UIVisualEffectView = UIVisualEffectView() - result.addSubview(blurView) - blurView.pin(to: result) - - ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _, _ in - blurView?.effect = UIBlurEffect(style: theme.blurStyle) - } - - return result - }() - - private lazy var inputTextView: InputTextView = { - // HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't - // be able to calculate what size it should be to accommodate the draft text. As a workaround, we - // just calculate the max width that the input text view is allowed to be and pass it in. See - // setUpViewHierarchy() for why these values are the way they are. - let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 - let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment) - let result = InputTextView(delegate: self, maxWidth: maxWidth) - result.accessibilityLabel = "contentDescriptionMessageComposition".localized() - result.accessibilityIdentifier = "Message input box" - result.isAccessibilityElement = true - - return result - }() - - private lazy var disabledInputLabel: UILabel = { - let label: UILabel = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .systemFont(ofSize: Values.smallFontSize) - label.themeTextColor = .textPrimary - label.textAlignment = .center - label.alpha = 0 - label.numberOfLines = 0 - label.lineBreakMode = .byWordWrapping - - return label - }() - - private lazy var proStackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ characterLimitLabel, sessionProBadge ]) - result.axis = .vertical - result.spacing = Values.verySmallSpacing - result.alignment = .center - result.addGestureRecognizer(characterLimitLabelTapGestureRecognizer) - result.alpha = 0 - - return result - }() - private lazy var characterLimitLabelTapGestureRecognizer: UITapGestureRecognizer = { - let result: UITapGestureRecognizer = UITapGestureRecognizer() - result.addTarget(self, action: #selector(characterLimitLabelTapped)) - result.isEnabled = false - - return result - }() - - private lazy var characterLimitLabel: UILabel = { - let label: UILabel = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .systemFont(ofSize: Values.smallFontSize) - label.themeTextColor = .textPrimary - label.textAlignment = .center - - return label - }() - - private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .medium) - result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro - - return result - }() - - private lazy var additionalContentContainer = UIView() - - public var isInputFirstResponder: Bool { - inputTextView.isFirstResponder - } - - // MARK: - Initialization - - init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate, using dependencies: Dependencies) { - self.dependencies = dependencies - self.threadVariant = threadVariant - self.delegate = delegate - self.sessionProState = dependencies[singleton: .sessionProState] - - super.init(frame: CGRect.zero) - - setUpViewHierarchy() - - self.sessionProState?.sessionProStatePublisher - .subscribe(on: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { [weak self] sessionProPlanState in - let isPro: Bool = { - switch sessionProPlanState { - case .active, .refunding : return true - case .none, .expired: return false - } - }() - self?.sessionProBadge.isHidden = isPro - self?.updateNumberOfCharactersLeft((self?.inputTextView.text ?? "")) - } - ) - .store(in: &disposables) - } - - override init(frame: CGRect) { - preconditionFailure("Use init(delegate:) instead.") - } - - required init?(coder: NSCoder) { - preconditionFailure("Use init(delegate:) instead.") - } - - deinit { - linkPreviewLoadTask?.cancel() - } - - private func setUpViewHierarchy() { - autoresizingMask = .flexibleHeight - - addGestureRecognizer(tapGestureRecognizer) - addGestureRecognizer(swipeGestureRecognizer) - - // Background & blur - let backgroundView = UIView() - backgroundView.themeBackgroundColor = .backgroundSecondary - backgroundView.alpha = Values.lowOpacity - addSubview(backgroundView) - backgroundView.pin(to: self) - - let blurView = UIVisualEffectView() - addSubview(blurView) - blurView.pin(to: self) - - ThemeManager.onThemeChange(observer: blurView) { [weak blurView] theme, _, _ in - blurView?.effect = UIBlurEffect(style: theme.blurStyle) - } - - // Separator - let separator = UIView() - separator.themeBackgroundColor = .borderSeparator - separator.set(.height, to: Values.separatorThickness) - addSubview(separator) - separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) - - // Bottom stack view - let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ]) - bottomStackView.axis = .horizontal - bottomStackView.spacing = Values.smallSpacing - bottomStackView.alignment = .center - self.bottomStackView = bottomStackView - - // Main stack view - let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ]) - mainStackView.axis = .vertical - mainStackView.isLayoutMarginsRelativeArrangement = true - - let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 - mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment) - addSubview(mainStackView) - mainStackView.pin(.top, to: .bottom, of: separator) - mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) - mainStackView.pin(.bottom, to: .bottom, of: self) - - // Pro stack view - addSubview(proStackView) - proStackView.pin(.bottom, to: .bottom, of: inputTextView) - proStackView.center(.horizontal, in: sendButton) - - addSubview(disabledInputLabel) - - disabledInputLabel.pin(.top, to: .top, of: attachmentsButton) - disabledInputLabel.pin(.leading, to: .leading, of: inputTextView) - disabledInputLabel.pin(.trailing, to: .trailing, of: inputTextView) - disabledInputLabel.set(.height, to: InputViewButton.expandedSize) - - // Mentions - insertSubview(mentionsViewContainer, belowSubview: mainStackView) - mentionsViewContainer.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) - mentionsViewContainer.pin(.bottom, to: .top, of: self) - mentionsViewContainer.addSubview(mentionsView) - mentionsView.pin(to: mentionsViewContainer) - mentionsViewHeightConstraint.isActive = true - - // Voice message button - addSubview(voiceMessageButtonContainer) - voiceMessageButtonContainer.center(in: sendButton) - } - - // MARK: - Updating - - @MainActor func inputTextViewDidChangeSize(_ inputTextView: InputTextView) { - invalidateIntrinsicContentSize() - self.bottomStackView?.alignment = (inputTextView.contentSize.height > inputTextView.minHeight) ? .top : .center - } - - @MainActor func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { - let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - sendButton.isHidden = !hasText - voiceMessageButtonContainer.isHidden = hasText - autoGenerateLinkPreviewIfPossible() - - delegate?.inputTextViewDidChangeContent(inputTextView) - } - - @MainActor func updateNumberOfCharactersLeft(_ text: String) { - let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( - for: text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: dependencies[cache: .libSession].isSessionPro - ) - characterLimitLabel.text = "\(numberOfCharactersLeft.formatted(format: .abbreviated(decimalPlaces: 1)))" - characterLimitLabel.themeTextColor = (numberOfCharactersLeft < 0) ? .danger : .textPrimary - proStackView.alpha = (numberOfCharactersLeft <= Self.thresholdForCharacterLimit) ? 1 : 0 - characterLimitLabelTapGestureRecognizer.isEnabled = (numberOfCharactersLeft < Self.thresholdForCharacterLimit) - } - - @MainActor func didPasteImageDataFromPasteboard(_ inputTextView: InputTextView, imageData: Data) { - delegate?.didPasteImageDataFromPasteboard(imageData) - } - - // We want to show either a link preview or a quote draft, but never both at the same time. When trying to - // generate a link preview, wait until we're sure that we'll be able to build a link preview from the given - // URL before removing the quote draft. - - private func handleQuoteDraftChanged() { - additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - linkPreviewInfo = nil - - guard let quoteDraftInfo = quoteDraftInfo else { return } - - let hInset: CGFloat = 6 // Slight visual adjustment - - let quoteView: QuoteView = QuoteView( - for: .draft, - authorId: quoteDraftInfo.model.authorId, - quotedText: quoteDraftInfo.model.body, - threadVariant: threadVariant, - currentUserSessionIds: quoteDraftInfo.model.currentUserSessionIds, - direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming), - attachment: quoteDraftInfo.model.attachment, - using: dependencies - ) { [weak self] in - self?.quoteDraftInfo = nil - } - - additionalContentContainer.addSubview(quoteView) - quoteView.pin(.leading, to: .leading, of: additionalContentContainer, withInset: hInset) - quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12) - quoteView.pin(.trailing, to: .trailing, of: additionalContentContainer, withInset: -hInset) - quoteView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -6) - } - - private func autoGenerateLinkPreviewIfPossible() { - // Don't allow link previews on 'none' or 'textOnly' input - guard inputState.allowedInputTypes == .all else { return } - - // Suggest that the user enable link previews if they haven't already and we haven't - // told them about link previews yet - let text = inputTextView.text! - DispatchQueue.global(qos: .userInitiated).async { [weak self, dependencies] in - let areLinkPreviewsEnabled: Bool = dependencies.mutate(cache: .libSession) { cache in - cache.get(.areLinkPreviewsEnabled) - } - - if - !LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && - !areLinkPreviewsEnabled && - !dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion] - { - DispatchQueue.main.async { - self?.delegate?.showLinkPreviewSuggestionModal() - } - dependencies[defaults: .standard, key: .hasSeenLinkPreviewSuggestion] = true - return - } - // Check that link previews are enabled - guard areLinkPreviewsEnabled else { return } - - // Proceed - DispatchQueue.main.async { - self?.autoGenerateLinkPreview() - } - } - } - - @MainActor func autoGenerateLinkPreview() { - // Check that a valid URL is present - guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange, using: dependencies) else { - return - } - - // Guard against obsolete updates - guard linkPreviewURL != self.linkPreviewInfo?.url else { return } - - // Clear content container - additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - quoteDraftInfo = nil - - // Set the state to loading - linkPreviewInfo = (url: linkPreviewURL, draft: nil) - linkPreviewView.update(with: LinkPreview.LoadingState(), isOutgoing: false, using: dependencies) - - // Add the link preview view - additionalContentContainer.addSubview(linkPreviewView) - linkPreviewView.pin(.leading, to: .leading, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset) - linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10) - linkPreviewView.pin(.trailing, to: .trailing, of: additionalContentContainer) - linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4) - - // Build the link preview - linkPreviewLoadTask?.cancel() - linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self, allowedInputTypes = inputState.allowedInputTypes, dependencies] in - do { - /// Load the draft - let draft: LinkPreviewDraft = try await LinkPreview.tryToBuildPreviewInfo( - previewUrl: linkPreviewURL, - skipImageDownload: (allowedInputTypes != .all), /// Disable if attachments are disabled - using: dependencies - ) - try Task.checkCancellation() - - await MainActor.run { [weak self] in - guard let self else { return } - guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete - - linkPreviewInfo = (url: linkPreviewURL, draft: draft) - linkPreviewView.update( - with: LinkPreview.DraftState(linkPreviewDraft: draft), - isOutgoing: false, - using: dependencies - ) - setNeedsLayout() - layoutIfNeeded() - } - } - catch { - await MainActor.run { [weak self] in - guard let self else { return } - guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete - - linkPreviewInfo = nil - additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - setNeedsLayout() - layoutIfNeeded() - } - } - } - } - - func setMessageInputState(_ updatedInputState: SessionThreadViewModel.MessageInputState) { - guard inputState != updatedInputState else { return } - - self.accessibilityIdentifier = updatedInputState.accessibility?.identifier - self.accessibilityLabel = updatedInputState.accessibility?.label - tapGestureRecognizer.isEnabled = (updatedInputState.allowedInputTypes == .none) - - inputState = updatedInputState - disabledInputLabel.text = (updatedInputState.message ?? "") - disabledInputLabel.accessibilityIdentifier = updatedInputState.messageAccessibility?.identifier - disabledInputLabel.accessibilityLabel = updatedInputState.messageAccessibility?.label - - attachmentsButton.isSoftDisabled = (updatedInputState.allowedInputTypes != .all) - voiceMessageButton.isSoftDisabled = (updatedInputState.allowedInputTypes != .all) - - UIView.animate(withDuration: 0.3) { [weak self] in - self?.bottomStackView?.arrangedSubviews.forEach { $0.alpha = (updatedInputState.allowedInputTypes != .none ? 1 : 0) } - - self?.attachmentsButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) - self?.attachmentsButton.mainButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) - - self?.voiceMessageButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) - self?.voiceMessageButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) - - self?.disabledInputLabel.alpha = (updatedInputState.allowedInputTypes != .none ? 0 : Values.mediumOpacity) - } - } - - // MARK: - Interaction - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - // Needed so that the user can tap the buttons when the expanding attachments button is expanded - let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton, - attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ] - - if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) { - return buttonContainer - } - - return super.hitTest(point, with: event) - } - - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer, - attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ] - let isPointInsideAttachmentsButton = buttonContainers - .contains { $0.superview!.convert($0.frame, to: self).contains(point) } - - if isPointInsideAttachmentsButton { - // Needed so that the user can tap the buttons when the expanding attachments button is expanded - return true - } - - if mentionsViewContainer.frame.contains(point) { - // Needed so that the user can tap mentions - return true - } - - return super.point(inside: point, with: event) - } - - @MainActor func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { - if inputViewButton == sendButton { delegate?.handleSendButtonTapped() } - if inputViewButton == voiceMessageButton && inputState.allowedInputTypes != .all { - delegate?.handleDisabledVoiceMessageButtonTapped() - } - } - - @MainActor func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) { - guard inputViewButton == voiceMessageButton else { return } - guard inputState.allowedInputTypes == .all else { return } - - // Note: The 'showVoiceMessageUI' call MUST come before triggering 'startVoiceMessageRecording' - // because if something goes wrong it'll trigger `hideVoiceMessageUI` and we don't want it to - // end up in a state with the input content hidden - showVoiceMessageUI() - delegate?.startVoiceMessageRecording() - } - - @MainActor func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) { - guard - let voiceMessageRecordingView: VoiceMessageRecordingView = voiceMessageRecordingView, - inputViewButton == voiceMessageButton, - let location = touch?.location(in: voiceMessageRecordingView) - else { return } - - voiceMessageRecordingView.handleLongPressMoved(to: location) - } - - @MainActor func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?) { - guard - let voiceMessageRecordingView: VoiceMessageRecordingView = voiceMessageRecordingView, - inputViewButton == voiceMessageButton, - let location = touch?.location(in: voiceMessageRecordingView) - else { return } - - voiceMessageRecordingView.handleLongPressEnded(at: location) - } - - override func resignFirstResponder() -> Bool { - inputTextView.resignFirstResponder() - } - - @discardableResult - override func becomeFirstResponder() -> Bool { - inputTextView.becomeFirstResponder() - } - - func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) { - // Not relevant in this case - } - - @objc private func showVoiceMessageUI() { - guard let targetSuperview: UIView = voiceMessageButton.superview else { return } - - voiceMessageRecordingView?.removeFromSuperview() - let voiceMessageButtonFrame = targetSuperview.convert(voiceMessageButton.frame, to: self) - let voiceMessageRecordingView = VoiceMessageRecordingView( - voiceMessageButtonFrame: voiceMessageButtonFrame, - delegate: delegate - ) - voiceMessageRecordingView.alpha = 0 - addSubview(voiceMessageRecordingView) - - voiceMessageRecordingView.pin(to: self) - self.voiceMessageRecordingView = voiceMessageRecordingView - voiceMessageRecordingView.animate() - let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ] - UIView.animate(withDuration: 0.25) { - allOtherViews.forEach { $0.alpha = 0 } - } - } - - func hideVoiceMessageUI() { - let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ] - UIView.animate(withDuration: 0.25, animations: { - allOtherViews.forEach { $0.alpha = 1 } - self.voiceMessageRecordingView?.alpha = 0 - }, completion: { [weak self] _ in - self?.voiceMessageRecordingView?.removeFromSuperview() - self?.voiceMessageRecordingView = nil - }) - } - - func hideMentionsUI() { - UIView.animate( - withDuration: 0.25, - animations: { [weak self] in - self?.mentionsViewContainer.alpha = 0 - }, - completion: { [weak self] _ in - self?.mentionsViewHeightConstraint.constant = 0 - self?.mentionsView.contentOffset = CGPoint.zero - } - ) - } - - func showMentionsUI( - for candidates: [MentionInfo], - currentUserSessionIds: Set - ) { - mentionsView.currentUserSessionIds = currentUserSessionIds - mentionsView.candidates = candidates - - let mentionCellHeight = (ProfilePictureView.Size.message.viewSize + 2 * Values.smallSpacing) - mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight - layoutIfNeeded() - - UIView.animate(withDuration: 0.25) { - self.mentionsViewContainer.alpha = 1 - } - } - - @MainActor func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) { - delegate?.handleMentionSelected(mentionInfo, from: view) - } - - func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) { - // Do nothing - } - - @objc private func disabledInputTapped() { - delegate?.handleDisabledInputTapped() - } - - @objc private func characterLimitLabelTapped() { - delegate?.handleCharacterLimitLabelTapped() - } - - @objc private func didSwipeDown() { - inputTextView.resignFirstResponder() - } - - // MARK: - Convenience - - private func container(for button: InputViewButton) -> UIView { - let result: UIView = UIView() - result.addSubview(button) - result.set(.width, to: InputViewButton.expandedSize) - result.set(.height, to: InputViewButton.expandedSize) - button.center(in: result) - - return result - } -} - -// MARK: - Delegate - -protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate { - @MainActor func showLinkPreviewSuggestionModal() - @MainActor func handleSendButtonTapped() - @MainActor func handleDisabledInputTapped() - @MainActor func handleDisabledVoiceMessageButtonTapped() - @MainActor func handleCharacterLimitLabelTapped() - @MainActor func inputTextViewDidChangeContent(_ inputTextView: InputTextView) - @MainActor func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) - @MainActor func didPasteImageDataFromPasteboard(_ imageData: Data) -} diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 579b005740..b4ae45a5e5 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -998,21 +998,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold private func submitTransactionToProBackend() async { do { - let transactionId: String = try await { - guard await internalState.fakeAppleSubscriptionForDev else { - guard let transaction: Transaction = await internalState.purchaseTransaction else { - throw SessionProError.transactionNotFound - } - - return "\(transaction.id)" - } - - let bytes: [UInt8] = try dependencies[singleton: .crypto].tryGenerate(.randomBytes(8)) - return "DEV.\(bytes.toHexString())" - }() - - try await dependencies[singleton: .sessionProManager].addProPayment(transactionId: transactionId) - + /// Redemption is implicit now — there's no `/add_pro_payment`. Refreshing the pro state requests a + /// proof via `generate_pro_proof`, and the backend binds any unbound payment on that master-signed + /// request, so this dev action just drives that refresh. + try await dependencies[singleton: .sessionProManager].refreshProState() + dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), value: DeveloperSettingsProEvent.submittedTransaction("Success", false) diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index e9612055bb..ed633e177e 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -106,12 +106,13 @@ public extension Crypto.Generator { var cRecipientPubkey: bytes33 = bytes33() cServerPubkey.set(\.data, to: Data(hex: serverPubkey)) cRecipientPubkey.set(\.data, to: Data(hex: recipientPubkey)) + /// `session_protocol_encode_for_community_inbox` no longer takes `sent_timestamp_ms` + /// (removed in the §4 C-API changes); the group encoder above still does. result = session_protocol_encode_for_community_inbox( cPlaintext, cPlaintext.count, cEd25519SecretKey, cEd25519SecretKey.count, - sentTimestampMs, &cRecipientPubkey, &cServerPubkey, cRotatingProSecretKey, @@ -436,6 +437,28 @@ public extension Crypto.Generator { return try UUID(sessionProMasterPublicKey: masterKeyPair.publicKey) } } + + /// Deterministically derive the rotating Session Pro keypair for `nowUnixTimestampSeconds` via libsession + /// (`session_protocol_pro_rotating_seed`). The rotation schedule is libsession-owned and opaque to the + /// client — we just hand it the current time and use the seed it returns (no window/period logic here). + /// Every device on the account derives the same 32-byte seed for the same `now`, so concurrent proof + /// (re)generations converge rather than racing; we expand that seed to an ed25519 keypair for signing. + /// Replaces the old ad-hoc per-purchase rotating key. + static func sessionProRotatingKeyPair(nowUnixTimestampSeconds: Int64) -> Crypto.Generator { + return Crypto.Generator( + id: "sessionProRotatingKeyPair", + args: [nowUnixTimestampSeconds] + ) { dependencies in + let masterKeyPair: KeyPair = try dependencies[singleton: .crypto] + .tryGenerate(.sessionProMasterKeyPair()) + var rotatingSeed: [UInt8] = [UInt8](repeating: 0, count: 32) + + /// The C function uses the first 32 bytes of the 64-byte master secret key as the master seed. + session_protocol_pro_rotating_seed(masterKeyPair.secretKey, nowUnixTimestampSeconds, &rotatingSeed) + + return try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair(seed: Data(rotatingSeed))) + } + } } public extension UUID { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index dfca3c2cac..68c94cd348 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -331,6 +331,48 @@ public extension LibSession.Cache { /// Whole unix seconds on both sides — hand libsession the value directly. user_profile_set_pro_access_expiry(conf, Int64(proAccessExpiryTimestampSeconds)) } + + /// The unix timestamp (seconds) at which the user requested a refund, config-synced across devices, or + /// `0` if none (libsession also returns `0` for a value more than a week old — the staleness gate lives + /// there, not here). Replaces the old per-payment `refund_requested_ts` backend field. + var refundRequestedTimestampSeconds: UInt64 { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return 0 } + + return UInt64(max(0, user_profile_get_refund_requested(conf))) + } + + /// Record (or clear, with `0`) that the user requested a refund; propagates to their other devices. + func updateRefundRequested(_ refundRequestedTimestampSeconds: UInt64) { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return } + + user_profile_set_refund_requested(conf, Int64(refundRequestedTimestampSeconds)) + } + + /// The unix timestamp (seconds) at which a Pro purchase was initiated (the "purchase in flight" marker), + /// config-synced so other devices poll the entitlement through, or `0` if none (libsession applies the + /// same one-week staleness gate). + var proPrepaidTimestampSeconds: UInt64 { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return 0 } + + return UInt64(max(0, user_profile_get_pro_prepaid(conf))) + } + + /// Mark (or clear, with `0`) that a Pro purchase is in flight. A no-op in libsession if already Pro, and + /// cleared automatically once the entitlement lands. + func updateProPrepaid(_ proPrepaidTimestampSeconds: UInt64) { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return } + + user_profile_set_pro_prepaid(conf, Int64(proPrepaidTimestampSeconds)) + } + + /// When to (re)request a Pro proof given `now` (unix seconds): the returned unix timestamp is a renewal + /// target — renew now if it is `<= now`, otherwise schedule for then — or `0` for "no renewal needed". + /// libsession owns this decision now (replaces the old `autoRenewing`-gated client logic). + func proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: Int64) -> Int64 { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return 0 } + + return user_profile_get_pro_renewal_target(conf, nowUnixTimestampSeconds) + } } // MARK: - ProfileInfo diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 2f2f66b430..f1562edeec 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -1193,7 +1193,12 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func updateProConfig(proConfig: SessionPro.ProConfig) func removeProConfig() func updateProAccessExpiryTimestampSeconds(_ proAccessExpiryTimestampSeconds: UInt64) - + var refundRequestedTimestampSeconds: UInt64 { get } + var proPrepaidTimestampSeconds: UInt64 { get } + func updateRefundRequested(_ refundRequestedTimestampSeconds: UInt64) + func updateProPrepaid(_ proPrepaidTimestampSeconds: UInt64) + func proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: Int64) -> Int64 + func canPerformChange( threadId: String, threadVariant: SessionThread.Variant, @@ -1482,7 +1487,12 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func updateProConfig(proConfig: SessionPro.ProConfig) {} func removeProConfig() {} func updateProAccessExpiryTimestampSeconds(_ proAccessExpiryTimestampSeconds: UInt64) {} - + var refundRequestedTimestampSeconds: UInt64 { return 0 } + var proPrepaidTimestampSeconds: UInt64 { return 0 } + func updateRefundRequested(_ refundRequestedTimestampSeconds: UInt64) {} + func updateProPrepaid(_ proPrepaidTimestampSeconds: UInt64) {} + func proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: Int64) -> Int64 { return 0 } + func canPerformChange( threadId: String, threadVariant: SessionThread.Variant, diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 9019d117aa..607ad6b799 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -37,6 +37,14 @@ public actor SessionProManager: SessionProManagerType { private var proInvalidationTask: Task? private var appLifecycleObservingTask: Task? + /// Proof-renewal reconcile loop (Rev 2). `proofRenewalWakeTask` is the advisory in-foreground wake; + /// `proofGenerationTask` is the in-flight `generate_pro_proof`. `lastProofRequestAt` + `darkAttempt` + /// are ephemeral spacing/backoff state (re-derived from config each pass, reset on process death). + private var proofRenewalWakeTask: Task? + private var proofGenerationTask: Task? + private var lastProofRequestAt: TimeInterval = -.greatestFiniteMagnitude + private var darkAttempt: Int = 0 + /// The instant up to which we have already emitted "this profile's pro state just went stale" events /// /// Used as the lower bound of the window in `emitProInvalidationEvents(since:until:)` so that each lapse is emitted exactly @@ -92,7 +100,10 @@ public actor SessionProManager: SessionProManagerType { if dependencies[singleton: .appContext].isMainApp { try? await self?.refreshProState() } - + + /// Kick the foreground-anchored proof-renewal reconcile (main-app-gated inside) + await self?.reconcileProofRenewal() + await self?.hasCompletedInitialization.send(true) } } @@ -104,6 +115,8 @@ public actor SessionProManager: SessionProManagerType { proMockingObservationTask?.cancel() proInvalidationTask?.cancel() appLifecycleObservingTask?.cancel() + proofRenewalWakeTask?.cancel() + proofGenerationTask?.cancel() } public func ensureInitialized() async { @@ -115,16 +128,10 @@ public actor SessionProManager: SessionProManagerType { // MARK: - Functions nonisolated public func numberOfCharactersLeft(for content: String) -> Int { - let features: SessionPro.FeaturesForMessage = messageFeatures(for: content) - - switch features.status { - case .utfDecodingError: - /// If we got a decoding error then fallback - Log.error(.sessionPro, "Failed to decode content length due to error: \(features.error ?? "Unknown error")") - return (characterLimit - content.utf16.count) - - case .success, .exceedsCharacterLimit: return (characterLimit - features.codePointCount) - } + /// Count Unicode codepoints natively — for a (always valid) Swift `String`, + /// `unicodeScalars.count` is the codepoint count (surrogate pair = 1), matching what libsession's + /// simdutf path produced. No round-trip into libsession just to count. + return (characterLimit - content.unicodeScalars.count) } nonisolated public func proProofIsActive( @@ -132,22 +139,36 @@ public actor SessionProManager: SessionProManagerType { atTimestampMs timestampMs: UInt64 ) -> Bool { guard let proof: Network.SessionPro.ProProof else { return false } - + var cProProof: session_protocol_pro_proof = proof.libSessionValue - + return session_protocol_pro_proof_is_active(&cProProof, Int64(timestampMs / 1000)) } + + /// Whether the current user's own cached proof is usable for attaching to a message (Rev 2 §6.1 + /// validity): present, unexpired, AND not on the revocation list. `proProofIsActive` covers only expiry, + /// so this adds the revocation check (a revoked-but-unexpired proof must never be attached). + nonisolated public func currentUserProofIsValid(atTimestampMs timestampMs: UInt64) -> Bool { + guard + let proof: Network.SessionPro.ProProof = syncState.state.proof, + proProofIsActive(for: proof, atTimestampMs: timestampMs) + else { return false } + + let nowSeconds: TimeInterval = TimeInterval(timestampMs / 1000) + let proofRevocationTagHex: String = proof.revocationTag.toHexString() + let isRevoked: Bool = syncState.revocationList.contains { item in + TimeInterval(item.effectiveTimestampSeconds) <= nowSeconds && + item.revocationTag.toHexString() == proofRevocationTagHex + } + + return !isRevoked + } nonisolated public func messageFeatures(for message: String) -> SessionPro.FeaturesForMessage { - guard let cMessage: [CChar] = message.cString(using: .utf8) else { - return SessionPro.FeaturesForMessage.invalidString - } - + /// libsession no longer inspects the text — we pass the natively-counted codepoint count + /// (`unicodeScalars.count`) and it returns the required feature bitset + limit status. return SessionPro.FeaturesForMessage( - session_protocol_pro_features_for_utf8( - cMessage, - (cMessage.count - 1) /// Need to `- 1` to avoid counting the null-termination character - ) + session_protocol_pro_features_for_message(message.unicodeScalars.count) ) } @@ -238,7 +259,11 @@ public actor SessionProManager: SessionProManagerType { profileFeatures != .none || featuresForMessage.features != .none ), - let proof: Network.SessionPro.ProProof = syncState.state.proof + let proof: Network.SessionPro.ProProof = syncState.state.proof, + /// Send rule §6.1: a message carries a currently-VALID proof or NONE — never expired OR revoked. + /// Sending is never blocked by a lapsed proof (the message just goes out without Pro metadata); + /// Pro-*requiring* compose gating lives elsewhere and keys off subscription status, not the proof. + currentUserProofIsValid(atTimestampMs: syncState.dependencies.networkOffsetTimestampMs()) else { if featuresForMessage.status != .success { Log.error(.sessionPro, "Failed to get features for outgoing message due to error: \(featuresForMessage.error ?? "Unknown error")") @@ -360,10 +385,12 @@ public actor SessionProManager: SessionProManagerType { typealias ProInfo = ( proConfig: SessionPro.ProConfig?, profile: Profile, - accessExpiryTimestampSeconds: UInt64 + accessExpiryTimestampSeconds: UInt64, + refundRequestedTimestampSeconds: UInt64, + prepaidTimestampSeconds: UInt64 ) let proInfo: ProInfo = dependencies.mutate(cache: .libSession) { - ($0.proConfig, $0.profile, $0.proAccessExpiryTimestampSeconds) + ($0.proConfig, $0.profile, $0.proAccessExpiryTimestampSeconds, $0.refundRequestedTimestampSeconds, $0.proPrepaidTimestampSeconds) } let rotatingKeyPair: KeyPair? = try? proInfo.proConfig.map { config in @@ -392,9 +419,10 @@ public actor SessionProManager: SessionProManagerType { proof: .set(to: proInfo.proConfig?.proProof), profileFeatures: .set(to: proInfo.profile.proFeatures), accessExpiryTimestampSeconds: .set(to: proInfo.accessExpiryTimestampSeconds), + refundRequestedTimestampSeconds: .set(to: proInfo.refundRequestedTimestampSeconds), using: dependencies ) - + /// Store the updated events and emit updates self.syncState.update( rotatingKeyPair: .set(to: rotatingKeyPair), @@ -402,32 +430,41 @@ public actor SessionProManager: SessionProManagerType { ) self.rotatingKeyPair = rotatingKeyPair await self.stateStream.send(updatedState) - + /// If the `accessExpiryTimestampSeconds` value changed then we should trigger a refresh because it generally means that /// other device did something that should refresh the pro state if updatedState.accessExpiryTimestampSeconds != oldState.accessExpiryTimestampSeconds { try? await refreshProState() - + await dependencies.notify( key: .proAccessExpiryUpdated, value: proInfo.accessExpiryTimestampSeconds ) } + + /// Reconcile against the (possibly cross-device-updated) proof / prepaid marker / renewal target. A + /// pending purchase (`pro_prepaid` set, synced from another device) now surfaces as + /// `renewal_target <= now` via the gate, so the reconcile's dark path IS the acquisition poll — no + /// separate prepaid poll. + await reconcileProofRenewal() } public func purchasePro(productId: String) async throws { guard !dependencies[feature: .fakeAppleSubscriptionForDev] else { - let bytes: [UInt8] = try dependencies[singleton: .crypto].tryGenerate(.randomBytes(8)) - return try await addProPayment(transactionId: "DEV.\(bytes.toHexString())") // stringlint:ignore + /// Dev shortcut: skip StoreKit and just mark the purchase in-flight, then let the reconcile loop + /// pull the entitlement through (redemption is implicit — there's no add-payment call). + try await markPurchaseInFlight() + await reconcileProofRenewal() + return } - + let state: SessionPro.State = await stateStream.getCurrent() - + guard let product: Product = state.products.first(where: { $0.id == productId }) else { Log.error(.sessionPro, "Attempted to purchase invalid product: \(productId)") throw SessionProError.productNotFound } - + /// Attach a deterministic `appAccountToken` derived from the Pro master public key so the Pro backend can /// cryptographically bind this Apple payment to the master key (rather than trusting the transaction id to /// stay secret). This is mandatory — proceeding without it would leave the payment claimable by anyone who @@ -436,148 +473,284 @@ public actor SessionProManager: SessionProManagerType { .tryGenerate(.sessionProAppleAccountToken()) let options: Set = [ .appAccountToken(appleAccountToken) ] let result: Product.PurchaseResult = try await product.purchase(options: options) - + guard case .success(let verificationResult) = result else { switch result { case .success: throw SessionProError.unhandledBehaviour /// Invalid case case .pending: throw SessionProError.purchasePending case .userCancelled: throw SessionProError.purchaseCancelled - + @unknown default: Log.critical(.sessionPro, "An unhandled purchase result was received: \(result)") throw SessionProError.unhandledBehaviour } } - + let transaction: Transaction = try verificationResult.payloadValue - - /// There is a race condition where the client can try to register their payment before the Pro Backend has received the notification - /// from Apple that the payment has happened, due to this we need to try add the payment a few times with a small delay before - /// considering it an actual failure - let maxRetries: Int = 3 - - for index in 1...maxRetries { - do { - try await addProPayment(transactionId: "\(transaction.id)") - break /// Successfully registered the payment with the backend so no need to retry - } - catch { - /// If we reached the last retry then throw the error - if index == maxRetries { - Log.error(.sessionPro, "Failed to notify Pro backend of purchase due to error(s): \(error)") - throw error + + /// Apple accepted the payment (the verified transaction). Redemption is implicit: there is no + /// `/add_pro_payment` any more — the backend binds the account's unbound payments on any + /// master-signed request, and it learns of the payment from Apple, not from us. + /// + /// Record the "purchase in flight" marker in config FIRST, then `finish()`. `finish()` has no effect + /// on the payment — it only stops StoreKit redelivering the transaction via `Transaction.updates` — + /// so we defer it until our state is durably recorded: `markPurchaseInFlight` awaits the config + /// write, whose libsession dump is persisted to the local DB synchronously (the durable point). If + /// we crashed before that, StoreKit would re-hand us the transaction and we'd retry. Then the + /// reconcile loop (dark path) requests a proof via `generate_pro_proof`, binding the payment. + try await markPurchaseInFlight() + await transaction.finish() + await reconcileProofRenewal() + } + + /// Records the "purchase in flight" marker in the synced user config so every device polls the + /// entitlement through. A no-op in libsession if the account is already Pro, and cleared automatically + /// once the entitlement lands. + private func markPurchaseInFlight() async throws { + let nowSeconds: UInt64 = (await dependencies.networkOffsetTimestampMs() / 1000) + try await dependencies[singleton: .storage].write { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.updateProPrepaid(nowSeconds) } - - /// Small incremental backoff before trying again - try await Task.sleep(for: .milliseconds(index * 300)) } } - await transaction.finish() } - - public func addProPayment(transactionId: String) async throws { - // TODO: [PRO] Need to sort out logic for rotating this key pair. - /// First we need to add the pro payment to the Pro backend - let rotatingKeyPair: KeyPair = try ( - self.rotatingKeyPair ?? - dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) - ) - let request = try Network.SessionPro.addProPayment( - transactionId: transactionId, - masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), - rotatingKeyPair: rotatingKeyPair, - overallTimeout: 5, /// 5s timeout as per PRD - using: dependencies - ) - let response: Network.SessionPro.AddProPaymentOrGenerateProProofResponse = try await request - .send(using: dependencies) - - guard response.header.isSuccess else { - // Keep the raw backend diagnostic for the log; surface a user-facing message mapped from - // the error_code slug (localized `pro_error_`, falling back to the diagnostic). - let diagnostic: String = (response.header.error ?? response.header.errorCode ?? "unknown error") - Log.error(.sessionPro, "Failed to make purchase due to error(s): \(diagnostic)") - throw SessionProError.purchaseFailed(response.header.userFacingMessage) + + // MARK: -- Proof Renewal (Rev 2 reconcile loop) + + /// The Session Pro proof-renewal reconcile loop — the cross-platform design-of-record (Rev 2 §2). + /// + /// `pro_renewal_target(now)` owns the whole decision (timing + entitlement/`pro_prepaid` gate; no client + /// jitter or window math): `NONE`/`0` ⇒ DORMANT, a future ts ⇒ schedule a wake, `<= now` ⇒ a renewal or + /// acquisition is due. The renewal ACTION is a pure `generate_pro_proof` (§1.3) — the response type + + /// `account_expiry_ts` cover what a status fetch used to; `get_pro_status` (`refreshProState`) stays only + /// for the auto-renew / grace / refund display fields. + /// + /// `lastProofRequestAt`, `darkAttempt`, and the wake are ephemeral (re-derived from config each pass), so + /// suspension and process death both recover on the next reconcile. Single-flight is best-effort via the + /// `lastProofRequestAt` spacing check; an overlap is accepted (§1.9 — the §4 monotonic merge makes a late + /// duplicate a no-op: the deterministic rotating key + clamped expiry yield a byte-identical proof). + /// + /// A suspended iOS app can't run timers, so the wake is advisory: the guarantee is that every trigger + /// (`willEnterForeground`, config sync, init, purchase, `Transaction.updates`) re-runs this. + func reconcileProofRenewal() async { + proofRenewalWakeTask?.cancel() + proofRenewalWakeTask = nil + + guard dependencies[feature: .sessionProEnabled] else { return } + guard dependencies[singleton: .appContext].isMainApp else { return } + + let nowMs: UInt64 = await dependencies.networkOffsetTimestampMs() + let nowSeconds: Int64 = Int64(nowMs / 1000) + let now: TimeInterval = TimeInterval(nowSeconds) + + let target: Int64 = dependencies.mutate(cache: .libSession) { + $0.proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: nowSeconds) } - - // `already_redeemed` is gone from the error_code vocabulary (spec §5.1) — an ok add-payment always - // carries a proof (§5.2, ok ⟹ payload; a re-claim succeeds with one), so we always fall through to - // update the config below. - /// Update the config - try await dependencies[singleton: .storage].write { [dependencies] db in + /// DORMANT: nothing entitled and no pending purchase. Reset backoff; only a TRIGGER re-enters. + guard target != 0 else { darkAttempt = 0; return } + + /// Not yet due → arm exactly one wake at the target and reset the dark backoff. + if target > nowSeconds { + darkAttempt = 0 + armProofRenewalWake(afterSeconds: TimeInterval(target - nowSeconds)) + return + } + + /// Due. Covered (a currently-valid proof in hand — preemptive) spaces at a flat 60s; dark (no / + /// expired proof — renewal-after-offline or prepaid acquisition) uses a linear backoff, bounding an + /// abandoned purchase to ~15-min spacing. Covered resets the dark backoff (§2). + let haveValidProof: Bool = currentUserProofIsValid(atTimestampMs: nowMs) + if haveValidProof { darkAttempt = 0 } + let interval: TimeInterval = (haveValidProof ? 60 : TimeInterval(min(15 * darkAttempt, 900))) + + /// Spacing / best-effort single-flight / lost-completion recovery: if a request started too recently, + /// just (re)arm the wake for when the interval elapses and bail. + if (now - lastProofRequestAt) < interval { + armProofRenewalWake(afterSeconds: ((lastProofRequestAt + interval) - now)) + return + } + + lastProofRequestAt = now + if !haveValidProof { darkAttempt += 1 } + + /// Arm the next wake now (it also re-checks a lost/frozen completion), then fire the generate. + let nextInterval: TimeInterval = (haveValidProof ? 60 : TimeInterval(min(15 * darkAttempt, 900))) + armProofRenewalWake(afterSeconds: nextInterval) + startProofGeneration(nowUnixTimestampSeconds: nowSeconds) + } + + /// The advisory in-foreground wake. Unreliable across suspension (fine — a trigger re-runs reconcile); + /// reliable while foregrounded, which is where a send can happen. + private func armProofRenewalWake(afterSeconds delay: TimeInterval) { + /// Round up to whole seconds and use the integer `.seconds` overload (the `Double` one is iOS 16+). + let delaySeconds: Int = Int(max(0, delay).rounded(.up)) + proofRenewalWakeTask?.cancel() + proofRenewalWakeTask = Task { [weak self] in + do { try await Task.sleep(for: .seconds(delaySeconds)) } + catch { return } + await self?.reconcileProofRenewal() + } + } + + private enum ProofGenerationOutcome { + case response(Network.SessionPro.GenerateProProofResponse, rotatingKeyPair: KeyPair) + case transient + } + + /// Fire a `generate_pro_proof` (async), applying the outcome via `onProofComplete`. Not guarded against + /// overlap (§1.9); a superseding call cancels the prior task best-effort. + private func startProofGeneration(nowUnixTimestampSeconds: Int64) { + proofGenerationTask?.cancel() + proofGenerationTask = Task { [weak self] in + guard let self else { return } + + let outcome: ProofGenerationOutcome = await self.performProofGeneration( + nowUnixTimestampSeconds: nowUnixTimestampSeconds + ) + await self.onProofComplete(outcome) + } + } + + private func performProofGeneration(nowUnixTimestampSeconds: Int64) async -> ProofGenerationOutcome { + do { + /// Preserve the network wait (rather than fail-fast → dark backoff) so a connectivity outage + /// recovers promptly when the network returns, instead of waiting out the backed-off interval — + /// there is no "network reachable" trigger in the reconcile event set. + try await dependencies.ensureNetworkConnection(onWillStartWaiting: { + Log.info(.sessionPro, "Waiting for network to connect before renewing the Pro proof.") + }) + + let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto] + .tryGenerate(.sessionProRotatingKeyPair(nowUnixTimestampSeconds: nowUnixTimestampSeconds)) + let request = try Network.SessionPro.generateProProof( + masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + rotatingKeyPair: rotatingKeyPair, + using: dependencies + ) + let response: Network.SessionPro.GenerateProProofResponse = try await request.send(using: dependencies) + + return .response(response, rotatingKeyPair: rotatingKeyPair) + } + catch { + Log.error(.sessionPro, "Pro proof generation request failed (transient): \(error)") + return .transient + } + } + + /// Apply a `generate_pro_proof` outcome to config (Rev 2 §4). A stale response must never reduce + /// coverage — the guards read *live* config inside the write. Ends by reconciling again. + private func onProofComplete(_ outcome: ProofGenerationOutcome) async { + switch outcome { + case .transient: break /// nothing to write; the reconcile below re-arms the (backed-off) retry + + case .response(let response, let rotatingKeyPair): + switch response.outcome { + case .success: + await applyProofSuccess(response, rotatingKeyPair: rotatingKeyPair) + + /// Lapsed / no subscription → clear, but only if we don't currently hold a valid proof + /// (never let a stale failure wipe a fresh proof another device just landed). + /// `subscription_expired` carries a now-past `account_expiry` to refresh `E`. + case .subscriptionExpired: + await applyProofClear(accountExpiryTimestampSeconds: response.accountExpiryTimestampSeconds) + case .notSubscribed: + await applyProofClear(accountExpiryTimestampSeconds: nil) + + /// Revocation from a proof response is terminal: clear regardless of validity, no `E` + /// write, off the transient/backoff path. + case .revoked: + await applyProofRevoked() + + /// Transient / unrecognised → nothing (opaque-value discipline: fail closed non-destructively). + case .transient: + break + } + } + + await reconcileProofRenewal() + } + + /// success: monotonic upgrade of the proof (replace iff it extends coverage) + `E` co-write, all inside + /// one atomic mutation so the current-expiry read can't race the write. + private func applyProofSuccess( + _ response: Network.SessionPro.GenerateProProofResponse, + rotatingKeyPair: KeyPair + ) async { + try? await dependencies[singleton: .storage].write { [dependencies] db in try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile) { _ in - cache.updateProConfig( - proConfig: SessionPro.ProConfig( - rotatingPrivateKey: rotatingKeyPair.secretKey, - proProof: response.proof + let currentExpiry: UInt64 = (cache.proConfig?.proProof.expiryUnixTimestampSeconds ?? 0) + + /// Monotonic merge: ties (byte-identical same-period proofs) are no-ops → every device + /// converges on the longest-lived proof with no churn. Handles racing another client and + /// racing ourselves uniformly. + if response.proof.expiryUnixTimestampSeconds > currentExpiry { + cache.updateProConfig( + proConfig: SessionPro.ProConfig( + rotatingPrivateKey: rotatingKeyPair.secretKey, + proProof: response.proof + ) ) - ) + } + + /// `E` is written NOT gated by the proof guard (§4 H4): a mid-period horizon extension + /// keeps the same clamped proof expiry but a later `account_expiry`. It's advisory/soft. + if response.accountExpiryTimestampSeconds > 0 { + cache.updateProAccessExpiryTimestampSeconds(response.accountExpiryTimestampSeconds) + } } } } - - /// Send the proof and status events on the streams - /// - /// **Note:** We can assume that the users status is `active` since they just successfully added a pro payment and - /// received a pro proof - let proofIsActive: Bool = proProofIsActive( - for: response.proof, - atTimestampMs: await dependencies.networkOffsetTimestampMs() - ) - let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) - let oldState: SessionPro.State = await stateStream.getCurrent() - var updatedState: SessionPro.State = oldState.with( - status: .set(to: proStatus), - proof: .set(to: response.proof), - using: dependencies - ) - var needsUpdateProfile: Bool = false - - switch (oldState.status, updatedState.status) { - case (.never, .active): - let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - var proFeatures: SessionPro.ProfileFeatures = profile.proFeatures.inserting(.proBadge) - - if - let explicitPath: String = try? dependencies[singleton: .displayPictureManager].path(for: profile.displayPictureUrl), - let explicitURL: URL = URL(string: explicitPath), - let imageFrameBuffer: ImageDataManager.FrameBuffer = await dependencies[singleton: .imageDataManager].load(.url(explicitURL)), - imageFrameBuffer.frameCount > 1 - { - proFeatures = proFeatures.inserting(.animatedAvatar) + + /// Re-project the (now-updated) config into state — proof, rotating key, status, `E` all re-derive + /// consistently (and if the winning proof was an existing longer one, the rotating key matches it). + await updateWithLatestFromUserConfig() + try? await Profile.updateLocal(proFeatures: syncState.state.profileFeatures, using: dependencies) + } + + /// subscription_expired / not_subscribed clear — downgrade-guarded: apply only if there is no currently + /// valid (unexpired) proof, read inside the write. + private func applyProofClear(accountExpiryTimestampSeconds: UInt64?) async { + let nowSeconds: Int64 = Int64(await dependencies.networkOffsetTimestampMs() / 1000) + + try? await dependencies[singleton: .storage].write { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + /// Downgrade guard (read live config): never wipe a fresh, unexpired proof another device + /// just landed. `remove_pro_config` clears only `s`/`E`; it deliberately leaves + /// `pro_prepaid` so a pending purchase keeps polling (§7.3). + let hasUnexpiredProof: Bool = ((cache.proConfig?.proProof.expiryUnixTimestampSeconds ?? 0) > UInt64(max(0, nowSeconds))) + guard !hasUnexpiredProof else { return } + + cache.removeProConfig() + + if let accountExpiryTimestampSeconds: UInt64 = accountExpiryTimestampSeconds { + cache.updateProAccessExpiryTimestampSeconds(accountExpiryTimestampSeconds) + } } - - updatedState = updatedState.with( - profileFeatures: .set(to: proFeatures), - using: dependencies - ) - - needsUpdateProfile = true - - default: break + } } - - syncState.update( - rotatingKeyPair: .set(to: rotatingKeyPair), - state: .set(to: updatedState) - ) - self.rotatingKeyPair = rotatingKeyPair - await self.stateStream.send(updatedState) - - if needsUpdateProfile { - try await Profile.updateLocal( - proFeatures: syncState.state.profileFeatures, - using: dependencies - ) + + await updateWithLatestFromUserConfig() + } + + /// `revoked` from a proof response is authoritative and terminal — clear regardless of validity, no `E` + /// write (a horizon is meaningless once revoked). Mirrors the revocation-list path (§6.4). + private func applyProofRevoked() async { + try? await dependencies[singleton: .storage].write { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.removeProConfig() + } + } } - /// Just in case we refresh the pro state (this will avoid needless requests based on the current state but will resolve other - /// edge-cases since it's the main driver to the Pro state) - try? await refreshProState() + await updateWithLatestFromUserConfig() } - + // MARK: - Pro State Management private func updateProState(to newState: SessionPro.State) async { @@ -668,42 +841,26 @@ public actor SessionProManager: SessionProManagerType { syncState.update(state: .set(to: updatedState)) await self.stateStream.send(updatedState) oldState = updatedState - - switch response.status { - case .active, .expired: - try await refreshProProofIfNeeded( - currentProof: updatedState.proof, - accessExpiryTimestampSeconds: (updatedState.accessExpiryTimestampSeconds ?? 0), - autoRenewing: updatedState.autoRenewing, - status: updatedState.status - ) - - // Authoritative "never had Pro" — clear any local/synced Pro state. - case .never: - try await clearStateFromConfig( - accessExpiryTimestampSeconds: updatedState.accessExpiryTimestampSeconds - ) - // Non-destructive: `clearStateFromConfig` writes the SYNCED user config, so clearing on an - // unrecognised status would erase a still-valid proof across ALL of the user's devices - // (e.g. a future backend status an older client doesn't know). Fail-closed applies to - // *granting* Pro, not to *destroying* synced data — entitlement is governed by the proof's - // own signature + expiry, so we leave the proof exactly as-is here (and do NOT refresh/grant). - case .unknown(let code): - Log.warn(.sessionPro, "Unrecognised backend pro status '\(code)'; leaving the existing proof untouched.") - } - + /// `get_pro_status` is DISPLAY-ONLY now (Rev 2 §1.3): it refreshes auto-renew / grace / refund / + /// access-expiry fields but does NOT mint or clear the proof. The proof lifecycle — generate on + /// due, and the §4 clears on `subscription_expired` / `not_subscribed` / `revoked` — is owned + /// entirely by the reconcile loop's `generate_pro_proof` path, so we just kick a reconcile here + /// (a status change may make a renewal / acquisition due). + updatedState = oldState.with( loadingState: .set(to: .success), using: dependencies ) - + syncState.update(state: .set(to: updatedState)) await self.stateStream.send(updatedState) oldState = updatedState - + startStoreKitEntitlementsObservations() await entitlementsObservingTask?.value + + await reconcileProofRenewal() } catch { Log.error(.sessionPro, "Failed to retrieve pro status due to error(s): \(error)") @@ -718,106 +875,6 @@ public actor SessionProManager: SessionProManagerType { } } - private func refreshProProofIfNeeded( - currentProof: Network.SessionPro.ProProof?, - accessExpiryTimestampSeconds: UInt64, - autoRenewing: Bool, - status: Network.SessionPro.BackendUserProStatus - ) async throws { - let needsNewProof: Bool = { - let sixtyMinutesInSeconds: UInt64 = (60 * 60) - - guard let currentProof else { return true } - /// Proof expiry and access expiry are both whole unix seconds now. The network clock is the app's - /// millisecond clock, so convert it to seconds once for the comparison. - let nowSeconds: UInt64 = (dependencies.networkOffsetTimestampMs() / 1000) - guard - accessExpiryTimestampSeconds > sixtyMinutesInSeconds && - currentProof.expiryUnixTimestampSeconds > sixtyMinutesInSeconds - else { return autoRenewing } - - let sixtyMinutesBeforeAccessExpiry: UInt64 = (accessExpiryTimestampSeconds - sixtyMinutesInSeconds) - let sixtyMinutesBeforeProofExpiry: UInt64 = (currentProof.expiryUnixTimestampSeconds - sixtyMinutesInSeconds) - - return ( - sixtyMinutesBeforeProofExpiry < nowSeconds && - nowSeconds < sixtyMinutesBeforeAccessExpiry && - autoRenewing - ) - }() - - /// Only generate a new proof if we need one - guard status == .active && needsNewProof else { - try await dependencies[singleton: .storage].write { [dependencies] db in - try dependencies.mutate(cache: .libSession) { cache in - try cache.performAndPushChange(db, for: .userProfile) { _ in - cache.updateProAccessExpiryTimestampSeconds(accessExpiryTimestampSeconds) - } - } - } - return - } - - let rotatingKeyPair: KeyPair = try ( - self.rotatingKeyPair ?? - dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) - ) - - let request = try Network.SessionPro.generateProProof( - masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), - rotatingKeyPair: rotatingKeyPair, - using: dependencies - ) - let response: Network.SessionPro.AddProPaymentOrGenerateProProofResponse = try await request - .send(using: dependencies) - - guard response.header.isSuccess else { - let diagnostic: String = (response.header.error ?? response.header.errorCode ?? "unknown error") - Log.error(.sessionPro, "Failed to generate new pro proof due to error(s): \(diagnostic)") - throw SessionProError.generateProProofFailed(response.header.userFacingMessage) - } - - /// Send the proof and status events on the streams - /// - /// **Note:** We can assume that the users status is `active` since they just successfully generated a pro proof - let proofIsActive: Bool = proProofIsActive( - for: response.proof, - atTimestampMs: await dependencies.networkOffsetTimestampMs() - ) - let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) - let oldState: SessionPro.State = await stateStream.getCurrent() - let updatedState: SessionPro.State = oldState.with( - status: .set(to: proStatus), - using: dependencies - ) - - syncState.update( - rotatingKeyPair: .set(to: rotatingKeyPair), - state: .set(to: updatedState) - ) - self.rotatingKeyPair = rotatingKeyPair - await self.stateStream.send(updatedState) - - /// Update the config and trigger a local update - try await Profile.updateLocal( - proFeatures: syncState.state.profileFeatures, - using: dependencies - ) - try await dependencies[singleton: .storage].write { [dependencies] db in - try dependencies.mutate(cache: .libSession) { cache in - try cache.performAndPushChange(db, for: .userProfile) { _ in - cache.updateProConfig( - proConfig: SessionPro.ProConfig( - rotatingPrivateKey: rotatingKeyPair.secretKey, - proProof: response.proof - ) - ) - cache.updateProAccessExpiryTimestampSeconds(accessExpiryTimestampSeconds) - } - } - } - } - @MainActor public func cancelPro(scene: UIWindowScene) async throws { do { try await AppStore.showManageSubscriptions(in: scene) @@ -830,17 +887,19 @@ public actor SessionProManager: SessionProManagerType { } @MainActor public func requestRefund(scene: UIWindowScene) async throws { - guard let latestPaymentItem: Network.SessionPro.PaymentItem = await stateStream.getCurrent().latestPaymentItem else { + let currentState: SessionPro.State = await stateStream.getCurrent() + + guard let latestPaymentItem: Network.SessionPro.PaymentItem = currentState.latestPaymentItem else { throw SessionProError.noLatestPaymentItem } - - /// User has already requested a refund for this item - guard latestPaymentItem.refundRequestedTimestampSeconds == 0 else { + + /// User has already requested a refund — this is config-synced state now, not a per-payment field. + guard currentState.refundRequestedTimestampSeconds == 0 else { throw SessionProError.refundAlreadyRequestedForLatestPayment } - - /// Only Apple support refunding via this mechanism so no point continuing if we don't have a `appleTransactionId` - guard let transactionId: String = latestPaymentItem.appleTransactionId else { + + /// Only Apple supports refunding via this mechanism, so no point continuing without an Apple transaction + guard latestPaymentItem.appleTransactionId != nil else { throw SessionProError.nonOriginatedLatestPayment } @@ -878,25 +937,21 @@ public actor SessionProManager: SessionProManagerType { } } - /// The network clock is milliseconds; the refund-requested time is whole seconds like the rest of Pro. + /// Refund state is config-synced now (there's no `set_payment_refund_requested` backend call): record + /// the request in the user config via `user_profile_set_refund_requested`, which propagates to the + /// user's other devices. The network clock is milliseconds; refund-requested time is whole seconds + /// like the rest of Pro. let refundRequestedTimestampSeconds: UInt64 = await syncState.dependencies.networkOffsetTimestampMs() / 1000 - let request = try Network.SessionPro.setPaymentRefundRequested( - transactionId: transactionId, - refundRequestedTimestampSeconds: refundRequestedTimestampSeconds, - masterKeyPair: try syncState.dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), - using: syncState.dependencies - ) - let response: Network.SessionPro.SetPaymentRefundRequestedResponse = try await request - .send(using: syncState.dependencies) - - guard response.header.isSuccess else { - let diagnostic: String = (response.header.error ?? response.header.errorCode ?? "unknown error") - Log.error(.sessionPro, "Refund submission failed due to error(s): \(diagnostic)") - throw SessionProError.refundFailed(response.header.userFacingMessage) + try await syncState.dependencies[singleton: .storage].write { [dependencies = syncState.dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.updateRefundRequested(refundRequestedTimestampSeconds) + } + } } - - /// Need to refresh the pro state to get the updated payment item (which should now include a `refundRequestedTimestampSeconds`) - try await refreshProState() + + /// Re-read from the config so the (now config-backed) refund-pending flag lands in our state. + await updateWithLatestFromUserConfig() } // MARK: - Internal Functions @@ -1071,6 +1126,15 @@ public actor SessionProManager: SessionProManagerType { guard !Task.isCancelled else { return } await self?.scheduleNextProInvalidation() + + /// Returning to the foreground is also our robust proof-renewal trigger: re-run the + /// reconcile so a renewal target that elapsed while suspended is caught before the + /// user can send. (Badge rescheduling above handles other profiles; this handles + /// our own proof.) Gated to the lifecycle key so a contact's status change — the + /// other observed key — doesn't thrash the renewal task. + if key == .appLifecycle(.willEnterForeground) { + await self?.reconcileProofRenewal() + } } } } @@ -1124,7 +1188,12 @@ public actor SessionProManager: SessionProManagerType { } syncState.update(revocationList: .set(to:response.items)) - + + /// §6.4: the revocation-list path is authoritative — if the current user's OWN proof is + /// now revoked, clear the credential (regardless of expiry/validity). Otherwise a + /// revoked-but-unexpired proof would keep passing the expiry-only checks and get attached. + await clearOwnCredentialIfRevoked() + /// Send out a notification that the revocations list was updated, in case something wants to immediately respond await dependencies.notify( key: .proRevocationListUpdated, @@ -1156,12 +1225,19 @@ public actor SessionProManager: SessionProManagerType { do { switch result { case .verified(let transaction): - /// Register the payment with the Session Pro backend **before** finishing the transaction - `finish()` - /// tells StoreKit the transaction is fully handled so it won't be redelivered via `Transaction.updates`, - /// which would mean a failure here permanently loses a paid entitlement - try await addProPayment(transactionId: "\(transaction.id)") + /// Redemption is implicit now — there's no add-payment call. A verified transaction + /// (a renewal, or a purchase completed on another device) just needs the entitlement + /// pulled through: mark the purchase in-flight, refresh the display state, and let the + /// reconcile loop request the proof (`refreshProState` also kicks a reconcile at its + /// end, and the dark path binds a payment the backend hasn't yet). + /// + /// Record the marker (durably dumped by the config write) BEFORE `finish()`, so a + /// crash in between lets StoreKit redeliver the transaction rather than losing it — + /// `finish()` only stops that redelivery, it doesn't affect the payment. + try await markPurchaseInFlight() await transaction.finish() - break + try? await refreshProState() + await reconcileProofRenewal() case .unverified(_, let error): Log.error(.sessionPro, "Received an unverified transaction update: \(error)") @@ -1205,17 +1281,35 @@ public actor SessionProManager: SessionProManagerType { startStoreKitEntitlementsObservations() } - private func clearStateFromConfig(accessExpiryTimestampSeconds: UInt64?) async throws { - try await dependencies[singleton: .storage].write { [dependencies] db in + /// §6.4 self-revocation clear: if the current user's own proof's revocation tag is on the (now-updated) + /// revocation list with an effective instant that has passed, remove the credential — authoritative, + /// regardless of expiry. No-op when we hold no proof or it isn't revoked. + private func clearOwnCredentialIfRevoked() async { + let nowSeconds: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + + let ownProofRevocationTagHex: String? = dependencies.mutate(cache: .libSession) { + $0.proConfig?.proProof.revocationTag.toHexString() + } + + guard let ownProofRevocationTagHex: String = ownProofRevocationTagHex else { return } + + let isRevoked: Bool = syncState.revocationList.contains { item in + TimeInterval(item.effectiveTimestampSeconds) <= nowSeconds && + item.revocationTag.toHexString() == ownProofRevocationTagHex + } + + guard isRevoked else { return } + + Log.warn(.sessionPro, "Own Pro proof was revoked; clearing the credential.") + try? await dependencies[singleton: .storage].write { [dependencies] db in try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile) { _ in cache.removeProConfig() - - /// We should also update the `accessExpiryTimestampSeconds` stored in the config just in case - cache.updateProAccessExpiryTimestampSeconds(accessExpiryTimestampSeconds ?? 0) } } } + + await updateWithLatestFromUserConfig() } } @@ -1277,7 +1371,6 @@ public protocol SessionProManagerType: SessionProUIManagerType { func updateWithLatestFromUserConfig() async func purchasePro(productId: String) async throws - func addProPayment(transactionId: String) async throws func refreshProState(forceLoadingState: Bool) async throws @MainActor func requestRefund(scene: UIWindowScene) async throws @MainActor func cancelPro(scene: UIWindowScene) async throws diff --git a/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift index 89481903f5..3a0f3f9a54 100644 --- a/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift +++ b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift @@ -1376,12 +1376,11 @@ extension SessionProSettingsViewModel { originatingPlatform: state.proState.originatingPlatform, isNonOriginatingAccount: (state.proState.originatingAccount == .nonOriginatingAccount), requestedAt: { - guard - let refundRequestedTimestampSeconds = state.proState.latestPaymentItem?.refundRequestedTimestampSeconds, - refundRequestedTimestampSeconds > 0 - else { - return nil - } + /// Refund-pending is config-synced state now (`state.refundRequestedTimestampSeconds`), + /// not a per-payment backend field. + let refundRequestedTimestampSeconds = state.proState.refundRequestedTimestampSeconds + + guard refundRequestedTimestampSeconds > 0 else { return nil } return Date(timeIntervalSince1970: Double(refundRequestedTimestampSeconds)) }() diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift index 55867939fe..f0d9bda630 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift @@ -6,23 +6,21 @@ import SessionUtil public extension SessionPro { enum FeatureStatus: Equatable { case success - case utfDecodingError case exceedsCharacterLimit - + var libSessionValue: SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS { switch self { case .success: return SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS - case .utfDecodingError: return SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR case .exceedsCharacterLimit: return SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT } } - + init(_ libSessionValue: SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS) { switch libSessionValue { - case SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS: self = .success - case SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR: self = .utfDecodingError case SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT: self = .exceedsCharacterLimit - default: self = .utfDecodingError + /// Only `SUCCESS`/`EXCEEDS_CHARACTER_LIMIT` exist now (the message text — and thus any + /// decoding error — is no longer passed to libsession); default to `.success`. + default: self = .success } } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift index e7449ff5d6..8471992fb6 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift @@ -9,24 +9,19 @@ public extension SessionPro { public let status: FeatureStatus public let error: String? public let features: MessageFeatures - public let codePointCount: Int - - static let invalidString: FeaturesForMessage = FeaturesForMessage(status: .utfDecodingError) - + // MARK: - Initialization - - init(status: FeatureStatus, error: String? = nil, features: MessageFeatures = [], codePointCount: Int = 0) { + + init(status: FeatureStatus, error: String? = nil, features: MessageFeatures = []) { self.status = status self.error = error self.features = features - self.codePointCount = codePointCount } - + init(_ libSessionValue: session_protocol_pro_features_for_msg) { status = FeatureStatus(libSessionValue.status) error = libSessionValue.get(\.error, nullIfEmpty: true) features = MessageFeatures(libSessionValue.bitset) - codePointCount = libSessionValue.codepoint_count } } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProState.swift b/SessionMessagingKit/SessionPro/Types/SessionProState.swift index 6d90788484..18b845b88c 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProState.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProState.swift @@ -25,6 +25,10 @@ public extension SessionPro { public let autoRenewing: Bool public let nextAutoRenewingTimestampSeconds: UInt64? public let accessExpiryTimestampSeconds: UInt64? + /// Unix seconds at which a refund was requested (config-synced via `user_profile_get_refund_requested`), + /// or `0` if none. Refund-pending is no longer a per-payment backend field — it's cross-device config + /// state — so the manager reads it from libsession and stashes it here to drive `refundingStatus`. + public let refundRequestedTimestampSeconds: UInt64 public let latestPaymentItem: Network.SessionPro.PaymentItem? public let originatingPlatform: SessionProUI.ClientPlatform public let originatingAccount: SessionPro.OriginatingAccount @@ -50,6 +54,7 @@ public extension SessionPro.State { autoRenewing: false, nextAutoRenewingTimestampSeconds: nil, accessExpiryTimestampSeconds: 0, + refundRequestedTimestampSeconds: 0, latestPaymentItem: nil, originatingPlatform: .iOS, originatingAccount: .originatingAccount, @@ -69,6 +74,7 @@ internal extension SessionPro.State { autoRenewing: Update = .useExisting, nextAutoRenewingTimestampSeconds: Update = .useExisting, accessExpiryTimestampSeconds: Update = .useExisting, + refundRequestedTimestampSeconds: Update = .useExisting, latestPaymentItem: Update = .useExisting, using dependencies: Dependencies ) -> SessionPro.State { @@ -123,13 +129,16 @@ internal extension SessionPro.State { } }() + let finalRefundRequestedTimestampSeconds: UInt64 = refundRequestedTimestampSeconds.or(self.refundRequestedTimestampSeconds) let finalRefundingStatus: SessionPro.RefundingStatus = { switch dependencies[feature: .mockCurrentUserSessionProRefundingStatus] { case .simulate(let mockedValue): return mockedValue case .useActual: + /// Refund-pending is config-synced state now (not a per-payment backend field); the + /// manager reads `user_profile_get_refund_requested` into `refundRequestedTimestampSeconds`. return SessionPro.RefundingStatus( finalStatus == .active && - (finalLatestPaymentItem?.refundRequestedTimestampSeconds ?? 0) > 0 + finalRefundRequestedTimestampSeconds > 0 ) } }() @@ -147,6 +156,7 @@ internal extension SessionPro.State { autoRenewing: autoRenewing.or(self.autoRenewing), nextAutoRenewingTimestampSeconds: finalNextAutoRenewingTimestampSeconds, accessExpiryTimestampSeconds: finalAccessExpiryTimestampSeconds, + refundRequestedTimestampSeconds: finalRefundRequestedTimestampSeconds, latestPaymentItem: finalLatestPaymentItem, originatingPlatform: finalOriginatingPlatform, originatingAccount: finalOriginatingAccount, diff --git a/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift b/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift index 91de36b115..0bc8ff1f37 100644 --- a/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift +++ b/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift @@ -35,9 +35,10 @@ public extension SessionProPaymentScreenContent.SessionProPlanPaymentFlow { self = .refund( originatingPlatform: state.originatingPlatform, isNonOriginatingAccount: (state.originatingAccount == .nonOriginatingAccount), - requestedAt: (state.latestPaymentItem?.refundRequestedTimestampSeconds).map { - Date(timeIntervalSince1970: Double($0)) - } + requestedAt: (state.refundRequestedTimestampSeconds > 0 ? + Date(timeIntervalSince1970: Double(state.refundRequestedTimestampSeconds)) : + nil + ) ) // This should only happen when the pro status is mocking diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index 2b5750d32c..fdd450494f 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -203,7 +203,22 @@ class MockLibSessionCache: LibSessionCacheType, Mockable { func updateProAccessExpiryTimestampSeconds(_ proAccessExpiryTimestampSeconds: UInt64) { handler.mockNoReturn(args: [proAccessExpiryTimestampSeconds]) } - + + var refundRequestedTimestampSeconds: UInt64 { handler.mock() } + var proPrepaidTimestampSeconds: UInt64 { handler.mock() } + + func updateRefundRequested(_ refundRequestedTimestampSeconds: UInt64) { + handler.mockNoReturn(args: [refundRequestedTimestampSeconds]) + } + + func updateProPrepaid(_ proPrepaidTimestampSeconds: UInt64) { + handler.mockNoReturn(args: [proPrepaidTimestampSeconds]) + } + + func proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: Int64) -> Int64 { + handler.mock(args: [nowUnixTimestampSeconds]) + } + func canPerformChange( threadId: String, threadVariant: SessionThread.Variant, diff --git a/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift b/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift deleted file mode 100644 index 04d002e6d7..0000000000 --- a/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtil -import SessionUtilitiesKit - -public extension Network.SessionPro { - struct AddProPaymentOrGenerateProProofResponse: Equatable { - public let header: ResponseHeader - public let proof: ProProof - - /// Parse the RAW response bytes via libsession. The client never inspects or assumes the wire - /// format — the request is fetched as raw `Data` and handed straight to libsession's parser - /// (a single parser covers both add-payment and generate-proof — each returns a proof). - public init(parsing data: Data) { - var result = data.withUnsafeBytes { bytes in - session_pro_backend_pro_proof_response_parse( - bytes.baseAddress?.assumingMemoryBound(to: CChar.self), - data.count - ) - } - defer { session_pro_backend_pro_proof_response_free(&result) } - - self.header = ResponseHeader(result.header) - self.proof = ProProof(result.proof) - } - } -} diff --git a/SessionNetworkingKit/SessionPro/Requests/GenerateProProofResponse.swift b/SessionNetworkingKit/SessionPro/Requests/GenerateProProofResponse.swift new file mode 100644 index 0000000000..be44eeb250 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/GenerateProProofResponse.swift @@ -0,0 +1,61 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + /// Parsed `generate_pro_proof` response. Redemption is implicit now (there's no `/add_pro_payment`); + /// the backend binds an account's unbound payments on any master-signed request, so a `generate_pro_proof` + /// is all the client sends after a purchase — this is the sole consumer of libsession's proof parser. + struct GenerateProProofResponse: Equatable { + public let header: ResponseHeader + public let proof: ProProof + /// The account's true, grace-inclusive entitlement end (unix seconds), or `0` if this response + /// carries no horizon. Advisory + unsigned (not an entitlement authority, not in the proof sig) — + /// used only to refresh the cached access expiry `E` for display / preemptive-renewal timing. + /// Present on a successful proof and on `subscription_expired` (a now-past value); `0` otherwise. + public let accountExpiryTimestampSeconds: UInt64 + + /// The Rev 2 §4 ON_COMPLETE outcome, derived from the header. Success carries a fresh proof + + /// account expiry; the failure slugs drive config clears; anything unrecognised — including a + /// backend fault (`status == ERROR`) — is `transient`, i.e. fail closed non-destructively (the + /// opaque-value discipline: an unknown `error_code` slug must never trigger a destructive write). + public enum Outcome: Equatable { + case success + case subscriptionExpired + case notSubscribed + case revoked + case transient + } + + // stringlint:ignore_contents + public var outcome: Outcome { + guard !header.isSuccess else { return .success } + + switch header.errorCode { + case "subscription_expired": return .subscriptionExpired + case "not_subscribed": return .notSubscribed + case "revoked": return .revoked + default: return .transient + } + } + + /// Parse the RAW response bytes via libsession. The client never inspects or assumes the wire + /// format — the request is fetched as raw `Data` and handed straight to libsession's parser. + public init(parsing data: Data) { + var result = data.withUnsafeBytes { bytes in + session_pro_backend_pro_proof_response_parse( + bytes.baseAddress?.assumingMemoryBound(to: CChar.self), + data.count + ) + } + defer { session_pro_backend_pro_proof_response_free(&result) } + + self.header = ResponseHeader(result.header) + self.proof = ProProof(result.proof) + /// Whole unix seconds; `0` = absent. + self.accountExpiryTimestampSeconds = UInt64(max(0, result.account_expiry_ts)) + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift b/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift index 4a9f40fecc..48e1d4f7a9 100644 --- a/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift +++ b/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift @@ -15,7 +15,6 @@ public extension Network.SessionPro { public let autoRenewing: Bool public let expiryTimestampSeconds: UInt64 public let gracePeriodDurationSeconds: UInt64 - public let refundRequestedTimestampSeconds: UInt64 /// The most recent payment, or `nil` when the account has none (`has_latest_payment == false`). public let latestPaymentItem: PaymentItem? @@ -40,7 +39,7 @@ public extension Network.SessionPro { /// Whole unix seconds on the wire and in our domain — direct assigns, no conversion. self.expiryTimestampSeconds = UInt64(max(0, result.expiry_ts)) self.gracePeriodDurationSeconds = UInt64(max(0, result.grace_period_duration)) - self.refundRequestedTimestampSeconds = UInt64(max(0, result.refund_requested_ts)) + /// `refund_requested_ts` is gone from the response — refund-pending is config-synced state now. self.latestPaymentItem = (result.has_latest_payment ? PaymentItem(result.latest_payment) : nil) } } diff --git a/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift b/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift deleted file mode 100644 index 672ae6b0fb..0000000000 --- a/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtil -import SessionUtilitiesKit - -public extension Network.SessionPro { - struct SetPaymentRefundRequestedResponse: Equatable { - public let header: ResponseHeader - public let updated: Bool - - /// Parse the RAW response bytes via libsession — the client never inspects/assumes the wire. - public init(parsing data: Data) { - var result = data.withUnsafeBytes { bytes in - session_pro_backend_set_payment_refund_requested_response_parse( - bytes.baseAddress?.assumingMemoryBound(to: CChar.self), - data.count - ) - } - defer { session_pro_backend_set_payment_refund_requested_response_free(&result) } - - self.header = ResponseHeader(result.header) - self.updated = result.updated - } - } -} diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift index 95342258ff..4e21f30863 100644 --- a/SessionNetworkingKit/SessionPro/SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -20,24 +20,14 @@ public extension Network.SessionPro { Task { do { - let addProProofRequest = try? Network.SessionPro.addProPayment( - transactionId: "12345678", - masterKeyPair: masterKeyPair, - rotatingKeyPair: rotatingKeyPair, - overallTimeout: 5, - using: dependencies - ) - let addProProofResponse: AddProPaymentOrGenerateProProofResponse? = try await addProProofRequest? - .send(using: dependencies) - let proProofRequest = try? Network.SessionPro.generateProProof( masterKeyPair: masterKeyPair, rotatingKeyPair: rotatingKeyPair, using: dependencies ) - let proProofResponse: AddProPaymentOrGenerateProProofResponse? = try await proProofRequest? + let proProofResponse: GenerateProProofResponse? = try await proProofRequest? .send(using: dependencies) - + let proStatusRequest = try? Network.SessionPro.getProStatus( masterKeyPair: masterKeyPair, using: dependencies @@ -53,7 +43,6 @@ public extension Network.SessionPro { .send(using: dependencies) await MainActor.run { - let tmp1 = addProProofResponse let tmp2 = proProofResponse let tmp3 = proStatusResponse let tmp4 = proRevocationsResponse @@ -66,55 +55,18 @@ public extension Network.SessionPro { } } - static func addProPayment( - transactionId: String, - masterKeyPair: KeyPair, - rotatingKeyPair: KeyPair, - overallTimeout: TimeInterval, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - let masterPrivateKey: [UInt8] = masterKeyPair.secretKey - let rotatingPrivateKey: [UInt8] = rotatingKeyPair.secretKey - /// App Store transaction id is the opaque `payment_id` verbatim (single-part provider) - let paymentId: [UInt8] = Array(transactionId.utf8) - /// libsession builds the entire request — signs it, serialises it, and pairs the endpoint + - /// content-type. We relay `endpoint`/`content_type`/`body` verbatim and never touch the wire. - let proRequest: ProRequest = try ProRequest { - session_pro_backend_add_pro_payment_request_build( - masterPrivateKey, - masterPrivateKey.count, - rotatingPrivateKey, - rotatingPrivateKey.count, - PaymentProvider.appStore.code, - paymentId, - paymentId.count - ) - } - - return try Network.PreparedRequest( - request: try Request( - method: .post, - endpoint: proRequest.endpoint, - headers: [.contentType: proRequest.contentType], - body: proRequest.body, - overallTimeout: overallTimeout, - using: dependencies - ), - responseType: Data.self, - using: dependencies - ) - /// Response bytes go straight to libsession's parser — no Codable/JSON on our side - .map { _, data in AddProPaymentOrGenerateProProofResponse(parsing: data) } - } - - /// Generate a pro proof for the provided `rotatingKeyPair` + /// Generate a pro proof for the provided `rotatingKeyPair`. /// - /// **Note:** If the user doesn't currently have an active Session Pro subscription then this will return an error + /// Redemption is implicit: the Pro backend binds the account's unbound payments on any master-signed + /// request, so after a purchase the client simply requests a proof here (there's no `/add_pro_payment`). + /// + /// **Note:** If the user doesn't currently have an active Session Pro subscription (and no in-flight + /// payment to bind) then this will return an error. static func generateProProof( masterKeyPair: KeyPair, rotatingKeyPair: KeyPair, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { let masterPrivateKey: [UInt8] = masterKeyPair.secretKey let rotatingPrivateKey: [UInt8] = rotatingKeyPair.secretKey let timestampSeconds: Int64 = Int64(dependencies.networkOffsetTimestampMs() / 1000) @@ -139,9 +91,9 @@ public extension Network.SessionPro { responseType: Data.self, using: dependencies ) - .map { _, data in AddProPaymentOrGenerateProProofResponse(parsing: data) } + .map { _, data in GenerateProProofResponse(parsing: data) } } - + static func getProStatus( masterKeyPair: KeyPair, using dependencies: Dependencies @@ -192,40 +144,4 @@ public extension Network.SessionPro { ) .map { _, data in GetProRevocationsResponse(parsing: data) } } - - static func setPaymentRefundRequested( - transactionId: String, - refundRequestedTimestampSeconds: UInt64, - masterKeyPair: KeyPair, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - let masterPrivateKey: [UInt8] = masterKeyPair.secretKey - /// The request signing time — the network clock is milliseconds, converted once to whole seconds. - let timestampSeconds: Int64 = Int64(dependencies.networkOffsetTimestampMs() / 1000) - let paymentId: [UInt8] = Array(transactionId.utf8) - let proRequest: ProRequest = try ProRequest { - session_pro_backend_set_payment_refund_requested_request_build( - masterPrivateKey, - masterPrivateKey.count, - timestampSeconds, - Int64(refundRequestedTimestampSeconds), - PaymentProvider.appStore.code, - paymentId, - paymentId.count - ) - } - - return try Network.PreparedRequest( - request: try Request( - method: .post, - endpoint: proRequest.endpoint, - headers: [.contentType: proRequest.contentType], - body: proRequest.body, - using: dependencies - ), - responseType: Data.self, - using: dependencies - ) - .map { _, data in SetPaymentRefundRequestedResponse(parsing: data) } - } } diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift b/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift index 609999b687..f8d41c9a3d 100644 --- a/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift +++ b/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift @@ -19,7 +19,6 @@ public extension Network.SessionPro { public let gracePeriodDurationSeconds: UInt64 public let platformRefundExpiryTimestampSeconds: UInt64 public let revokedTimestampMs: UInt64 - public let refundRequestedTimestampSeconds: UInt64 /// Opaque payment identifier (the value passed at add-payment). Multi-part providers fold their /// parts into this one string per a backend-defined composite; libsession does not interpret it. @@ -54,7 +53,8 @@ public extension Network.SessionPro { gracePeriodDurationSeconds = UInt64(max(0, libSessionValue.grace_period_duration)) platformRefundExpiryTimestampSeconds = UInt64(max(0, libSessionValue.platform_refund_expiry_ts)) revokedTimestampMs = UInt64(max(0, libSessionValue.revoked_ts) * 1000) - refundRequestedTimestampSeconds = UInt64(max(0, libSessionValue.refund_requested_ts)) + /// `refund_requested_ts` is gone from the payment item — refund-pending is config-synced state + /// now (`user_profile_get_refund_requested`), not a per-payment backend field. paymentId = libSessionValue.get(\.payment_id) }