Path: blob/main/SignalServiceKit/Subscriptions/Backups/BackupSubscriptionRedemptionContext.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
final class BackupSubscriptionRedemptionContext: Codable {
/// Represents the state of an in-progress attempt to redeem a subscription.
///
/// It's important that we update this over the duration of the job, because
/// (generally) once we've made a receipt-credential-related request to the
/// server remote state has been set. If the app exits between making two
/// requests, we need to have stored the data we sent in the first request
/// so we can retry the second.
///
/// Much like for donations, there are two network requests required to
/// redeem a receipt credential for a Backups subscription.
///
/// The first is to "request a receipt credential", which takes a
/// locally-generated "receipt credential request" and returns us data we
/// can use to construct a "receipt credential presentation". Once we have
/// the receipt credential presentation, we can discard the receipt
/// credential request.
///
/// The second is to "redeem the receipt credential", which sends the
/// receipt credential presentation from the first request to the service,
/// which validates it and subsequently records that our account is now
/// eligible (or has extended its eligibility) for paid-tier Backups. When
/// this completes, the attempt is complete.
enum RedemptionAttemptState {
/// This attempt is at a clean slate.
case unattempted
/// We need to request a receipt credential, using the associated
/// request and context objects.
///
/// Note that it is safe to request a receipt credential multiple times,
/// as long as the request/context are the same across retries. Receipt
/// credential requests do not expire, and the returned receipt
/// credential will always correspond to the latest entitling
/// transaction.
case receiptCredentialRequesting(
request: ReceiptCredentialRequest,
context: ReceiptCredentialRequestContext,
)
/// We have a receipt credential, and need to redeem it.
///
/// Note that it is safe to attempt to redeem a receipt credential
/// multiple times for the same subscription period.
case receiptCredentialRedemption(ReceiptCredential)
}
let subscriberId: Data
/// `nil` for legacy contexts.
let subscriptionEndOfCurrentPeriod: Date?
var attemptState: RedemptionAttemptState
init(
subscriberId: Data,
subscriptionEndOfCurrentPeriod: Date,
) {
self.subscriberId = subscriberId
self.subscriptionEndOfCurrentPeriod = subscriptionEndOfCurrentPeriod
self.attemptState = .unattempted
}
// MARK: -
private enum StoreKeys {
static let context = "context"
}
private static let kvStore = KeyValueStore(collection: "BackupSubscriptionRedemptionContext")
static func fetch(tx: DBReadTransaction) -> BackupSubscriptionRedemptionContext? {
guard let jsonData = kvStore.getData(StoreKeys.context, transaction: tx) else {
return nil
}
do {
return try JSONDecoder().decode(BackupSubscriptionRedemptionContext.self, from: jsonData)
} catch {
owsFailDebug("Failed to decode context! \(error)")
return nil
}
}
func upsert(tx: DBWriteTransaction) {
let jsonData: Data
do {
jsonData = try JSONEncoder().encode(self)
} catch {
owsFailDebug("Failed to encode context! \(error)")
return
}
Self.kvStore.setData(jsonData, key: StoreKeys.context, transaction: tx)
}
func delete(tx: DBWriteTransaction) {
Self.kvStore.removeValue(forKey: StoreKeys.context, transaction: tx)
}
// MARK: - Codable
private enum CodingKeys: String, CodingKey {
case subscriberId
case subscriptionEndOfCurrentPeriod
case receiptCredentialRequest
case receiptCredentialRequestContext
case receiptCredential
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.subscriberId = try container.decode(Data.self, forKey: .subscriberId)
self.subscriptionEndOfCurrentPeriod = try container.decodeIfPresent(Date.self, forKey: .subscriptionEndOfCurrentPeriod)
if
let requestData = try container.decodeIfPresent(Data.self, forKey: .receiptCredentialRequest),
let contextData = try container.decodeIfPresent(Data.self, forKey: .receiptCredentialRequestContext)
{
attemptState = .receiptCredentialRequesting(
request: try ReceiptCredentialRequest(contents: requestData),
context: try ReceiptCredentialRequestContext(contents: contextData),
)
} else if
let credentialData = try container.decodeIfPresent(Data.self, forKey: .receiptCredential)
{
attemptState = .receiptCredentialRedemption(
try ReceiptCredential(contents: credentialData),
)
} else {
attemptState = .unattempted
}
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(subscriberId, forKey: .subscriberId)
try container.encodeIfPresent(subscriptionEndOfCurrentPeriod, forKey: .subscriptionEndOfCurrentPeriod)
switch attemptState {
case .receiptCredentialRequesting(let request, let context):
try container.encode(request.serialize(), forKey: .receiptCredentialRequest)
try container.encode(context.serialize(), forKey: .receiptCredentialRequestContext)
case .receiptCredentialRedemption(let credential):
try container.encode(credential.serialize(), forKey: .receiptCredential)
case .unattempted:
break
}
}
}