From fd4d27dbd8d91cbf1688ba4140c2292638e690d2 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 19:05:20 -0300 Subject: [PATCH 1/6] =?UTF-8?q?Pro:=20redemption=20reflow=20=E2=80=94=20im?= =?UTF-8?q?plicit=20redemption,=20libsession-owned=20renewal/rotation,=20c?= =?UTF-8?q?onfig-synced=20refund/prepaid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns iOS with the 5-repo Session Pro "redemption reflow" (against libsession 58bafc4a). 1. /add_pro_payment is gone — redemption is implicit. Deleted addProPayment (and the purchase retry loop + the Transaction.updates add-payment call); after a purchase the client just requests a proof via generate_pro_proof and the backend binds the account's unbound payments on any master-signed request. Removed the setPaymentRefundRequested and add_pro_payment request builders and the now-dead SetPaymentRefundRequestedResponse; the AddProPaymentOrGenerateProProofResponse type collapses to GenerateProProofResponse. 2. Rotating seed + renewal owned by libsession. Rotating key is now the deterministic weekly seed via session_protocol_pro_rotating_seed (new sessionProRotatingKeyPair generator), replacing the ad-hoc self.rotatingKeyPair ?? ed25519KeyPair(). Renewal timing polls user_profile_get_pro_renewal_target(now) (<= now ⇒ renew) instead of the buggy autoRenewing-gated needsNewProof logic. 3. Refund state is config-synced. requestRefund records it via user_profile_set_refund_requested after Apple's beginRefundRequest (no backend call); the pending flag reads from user_profile_get_refund_requested. Dropped refund_requested_ts from PaymentItem / GetProStatusResponse and carry it on SessionPro.State (drives refundingStatus). 4. pro_prepaid "purchase in flight" marker set in purchasePro's .success branch (user_profile_set_pro_prepaid) so other devices poll the entitlement through. 5. Client-side prepaid poll (startPrepaidPoll): while pro_prepaid is set and not-yet-Pro, poll with a capped exponential backoff; stops when the marker clears (entitlement landed, already Pro, or libsession's one-week staleness gate). 6. §4 C-API: dropped the removed sent_timestamp_ms arg from session_protocol_encode_for_community_inbox. (iOS never calls session_encrypt_for_group / session_decrypt_group_message, and encode_for_group is unchanged, so nothing else to do.) Adds the refund/prepaid/renewal accessors to LibSessionCacheType (+ no-op defaults + mock). Built green (app + tests) via Session_CompileLibSession. Co-Authored-By: Claude Opus 4.8 (1M context) --- Session.xcodeproj/project.pbxproj | 12 +- .../DeveloperSettingsProViewModel.swift | 20 +- .../Crypto/Crypto+LibSession.swift | 24 +- .../LibSession+UserProfile.swift | 42 +++ .../LibSession+SessionMessagingKit.swift | 14 +- .../SessionPro/SessionProManager.swift | 321 +++++++----------- .../SessionProSettingsViewModel.swift | 11 +- .../SessionPro/Types/SessionProState.swift | 12 +- .../Utilities/SessionPro+Convenience.swift | 7 +- .../_TestUtilities/MockLibSessionCache.swift | 17 +- ...e.swift => GenerateProProofResponse.swift} | 8 +- .../Requests/GetProStatusResponse.swift | 3 +- .../SetPaymentRefundRequestedResponse.swift | 26 -- .../SessionPro/SessionProAPI.swift | 106 +----- .../SessionPro/Types/PaymentItem.swift | 4 +- 15 files changed, 271 insertions(+), 356 deletions(-) rename SessionNetworkingKit/SessionPro/Requests/{AddProPaymentOrGenerateProProofResponse.swift => GenerateProProofResponse.swift} (68%) delete mode 100644 SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift 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/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..6844a29cb2 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,27 @@ public extension Crypto.Generator { return try UUID(sessionProMasterPublicKey: masterKeyPair.publicKey) } } + + /// Deterministically derive the weekly rotating Session Pro keypair for the rotation period containing + /// `nowUnixTimestampSeconds`. libsession (`session_protocol_pro_rotating_seed`) floors `now` to its + /// rotation period and derives the same 32-byte seed on every device of the account, 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..8f329299dd 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -36,6 +36,7 @@ public actor SessionProManager: SessionProManagerType { private var proMockingObservationTask: Task? private var proInvalidationTask: Task? private var appLifecycleObservingTask: Task? + private var prepaidPollTask: Task? /// The instant up to which we have already emitted "this profile's pro state just went stale" events /// @@ -104,6 +105,7 @@ public actor SessionProManager: SessionProManagerType { proMockingObservationTask?.cancel() proInvalidationTask?.cancel() appLifecycleObservingTask?.cancel() + prepaidPollTask?.cancel() } public func ensureInitialized() async { @@ -360,10 +362,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 +396,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 +407,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 ) } + + /// A purchase in flight (possibly initiated on another device — the marker is config-synced) that + /// hasn't landed yet means we should be polling the backend to pull the entitlement through. + if proInfo.prepaidTimestampSeconds > 0 && proStatus != .active { + startPrepaidPoll() + } } 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 prepaid poll + /// pull the entitlement through (redemption is implicit — there's no add-payment call). + try await markPurchaseInFlight() + startPrepaidPoll() + 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,146 +450,82 @@ 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 - } - - /// Small incremental backoff before trying again - try await Task.sleep(for: .milliseconds(index * 300)) - } - } + + /// 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 + /// client-side prepaid poll (item 5) requests a proof via `generate_pro_proof`. + try await markPurchaseInFlight() await transaction.finish() + startPrepaidPoll() } - - 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) - } - - // `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 + /// 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.updateProConfig( - proConfig: SessionPro.ProConfig( - rotatingPrivateKey: rotatingKeyPair.secretKey, - proProof: response.proof - ) - ) + cache.updateProPrepaid(nowSeconds) } } } - - /// 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) + } + + /// Poll for the entitlement while a purchase is in flight. libsession won't own this cadence (its + /// `pro_renewal_target` returns "now" whenever there's no proof, so it can't throttle us), so we use a + /// capped exponential backoff and stop as soon as the prepaid marker clears — the entitlement landed, + /// we became Pro, or libsession's one-week staleness gate expired the marker. Foreground-oriented: a + /// suspended app won't fire `Task.sleep`, and a fresh poll is kicked again on the next relevant event. + private func startPrepaidPoll() { + prepaidPollTask?.cancel() + prepaidPollTask = Task { [weak self] in + guard let self else { return } + var delaySeconds: Int = 2 + let maxDelaySeconds: Int = 120 + + while !Task.isCancelled { + let prepaidTimestampSeconds: UInt64 = dependencies.mutate(cache: .libSession) { + $0.proPrepaidTimestampSeconds } - - 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 - ) - } - /// 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() + /// Marker cleared (entitlement landed / stale-gated), or we're already Pro → nothing to poll. + guard prepaidTimestampSeconds > 0, !currentUserIsCurrentlyPro else { return } + + /// Requesting the status drives `generate_pro_proof` (implicit redemption binds the in-flight + /// payment); on success the proof lands, libsession clears the prepaid marker, and we exit. + try? await refreshProState() + + guard !currentUserIsCurrentlyPro else { return } + + do { try await Task.sleep(for: .seconds(delaySeconds)) } + catch { return } + + delaySeconds = min(maxDelaySeconds, (delaySeconds * 2)) + } + } } // MARK: - Pro State Management @@ -672,9 +622,7 @@ public actor SessionProManager: SessionProManagerType { switch response.status { case .active, .expired: try await refreshProProofIfNeeded( - currentProof: updatedState.proof, accessExpiryTimestampSeconds: (updatedState.accessExpiryTimestampSeconds ?? 0), - autoRenewing: updatedState.autoRenewing, status: updatedState.status ) @@ -719,33 +667,20 @@ 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 - ) - }() - + /// libsession owns the renewal decision now — this replaces the old bespoke `autoRenewing`-gated + /// logic, which never renewed non-auto-renewing or expired proofs. Given `now`, `pro_renewal_target` + /// returns the unix timestamp at which a renewal should be attempted: `<= now` means renew now, a + /// future value would schedule a preemptive renewal (we re-evaluate it on each refresh rather than + /// arming a dedicated timer), and `0` means no renewal is needed. + let nowSeconds: Int64 = Int64(await dependencies.networkOffsetTimestampMs() / 1000) + let renewalTarget: Int64 = dependencies.mutate(cache: .libSession) { + $0.proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: nowSeconds) + } + let needsNewProof: Bool = (renewalTarget != 0 && renewalTarget <= nowSeconds) + /// Only generate a new proof if we need one guard status == .active && needsNewProof else { try await dependencies[singleton: .storage].write { [dependencies] db in @@ -757,18 +692,18 @@ public actor SessionProManager: SessionProManagerType { } return } - - let rotatingKeyPair: KeyPair = try ( - self.rotatingKeyPair ?? - dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) - ) - + + /// Deterministic weekly rotating key (every device derives the same one for the rotation period, so + /// concurrent regenerations converge) — owned by libsession now, not a random ad-hoc key. + let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto] + .tryGenerate(.sessionProRotatingKeyPair(nowUnixTimestampSeconds: nowSeconds)) + 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 + let response: Network.SessionPro.GenerateProProofResponse = try await request .send(using: dependencies) guard response.header.isSuccess else { @@ -830,17 +765,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 +815,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 @@ -1156,12 +1089,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 (which regenerates the proof + /// via the renewal target for renewals), and start the prepaid poll for a payment the + /// backend hasn't bound 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() + startPrepaidPoll() case .unverified(_, let error): Log.error(.sessionPro, "Received an unverified transaction update: \(error)") @@ -1277,7 +1217,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/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/GenerateProProofResponse.swift similarity index 68% rename from SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift rename to SessionNetworkingKit/SessionPro/Requests/GenerateProProofResponse.swift index 04d002e6d7..464dc591fb 100644 --- a/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift +++ b/SessionNetworkingKit/SessionPro/Requests/GenerateProProofResponse.swift @@ -5,13 +5,15 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - struct AddProPaymentOrGenerateProProofResponse: Equatable { + /// 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 /// 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). + /// 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( 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) } From a1a04a7250fa8889a390f78ee3e94c3e1dcee582 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 23:39:55 -0300 Subject: [PATCH 2/6] Pro: native codepoint counting for pro_features_for_message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libsession's session_protocol_pro_features_for_utf8/_utf16 are gone (as of a4809f7c), replaced by session_protocol_pro_features_for_message(codepoint_count) — it no longer inspects the text; the caller passes the codepoint count. - messageFeatures(for:): pass message.unicodeScalars.count (the Unicode codepoint count for an always-valid Swift String; surrogate pair = 1, matching libsession's simdutf path) instead of round-tripping the UTF-8 bytes through libsession to count. - numberOfCharactersLeft(for:): collapse to characterLimit - content.unicodeScalars.count (no messageFeatures round-trip). - FeatureStatus: drop .utfDecodingError (and its UTF_DECODING_ERROR mappings) — there's no text for libsession to fail decoding on; only .success / .exceedsCharacterLimit remain (unknown defaults to .success). - FeaturesForMessage: drop codePointCount (output field removed from the C struct — it only echoed the input) and the now-unreachable .invalidString. - ConversationViewModel: drop the .utfDecodingError case and its .utf16.count fallback. NOTE: iOS verification uses Session_CompileLibSession (source at a4809f7c), so this is NOT gated on the SPM re-publish (task #24) — that only affects the default scheme / CI. Built green (app + tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Conversations/ConversationViewModel.swift | 8 ------ .../SessionPro/SessionProManager.swift | 25 ++++++------------- .../Types/SessionProFeatureStatus.swift | 12 ++++----- .../Types/SessionProFeaturesForMessage.swift | 13 +++------- 4 files changed, 16 insertions(+), 42 deletions(-) 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/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 8f329299dd..2a643ee375 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -117,16 +117,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( @@ -141,15 +135,10 @@ public actor SessionProManager: SessionProManagerType { } 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) ) } 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 } } } From 54a85cfc37efcc316539997b09c9a1cc7d56321e Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 23:58:09 -0300 Subject: [PATCH 3/6] Cleanup: delete dead UIKit InputView orphan (superseded by SessionUIKit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session/Conversations/Input View/InputView.swift was tracked in git but had no fileRef in Session.xcodeproj, so it was never compiled — a leftover from promoting the input view into SessionUIKit as a public class. It also referenced LibSession.numberOfCharactersLeft(for:isSessionPro:), which exists nowhere in the tree (only invisible because the file never compiled). The live input view is SessionUIKit/Components/Input View/InputView.swift. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Conversations/Input View/InputView.swift | 695 ------------------ 1 file changed, 695 deletions(-) delete mode 100644 Session/Conversations/Input View/InputView.swift 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) -} From 0c049bbd44ba79e0271b1f696dce81155ac84086 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 13:34:56 -0300 Subject: [PATCH 4/6] Pro: reword rotating-seed comments to not assert libsession's rotation window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client passes raw `now` into session_protocol_pro_rotating_seed and does no window/period arithmetic — but two of my comments characterized the schedule as 'weekly' / 'rotation period'. The rotation schedule is libsession-owned and may change; reword to 'the seed for now, libsession owns the schedule'. Comment-only; no code change. (Mirrors betwixt's android/desktop wording pass.) Co-Authored-By: Claude Opus 4.8 (1M context) --- SessionMessagingKit/Crypto/Crypto+LibSession.swift | 11 ++++++----- .../SessionPro/SessionProManager.swift | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 6844a29cb2..ed633e177e 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -438,11 +438,12 @@ public extension Crypto.Generator { } } - /// Deterministically derive the weekly rotating Session Pro keypair for the rotation period containing - /// `nowUnixTimestampSeconds`. libsession (`session_protocol_pro_rotating_seed`) floors `now` to its - /// rotation period and derives the same 32-byte seed on every device of the account, 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. + /// 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", diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 2a643ee375..f6c151fe4a 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -682,8 +682,8 @@ public actor SessionProManager: SessionProManagerType { return } - /// Deterministic weekly rotating key (every device derives the same one for the rotation period, so - /// concurrent regenerations converge) — owned by libsession now, not a random ad-hoc key. + /// Deterministic rotating key for `now` (libsession owns the rotation schedule; every device derives + /// the same seed for the same `now`, so concurrent regenerations converge) — not a random ad-hoc key. let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto] .tryGenerate(.sessionProRotatingKeyPair(nowUnixTimestampSeconds: nowSeconds)) From 0224661caebf7863495bc595bc535bbaa1785f0c Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 17:04:35 -0300 Subject: [PATCH 5/6] Pro: foreground-anchored proof renewal + never-attach-expired-proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the single-idle-device renewal gap (valid subscription, proof near/at expiry, nothing was triggering a renewal) using the settled cross-client reconcile contract. Renewal (scheduleNextProofRenewal): deterministic reconcile driven entirely by libsession's pro_renewal_target(now) — no client-side window or jitter. Due (<= now) → refreshProState (re-confirms the backend subscription status, since a lapsed local proof flips inferred status to .expired while the subscription is still active — then regenerates the proof); future → Task.sleep to the target; retry-on-failure keyed on proof validity (dark 15s / covered 60s, no backoff). Loop state is ephemeral (re-derived from config each pass), so suspension / process death recover on the next reconcile; refreshProState's single-flight guard prevents two generate_pro_proof in flight. Triggers: willEnterForeground (the robust one — iOS suspends the process so a Task.sleep across a background period is untrustworthy; the foreground re-run catches anything that lapsed while suspended, before the user can send), plus init and updateWithLatestFromUserConfig (cross-device proof updates). The in-foreground Task.sleep is the secondary path for the app-stays-open case. Send rule 1: attachProInfoIfNeeded now attaches a currently-valid proof or NONE (proProofIsActive guard) — never a known-expired one. Sending is never blocked by a lapsed proof; the message just goes out without Pro metadata. startPrepaidPoll still owns the purchase-in-flight (dark/no-proof) case; it folds into this loop once libsession's renewal_target entitlement/prepaid gate lands (deferred per betwixt). Built green (app + tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SessionPro/SessionProManager.swift | 98 ++++++++++++++++++- 1 file changed, 95 insertions(+), 3 deletions(-) diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index f6c151fe4a..3a0fdc6085 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -37,6 +37,7 @@ public actor SessionProManager: SessionProManagerType { private var proInvalidationTask: Task? private var appLifecycleObservingTask: Task? private var prepaidPollTask: Task? + private var proofRenewalTask: Task? /// The instant up to which we have already emitted "this profile's pro state just went stale" events /// @@ -93,7 +94,10 @@ public actor SessionProManager: SessionProManagerType { if dependencies[singleton: .appContext].isMainApp { try? await self?.refreshProState() } - + + /// Arm the foreground-anchored proof-renewal reconcile (main-app-gated inside) + await self?.scheduleNextProofRenewal() + await self?.hasCompletedInitialization.send(true) } } @@ -106,6 +110,7 @@ public actor SessionProManager: SessionProManagerType { proInvalidationTask?.cancel() appLifecycleObservingTask?.cancel() prepaidPollTask?.cancel() + proofRenewalTask?.cancel() } public func ensureInitialized() async { @@ -229,7 +234,14 @@ 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 1: a message carries a currently-valid proof or NONE — never a known-expired one. + /// 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. + proProofIsActive( + for: proof, + 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")") @@ -413,6 +425,9 @@ public actor SessionProManager: SessionProManagerType { if proInfo.prepaidTimestampSeconds > 0 && proStatus != .active { startPrepaidPoll() } + + /// Re-arm the renewal reconcile against the (possibly cross-device-updated) proof / renewal target. + scheduleNextProofRenewal() } public func purchasePro(productId: String) async throws { @@ -516,7 +531,75 @@ public actor SessionProManager: SessionProManagerType { } } } - + + /// Foreground-anchored proof renewal — the deterministic reconcile loop (shared cross-client contract). + /// + /// libsession's `pro_renewal_target(now)` owns *whether/when* to renew, with NO client-side window or + /// jitter: `<= now` ⇒ renew now, a future value ⇒ renew at that instant, `0` ⇒ no renewal needed. + /// + /// iOS suspends the process, so a `Task.sleep` targeting a far-off instant is unreliable across a + /// background period — we do NOT trust it. The robust trigger is `willEnterForeground` (and init / + /// config sync), which re-invoke this and catch anything that lapsed while suspended. The in-foreground + /// `Task.sleep` below is the secondary path for when the app stays foregrounded across the instant. + /// All loop state is ephemeral (re-derived from config each pass), so process death / suspension both + /// recover on the next reconcile. `refreshProState` is single-flighted (`isRefreshingState`), so this + /// can't race the other refresh triggers into two `generate_pro_proof`s. + /// + /// **Note:** the separate `startPrepaidPoll` still owns the purchase-in-flight ("dark", no-proof) case + /// until libsession's `renewal_target` gate lands (returning `<= now` only when there's a real + /// entitlement or `pro_prepaid`); at that point the prepaid poll folds into this loop. + private func scheduleNextProofRenewal() { + proofRenewalTask?.cancel() + + guard dependencies[feature: .sessionProEnabled] else { return } + guard dependencies[singleton: .appContext].isMainApp else { return } + + proofRenewalTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + + let nowSeconds: Int64 = Int64(await dependencies.networkOffsetTimestampMs() / 1000) + let target: Int64 = dependencies.mutate(cache: .libSession) { + $0.proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: nowSeconds) + } + + /// No renewal needed → nothing to arm; a foreground / config trigger restarts us if that changes. + guard target != 0 else { return } + + /// Future target → sleep until then (reliable only while foregrounded), then re-evaluate. + if target > nowSeconds { + do { try await Task.sleep(for: .seconds(Int(target - nowSeconds))) } + catch { return } /// cancelled — whoever cancelled reschedules + continue + } + + /// Due now. `refreshProState` re-confirms the *backend* subscription status (the local proof + /// may have lapsed, flipping our inferred status to `.expired`, while the subscription is + /// still active) and regenerates the proof when appropriate. + let hadValidProof: Bool = currentUserIsCurrentlyPro + do { try await refreshProState() } + catch { + /// Due-but-failed (network). Retry at the contract cadence keyed on proof validity — + /// dark (no/expired proof) → 15s, covered (valid proof, preemptive) → 60s — no backoff. + do { try await Task.sleep(for: .seconds(hadValidProof ? 60 : 15)) } + catch { return } + continue + } + + /// Reconcile succeeded. Re-derive the target: a real renewal pushes it into the future (→ + /// re-arm the timer next loop); `0` means done. If it's somehow still `<= now` (e.g. a + /// non-active account whose proof can't be minted — expected pre-gate), exit rather than + /// hot-spin; a later foreground / config trigger will re-run us if the situation changes. + let newNowSeconds: Int64 = Int64(await dependencies.networkOffsetTimestampMs() / 1000) + let newTarget: Int64 = dependencies.mutate(cache: .libSession) { + $0.proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: newNowSeconds) + } + + guard newTarget != 0 && newTarget > newNowSeconds else { return } + } + } + } + // MARK: - Pro State Management private func updateProState(to newState: SessionPro.State) async { @@ -993,6 +1076,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?.scheduleNextProofRenewal() + } } } } From aab9b480d3f5d1224289875b6a2dbaa84cd54055 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 19:57:12 -0300 Subject: [PATCH 6/6] =?UTF-8?q?Pro:=20renewal-loop=20fold-in=20to=20Rev=20?= =?UTF-8?q?2=20(pure=20generate,=20=C2=A74=20ON=5FCOMPLETE,=20prepaid=20fo?= =?UTF-8?q?lded,=20=C2=A76=20revocation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the iOS renewal loop into the agreed cross-client design-of-record (Rev 2), against libsession 078c611b (gated pro_renewal_target + account_expiry_ts on the proof response). Reconcile loop (§2): reconcileProofRenewal is now the event-driven RECONCILE — reads pro_renewal_target(now) (NONE→dormant / future→one wake / <=now→due), with ephemeral lastRequestAt + darkAttempt spacing. Covered spaces flat 60s; dark uses the linear backoff min(15s×darkAttempt, 900s) (covered resets darkAttempt). Single-flight is best-effort via the spacing check — overlap accepted (§1.9; the §4 monotonic merge no-ops a byte-identical late duplicate). The advisory in-foreground wake re-runs reconcile; the guarantee is the trigger set (willEnterForeground / config-sync / init / purchase / Transaction.updates). No unarmed-exit (the gate returns NONE for non-entitled), no durable state. Renewal action (§1.3): pure generate_pro_proof — refreshProState/get_pro_status is display-only now (auto-renew/grace/refund fields) and no longer mints or clears the proof. Preserves the ensureNetworkConnection wait so a connectivity outage recovers promptly rather than backoff-lagged. ON_COMPLETE (§4), reads live config inside one atomic mutation: success → replace proof iff new.expiry > current.expiry + write E←account_expiry (ungated, H4); subscription_expired / not_subscribed → clear iff no unexpired proof (leaves pro_prepaid, §7.3); revoked → terminal clear, no E; transient / unrecognized → nothing (opaque-value fail-closed). Prepaid poll folded in: removed startPrepaidPoll — a pending purchase surfaces as renewal_target<=now (dark path) via the gate. purchasePro / Transaction.updates now markPurchaseInFlight + reconcile. Send (§6): attach requires unexpired AND unrevoked (currentUserProofIsValid); the revocation-list job clears our own credential when our own proof is revoked (clearOwnCredentialIfRevoked). GenerateProProofResponse gains accountExpiryTimestampSeconds + the typed Outcome (success/subscriptionExpired/notSubscribed/revoked/transient) from header.status + error_code slug. Built green (app + tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SessionPro/SessionProManager.swift | 523 ++++++++++-------- .../Requests/GenerateProProofResponse.swift | 31 ++ 2 files changed, 329 insertions(+), 225 deletions(-) diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 3a0fdc6085..607ad6b799 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -36,8 +36,14 @@ public actor SessionProManager: SessionProManagerType { private var proMockingObservationTask: Task? private var proInvalidationTask: Task? private var appLifecycleObservingTask: Task? - private var prepaidPollTask: Task? - private var proofRenewalTask: 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 /// @@ -95,8 +101,8 @@ public actor SessionProManager: SessionProManagerType { try? await self?.refreshProState() } - /// Arm the foreground-anchored proof-renewal reconcile (main-app-gated inside) - await self?.scheduleNextProofRenewal() + /// Kick the foreground-anchored proof-renewal reconcile (main-app-gated inside) + await self?.reconcileProofRenewal() await self?.hasCompletedInitialization.send(true) } @@ -109,8 +115,8 @@ public actor SessionProManager: SessionProManagerType { proMockingObservationTask?.cancel() proInvalidationTask?.cancel() appLifecycleObservingTask?.cancel() - prepaidPollTask?.cancel() - proofRenewalTask?.cancel() + proofRenewalWakeTask?.cancel() + proofGenerationTask?.cancel() } public func ensureInitialized() async { @@ -133,11 +139,30 @@ 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 { /// libsession no longer inspects the text — we pass the natively-counted codepoint count @@ -235,13 +260,10 @@ public actor SessionProManager: SessionProManagerType { featuresForMessage.features != .none ), let proof: Network.SessionPro.ProProof = syncState.state.proof, - /// Send rule 1: a message carries a currently-valid proof or NONE — never a known-expired one. + /// 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. - proProofIsActive( - for: proof, - atTimestampMs: syncState.dependencies.networkOffsetTimestampMs() - ) + 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")") @@ -420,22 +442,19 @@ public actor SessionProManager: SessionProManagerType { ) } - /// A purchase in flight (possibly initiated on another device — the marker is config-synced) that - /// hasn't landed yet means we should be polling the backend to pull the entitlement through. - if proInfo.prepaidTimestampSeconds > 0 && proStatus != .active { - startPrepaidPoll() - } - - /// Re-arm the renewal reconcile against the (possibly cross-device-updated) proof / renewal target. - scheduleNextProofRenewal() + /// 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 { - /// Dev shortcut: skip StoreKit and just mark the purchase in-flight, then let the prepaid poll + /// 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() - startPrepaidPoll() + await reconcileProofRenewal() return } @@ -478,10 +497,10 @@ public actor SessionProManager: SessionProManagerType { /// 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 - /// client-side prepaid poll (item 5) requests a proof via `generate_pro_proof`. + /// reconcile loop (dark path) requests a proof via `generate_pro_proof`, binding the payment. try await markPurchaseInFlight() await transaction.finish() - startPrepaidPoll() + await reconcileProofRenewal() } /// Records the "purchase in flight" marker in the synced user config so every device polls the @@ -498,106 +517,238 @@ public actor SessionProManager: SessionProManagerType { } } - /// Poll for the entitlement while a purchase is in flight. libsession won't own this cadence (its - /// `pro_renewal_target` returns "now" whenever there's no proof, so it can't throttle us), so we use a - /// capped exponential backoff and stop as soon as the prepaid marker clears — the entitlement landed, - /// we became Pro, or libsession's one-week staleness gate expired the marker. Foreground-oriented: a - /// suspended app won't fire `Task.sleep`, and a fresh poll is kicked again on the next relevant event. - private func startPrepaidPoll() { - prepaidPollTask?.cancel() - prepaidPollTask = Task { [weak self] in - guard let self else { return } - var delaySeconds: Int = 2 - let maxDelaySeconds: Int = 120 + // MARK: -- Proof Renewal (Rev 2 reconcile loop) - while !Task.isCancelled { - let prepaidTimestampSeconds: UInt64 = dependencies.mutate(cache: .libSession) { - $0.proPrepaidTimestampSeconds - } + /// 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 - /// Marker cleared (entitlement landed / stale-gated), or we're already Pro → nothing to poll. - guard prepaidTimestampSeconds > 0, !currentUserIsCurrentlyPro else { return } + guard dependencies[feature: .sessionProEnabled] else { return } + guard dependencies[singleton: .appContext].isMainApp else { return } - /// Requesting the status drives `generate_pro_proof` (implicit redemption binds the in-flight - /// payment); on success the proof lands, libsession clears the prepaid marker, and we exit. - try? await refreshProState() + let nowMs: UInt64 = await dependencies.networkOffsetTimestampMs() + let nowSeconds: Int64 = Int64(nowMs / 1000) + let now: TimeInterval = TimeInterval(nowSeconds) - guard !currentUserIsCurrentlyPro else { return } + let target: Int64 = dependencies.mutate(cache: .libSession) { + $0.proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: nowSeconds) + } - do { try await Task.sleep(for: .seconds(delaySeconds)) } - catch { return } + /// DORMANT: nothing entitled and no pending purchase. Reset backoff; only a TRIGGER re-enters. + guard target != 0 else { darkAttempt = 0; return } - delaySeconds = min(maxDelaySeconds, (delaySeconds * 2)) - } + /// 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) } - /// Foreground-anchored proof renewal — the deterministic reconcile loop (shared cross-client contract). - /// - /// libsession's `pro_renewal_target(now)` owns *whether/when* to renew, with NO client-side window or - /// jitter: `<= now` ⇒ renew now, a future value ⇒ renew at that instant, `0` ⇒ no renewal needed. - /// - /// iOS suspends the process, so a `Task.sleep` targeting a far-off instant is unreliable across a - /// background period — we do NOT trust it. The robust trigger is `willEnterForeground` (and init / - /// config sync), which re-invoke this and catch anything that lapsed while suspended. The in-foreground - /// `Task.sleep` below is the secondary path for when the app stays foregrounded across the instant. - /// All loop state is ephemeral (re-derived from config each pass), so process death / suspension both - /// recover on the next reconcile. `refreshProState` is single-flighted (`isRefreshingState`), so this - /// can't race the other refresh triggers into two `generate_pro_proof`s. - /// - /// **Note:** the separate `startPrepaidPoll` still owns the purchase-in-flight ("dark", no-proof) case - /// until libsession's `renewal_target` gate lands (returning `<= now` only when there's a real - /// entitlement or `pro_prepaid`); at that point the prepaid poll folds into this loop. - private func scheduleNextProofRenewal() { - proofRenewalTask?.cancel() + /// 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() + } + } - guard dependencies[feature: .sessionProEnabled] else { return } - guard dependencies[singleton: .appContext].isMainApp else { return } + private enum ProofGenerationOutcome { + case response(Network.SessionPro.GenerateProProofResponse, rotatingKeyPair: KeyPair) + case transient + } - proofRenewalTask = Task { [weak self] in - while !Task.isCancelled { - guard let self else { return } + /// 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 nowSeconds: Int64 = Int64(await dependencies.networkOffsetTimestampMs() / 1000) - let target: Int64 = dependencies.mutate(cache: .libSession) { - $0.proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: nowSeconds) - } + let outcome: ProofGenerationOutcome = await self.performProofGeneration( + nowUnixTimestampSeconds: nowUnixTimestampSeconds + ) + await self.onProofComplete(outcome) + } + } - /// No renewal needed → nothing to arm; a foreground / config trigger restarts us if that changes. - guard target != 0 else { return } + 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.") + }) - /// Future target → sleep until then (reliable only while foregrounded), then re-evaluate. - if target > nowSeconds { - do { try await Task.sleep(for: .seconds(Int(target - nowSeconds))) } - catch { return } /// cancelled — whoever cancelled reschedules - continue + 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 } + } - /// Due now. `refreshProState` re-confirms the *backend* subscription status (the local proof - /// may have lapsed, flipping our inferred status to `.expired`, while the subscription is - /// still active) and regenerates the proof when appropriate. - let hadValidProof: Bool = currentUserIsCurrentlyPro - do { try await refreshProState() } - catch { - /// Due-but-failed (network). Retry at the contract cadence keyed on proof validity — - /// dark (no/expired proof) → 15s, covered (valid proof, preemptive) → 60s — no backoff. - do { try await Task.sleep(for: .seconds(hadValidProof ? 60 : 15)) } - catch { return } - continue + 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 + 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) + } } + } + } + + /// 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 } - /// Reconcile succeeded. Re-derive the target: a real renewal pushes it into the future (→ - /// re-arm the timer next loop); `0` means done. If it's somehow still `<= now` (e.g. a - /// non-active account whose proof can't be minted — expected pre-gate), exit rather than - /// hot-spin; a later foreground / config trigger will re-run us if the situation changes. - let newNowSeconds: Int64 = Int64(await dependencies.networkOffsetTimestampMs() / 1000) - let newTarget: Int64 = dependencies.mutate(cache: .libSession) { - $0.proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: newNowSeconds) + cache.removeProConfig() + + if let accountExpiryTimestampSeconds: UInt64 = accountExpiryTimestampSeconds { + cache.updateProAccessExpiryTimestampSeconds(accountExpiryTimestampSeconds) + } } + } + } - guard newTarget != 0 && newTarget > newNowSeconds else { return } + 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() + } } } + + await updateWithLatestFromUserConfig() } // MARK: - Pro State Management @@ -690,40 +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( - accessExpiryTimestampSeconds: (updatedState.accessExpiryTimestampSeconds ?? 0), - 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)") @@ -738,93 +875,6 @@ public actor SessionProManager: SessionProManagerType { } } - private func refreshProProofIfNeeded( - accessExpiryTimestampSeconds: UInt64, - status: Network.SessionPro.BackendUserProStatus - ) async throws { - /// libsession owns the renewal decision now — this replaces the old bespoke `autoRenewing`-gated - /// logic, which never renewed non-auto-renewing or expired proofs. Given `now`, `pro_renewal_target` - /// returns the unix timestamp at which a renewal should be attempted: `<= now` means renew now, a - /// future value would schedule a preemptive renewal (we re-evaluate it on each refresh rather than - /// arming a dedicated timer), and `0` means no renewal is needed. - let nowSeconds: Int64 = Int64(await dependencies.networkOffsetTimestampMs() / 1000) - let renewalTarget: Int64 = dependencies.mutate(cache: .libSession) { - $0.proRenewalTargetTimestampSeconds(nowUnixTimestampSeconds: nowSeconds) - } - let needsNewProof: Bool = (renewalTarget != 0 && renewalTarget <= nowSeconds) - - /// 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 - } - - /// Deterministic rotating key for `now` (libsession owns the rotation schedule; every device derives - /// the same seed for the same `now`, so concurrent regenerations converge) — not a random ad-hoc key. - let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto] - .tryGenerate(.sessionProRotatingKeyPair(nowUnixTimestampSeconds: nowSeconds)) - - 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) - - 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) @@ -1083,7 +1133,7 @@ public actor SessionProManager: SessionProManagerType { /// 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?.scheduleNextProofRenewal() + await self?.reconcileProofRenewal() } } } @@ -1138,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, @@ -1172,9 +1227,9 @@ public actor SessionProManager: SessionProManagerType { case .verified(let transaction): /// 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 (which regenerates the proof - /// via the renewal target for renewals), and start the prepaid poll for a payment the - /// backend hasn't bound yet. + /// 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 — @@ -1182,7 +1237,7 @@ public actor SessionProManager: SessionProManagerType { try await markPurchaseInFlight() await transaction.finish() try? await refreshProState() - startPrepaidPoll() + await reconcileProofRenewal() case .unverified(_, let error): Log.error(.sessionPro, "Received an unverified transaction update: \(error)") @@ -1226,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() } } diff --git a/SessionNetworkingKit/SessionPro/Requests/GenerateProProofResponse.swift b/SessionNetworkingKit/SessionPro/Requests/GenerateProProofResponse.swift index 464dc591fb..be44eeb250 100644 --- a/SessionNetworkingKit/SessionPro/Requests/GenerateProProofResponse.swift +++ b/SessionNetworkingKit/SessionPro/Requests/GenerateProProofResponse.swift @@ -11,6 +11,35 @@ public extension Network.SessionPro { 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. @@ -25,6 +54,8 @@ public extension Network.SessionPro { self.header = ResponseHeader(result.header) self.proof = ProProof(result.proof) + /// Whole unix seconds; `0` = absent. + self.accountExpiryTimestampSeconds = UInt64(max(0, result.account_expiry_ts)) } } }