ui: buy a badge in the app through the App Store or Google Play (#7608)

* ui: buy a badge in the app through the App Store or Google Play

* core, ios, android: create the badge store purchase record before the store charges

* core, ios, android: fixes from the store lane review

* ios, android: show a store purchase's badge only on the profile it was credited to
This commit is contained in:
spaced4ndy authored and GitHub committed 2026-10-01 08:46:35 +00:00
1 parent 0ee34926bc
commit e3e4709381
38 files changed
+1607 -343

No files matched your search

+47 -9
View File
@@ -2196,11 +2196,44 @@ func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user:
throw r.unexpected
}
enum BadgePurchaseResult {
case redeemed(user: User, badgeState: BadgeState?)
}
// log: false because a store receipt is a bearer secret, like a badge code - it is in the command.
// nil when the user cancels the retry alert, which is offered only when retry is set.
func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, _ payment: ServicePayment, retry: Bool) async throws -> BadgePurchaseResult? {
let cmd = ChatCommand.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: payment)
let r: APIResult<ChatResponse2>?
if retry {
r = await chatApiSendCmdWithRetry(cmd, log: false)
} else {
let res: APIResult<ChatResponse2> = await chatApiSendCmd(cmd, log: false)
r = res
}
guard let r else { return nil }
switch r {
case let .result(.badgeRedeemed(user, _, _, badgeState)): return .redeemed(user: user, badgeState: badgeState)
default: throw r.unexpected
}
}
func apiCreateBadgeInvoice(_ userId: Int64) async throws -> String {
let r: ChatResponse2 = try await chatSendCmd(.apiCreateBadgeInvoice(userId: userId))
if case let .badgeInvoice(_, invoiceId) = r { return invoiceId }
throw r.unexpected
}
func apiCloseBadgeInvoice(_ userId: Int64, _ invoiceId: String) async throws {
try await sendCommandOkResp(.apiCloseBadgeInvoice(userId: userId, invoiceId: invoiceId))
}
// localized where the user can act on it; otherwise the error itself, so a screenshot says what happened
func redeemErrorText(_ error: Error) -> String {
if case let .error(.badgeRedeemError(e)) = error as? ChatError {
switch e {
case .invalidCode: return NSLocalizedString("This code is not valid.", comment: "alert message")
case .invalidReceipt: break
case .serviceNotConfigured: return NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message")
case .badgeActive: return NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message")
case let .serviceError(code): if let text = badgeServiceErrorText(code) { return text }
@@ -2212,15 +2245,15 @@ func redeemErrorText(_ error: Error) -> String {
return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
}
func apiGetBadgeState(_ userId: Int64) async throws -> BadgeState? {
func apiGetBadgeState(_ userId: Int64) async throws -> (badgeState: BadgeState?, storePurchases: [BadgeStorePurchase]) {
let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeState(userId: userId))
if case let .badgeState(_, badgeState) = r { return badgeState }
if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) }
throw r.unexpected
}
func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? {
func apiGetBadgeStateSync(_ userId: Int64) throws -> (badgeState: BadgeState?, storePurchases: [BadgeStorePurchase]) {
let r: ChatResponse2 = try chatSendCmdSync(.apiGetBadgeState(userId: userId))
if case let .badgeState(_, badgeState) = r { return badgeState }
if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) }
throw r.unexpected
}
@@ -2232,7 +2265,7 @@ func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws -
func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? {
let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode))
if case let .badgeState(_, badgeState) = r { return badgeState }
if case let .badgeState(_, badgeState, _) = r { return badgeState }
throw r.unexpected
}
@@ -2361,6 +2394,7 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws
ChatReceiver.shared.start()
m.chatRunning = true
chatLastStartGroupDefault.set(Date.now)
Task { await BadgeStore.shared.presentUnfinished() }
}
func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
@@ -2427,17 +2461,21 @@ func getUserChatData() throws {
private func loadBadgeState() {
do {
let userId = try currentUserId("loadBadgeState")
let badgeState = try apiGetBadgeStateSync(userId)
let (badgeState, storePurchases) = try apiGetBadgeStateSync(userId)
BadgeModel.shared.set(userId: userId, badgeState: badgeState)
BadgeStore.shared.setStorePurchases(userId, storePurchases)
} catch let error {
logger.error("loadBadgeState: \(responseError(error))")
}
}
private func loadBadgeStateAsync(_ userId: Int64) async {
func loadBadgeStateAsync(_ userId: Int64) async {
do {
let badgeState = try await apiGetBadgeState(userId)
await MainActor.run { BadgeModel.shared.set(userId: userId, badgeState: badgeState) }
let (badgeState, storePurchases) = try await apiGetBadgeState(userId)
await MainActor.run {
BadgeModel.shared.set(userId: userId, badgeState: badgeState)
BadgeStore.shared.setStorePurchases(userId, storePurchases)
}
} catch let error {
logger.error("loadBadgeState: \(responseError(error))")
}