diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index 94b641ae2..e022ee418 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -685,7 +685,7 @@ extension TrekShellPlugin { // A held one that was delivered stops the purchase here. One that was paid // but never set up is handed back as bought, so its instance is set up now. if let held = await TrekStore.currentEntitlement(prefix: "cloud.") { - if await TrekStore.isUnfinished(held.id) { + if await TrekStore.awaitingSetup(held) { call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId]) } else { call.resolve(["kind": "subscribed"]) @@ -759,8 +759,8 @@ extension TrekShellPlugin { call.resolve(["held": false]) return } - let unfinished = await TrekStore.isUnfinished(held.id) - call.resolve(["held": !unfinished]) + let awaiting = await TrekStore.awaitingSetup(held) + call.resolve(["held": !awaiting]) } } } @@ -793,17 +793,54 @@ enum TrekStore { } } - static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64, productId: String)? { - var newest: (date: Date, jws: String, id: UInt64, productId: String)? + typealias Held = (jws: String, id: UInt64, productId: String, firstPurchase: Bool) + + /// The subscription this Apple ID holds under `prefix`, newest first. The + /// entitlements on the device come first; on a fresh install they can still + /// be empty while the subscription runs, so the status of the subscription + /// group, which StoreKit asks the App Store for, decides then. + static func currentEntitlement(prefix: String) async -> Held? { + var newest: (date: Date, held: Held)? for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result, transaction.productID.hasPrefix(prefix), transaction.revocationDate == nil else { continue } if newest == nil || transaction.purchaseDate > newest!.date { - newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id, transaction.productID) + newest = (transaction.purchaseDate, held(result.jwsRepresentation, transaction)) } } - return newest.map { (jws: $0.jws, id: $0.id, productId: $0.productId) } + if let newest { return newest.held } + return await subscriptionGroupHeld(prefix: prefix) + } + + /// The plans the control plane sells (`cloud..`, as + /// productIdFor in iap.ts); one of them is enough to find their group. + private static let plans = ["start", "basis", "plus", "pro"] + + private static func subscriptionGroupHeld(prefix: String) async -> Held? { + let ids = plans.flatMap { ["\(prefix)\($0).monthly", "\(prefix)\($0).yearly"] } + guard let products = try? await Product.products(for: ids), + let group = products.compactMap({ $0.subscription?.subscriptionGroupID }).first, + let statuses = try? await Product.SubscriptionInfo.status(for: group) else { return nil } + let running: [Product.SubscriptionInfo.RenewalState] = [.subscribed, .inGracePeriod, .inBillingRetryPeriod] + for status in statuses where running.contains(status.state) { + if case .verified(let transaction) = status.transaction, transaction.revocationDate == nil { + return held(status.transaction.jwsRepresentation, transaction) + } + } + return nil + } + + private static func held(_ jws: String, _ transaction: Transaction) -> Held { + (jws: jws, id: transaction.id, productId: transaction.productID, firstPurchase: transaction.originalID == transaction.id) + } + + /// A first purchase that was paid but whose instance was never set up: handed + /// back as bought so it is set up now. A renewal the device has not finished + /// yet is no such purchase; its subscription is running and counts as held. + static func awaitingSetup(_ held: Held) async -> Bool { + guard held.firstPurchase else { return false } + return await isUnfinished(held.id) } /// Paid but not delivered yet: the shell finishes a transaction once its instance exists.