mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-06 01:18:11 +00:00
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:
@@ -193,6 +193,9 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case apiStandaloneFileInfo(url: String)
|
||||
// badges
|
||||
case apiRedeemBadgeCode(userId: Int64, code: String)
|
||||
case apiPurchaseBadge(userId: Int64, echoedInvoiceId: String?, payment: ServicePayment)
|
||||
case apiCreateBadgeInvoice(userId: Int64)
|
||||
case apiCloseBadgeInvoice(userId: Int64, invoiceId: String)
|
||||
case apiGetBadgeState(userId: Int64)
|
||||
case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64)
|
||||
case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String)
|
||||
@@ -419,6 +422,10 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)"
|
||||
case let .apiStandaloneFileInfo(link): return "/_download info \(link)"
|
||||
case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)"
|
||||
case let .apiPurchaseBadge(userId, echoedInvoiceId, payment):
|
||||
return "/_badge purchase \(userId)\(echoedInvoiceId.map { " invoice=\($0)" } ?? "") \(encodeJSON(payment))"
|
||||
case let .apiCreateBadgeInvoice(userId): return "/_badge invoice \(userId)"
|
||||
case let .apiCloseBadgeInvoice(userId, invoiceId): return "/_badge invoice close \(userId) \(invoiceId)"
|
||||
case let .apiGetBadgeState(userId): return "/_badge state \(userId)"
|
||||
case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)"
|
||||
case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode):
|
||||
@@ -611,6 +618,9 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile"
|
||||
case .apiStandaloneFileInfo: return "apiStandaloneFileInfo"
|
||||
case .apiRedeemBadgeCode: return "apiRedeemBadgeCode"
|
||||
case .apiPurchaseBadge: return "apiPurchaseBadge"
|
||||
case .apiCreateBadgeInvoice: return "apiCreateBadgeInvoice"
|
||||
case .apiCloseBadgeInvoice: return "apiCloseBadgeInvoice"
|
||||
case .apiGetBadgeState: return "apiGetBadgeState"
|
||||
case .apiGetBadgeLedger: return "apiGetBadgeLedger"
|
||||
case .apiAckBadgeAlert: return "apiAckBadgeAlert"
|
||||
@@ -667,9 +677,13 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd))
|
||||
case let .testStorageEncryption(key):
|
||||
return .testStorageEncryption(key: obfuscate(key))
|
||||
// a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
|
||||
// a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
|
||||
case let .apiRedeemBadgeCode(userId, code):
|
||||
return .apiRedeemBadgeCode(userId: userId, code: obfuscate(code))
|
||||
case let .apiPurchaseBadge(userId, echoedInvoiceId, .apple(jws)):
|
||||
return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: .apple(jws: obfuscate(jws)))
|
||||
case let .apiPurchaseBadge(userId, echoedInvoiceId, .google(productId, token)):
|
||||
return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: .google(productId: productId, token: obfuscate(token)))
|
||||
default: return self
|
||||
}
|
||||
}
|
||||
@@ -719,6 +733,34 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// core parses it with a "type" tag, not the key-per-case form Swift would synthesize
|
||||
enum ServicePayment: Encodable {
|
||||
case apple(jws: String)
|
||||
case google(productId: String, token: String)
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case let .apple(jws):
|
||||
try container.encode("apple", forKey: .type)
|
||||
try container.encode(jws, forKey: .jws)
|
||||
case let .google(productId, token):
|
||||
try container.encode("google", forKey: .type)
|
||||
try container.encode(productId, forKey: .productId)
|
||||
try container.encode(token, forKey: .token)
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type, jws, productId, token
|
||||
}
|
||||
}
|
||||
|
||||
struct BadgeStorePurchase: Decodable {
|
||||
var invoiceId: String?
|
||||
var transactionRef: String?
|
||||
}
|
||||
|
||||
// ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient.
|
||||
// Spec: spec/api.md#ChatResponse0
|
||||
enum ChatResponse0: Decodable, ChatAPIResult {
|
||||
@@ -1051,7 +1093,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
// badges
|
||||
// the full user, not UserRef: its profile carries the badge that setUserBadge just stored
|
||||
case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?)
|
||||
case badgeState(user: UserRef, badgeState: BadgeState?)
|
||||
case badgeInvoice(user: UserRef, invoiceId: String)
|
||||
case badgeState(user: UserRef, badgeState: BadgeState?, storePurchases: [BadgeStorePurchase]?)
|
||||
case badgeLedger(user: UserRef, badgeLedger: [StatementEntry])
|
||||
|
||||
var responseType: String {
|
||||
@@ -1105,6 +1148,7 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
case .archiveImported: "archiveImported"
|
||||
case .appSettings: "appSettings"
|
||||
case .badgeRedeemed: "badgeRedeemed"
|
||||
case .badgeInvoice: "badgeInvoice"
|
||||
case .badgeState: "badgeState"
|
||||
case .badgeLedger: "badgeLedger"
|
||||
}
|
||||
@@ -1161,7 +1205,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
case let .archiveImported(archiveErrors): return String(describing: archiveErrors)
|
||||
case let .appSettings(appSettings): return String(describing: appSettings)
|
||||
case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))")
|
||||
case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState))
|
||||
case let .badgeInvoice(u, invoiceId): return withUser(u, invoiceId)
|
||||
case let .badgeState(u, badgeState, storePurchases): return withUser(u, "\(String(describing: badgeState))\nstorePurchases: \(String(describing: storePurchases ?? []))")
|
||||
case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))")
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ struct SimpleXApp: App {
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
await updateCallInvitations()
|
||||
}
|
||||
Task { await BadgeStore.shared.presentUnfinished() }
|
||||
if let url = chatModel.appOpenUrlLater {
|
||||
await MainActor.run {
|
||||
chatModel.appOpenUrlLater = nil
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import StoreKit
|
||||
import SimpleXChat
|
||||
|
||||
// TODO [badges] product ids will come from app config and prices from the badge service catalog;
|
||||
// hardcoded here so the App Store integration can be tested before the purchase API lands.
|
||||
@@ -27,14 +28,25 @@ let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in
|
||||
BadgePeriod.allCases.map { badgeProductId(level, $0) }
|
||||
}
|
||||
|
||||
// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id.
|
||||
// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction,
|
||||
// which is how the service learns which invoice a store transaction settles.
|
||||
// the only products sent to the badge service: nothing delivers a subscription yet
|
||||
let badgeOneTimeProductIds: Set<String> = Set(BadgeLevel.allCases.map { badgeProductId($0, .oneMonth) })
|
||||
|
||||
// subscriptions are for sale once renewals are delivered
|
||||
let badgePeriodsForSale: [BadgePeriod] = [.oneMonth]
|
||||
|
||||
// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core.
|
||||
// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction.
|
||||
func newBadgeInvoiceId() -> UUID { UUID() }
|
||||
|
||||
// the page's app flag rides in the fragment, which never reaches the service
|
||||
let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true"
|
||||
|
||||
// where the store allows a link out to the badge page: the US for now, a set expected to widen.
|
||||
// An unknown storefront does not count, as this decides whether the store sees a link out of the app.
|
||||
var badgeBrowserAllowed: Bool {
|
||||
SKPaymentQueue.default().storefront?.countryCode == "USA"
|
||||
}
|
||||
|
||||
enum BadgePrice {
|
||||
case loading
|
||||
case price(String)
|
||||
@@ -56,6 +68,10 @@ struct BadgeStoreReceipt {
|
||||
let invoiceId: UUID?
|
||||
let environment: String?
|
||||
let signatureVerified: Bool
|
||||
let transaction: Transaction
|
||||
|
||||
// core mints the id in lower case, and UUID formats it in upper case
|
||||
var echoedInvoiceId: String? { invoiceId?.uuidString.lowercased() }
|
||||
}
|
||||
|
||||
enum BadgePurchaseOutcome {
|
||||
@@ -64,9 +80,17 @@ enum BadgePurchaseOutcome {
|
||||
case cancelled
|
||||
}
|
||||
|
||||
enum BadgePurchaseState {
|
||||
case issuing
|
||||
case waitingForApproval
|
||||
case checking
|
||||
}
|
||||
|
||||
enum BadgeStoreError: Error {
|
||||
case productUnavailable(productId: String)
|
||||
case unknownPurchaseResult
|
||||
case noActiveProfile
|
||||
case invalidInvoiceId(String)
|
||||
}
|
||||
|
||||
final class BadgeStore: ObservableObject {
|
||||
@@ -76,9 +100,44 @@ final class BadgeStore: ObservableObject {
|
||||
|
||||
@Published private var state: LoadState = .notLoaded
|
||||
private var products: [String: Product] = [:]
|
||||
// one-time transactions the store holds unfinished: each is a payment taken and not yet credited
|
||||
@Published private var unfinished: [UInt64: BadgeStoreReceipt] = [:]
|
||||
// core's open store purchases for the profile they were read for: core knows whose a purchase is
|
||||
@Published private var storePurchases: (userId: Int64, purchases: [BadgeStorePurchase])? = nil
|
||||
// invoices whose purchase this session is still waiting on the store for
|
||||
@Published private var buying: Set<String> = []
|
||||
// kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing
|
||||
@Published private var waitingForApproval = false
|
||||
// until the store has been asked once, a purchase made while the app was not running is unknown
|
||||
@Published private var reconciledOnce = false
|
||||
private var presenting: Set<UInt64> = []
|
||||
private var transactionUpdates: Task<Void, Never>? = nil
|
||||
|
||||
private init() {}
|
||||
|
||||
func purchaseState(_ userId: Int64?) -> BadgePurchaseState? {
|
||||
let purchases = openStorePurchases(userId)
|
||||
let held = Set(unfinished.values.compactMap { $0.echoedInvoiceId })
|
||||
if purchases.contains(where: { $0.invoiceId.map(held.contains) == true }) { return .issuing }
|
||||
if waitingForApproval { return .waitingForApproval }
|
||||
if purchases.contains(where: { $0.transactionRef == nil && $0.invoiceId.map(buying.contains) != true }) { return .checking }
|
||||
return nil
|
||||
}
|
||||
|
||||
func canBuy(_ userId: Int64?) -> Bool {
|
||||
reconciledOnce && buying.isEmpty && purchaseState(userId) == nil
|
||||
&& !openStorePurchases(userId).contains(where: { $0.transactionRef == nil })
|
||||
}
|
||||
|
||||
func setStorePurchases(_ userId: Int64, _ purchases: [BadgeStorePurchase]) {
|
||||
storePurchases = (userId, purchases)
|
||||
}
|
||||
|
||||
private func openStorePurchases(_ userId: Int64?) -> [BadgeStorePurchase] {
|
||||
guard let storePurchases, storePurchases.userId == userId else { return [] }
|
||||
return storePurchases.purchases
|
||||
}
|
||||
|
||||
func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice {
|
||||
switch state {
|
||||
case .notLoaded, .loading: return .loading
|
||||
@@ -122,33 +181,168 @@ final class BadgeStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome {
|
||||
// A one-time purchase has its core record before the store charges, and every store outcome reaches it.
|
||||
func purchase(_ level: BadgeLevel, _ period: BadgePeriod) async throws -> (outcome: BadgePurchaseOutcome, invoiceId: UUID) {
|
||||
let productId = badgeProductId(level, period)
|
||||
guard let product = await MainActor.run(body: { products[productId] }) else {
|
||||
throw BadgeStoreError.productUnavailable(productId: productId)
|
||||
}
|
||||
switch try await product.purchase(options: [.appAccountToken(invoiceId)]) {
|
||||
case let .success(verification):
|
||||
let transaction: Transaction
|
||||
let signatureVerified: Bool
|
||||
switch verification {
|
||||
case let .verified(t):
|
||||
transaction = t
|
||||
signatureVerified = true
|
||||
case let .unverified(t, _):
|
||||
transaction = t
|
||||
signatureVerified = false
|
||||
// a subscription is never sent to core, so nothing would ever finish it later
|
||||
guard badgeOneTimeProductIds.contains(productId) else {
|
||||
let invoiceId = newBadgeInvoiceId()
|
||||
let outcome = try await storePurchase(product, invoiceId)
|
||||
if case let .purchased(receipt) = outcome { await receipt.transaction.finish() }
|
||||
return (outcome, invoiceId)
|
||||
}
|
||||
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else {
|
||||
throw BadgeStoreError.noActiveProfile
|
||||
}
|
||||
let invoice = try await apiCreateBadgeInvoice(userId)
|
||||
guard let invoiceId = UUID(uuidString: invoice) else { throw BadgeStoreError.invalidInvoiceId(invoice) }
|
||||
await MainActor.run { _ = buying.insert(invoice) }
|
||||
await loadBadgeStateAsync(userId)
|
||||
do {
|
||||
let outcome = try await storePurchase(product, invoiceId)
|
||||
switch outcome {
|
||||
// finished only once the service answers for it, as an unfinished transaction is what the store re-delivers
|
||||
case let .purchased(receipt) where receipt.signatureVerified: await presentPurchase(receipt, interactive: true)
|
||||
case .purchased, .cancelled: await closeInvoice(userId, invoice)
|
||||
case .pending: break
|
||||
}
|
||||
// nothing is delivered in this build, so the transaction is finished right away; once the
|
||||
// service issues credentials it must only be finished after the credential is stored
|
||||
await transaction.finish()
|
||||
return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified))
|
||||
case .pending: return .pending
|
||||
await MainActor.run { _ = buying.remove(invoice) }
|
||||
return (outcome, invoiceId)
|
||||
} catch let error {
|
||||
await closeInvoice(userId, invoice)
|
||||
await MainActor.run { _ = buying.remove(invoice) }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func storePurchase(_ product: Product, _ invoiceId: UUID) async throws -> BadgePurchaseOutcome {
|
||||
switch try await product.purchase(options: [.appAccountToken(invoiceId)]) {
|
||||
case let .success(verification): return .purchased(storeReceipt(verification))
|
||||
case .pending:
|
||||
await MainActor.run { waitingForApproval = true }
|
||||
return .pending
|
||||
case .userCancelled: return .cancelled
|
||||
@unknown default: throw BadgeStoreError.unknownPurchaseResult
|
||||
}
|
||||
}
|
||||
|
||||
// only the purchase the user started may alert: an answer can reveal a profile other than the one on screen
|
||||
func presentPurchase(_ receipt: BadgeStoreReceipt, interactive: Bool) async {
|
||||
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }),
|
||||
await claim(receipt)
|
||||
else { return }
|
||||
do {
|
||||
switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws), retry: interactive) {
|
||||
case let .redeemed(user, badgeState):
|
||||
await MainActor.run {
|
||||
// finished below whichever profile was credited, as it is paid for; only the profile on screen shows it
|
||||
if active(user) {
|
||||
BadgeModel.shared.set(userId: user.userId, badgeState: badgeState)
|
||||
ChatModel.shared.updateUser(user)
|
||||
if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) }
|
||||
}
|
||||
}
|
||||
await finish(receipt)
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("BadgeStore.presentPurchase: \(responseError(error))")
|
||||
let refused = badgeReceiptRefused(error)
|
||||
if refused { await finish(receipt) }
|
||||
if interactive {
|
||||
let text = redeemErrorText(error)
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Purchase error", comment: "alert title"),
|
||||
message: refused ? text : text + "\n\n" + NSLocalizedString("The purchase will be retried, and the badge will arrive.", comment: "alert message")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadBadgeStateAsync(userId)
|
||||
await MainActor.run { _ = presenting.remove(receipt.transactionId) }
|
||||
}
|
||||
|
||||
// at launch and on return to the foreground, never on a timer
|
||||
func presentUnfinished() async {
|
||||
await listenForTransactions()
|
||||
var held: Set<String> = []
|
||||
for await verification in Transaction.unfinished {
|
||||
if let invoiceId = storeReceipt(verification).echoedInvoiceId { held.insert(invoiceId) }
|
||||
await reconcile(verification)
|
||||
}
|
||||
await closeAbandoned(held)
|
||||
await MainActor.run { reconciledOnce = true }
|
||||
}
|
||||
|
||||
// A record no held transaction names and no purchase here waits on. If the store still charges for one,
|
||||
// as when this runs between an invoice's creation and its purchase starting, the receipt reopens it.
|
||||
private func closeAbandoned(_ held: Set<String>) async {
|
||||
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return }
|
||||
await loadBadgeStateAsync(userId)
|
||||
let abandoned = await MainActor.run {
|
||||
openStorePurchases(userId)
|
||||
.compactMap { $0.transactionRef == nil ? $0.invoiceId : nil }
|
||||
.filter { !held.contains($0) && !buying.contains($0) }
|
||||
}
|
||||
for invoiceId in abandoned {
|
||||
await closeInvoice(userId, invoiceId)
|
||||
}
|
||||
}
|
||||
|
||||
private func closeInvoice(_ userId: Int64, _ invoiceId: String) async {
|
||||
do {
|
||||
try await apiCloseBadgeInvoice(userId, invoiceId)
|
||||
} catch let error {
|
||||
logger.error("BadgeStore.closeInvoice: \(responseError(error))")
|
||||
}
|
||||
await loadBadgeStateAsync(userId)
|
||||
}
|
||||
|
||||
// transactions the store settles outside a purchase call, such as an approved Ask to Buy
|
||||
@MainActor
|
||||
private func listenForTransactions() {
|
||||
if transactionUpdates == nil {
|
||||
transactionUpdates = Task.detached {
|
||||
for await verification in Transaction.updates {
|
||||
await BadgeStore.shared.reconcile(verification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// an unverified one-time transaction is never sent, and left unfinished rather than lost
|
||||
private func reconcile(_ verification: VerificationResult<Transaction>) async {
|
||||
let receipt = storeReceipt(verification)
|
||||
if !badgeOneTimeProductIds.contains(receipt.productId) {
|
||||
await receipt.transaction.finish()
|
||||
} else if receipt.signatureVerified {
|
||||
await presentPurchase(receipt, interactive: false)
|
||||
} else if let invoiceId = receipt.echoedInvoiceId,
|
||||
let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) {
|
||||
await closeInvoice(userId, invoiceId)
|
||||
}
|
||||
}
|
||||
|
||||
// one request per transaction: the purchase itself, launch, foreground and the store can each present it
|
||||
@MainActor
|
||||
private func claim(_ receipt: BadgeStoreReceipt) -> Bool {
|
||||
guard presenting.insert(receipt.transactionId).inserted else { return false }
|
||||
unfinished[receipt.transactionId] = receipt
|
||||
// an approved Ask to Buy arrives as a transaction, which ends the wait
|
||||
waitingForApproval = false
|
||||
return true
|
||||
}
|
||||
|
||||
private func finish(_ receipt: BadgeStoreReceipt) async {
|
||||
await receipt.transaction.finish()
|
||||
await MainActor.run { unfinished[receipt.transactionId] = nil }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func startLoading() -> Bool {
|
||||
switch state {
|
||||
@@ -172,15 +366,28 @@ private func compactPrice(_ product: Product) -> String {
|
||||
: product.displayPrice
|
||||
}
|
||||
|
||||
private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt {
|
||||
private func storeReceipt(_ verification: VerificationResult<Transaction>) -> BadgeStoreReceipt {
|
||||
let (t, signatureVerified) = switch verification {
|
||||
case let .verified(t): (t, true)
|
||||
case let .unverified(t, _): (t, false)
|
||||
}
|
||||
var environment: String? = nil
|
||||
if #available(iOS 16.0, *) { environment = t.environment.rawValue }
|
||||
return BadgeStoreReceipt(
|
||||
jws: jws,
|
||||
jws: verification.jwsRepresentation,
|
||||
productId: t.productID,
|
||||
transactionId: t.id,
|
||||
invoiceId: t.appAccountToken,
|
||||
environment: environment,
|
||||
signatureVerified: signatureVerified
|
||||
signatureVerified: signatureVerified,
|
||||
transaction: t
|
||||
)
|
||||
}
|
||||
|
||||
// the only answers after which the receipt will never be credited, so the store may stop re-delivering it
|
||||
private func badgeReceiptRefused(_ error: Error) -> Bool {
|
||||
if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError {
|
||||
return code == .receiptInvalid || code == .receiptUsed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//
|
||||
// BadgesCheckOrderView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 29.09.2026.
|
||||
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct BadgesCheckOrderView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject private var store = BadgeStore.shared
|
||||
let level: BadgeLevel
|
||||
let period: BadgePeriod
|
||||
@State private var purchasing = false
|
||||
// presented from this view, not AlertManager: its host is behind the sheet these views open in
|
||||
@State private var alert: SomeAlert?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
Text("Check your order")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
orderRow("Your badge", Text(level.title))
|
||||
orderRow("Duration", Text(period.label))
|
||||
orderRow("Total", period.priceText(store.price(level, period)))
|
||||
}
|
||||
.padding(.top, 20)
|
||||
|
||||
Text("Charged by the App Store to the account on this device.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
payButton()
|
||||
.padding(.vertical, 10)
|
||||
BadgeBillingFooter(period: period)
|
||||
}
|
||||
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 0)
|
||||
.padding(.bottom, 20)
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await store.load() }
|
||||
.alert(item: $alert) { $0.alert }
|
||||
}
|
||||
|
||||
private func orderRow(_ title: LocalizedStringKey, _ value: Text) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Spacer()
|
||||
value
|
||||
}
|
||||
.font(.body)
|
||||
}
|
||||
|
||||
private func payButton() -> some View {
|
||||
let price = store.price(level, period)
|
||||
let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId)
|
||||
return Button {
|
||||
purchase()
|
||||
} label: {
|
||||
period.payText(price)
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: disabled))
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
private func purchase() {
|
||||
purchasing = true
|
||||
Task {
|
||||
do {
|
||||
let (outcome, invoiceId) = try await store.purchase(level, period)
|
||||
if case let .purchased(receipt) = outcome {
|
||||
if !badgeOneTimeProductIds.contains(receipt.productId) {
|
||||
await MainActor.run { showPurchasedAlert(receipt, invoiceId) }
|
||||
} else if !receipt.signatureVerified {
|
||||
await MainActor.run {
|
||||
alert = SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Cannot verify this purchase",
|
||||
message: "The store returned a transaction that Apple has not signed. SimpleX cannot verify this purchase with the App Store."
|
||||
),
|
||||
id: "badgePurchaseUnverified"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run { purchasing = false }
|
||||
} catch let error {
|
||||
logger.error("BadgesCheckOrderView.purchase: \(String(describing: error))")
|
||||
await MainActor.run {
|
||||
purchasing = false
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Purchase error"),
|
||||
message: Text(verbatim: String(describing: error))
|
||||
),
|
||||
id: "badgePurchaseError"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [badges] store integration diagnostics - replaced by the issued badge once subscriptions are delivered.
|
||||
private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) {
|
||||
let returnedInvoice: String
|
||||
if let returned = receipt.invoiceId {
|
||||
returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)"
|
||||
} else {
|
||||
returnedInvoice = "none"
|
||||
}
|
||||
var lines = [
|
||||
"Product: \(receipt.productId)",
|
||||
"Invoice: \(invoiceId.uuidString)",
|
||||
"Invoice returned by Apple: \(returnedInvoice)",
|
||||
"Transaction: \(receipt.transactionId)"
|
||||
]
|
||||
if let environment = receipt.environment { lines.append("Environment: \(environment)") }
|
||||
lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")")
|
||||
let summary = lines.joined(separator: "\n")
|
||||
// logged as well as shown: the alert races StoreKit's own sheets, the log always lands
|
||||
logger.debug("badge purchase succeeded\n\(summary)")
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Purchase successful"),
|
||||
message: Text(verbatim: summary)
|
||||
),
|
||||
id: "badgePurchased"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct BadgesCheckOrderView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
BadgesCheckOrderView(level: .supporter, period: .oneMonth)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@ struct BadgesChooseBadgeView: View {
|
||||
@ObservedObject private var store = BadgeStore.shared
|
||||
@State private var selectedLevel: BadgeLevel = .supporter
|
||||
@State private var continueActive = false
|
||||
@State private var howItWorksActive = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
@@ -91,8 +90,15 @@ struct BadgesChooseBadgeView: View {
|
||||
VStack(spacing: 10) {
|
||||
continueButton()
|
||||
.padding(.vertical, 10)
|
||||
howItWorksButton()
|
||||
.frame(height: 22)
|
||||
// redeeming a code is here only when Support SimpleX offers the browser instead
|
||||
Group {
|
||||
if badgeBrowserAllowed {
|
||||
RedeemCodeButton()
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
}
|
||||
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
|
||||
}
|
||||
@@ -160,29 +166,6 @@ struct BadgesChooseBadgeView: View {
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
private func howItWorksButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
howItWorksActive = true
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "info.circle")
|
||||
Text("How badges protect your privacy").fontWeight(.medium)
|
||||
}
|
||||
.font(.body)
|
||||
}
|
||||
|
||||
NavigationLink(isActive: $howItWorksActive) {
|
||||
BadgesHowItWorksView()
|
||||
.modifier(ThemedBackground())
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BadgesChooseBadgeView_Previews: PreviewProvider {
|
||||
|
||||
@@ -64,10 +64,8 @@ struct BadgesHowLongView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject private var store = BadgeStore.shared
|
||||
let level: BadgeLevel
|
||||
@State private var selectedPeriod: BadgePeriod = .monthly
|
||||
@State private var purchasing = false
|
||||
// presented from this view, not AlertManager: its host is behind the sheet these views open in
|
||||
@State private var alert: SomeAlert?
|
||||
@State private var selectedPeriod: BadgePeriod = badgePeriodsForSale.contains(.monthly) ? .monthly : .oneMonth
|
||||
@State private var continueActive = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
@@ -91,26 +89,19 @@ struct BadgesHowLongView: View {
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
// fixedSize + maxHeight on the cards so all three match the tallest one -
|
||||
// fixedSize + maxHeight on the cards so they all match the tallest one -
|
||||
// only Annual carries a savings line, and prices wrap at large fonts
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
periodCard(.oneMonth)
|
||||
periodCard(.monthly)
|
||||
periodCard(.annual)
|
||||
ForEach(badgePeriodsForSale) { periodCard($0) }
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
payButton()
|
||||
continueButton()
|
||||
.padding(.vertical, 10)
|
||||
Text(billingFooter)
|
||||
.font(.footnote)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(height: 22)
|
||||
BadgeBillingFooter(period: selectedPeriod)
|
||||
}
|
||||
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
|
||||
}
|
||||
@@ -123,7 +114,6 @@ struct BadgesHowLongView: View {
|
||||
.frame(maxHeight: .infinity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await store.load() }
|
||||
.alert(item: $alert) { $0.alert }
|
||||
}
|
||||
|
||||
private func periodCard(_ period: BadgePeriod) -> some View {
|
||||
@@ -166,85 +156,38 @@ struct BadgesHowLongView: View {
|
||||
period == .annual ? store.annualSavings(level) : nil
|
||||
}
|
||||
|
||||
private func payButton() -> some View {
|
||||
let price = store.price(level, selectedPeriod)
|
||||
let disabled = !price.canPurchase || purchasing
|
||||
return Button {
|
||||
purchase()
|
||||
} label: {
|
||||
selectedPeriod.payText(price)
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: disabled))
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
private func purchase() {
|
||||
let period = selectedPeriod
|
||||
let invoiceId = newBadgeInvoiceId()
|
||||
purchasing = true
|
||||
Task {
|
||||
do {
|
||||
let outcome = try await store.purchase(level, period, invoiceId: invoiceId)
|
||||
await MainActor.run {
|
||||
purchasing = false
|
||||
switch outcome {
|
||||
case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId)
|
||||
case .pending:
|
||||
alert = SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Purchase pending",
|
||||
message: "The purchase is awaiting approval. This build does not deliver purchases approved later."
|
||||
),
|
||||
id: "badgePurchasePending"
|
||||
)
|
||||
case .cancelled: break
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("BadgesHowLongView.purchase: \(String(describing: error))")
|
||||
await MainActor.run {
|
||||
purchasing = false
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Purchase error"),
|
||||
message: Text(verbatim: String(describing: error))
|
||||
),
|
||||
id: "badgePurchaseError"
|
||||
)
|
||||
}
|
||||
private func continueButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
continueActive = true
|
||||
} label: {
|
||||
Text("Continue")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
|
||||
NavigationLink(isActive: $continueActive) {
|
||||
BadgesCheckOrderView(level: level, period: selectedPeriod)
|
||||
.modifier(ThemedBackground())
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands.
|
||||
private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) {
|
||||
let returnedInvoice: String
|
||||
if let returned = receipt.invoiceId {
|
||||
returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)"
|
||||
} else {
|
||||
returnedInvoice = "none"
|
||||
}
|
||||
var lines = [
|
||||
"Product: \(receipt.productId)",
|
||||
"Invoice: \(invoiceId.uuidString)",
|
||||
"Invoice returned by Apple: \(returnedInvoice)",
|
||||
"Transaction: \(receipt.transactionId)"
|
||||
]
|
||||
if let environment = receipt.environment { lines.append("Environment: \(environment)") }
|
||||
lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")")
|
||||
lines.append("Token: \(receipt.jws.count) bytes")
|
||||
let summary = lines.joined(separator: "\n")
|
||||
// logged as well as shown: the alert races StoreKit's own sheets, the log always lands
|
||||
logger.debug("badge purchase succeeded\n\(summary)")
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Purchase successful"),
|
||||
message: Text(verbatim: summary),
|
||||
primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws },
|
||||
secondaryButton: .cancel(Text("Ok"))
|
||||
),
|
||||
id: "badgePurchased"
|
||||
)
|
||||
struct BadgeBillingFooter: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let period: BadgePeriod
|
||||
|
||||
var body: some View {
|
||||
Text(billingFooter)
|
||||
.font(.footnote)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(height: 22)
|
||||
}
|
||||
|
||||
private var billingFooter: LocalizedStringKey {
|
||||
@@ -252,7 +195,7 @@ struct BadgesHowLongView: View {
|
||||
var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22
|
||||
let stubDate = Calendar.current.date(from: comps) ?? Date()
|
||||
let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none)
|
||||
switch selectedPeriod {
|
||||
switch period {
|
||||
case .monthly, .annual: return "Renews on \(date). Cancel anytime."
|
||||
case .oneMonth: return "Ends on \(date)."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// BadgesPurchaseStateView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 29.09.2026.
|
||||
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct BadgesPurchaseStateView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let purchaseState: BadgePurchaseState
|
||||
var showsAsSheet: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
Text(title)
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(message)
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
|
||||
ProgressView().scaleEffect(2)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, showsAsSheet ? 48 : 0)
|
||||
.padding(.bottom, 20)
|
||||
.frame(maxHeight: .infinity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var title: LocalizedStringKey {
|
||||
switch purchaseState {
|
||||
case .issuing: "Issuing your badge"
|
||||
case .waitingForApproval: "Waiting for approval"
|
||||
case .checking: "Checking your purchase"
|
||||
}
|
||||
}
|
||||
|
||||
private var message: LocalizedStringKey {
|
||||
switch purchaseState {
|
||||
case .issuing: "The store has taken the payment. Keeps trying if you close the app."
|
||||
case .waitingForApproval: "Nothing has been charged."
|
||||
case .checking: "The store has not confirmed a purchase yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,14 @@ import SimpleXChat
|
||||
|
||||
struct BadgesSupportSimplexView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject private var store = BadgeStore.shared
|
||||
// set true when presented as a sheet root (from the chat-list banner) — that path doesn't
|
||||
// reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top
|
||||
var showsAsSheet: Bool = false
|
||||
@State private var whyBuiltActive = false
|
||||
@State private var howItWorksActive = false
|
||||
@State private var chooseLevelActive = false
|
||||
@State private var redeemCodeActive = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
@@ -34,8 +35,6 @@ struct BadgesSupportSimplexView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// TODO [badges] restore whyBuiltButton() when in-app purchase lands: the level screen
|
||||
// returns to the flow and howItWorksButton() moves there, leaving this one alone here.
|
||||
howItWorksButton()
|
||||
|
||||
Spacer(minLength: 0)
|
||||
@@ -47,10 +46,16 @@ struct BadgesSupportSimplexView: View {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
buyInBrowserButton()
|
||||
chooseLevelButton()
|
||||
.padding(.vertical, 10)
|
||||
redeemCodeButton()
|
||||
.frame(height: 22)
|
||||
Group {
|
||||
if badgeBrowserAllowed {
|
||||
buyInBrowserButton()
|
||||
} else {
|
||||
RedeemCodeButton()
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
}
|
||||
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
|
||||
}
|
||||
@@ -65,15 +70,16 @@ struct BadgesSupportSimplexView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// the in-app purchase path, kept compiling and uncalled until payments return after the MVP
|
||||
private func chooseLevelButton() -> some View {
|
||||
ZStack {
|
||||
let disabled = !store.canBuy(chatModel.currentUser?.userId)
|
||||
return ZStack {
|
||||
Button {
|
||||
chooseLevelActive = true
|
||||
} label: {
|
||||
Text("Choose your level")
|
||||
Text("Choose your badge")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: disabled))
|
||||
.disabled(disabled)
|
||||
|
||||
NavigationLink(isActive: $chooseLevelActive) {
|
||||
BadgesChooseBadgeView()
|
||||
@@ -125,7 +131,23 @@ struct BadgesSupportSimplexView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func redeemCodeButton() -> some View {
|
||||
private func buyInBrowserButton() -> some View {
|
||||
Button {
|
||||
UIApplication.shared.open(URL(string: badgePageUrl)!)
|
||||
} label: {
|
||||
Text("Buy in browser")
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RedeemCodeButton: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State private var redeemCodeActive = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Button {
|
||||
redeemCodeActive = true
|
||||
@@ -146,15 +168,6 @@ struct BadgesSupportSimplexView: View {
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
private func buyInBrowserButton() -> some View {
|
||||
Button {
|
||||
UIApplication.shared.open(URL(string: badgePageUrl)!)
|
||||
} label: {
|
||||
Text("Buy in browser")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
}
|
||||
}
|
||||
|
||||
struct PhoneSupporterHero: View {
|
||||
|
||||
@@ -12,6 +12,7 @@ import SimpleXChat
|
||||
struct BadgesView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject private var badgeModel = BadgeModel.shared
|
||||
@ObservedObject private var store = BadgeStore.shared
|
||||
var showsAsSheet: Bool = false
|
||||
|
||||
private var shownBadge: BadgeState? {
|
||||
@@ -25,12 +26,17 @@ struct BadgesView: View {
|
||||
if let badgeState = shownBadge {
|
||||
BadgesYourBadgeView(badgeState: badgeState, showsAsSheet: showsAsSheet)
|
||||
.transition(.opacity)
|
||||
} else if let purchaseState = store.purchaseState(chatModel.currentUser?.userId) {
|
||||
// holds the purchase screens' slot, so a consumable cannot be bought twice
|
||||
BadgesPurchaseStateView(purchaseState: purchaseState, showsAsSheet: showsAsSheet)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
BadgesSupportSimplexView(showsAsSheet: showsAsSheet)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.default, value: shownBadge != nil)
|
||||
.animation(.default, value: store.purchaseState(chatModel.currentUser?.userId))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ struct BadgesYourBadgeView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@ObservedObject private var store = BadgeStore.shared
|
||||
let badgeState: BadgeState
|
||||
var showsAsSheet: Bool = false
|
||||
|
||||
@@ -78,6 +79,15 @@ struct BadgesYourBadgeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if store.purchaseState(chatModel.currentUser?.userId) != nil {
|
||||
Section {
|
||||
Text("A store purchase is being processed.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} header: {
|
||||
Text("Store purchase")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
if developerTools {
|
||||
Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) {
|
||||
if let badge = chatModel.currentUser?.profile.localBadge {
|
||||
|
||||
@@ -168,6 +168,8 @@
|
||||
647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; };
|
||||
647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */; };
|
||||
647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2B30666C640032110E /* BadgesHowLongView.swift */; };
|
||||
64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */; };
|
||||
64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */; };
|
||||
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
|
||||
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
|
||||
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
|
||||
@@ -560,6 +562,8 @@
|
||||
647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = "<group>"; };
|
||||
647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesChooseBadgeView.swift; sourceTree = "<group>"; };
|
||||
647C9E2B30666C640032110E /* BadgesHowLongView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowLongView.swift; sourceTree = "<group>"; };
|
||||
64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesCheckOrderView.swift; sourceTree = "<group>"; };
|
||||
64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPurchaseStateView.swift; sourceTree = "<group>"; };
|
||||
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
|
||||
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
|
||||
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
|
||||
@@ -1219,6 +1223,8 @@
|
||||
children = (
|
||||
647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */,
|
||||
647C9E2B30666C640032110E /* BadgesHowLongView.swift */,
|
||||
64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */,
|
||||
64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */,
|
||||
644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */,
|
||||
64EB8A9A3054347A0089FFDF /* BadgesView.swift */,
|
||||
64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */,
|
||||
@@ -1672,6 +1678,8 @@
|
||||
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */,
|
||||
647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */,
|
||||
647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */,
|
||||
64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */,
|
||||
64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */,
|
||||
5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */,
|
||||
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */,
|
||||
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */,
|
||||
|
||||
@@ -826,6 +826,7 @@ public enum ChatErrorType: Decodable, Hashable {
|
||||
|
||||
public enum BadgeRedeemError: Decodable, Hashable {
|
||||
case invalidCode
|
||||
case invalidReceipt
|
||||
case serviceNotConfigured
|
||||
case badgeActive
|
||||
case serviceError(serviceError: BadgeServiceErrorCode)
|
||||
@@ -846,6 +847,7 @@ public enum BadgeServiceErrorCode: Decodable, Hashable {
|
||||
case paymentNotEntitled
|
||||
case paymentPending
|
||||
case providerUnavailable
|
||||
case providerNotConfigured
|
||||
case rateLimited
|
||||
case codeInvalid
|
||||
case codeUsed
|
||||
@@ -867,6 +869,7 @@ public enum BadgeServiceErrorCode: Decodable, Hashable {
|
||||
case .paymentNotEntitled: "payment_not_entitled"
|
||||
case .paymentPending: "payment_pending"
|
||||
case .providerUnavailable: "provider_unavailable"
|
||||
case .providerNotConfigured: "provider_not_configured"
|
||||
case .rateLimited: "rate_limited"
|
||||
case .codeInvalid: "code_invalid"
|
||||
case .codeUsed: "code_used"
|
||||
@@ -890,6 +893,7 @@ public enum BadgeServiceErrorCode: Decodable, Hashable {
|
||||
case "payment_not_entitled": self = .paymentNotEntitled
|
||||
case "payment_pending": self = .paymentPending
|
||||
case "provider_unavailable": self = .providerUnavailable
|
||||
case "provider_not_configured": self = .providerNotConfigured
|
||||
case "rate_limited": self = .rateLimited
|
||||
case "code_invalid": self = .codeInvalid
|
||||
case "code_used": self = .codeUsed
|
||||
@@ -911,8 +915,10 @@ public func badgeServiceErrorText(_ code: BadgeServiceErrorCode) -> String? {
|
||||
case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message")
|
||||
case .unknownPurchaseKey: NSLocalizedString("The badge service does not recognize this badge.", comment: "alert message")
|
||||
case .internalError: NSLocalizedString("The badge service reported an internal error.", comment: "alert message")
|
||||
case .receiptInvalid: NSLocalizedString("The store did not confirm this purchase.", comment: "alert message")
|
||||
case .receiptUsed: NSLocalizedString("This purchase was already credited to a badge on another device or profile.", comment: "alert message")
|
||||
case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .productUnavailable,
|
||||
.paymentNotEntitled, .paymentPending, .providerUnavailable, .receiptInvalid, .receiptUsed, .unknown: nil
|
||||
.paymentNotEntitled, .paymentPending, .providerUnavailable, .providerNotConfigured, .unknown: nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import chat.simplex.common.views.badges.BadgeProduct
|
||||
import chat.simplex.common.views.badges.BadgePurchaseOutcome
|
||||
import chat.simplex.common.views.badges.BadgeStoreError
|
||||
import chat.simplex.common.views.badges.BadgeStoreProductId
|
||||
import chat.simplex.common.views.badges.BadgeStoreReceipt
|
||||
|
||||
// Play Billing is only in the google flavor, so the Play country stays unknown here
|
||||
fun loadPlayStoreCountry() {}
|
||||
@@ -22,3 +23,8 @@ suspend fun loadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptio
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome =
|
||||
throw BadgeStoreError.StoreUnavailable
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {}
|
||||
|
||||
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> = emptyList()
|
||||
|
||||
@@ -5,6 +5,7 @@ import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.platform.androidPlayStoreCountry
|
||||
import chat.simplex.common.platform.mainActivity
|
||||
import chat.simplex.common.views.badges.*
|
||||
import chat.simplex.common.views.helpers.withLongRunningApi
|
||||
import com.android.billingclient.api.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -80,8 +81,7 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
|
||||
val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable
|
||||
val client = connectedBadgeBillingClient()
|
||||
val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId)
|
||||
val details = offer.details
|
||||
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details)
|
||||
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(offer.details)
|
||||
offer.offerToken?.let { productParams.setOfferToken(it) }
|
||||
val params = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(listOf(productParams.build()))
|
||||
@@ -94,19 +94,42 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
|
||||
if (launched.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage)
|
||||
}
|
||||
val outcome = purchase.await()
|
||||
if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt)
|
||||
return outcome
|
||||
return purchase.await()
|
||||
} finally {
|
||||
badgePurchase = null
|
||||
}
|
||||
}
|
||||
|
||||
// one-time purchases Play still holds: bought and not consumed, or awaiting a slow payment
|
||||
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> {
|
||||
val client = connectedBadgeBillingClient()
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()
|
||||
val queried = CompletableDeferred<List<Purchase>>()
|
||||
client.queryPurchasesAsync(params) { result, purchases ->
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
queried.complete(purchases)
|
||||
} else {
|
||||
queried.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage))
|
||||
}
|
||||
}
|
||||
return queried.await().mapNotNull(::badgePurchaseOutcome)
|
||||
}
|
||||
|
||||
private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->
|
||||
val pending = badgePurchase ?: return@PurchasesUpdatedListener
|
||||
val pending = badgePurchase
|
||||
if (pending == null) {
|
||||
// settled outside a purchase call, such as a slow payment completing
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
|
||||
withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcile(it) } }
|
||||
}
|
||||
return@PurchasesUpdatedListener
|
||||
}
|
||||
when {
|
||||
result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null ->
|
||||
pending.complete(badgePurchaseOutcome(purchases))
|
||||
result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> {
|
||||
val outcomes = purchases.mapNotNull(::badgePurchaseOutcome)
|
||||
pending.complete(outcomes.firstOrNull() ?: BadgePurchaseOutcome.Cancelled)
|
||||
withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcile(it) } }
|
||||
}
|
||||
result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED ->
|
||||
pending.complete(BadgePurchaseOutcome.Cancelled)
|
||||
else ->
|
||||
@@ -114,10 +137,9 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p
|
||||
}
|
||||
}
|
||||
|
||||
private fun badgePurchaseOutcome(purchases: List<Purchase>): BadgePurchaseOutcome {
|
||||
val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled
|
||||
if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending
|
||||
return BadgePurchaseOutcome.Purchased(
|
||||
private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) {
|
||||
Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending
|
||||
Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased(
|
||||
BadgeStoreReceipt(
|
||||
token = purchase.purchaseToken,
|
||||
productId = purchase.products.firstOrNull() ?: "",
|
||||
@@ -125,6 +147,7 @@ private fun badgePurchaseOutcome(purchases: List<Purchase>): BadgePurchaseOutcom
|
||||
invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId
|
||||
)
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private suspend fun connectedBadgeBillingClient(): BillingClient {
|
||||
@@ -203,21 +226,20 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? {
|
||||
return BadgeOffer(id, product, this, offer.offerToken)
|
||||
}
|
||||
|
||||
// nothing is delivered in this build, so the purchase is finished right away; once the service issues
|
||||
// credentials it must only be finished after the credential is stored. One-time products are consumed
|
||||
// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged
|
||||
// purchase after 3 days.
|
||||
private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) {
|
||||
// consumed if one-time so it can be bought again, else acknowledged, as Play refunds an unacknowledged purchase
|
||||
// after 3 days; decided by product id, as after a restart there is no ProductDetails for the purchase
|
||||
suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {
|
||||
val client = connectedBadgeBillingClient()
|
||||
val done = CompletableDeferred<BillingResult>()
|
||||
if (details.productType == BillingClient.ProductType.SUBS) {
|
||||
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.acknowledgePurchase(params) { done.complete(it) }
|
||||
} else {
|
||||
if (receipt.productId in badgeOneTimeProductIds) {
|
||||
val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.consumeAsync(params) { result, _ -> done.complete(result) }
|
||||
} else {
|
||||
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.acknowledgePurchase(params) { done.complete(it) }
|
||||
}
|
||||
val result = done.await()
|
||||
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}")
|
||||
throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.badges.BadgeProduct
|
||||
import chat.simplex.common.views.badges.BadgePurchaseOutcome
|
||||
import chat.simplex.common.views.badges.BadgeStore
|
||||
import chat.simplex.common.views.badges.BadgeStoreProductId
|
||||
import chat.simplex.common.views.badges.BadgeStoreReceipt
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.database.deleteOldChatArchive
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -113,6 +115,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
}
|
||||
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
||||
}
|
||||
BadgeStore.presentUnfinished()
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
@@ -352,6 +355,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
|
||||
override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId)
|
||||
|
||||
override suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) = finishBadgePurchase(receipt)
|
||||
|
||||
override suspend fun androidUnfinishedBadgePurchases(): List<BadgePurchaseOutcome> = unfinishedBadgePurchases()
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
override fun androidLockPortraitOrientation() {
|
||||
|
||||
+82
-9
@@ -27,6 +27,7 @@ import chat.simplex.common.model.SMPProxyMode.Always
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.badges.BadgeStore
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.item.contentModerationPostLink
|
||||
import chat.simplex.common.views.chat.item.showContentBlockedAlert
|
||||
@@ -587,11 +588,40 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
// log = false because a store receipt is a bearer secret, like a badge code - it is in the command.
|
||||
// null when the user cancels the retry alert, which is offered only when retry is set.
|
||||
suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment, retry: Boolean): BadgePurchaseResult? {
|
||||
val cmd = CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment)
|
||||
val r = (if (retry) sendCmdWithRetry(rh, cmd, log = false) else sendCmd(rh, cmd, log = false)) ?: return null
|
||||
return when {
|
||||
r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Redeemed(r.res.user.updateRemoteHostId(rh), r.res.badgeState)
|
||||
r is API.Error -> BadgePurchaseResult.Failed(r.err)
|
||||
else -> {
|
||||
// the response type alone - it names a case or a JSON key, never the service's message
|
||||
Log.e(TAG, "apiPurchaseBadge: unexpected ${r.responseType}")
|
||||
BadgePurchaseResult.Failed(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiCreateBadgeInvoice(rh: Long?, userId: Long): String {
|
||||
val r = sendCmd(rh, CC.ApiCreateBadgeInvoice(userId))
|
||||
if (r is API.Result && r.res is CR.BadgeInvoice) return r.res.invoiceId
|
||||
throw Exception("apiCreateBadgeInvoice: unexpected ${r.responseType}")
|
||||
}
|
||||
|
||||
suspend fun apiCloseBadgeInvoice(rh: Long?, userId: Long, invoiceId: String) {
|
||||
val r = sendCmd(rh, CC.ApiCloseBadgeInvoice(userId, invoiceId))
|
||||
if (r is API.Result && r.res is CR.CmdOk) return
|
||||
throw Exception("apiCloseBadgeInvoice: unexpected ${r.responseType}")
|
||||
}
|
||||
|
||||
// localized where the user can act on it; otherwise the error itself, so a screenshot says what happened
|
||||
fun redeemErrorText(err: ChatError?): String {
|
||||
if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.CEBadgeRedeemError) {
|
||||
when (val e = err.errorType.badgeRedeemError) {
|
||||
is BadgeRedeemError.InvalidCode -> return generalGetString(MR.strings.badges_error_invalid_code)
|
||||
is BadgeRedeemError.InvalidReceipt -> {}
|
||||
is BadgeRedeemError.ServiceNotConfigured -> return generalGetString(MR.strings.badges_error_service_not_configured)
|
||||
is BadgeRedeemError.BadgeActive -> return generalGetString(MR.strings.badges_error_already_active)
|
||||
is BadgeRedeemError.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it }
|
||||
@@ -602,9 +632,9 @@ object ChatController {
|
||||
return "${generalGetString(MR.strings.error_prefix)}: ${err?.string ?: generalGetString(MR.strings.badges_error_unknown)}"
|
||||
}
|
||||
|
||||
suspend fun apiGetBadgeState(rh: Long?, userId: Long): BadgeState? {
|
||||
suspend fun apiGetBadgeState(rh: Long?, userId: Long): Pair<BadgeState?, List<BadgeStorePurchase>> {
|
||||
val r = sendCmd(rh, CC.ApiGetBadgeState(userId))
|
||||
if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState
|
||||
if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState to r.res.storePurchases
|
||||
throw Exception("apiGetBadgeState: unexpected ${r.responseType}")
|
||||
}
|
||||
|
||||
@@ -637,11 +667,14 @@ object ChatController {
|
||||
|
||||
// Not thrown: a failed badge read must not stop the app starting, and the model is left alone
|
||||
// rather than set to nil, which would read as "no badge".
|
||||
private suspend fun loadBadgeState(rhId: Long?) {
|
||||
suspend fun loadBadgeState(rhId: Long?) {
|
||||
try {
|
||||
val userId = currentUserId("loadBadgeState")
|
||||
val badgeState = apiGetBadgeState(rhId, userId)
|
||||
withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) }
|
||||
val (badgeState, storePurchases) = apiGetBadgeState(rhId, userId)
|
||||
withContext(Dispatchers.Main) {
|
||||
BadgeModel.set(rhId, userId, badgeState)
|
||||
BadgeStore.setStorePurchases(rhId, userId, storePurchases)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "loadBadgeState: ${e.message}")
|
||||
}
|
||||
@@ -686,6 +719,7 @@ object ChatController {
|
||||
}
|
||||
apiStartChat()
|
||||
appPrefs.chatStopped.set(false)
|
||||
withLongRunningApi { BadgeStore.presentUnfinished() }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "failed starting chat $e")
|
||||
throw e
|
||||
@@ -3622,7 +3656,7 @@ object ChatController {
|
||||
getUserChatData(null)
|
||||
}
|
||||
|
||||
private fun activeUser(rhId: Long?, user: UserLike): Boolean =
|
||||
fun activeUser(rhId: Long?, user: UserLike): Boolean =
|
||||
rhId == chatModel.remoteHostId() && user.userId == chatModel.currentUser.value?.userId
|
||||
|
||||
private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
|
||||
@@ -3884,6 +3918,15 @@ sealed class BadgeRedeemResult {
|
||||
class Failed(val err: ChatError?): BadgeRedeemResult()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BadgeStorePurchase(val invoiceId: String? = null, val transactionRef: String? = null)
|
||||
|
||||
sealed class BadgePurchaseResult {
|
||||
class Redeemed(val user: User, val badgeState: BadgeState?): BadgePurchaseResult()
|
||||
// err is null for a response of an unexpected type, which is logged where it is received
|
||||
class Failed(val err: ChatError?): BadgePurchaseResult()
|
||||
}
|
||||
|
||||
// ChatCommand
|
||||
// Spec: spec/api.md#CC
|
||||
sealed class CC {
|
||||
@@ -4064,6 +4107,9 @@ sealed class CC {
|
||||
class ApiStandaloneFileInfo(val url: String): CC()
|
||||
// badges
|
||||
class ApiRedeemBadgeCode(val userId: Long, val code: String): CC()
|
||||
class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val payment: ServicePayment): CC()
|
||||
class ApiCreateBadgeInvoice(val userId: Long): CC()
|
||||
class ApiCloseBadgeInvoice(val userId: Long, val invoiceId: String): CC()
|
||||
class ApiGetBadgeState(val userId: Long): CC()
|
||||
class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC()
|
||||
class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC()
|
||||
@@ -4289,6 +4335,9 @@ sealed class CC {
|
||||
is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}"
|
||||
is ApiStandaloneFileInfo -> "/_download info $url"
|
||||
is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code"
|
||||
is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""} ${json.encodeToString(payment)}"
|
||||
is ApiCreateBadgeInvoice -> "/_badge invoice $userId"
|
||||
is ApiCloseBadgeInvoice -> "/_badge invoice close $userId $invoiceId"
|
||||
is ApiGetBadgeState -> "/_badge state $userId"
|
||||
is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId"
|
||||
is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode"
|
||||
@@ -4473,6 +4522,9 @@ sealed class CC {
|
||||
is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile"
|
||||
is ApiStandaloneFileInfo -> "apiStandaloneFileInfo"
|
||||
is ApiRedeemBadgeCode -> "apiRedeemBadgeCode"
|
||||
is ApiPurchaseBadge -> "apiPurchaseBadge"
|
||||
is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice"
|
||||
is ApiCloseBadgeInvoice -> "apiCloseBadgeInvoice"
|
||||
is ApiGetBadgeState -> "apiGetBadgeState"
|
||||
is ApiGetBadgeLedger -> "apiGetBadgeLedger"
|
||||
is ApiAckBadgeAlert -> "apiAckBadgeAlert"
|
||||
@@ -4497,8 +4549,12 @@ sealed class CC {
|
||||
is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd))
|
||||
is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd))
|
||||
is TestStorageEncryption -> TestStorageEncryption(obfuscate(key))
|
||||
// a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
|
||||
// a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
|
||||
is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code))
|
||||
is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, when (payment) {
|
||||
is ServicePayment.Apple -> ServicePayment.Apple(obfuscate(payment.jws))
|
||||
is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token))
|
||||
})
|
||||
else -> this
|
||||
}
|
||||
|
||||
@@ -4532,6 +4588,13 @@ sealed class CC {
|
||||
}
|
||||
}
|
||||
|
||||
// encoded as the base type, so the "type" tag core parses by is included
|
||||
@Serializable
|
||||
sealed class ServicePayment {
|
||||
@Serializable @SerialName("apple") class Apple(val jws: String): ServicePayment()
|
||||
@Serializable @SerialName("google") class Google(val productId: String, val token: String): ServicePayment()
|
||||
}
|
||||
|
||||
fun onOff(b: Boolean): String = if (b) "on" else "off"
|
||||
|
||||
// /_badge ack takes the kind in core's text encoding, not the JSON tag
|
||||
@@ -6865,7 +6928,8 @@ sealed class CR {
|
||||
// badges
|
||||
// the full user, not UserRef: its profile carries the badge that setUserBadge just stored
|
||||
@Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR()
|
||||
@Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR()
|
||||
@Serializable @SerialName("badgeInvoice") class BadgeInvoice(val user: UserRef, val invoiceId: String): CR()
|
||||
@Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List<BadgeStorePurchase> = emptyList()): CR()
|
||||
@Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List<StatementEntry>): CR()
|
||||
@Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR()
|
||||
@Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR()
|
||||
@@ -7056,6 +7120,7 @@ sealed class CR {
|
||||
is ArchiveImported -> "archiveImported"
|
||||
is AppSettingsR -> "appSettings"
|
||||
is BadgeRedeemed -> "badgeRedeemed"
|
||||
is BadgeInvoice -> "badgeInvoice"
|
||||
is BadgeStateR -> "badgeState"
|
||||
is BadgeLedger -> "badgeLedger"
|
||||
is BadgeChanged -> "badgeChanged"
|
||||
@@ -7264,7 +7329,8 @@ sealed class CR {
|
||||
is ArchiveImported -> "${archiveErrors.map { it.string } }"
|
||||
is AppSettingsR -> json.encodeToString(appSettings)
|
||||
is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}")
|
||||
is BadgeStateR -> withUser(user, json.encodeToString(badgeState))
|
||||
is BadgeInvoice -> withUser(user, invoiceId)
|
||||
is BadgeStateR -> withUser(user, "${json.encodeToString(badgeState)}\nstorePurchases: ${json.encodeToString(storePurchases)}")
|
||||
is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger))
|
||||
is BadgeChanged -> withUser(user, json.encodeToString(badgeState))
|
||||
is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert))
|
||||
@@ -7323,6 +7389,7 @@ sealed class SimplexDomainError {
|
||||
sealed class BadgeRedeemError {
|
||||
val string: String get() = when (this) {
|
||||
is InvalidCode -> "invalidCode"
|
||||
is InvalidReceipt -> "invalidReceipt"
|
||||
is ServiceNotConfigured -> "serviceNotConfigured"
|
||||
is BadgeActive -> "badgeActive"
|
||||
is ServiceError -> "serviceError ${serviceError.text}"
|
||||
@@ -7331,6 +7398,7 @@ sealed class BadgeRedeemError {
|
||||
is CredentialNotVerified -> "credentialNotVerified"
|
||||
}
|
||||
@Serializable @SerialName("invalidCode") object InvalidCode : BadgeRedeemError()
|
||||
@Serializable @SerialName("invalidReceipt") object InvalidReceipt : BadgeRedeemError()
|
||||
@Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError()
|
||||
@Serializable @SerialName("badgeActive") object BadgeActive : BadgeRedeemError()
|
||||
@Serializable @SerialName("serviceError") class ServiceError(val serviceError: BadgeServiceErrorCode) : BadgeRedeemError()
|
||||
@@ -7352,6 +7420,7 @@ sealed class BadgeServiceErrorCode {
|
||||
object PaymentNotEntitled: BadgeServiceErrorCode()
|
||||
object PaymentPending: BadgeServiceErrorCode()
|
||||
object ProviderUnavailable: BadgeServiceErrorCode()
|
||||
object ProviderNotConfigured: BadgeServiceErrorCode()
|
||||
object RateLimited: BadgeServiceErrorCode()
|
||||
object CodeInvalid: BadgeServiceErrorCode()
|
||||
object CodeUsed: BadgeServiceErrorCode()
|
||||
@@ -7373,6 +7442,7 @@ sealed class BadgeServiceErrorCode {
|
||||
is PaymentNotEntitled -> "payment_not_entitled"
|
||||
is PaymentPending -> "payment_pending"
|
||||
is ProviderUnavailable -> "provider_unavailable"
|
||||
is ProviderNotConfigured -> "provider_not_configured"
|
||||
is RateLimited -> "rate_limited"
|
||||
is CodeInvalid -> "code_invalid"
|
||||
is CodeUsed -> "code_used"
|
||||
@@ -7392,6 +7462,8 @@ fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) {
|
||||
is BadgeServiceErrorCode.UnsupportedVersion -> generalGetString(MR.strings.badges_error_unsupported_version)
|
||||
is BadgeServiceErrorCode.UnknownPurchaseKey -> generalGetString(MR.strings.badges_error_unknown_purchase)
|
||||
is BadgeServiceErrorCode.Internal -> generalGetString(MR.strings.badges_error_service_internal)
|
||||
is BadgeServiceErrorCode.ReceiptInvalid -> generalGetString(MR.strings.badges_error_receipt_invalid)
|
||||
is BadgeServiceErrorCode.ReceiptUsed -> generalGetString(MR.strings.badges_error_receipt_used)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -7409,6 +7481,7 @@ object BadgeServiceErrorCodeSerializer : KSerializer<BadgeServiceErrorCode> {
|
||||
"payment_not_entitled" -> BadgeServiceErrorCode.PaymentNotEntitled
|
||||
"payment_pending" -> BadgeServiceErrorCode.PaymentPending
|
||||
"provider_unavailable" -> BadgeServiceErrorCode.ProviderUnavailable
|
||||
"provider_not_configured" -> BadgeServiceErrorCode.ProviderNotConfigured
|
||||
"rate_limited" -> BadgeServiceErrorCode.RateLimited
|
||||
"code_invalid" -> BadgeServiceErrorCode.CodeInvalid
|
||||
"code_used" -> BadgeServiceErrorCode.CodeUsed
|
||||
|
||||
+3
@@ -13,6 +13,7 @@ import chat.simplex.common.views.badges.BadgeProduct
|
||||
import chat.simplex.common.views.badges.BadgePurchaseOutcome
|
||||
import chat.simplex.common.views.badges.BadgeStoreError
|
||||
import chat.simplex.common.views.badges.BadgeStoreProductId
|
||||
import chat.simplex.common.views.badges.BadgeStoreReceipt
|
||||
import kotlinx.coroutines.Job
|
||||
import java.io.Closeable
|
||||
|
||||
@@ -44,6 +45,8 @@ interface PlatformInterface {
|
||||
return emptyList()
|
||||
}
|
||||
suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable
|
||||
suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) {}
|
||||
suspend fun androidUnfinishedBadgePurchases(): List<BadgePurchaseOutcome> = emptyList()
|
||||
val androidApiLevel: Int? get() = null
|
||||
// The build distributed via Google Play, which has to follow its policies
|
||||
val androidIsPlayStoreBuild: Boolean get() = false
|
||||
|
||||
+227
-7
@@ -1,7 +1,17 @@
|
||||
package chat.simplex.common.views.badges
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.NumberFormat
|
||||
import java.util.Currency
|
||||
import java.util.UUID
|
||||
@@ -29,9 +39,14 @@ val badgeStoreProductIds: List<BadgeStoreProductId> = BadgeLevel.entries.flatMap
|
||||
BadgePeriod.entries.map { badgeStoreProductId(level, it) }
|
||||
}
|
||||
|
||||
// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id.
|
||||
// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service
|
||||
// learns which invoice a store transaction settles.
|
||||
// the only products sent to the badge service: nothing delivers a subscription yet
|
||||
val badgeOneTimeProductIds: Set<String> = badgeStoreProductIds.filter { it.basePlanId == null }.map { it.productId }.toSet()
|
||||
|
||||
// subscriptions are for sale once renewals are delivered
|
||||
val badgePeriodsForSale: List<BadgePeriod> = listOf(BadgePeriod.OneMonth)
|
||||
|
||||
// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core.
|
||||
// Sent to Play as obfuscatedAccountId and echoed back on the purchase.
|
||||
fun newBadgeInvoiceId(): String = UUID.randomUUID().toString()
|
||||
|
||||
// the page's app flag rides in the fragment, which never reaches the service
|
||||
@@ -39,6 +54,16 @@ val badgePageUrl: String =
|
||||
if (appPlatform.isAndroid) "https://badges.simplex.chat/#/tier?app=true"
|
||||
else "https://badges.simplex.chat/#/tier?app=desktop"
|
||||
|
||||
// where the store allows a link out to the badge page: the US for now, a set expected to widen.
|
||||
// An unknown country does not count, as this decides whether the store sees a link out of the app.
|
||||
@Composable
|
||||
fun badgeBrowserAllowed(): Boolean {
|
||||
if (androidPlayStoreCountry.value == null) {
|
||||
LaunchedEffect(Unit) { platform.androidLoadPlayStoreCountry() }
|
||||
}
|
||||
return androidPlayStoreCountry.value == "US"
|
||||
}
|
||||
|
||||
// what the platform store knows about one product; ProductDetails cannot cross into commonMain
|
||||
data class BadgeProduct(
|
||||
val id: BadgeStoreProductId,
|
||||
@@ -92,16 +117,24 @@ sealed class BadgePurchaseOutcome {
|
||||
object Cancelled: BadgePurchaseOutcome()
|
||||
}
|
||||
|
||||
enum class BadgePurchaseState {
|
||||
Issuing,
|
||||
WaitingForApproval,
|
||||
Checking
|
||||
}
|
||||
|
||||
sealed class BadgeStoreError: Exception() {
|
||||
class ProductUnavailable(val productId: String): BadgeStoreError()
|
||||
class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError()
|
||||
object StoreUnavailable: BadgeStoreError()
|
||||
object NoActiveProfile: BadgeStoreError()
|
||||
|
||||
override val message: String
|
||||
get() = when (this) {
|
||||
is ProductUnavailable -> "productUnavailable(productId: $productId)"
|
||||
is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)"
|
||||
is StoreUnavailable -> "storeUnavailable"
|
||||
is NoActiveProfile -> "noActiveProfile"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +144,42 @@ object BadgeStore {
|
||||
private val state = mutableStateOf(LoadState.NotLoaded)
|
||||
// snapshot state so a composable reading only the products still recomposes when they arrive
|
||||
private val products = mutableStateOf<Map<BadgeStoreProductId, BadgeProduct>>(emptyMap())
|
||||
// one-time purchases the store holds unfinished: each is a payment taken and not yet credited
|
||||
private val unfinished = mutableStateOf<Map<String, BadgeStoreReceipt>>(emptyMap())
|
||||
// core's open store purchases for the profile they were read for: core knows whose a purchase is
|
||||
private val storePurchases = mutableStateOf<Triple<Long?, Long, List<BadgeStorePurchase>>?>(null)
|
||||
// invoices whose purchase this session is still waiting on the store for
|
||||
private val buying = mutableStateOf<Set<String>>(emptySet())
|
||||
private val waitingForApproval = mutableStateOf(false)
|
||||
// until the store has been asked once, a purchase made while the app was not running is unknown
|
||||
private val reconciledOnce = mutableStateOf(false)
|
||||
// read and written on the main thread only
|
||||
private val presenting = mutableSetOf<String>()
|
||||
|
||||
fun purchaseState(userId: Long?): BadgePurchaseState? {
|
||||
if (!platform.androidHasPlatformStore) return null
|
||||
val purchases = openStorePurchases(userId)
|
||||
val held = unfinished.value.values.mapNotNull { it.invoiceId }.toSet()
|
||||
return when {
|
||||
purchases.any { it.invoiceId?.let(held::contains) == true } -> BadgePurchaseState.Issuing
|
||||
waitingForApproval.value -> BadgePurchaseState.WaitingForApproval
|
||||
purchases.any { it.transactionRef == null && it.invoiceId?.let(buying.value::contains) != true } -> BadgePurchaseState.Checking
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun canBuy(userId: Long?): Boolean =
|
||||
reconciledOnce.value && buying.value.isEmpty() && purchaseState(userId) == null
|
||||
&& openStorePurchases(userId).none { it.transactionRef == null }
|
||||
|
||||
fun setStorePurchases(rhId: Long?, userId: Long, purchases: List<BadgeStorePurchase>) {
|
||||
storePurchases.value = Triple(rhId, userId, purchases)
|
||||
}
|
||||
|
||||
private fun openStorePurchases(userId: Long?): List<BadgeStorePurchase> {
|
||||
val (rhId, readFor, purchases) = storePurchases.value ?: return emptyList()
|
||||
return if (rhId == chatModel.remoteHostId() && readFor == userId) purchases else emptyList()
|
||||
}
|
||||
|
||||
fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) {
|
||||
LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading
|
||||
@@ -151,11 +220,42 @@ object BadgeStore {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome {
|
||||
// A one-time purchase has its core record before the store charges, and every store outcome reaches it.
|
||||
suspend fun purchase(level: BadgeLevel, period: BadgePeriod): Pair<BadgePurchaseOutcome, String> {
|
||||
val id = badgeStoreProductId(level, period)
|
||||
if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId)
|
||||
if (useBadgeTestProducts) {
|
||||
return BadgePurchaseOutcome.Purchased(
|
||||
// a subscription is never sent to core, so nothing would ever finish it later
|
||||
if (id.productId !in badgeOneTimeProductIds) {
|
||||
val invoiceId = newBadgeInvoiceId()
|
||||
val outcome = storePurchase(id, invoiceId)
|
||||
if (outcome is BadgePurchaseOutcome.Purchased) finish(outcome.receipt)
|
||||
return outcome to invoiceId
|
||||
}
|
||||
val rhId = chatModel.remoteHostId()
|
||||
val userId = chatModel.currentUser.value?.userId ?: throw BadgeStoreError.NoActiveProfile
|
||||
val invoiceId = chatModel.controller.apiCreateBadgeInvoice(rhId, userId)
|
||||
withContext(Dispatchers.Main) { buying.value += invoiceId }
|
||||
chatModel.controller.loadBadgeState(rhId)
|
||||
try {
|
||||
val outcome = storePurchase(id, invoiceId)
|
||||
when (outcome) {
|
||||
// finished only once the service answers for it, as an unfinished purchase is what the store re-delivers
|
||||
is BadgePurchaseOutcome.Purchased -> presentPurchase(outcome.receipt, interactive = true)
|
||||
is BadgePurchaseOutcome.Cancelled -> closeInvoice(userId, invoiceId)
|
||||
is BadgePurchaseOutcome.Pending -> {}
|
||||
}
|
||||
return outcome to invoiceId
|
||||
} catch (e: Exception) {
|
||||
closeInvoice(userId, invoiceId)
|
||||
throw e
|
||||
} finally {
|
||||
withContext(Dispatchers.Main + NonCancellable) { buying.value -= invoiceId }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storePurchase(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome {
|
||||
val outcome = if (useBadgeTestProducts) {
|
||||
BadgePurchaseOutcome.Purchased(
|
||||
BadgeStoreReceipt(
|
||||
token = "test-${UUID.randomUUID()}",
|
||||
productId = id.productId,
|
||||
@@ -164,8 +264,121 @@ object BadgeStore {
|
||||
environment = "test products"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
platform.androidPurchaseBadge(id, invoiceId)
|
||||
}
|
||||
if (outcome is BadgePurchaseOutcome.Pending) withContext(Dispatchers.Main) { waitingForApproval.value = true }
|
||||
return outcome
|
||||
}
|
||||
|
||||
// only the purchase the user started may alert: an answer can reveal a profile other than the one on screen
|
||||
suspend fun presentPurchase(receipt: BadgeStoreReceipt, interactive: Boolean) {
|
||||
val rhId = chatModel.remoteHostId()
|
||||
val userId = chatModel.currentUser.value?.userId ?: return
|
||||
if (!claim(receipt)) return
|
||||
try {
|
||||
when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token), retry = interactive)) {
|
||||
is BadgePurchaseResult.Redeemed -> {
|
||||
withContext(Dispatchers.Main) {
|
||||
// finished below whichever profile was credited, as it is paid for; only the profile on screen shows it
|
||||
if (chatModel.controller.activeUser(rhId, r.user)) {
|
||||
BadgeModel.set(rhId, r.user.userId, r.badgeState)
|
||||
chatModel.updateUser(r.user)
|
||||
if (r.badgeState?.shown == true) appPrefs.supporterBannerShown.set(true)
|
||||
}
|
||||
}
|
||||
finish(receipt)
|
||||
}
|
||||
is BadgePurchaseResult.Failed -> {
|
||||
Log.e(TAG, "BadgeStore.presentPurchase: ${r.err?.string}")
|
||||
val refused = badgeReceiptRefused(r.err)
|
||||
if (refused) finish(receipt)
|
||||
if (interactive) {
|
||||
val text = chatModel.controller.redeemErrorText(r.err)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badges_purchase_error),
|
||||
text = if (refused) text else text + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry)
|
||||
)
|
||||
}
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
} finally {
|
||||
chatModel.controller.loadBadgeState(rhId)
|
||||
withContext(Dispatchers.Main + NonCancellable) { presenting.remove(receipt.token) }
|
||||
}
|
||||
}
|
||||
|
||||
// at launch and on return to the foreground, never on a timer
|
||||
suspend fun presentUnfinished() {
|
||||
try {
|
||||
if (useBadgeTestProducts || !platform.androidHasPlatformStore) return
|
||||
val purchases = try {
|
||||
platform.androidUnfinishedBadgePurchases()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}")
|
||||
return
|
||||
}
|
||||
// Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here
|
||||
withContext(Dispatchers.Main) { waitingForApproval.value = purchases.any { it is BadgePurchaseOutcome.Pending } }
|
||||
val held = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.invoiceId }.toSet()
|
||||
purchases.forEach { reconcile(it) }
|
||||
closeAbandoned(held)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true }
|
||||
}
|
||||
}
|
||||
|
||||
// A record no held purchase names and no purchase here waits on. If the store still charges for one,
|
||||
// as when this runs between an invoice's creation and its purchase starting, the receipt reopens it.
|
||||
private suspend fun closeAbandoned(held: Set<String>) {
|
||||
val userId = chatModel.currentUser.value?.userId ?: return
|
||||
chatModel.controller.loadBadgeState(chatModel.remoteHostId())
|
||||
val abandoned = withContext(Dispatchers.Main) {
|
||||
openStorePurchases(userId)
|
||||
.mapNotNull { if (it.transactionRef == null) it.invoiceId else null }
|
||||
.filter { it !in held && it !in buying.value }
|
||||
}
|
||||
abandoned.forEach { closeInvoice(userId, it) }
|
||||
}
|
||||
|
||||
private suspend fun closeInvoice(userId: Long, invoiceId: String) {
|
||||
val rhId = chatModel.remoteHostId()
|
||||
try {
|
||||
chatModel.controller.apiCloseBadgeInvoice(rhId, userId, invoiceId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "BadgeStore.closeInvoice: ${e.message}")
|
||||
}
|
||||
chatModel.controller.loadBadgeState(rhId)
|
||||
}
|
||||
|
||||
suspend fun reconcile(outcome: BadgePurchaseOutcome) {
|
||||
when (outcome) {
|
||||
is BadgePurchaseOutcome.Purchased ->
|
||||
if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt)
|
||||
else presentPurchase(outcome.receipt, interactive = false)
|
||||
is BadgePurchaseOutcome.Pending -> withContext(Dispatchers.Main) { waitingForApproval.value = true }
|
||||
is BadgePurchaseOutcome.Cancelled -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// one request per purchase: the purchase itself, launch, foreground and the store can each present it
|
||||
private suspend fun claim(receipt: BadgeStoreReceipt): Boolean = withContext(Dispatchers.Main) {
|
||||
if (!presenting.add(receipt.token)) return@withContext false
|
||||
unfinished.value += receipt.token to receipt
|
||||
// a slow payment that completes arrives as a purchase, which ends the wait
|
||||
waitingForApproval.value = false
|
||||
true
|
||||
}
|
||||
|
||||
private suspend fun finish(receipt: BadgeStoreReceipt) {
|
||||
try {
|
||||
if (!useBadgeTestProducts) platform.androidFinishBadgePurchase(receipt)
|
||||
withContext(Dispatchers.Main) { unfinished.value -= receipt.token }
|
||||
} catch (e: Exception) {
|
||||
// still unfinished: the store re-delivers it, and the next answer for it finishes it
|
||||
Log.e(TAG, "BadgeStore.finish: ${e.message}")
|
||||
}
|
||||
return platform.androidPurchaseBadge(id, invoiceId)
|
||||
}
|
||||
|
||||
private fun startLoading(): Boolean = when (state.value) {
|
||||
@@ -190,3 +403,10 @@ private fun compactPrice(product: BadgeProduct): String {
|
||||
product.displayPrice
|
||||
}
|
||||
}
|
||||
|
||||
// the only answers after which the receipt will never be credited, so the store may stop re-delivering it
|
||||
private fun badgeReceiptRefused(err: ChatError?): Boolean {
|
||||
val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError
|
||||
val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError
|
||||
return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed
|
||||
}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package chat.simplex.common.views.badges
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingActionButton
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: ModalManager) {
|
||||
val purchasing = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
CloseWhenPurchaseInFlight(modalManager)
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
maxIntrinsicSize = true,
|
||||
) {
|
||||
Text(
|
||||
stringResource(MR.strings.badges_check_your_order_title),
|
||||
style = MaterialTheme.typography.h1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Column(Modifier.fillMaxWidth().padding(top = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OrderRow(MR.strings.badges_your_badge, stringResource(level.title))
|
||||
OrderRow(MR.strings.badges_order_duration, stringResource(period.label))
|
||||
OrderRow(MR.strings.badges_order_total, period.priceText(BadgeStore.price(level, period)))
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(MR.strings.badges_charged_by_play),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 20.dp))
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
PayButton(level, period, purchasing)
|
||||
BadgeBillingFooter(period)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OrderRow(title: StringResource, value: String) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(title), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.secondary)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(value, style = MaterialTheme.typography.body1)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PayButton(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState<Boolean>) {
|
||||
val price = BadgeStore.price(level, period)
|
||||
val (labelId, labelArg) = period.payLabel(price)
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = labelId,
|
||||
labelArg = labelArg,
|
||||
onboarding = null,
|
||||
enabled = price.canPurchase && !purchasing.value && BadgeStore.canBuy(chatModel.currentUser.value?.userId),
|
||||
onclick = { purchase(level, period, purchasing) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState<Boolean>) {
|
||||
purchasing.value = true
|
||||
// not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls
|
||||
withLongRunningApi {
|
||||
try {
|
||||
val (outcome, invoiceId) = BadgeStore.purchase(level, period)
|
||||
if (outcome is BadgePurchaseOutcome.Purchased && outcome.receipt.productId !in badgeOneTimeProductIds) {
|
||||
showPurchasedAlert(outcome.receipt, invoiceId)
|
||||
}
|
||||
purchasing.value = false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "BadgesCheckOrderView.purchase: ${e.stackTraceToString()}")
|
||||
purchasing.value = false
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badges_purchase_error),
|
||||
text = e.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [badges] store integration diagnostics - replaced by the issued badge once subscriptions are delivered.
|
||||
private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String) {
|
||||
val returnedInvoice = when (receipt.invoiceId) {
|
||||
null -> "none"
|
||||
invoiceId -> "yes"
|
||||
else -> "mismatch: ${receipt.invoiceId}"
|
||||
}
|
||||
val lines = mutableListOf(
|
||||
"Product: ${receipt.productId}",
|
||||
"Invoice: $invoiceId",
|
||||
"Invoice returned by Google: $returnedInvoice",
|
||||
"Order: ${receipt.orderId ?: "none"}"
|
||||
)
|
||||
if (receipt.environment != null) lines.add("Environment: ${receipt.environment}")
|
||||
val summary = lines.joinToString("\n")
|
||||
Log.d(TAG, "badge purchase succeeded\n$summary")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badges_purchase_successful),
|
||||
text = summary
|
||||
)
|
||||
}
|
||||
+8
-6
@@ -58,6 +58,7 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) {
|
||||
var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) }
|
||||
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
CloseWhenPurchaseInFlight(modalManager)
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
@@ -108,11 +109,12 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) {
|
||||
// the action button (matches onboarding pattern where its own 7.5dp top padding is the gap).
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ContinueButton(selectedLevel, modalManager)
|
||||
TextButtonBelowOnboardingButton(
|
||||
text = stringResource(MR.strings.badges_how_it_works_button),
|
||||
icon = painterResource(MR.images.ic_info),
|
||||
onClick = { modalManager.showModal { BadgesHowItWorksView() } }
|
||||
)
|
||||
// redeeming a code is here only when Support SimpleX offers the browser instead
|
||||
if (badgeBrowserAllowed()) {
|
||||
RedeemCodeButton(modalManager)
|
||||
} else {
|
||||
TextButtonBelowOnboardingButton("", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +156,7 @@ private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager
|
||||
labelId = MR.strings.badges_continue,
|
||||
onboarding = null,
|
||||
onclick = {
|
||||
modalManager.showModal { BadgesHowLongView(selectedLevel) }
|
||||
modalManager.showModal { BadgesHowLongView(selectedLevel, modalManager) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+28
-88
@@ -10,9 +10,6 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -68,12 +65,11 @@ enum class BadgePeriod {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgesHowLongView(level: BadgeLevel) {
|
||||
var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) }
|
||||
val purchasing = remember { mutableStateOf(false) }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager) {
|
||||
var selectedPeriod by remember { mutableStateOf(if (BadgePeriod.Monthly in badgePeriodsForSale) BadgePeriod.Monthly else BadgePeriod.OneMonth) }
|
||||
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
CloseWhenPurchaseInFlight(modalManager)
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
@@ -102,36 +98,41 @@ fun BadgesHowLongView(level: BadgeLevel) {
|
||||
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 8.dp))
|
||||
|
||||
// IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one -
|
||||
// IntrinsicSize.Max + fillMaxHeight on children so the cards all match the tallest one -
|
||||
// only Annual carries a savings line, and prices wrap at large fonts.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(IntrinsicSize.Max),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
badgePeriodsForSale.forEach { period ->
|
||||
PeriodCard(level, period, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 8.dp))
|
||||
|
||||
// Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a
|
||||
// TextButton so the footer has no hover/click affordance.
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
PayButton(level, selectedPeriod, purchasing, clipboard)
|
||||
Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()),
|
||||
Modifier.padding(vertical = 5.dp),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
ContinueButton(level, selectedPeriod, modalManager)
|
||||
BadgeBillingFooter(selectedPeriod)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a
|
||||
// TextButton so the footer has no hover/click affordance.
|
||||
@Composable
|
||||
fun BadgeBillingFooter(period: BadgePeriod) {
|
||||
Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
stringResource(billingFooter(period)).format(stubBillingDate()),
|
||||
Modifier.padding(vertical = 5.dp),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) {
|
||||
val isSelected = period == selectedPeriod
|
||||
@@ -173,76 +174,15 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B
|
||||
private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? =
|
||||
if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null
|
||||
|
||||
// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and
|
||||
// this button stays disabled - it will offer Stripe/crypto payment instead
|
||||
@Composable
|
||||
private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState<Boolean>, clipboard: ClipboardManager) {
|
||||
val price = BadgeStore.price(level, selectedPeriod)
|
||||
val (labelId, labelArg) = selectedPeriod.payLabel(price)
|
||||
private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager) {
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = labelId,
|
||||
labelArg = labelArg,
|
||||
labelId = MR.strings.badges_continue,
|
||||
onboarding = null,
|
||||
enabled = price.canPurchase && !purchasing.value,
|
||||
onclick = { purchase(level, selectedPeriod, purchasing, clipboard) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState<Boolean>, clipboard: ClipboardManager) {
|
||||
val invoiceId = newBadgeInvoiceId()
|
||||
purchasing.value = true
|
||||
// not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls
|
||||
withLongRunningApi {
|
||||
try {
|
||||
val outcome = BadgeStore.purchase(level, period, invoiceId)
|
||||
purchasing.value = false
|
||||
when (outcome) {
|
||||
is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard)
|
||||
is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badges_purchase_pending),
|
||||
text = generalGetString(MR.strings.badges_purchase_pending_desc)
|
||||
)
|
||||
is BadgePurchaseOutcome.Cancelled -> {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "BadgesHowLongView.purchase: ${e.stackTraceToString()}")
|
||||
purchasing.value = false
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badges_purchase_error),
|
||||
text = e.toString()
|
||||
)
|
||||
onclick = {
|
||||
modalManager.showModal { BadgesCheckOrderView(level, selectedPeriod, modalManager) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands.
|
||||
private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) {
|
||||
val returnedInvoice = when (receipt.invoiceId) {
|
||||
null -> "none"
|
||||
invoiceId -> "yes"
|
||||
else -> "mismatch: ${receipt.invoiceId}"
|
||||
}
|
||||
val lines = mutableListOf(
|
||||
"Product: ${receipt.productId}",
|
||||
"Invoice: $invoiceId",
|
||||
"Invoice returned by Google: $returnedInvoice",
|
||||
"Order: ${receipt.orderId ?: "none"}"
|
||||
)
|
||||
if (receipt.environment != null) lines.add("Environment: ${receipt.environment}")
|
||||
lines.add("Token: ${receipt.token.length} bytes")
|
||||
val summary = lines.joinToString("\n")
|
||||
Log.d(TAG, "badge purchase succeeded\n$summary")
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.badges_purchase_successful),
|
||||
text = summary,
|
||||
confirmText = "Copy token",
|
||||
onConfirm = {
|
||||
clipboard.setText(AnnotatedString(receipt.token))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
},
|
||||
dismissText = generalGetString(MR.strings.ok),
|
||||
parseHtml = false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package chat.simplex.common.views.badges
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
fun BadgesPurchaseStateView(purchaseState: BadgePurchaseState) {
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
maxIntrinsicSize = true,
|
||||
) {
|
||||
Text(
|
||||
stringResource(purchaseState.title),
|
||||
style = MaterialTheme.typography.h1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(purchaseState.message),
|
||||
style = MaterialTheme.typography.body1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
private val BadgePurchaseState.title: StringResource
|
||||
get() = when (this) {
|
||||
BadgePurchaseState.Issuing -> MR.strings.badges_issuing_title
|
||||
BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_title
|
||||
BadgePurchaseState.Checking -> MR.strings.badges_checking_title
|
||||
}
|
||||
|
||||
private val BadgePurchaseState.message: StringResource
|
||||
get() = when (this) {
|
||||
BadgePurchaseState.Issuing -> MR.strings.badges_issuing_body
|
||||
BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_body
|
||||
BadgePurchaseState.Checking -> MR.strings.badges_checking_body
|
||||
}
|
||||
+1
@@ -99,6 +99,7 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) {
|
||||
val code = remember { mutableStateOf(TextFieldValue("")) }
|
||||
val canonicalCode = remember { mutableStateOf<String?>(null) }
|
||||
val submitting = remember { mutableStateOf(false) }
|
||||
CloseWhenPurchaseInFlight(modalManager)
|
||||
|
||||
// when the text is unchanged, the field's own value is kept: it carries the cursor position and the
|
||||
// keyboard's composition state, which BasicTextField loses unless they are passed back to it
|
||||
|
||||
+23
-7
@@ -55,8 +55,6 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// TODO [badges] restore WhyBuiltButton() when in-app purchase lands: the level screen
|
||||
// returns to the flow and HowItWorksButton() moves there, leaving this one alone here.
|
||||
HowItWorksButton(modalManager)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
@@ -66,19 +64,28 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
BuyInBrowserButton(modalManager)
|
||||
RedeemCodeButton(modalManager)
|
||||
if (platform.androidHasPlatformStore) {
|
||||
ChooseLevelButton(modalManager)
|
||||
if (badgeBrowserAllowed()) {
|
||||
BuyInBrowserTextButton()
|
||||
} else {
|
||||
RedeemCodeButton(modalManager)
|
||||
}
|
||||
} else {
|
||||
BuyInBrowserButton(modalManager)
|
||||
RedeemCodeButton(modalManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the in-app purchase path, kept compiling and uncalled until payments return after the MVP
|
||||
@Composable
|
||||
private fun ChooseLevelButton(modalManager: ModalManager) {
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = MR.strings.badges_choose_your_level,
|
||||
labelId = MR.strings.badges_choose_your_badge_title,
|
||||
onboarding = null,
|
||||
enabled = BadgeStore.canBuy(chatModel.currentUser.value?.userId),
|
||||
onclick = {
|
||||
modalManager.showModal { BadgesChooseBadgeView(modalManager) }
|
||||
}
|
||||
@@ -111,13 +118,22 @@ private fun HowItWorksButton(modalManager: ModalManager) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RedeemCodeButton(modalManager: ModalManager) {
|
||||
fun RedeemCodeButton(modalManager: ModalManager) {
|
||||
TextButtonBelowOnboardingButton(
|
||||
text = stringResource(MR.strings.badges_redeem_code_button),
|
||||
onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager) } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuyInBrowserTextButton() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
TextButtonBelowOnboardingButton(
|
||||
text = stringResource(MR.strings.badges_buy_in_browser),
|
||||
onClick = { uriHandler.openUriCatching(badgePageUrl) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuyInBrowserButton(modalManager: ModalManager) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
+19
-1
@@ -2,6 +2,7 @@ package chat.simplex.common.views.badges
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import chat.simplex.common.model.BadgeModel
|
||||
import chat.simplex.common.model.BadgeState
|
||||
import chat.simplex.common.platform.chatModel
|
||||
@@ -19,9 +20,16 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) {
|
||||
|
||||
// the card look is a modal setting, so the modal is composed here to follow the screen shown
|
||||
ModalView(close, cardScreen = shownBadge != null) {
|
||||
AnimatedContent(targetState = shownBadge, transitionSpec = { fadeIn() with fadeOut() }, contentKey = { it != null }) { badgeState ->
|
||||
AnimatedContent(
|
||||
targetState = shownBadge to BadgeStore.purchaseState(chatModel.currentUser.value?.userId),
|
||||
transitionSpec = { fadeIn() with fadeOut() },
|
||||
contentKey = { (badgeState, purchaseState) -> (badgeState != null) to purchaseState }
|
||||
) { (badgeState, purchaseState) ->
|
||||
if (badgeState != null) {
|
||||
BadgesYourBadgeView(badgeState, modalManager)
|
||||
} else if (purchaseState != null) {
|
||||
// holds the purchase screens' slot, so a consumable cannot be bought twice
|
||||
BadgesPurchaseStateView(purchaseState)
|
||||
} else {
|
||||
BadgesSupportSimplexView(modalManager)
|
||||
}
|
||||
@@ -29,6 +37,16 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// each purchase screen closes itself when it recomposes with a purchase in flight, as iOS pops them
|
||||
// when Support SimpleX gives way to it
|
||||
@Composable
|
||||
fun CloseWhenPurchaseInFlight(modalManager: ModalManager) {
|
||||
val inFlight = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null
|
||||
LaunchedEffect(inFlight) {
|
||||
if (inFlight) modalManager.closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
// ModalManager.end, not start: every caller is in the chat, which on desktop is the right pane
|
||||
fun openBadgesView() {
|
||||
ModalManager.end.showCustomModal { close -> BadgesView(ModalManager.end, close) }
|
||||
|
||||
+8
@@ -78,6 +78,14 @@ fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) {
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
if (BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null) {
|
||||
SectionView(stringResource(MR.strings.badges_store_purchase_title)) {
|
||||
SectionItemView {
|
||||
Text(stringResource(MR.strings.badges_store_purchase_processing), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
if (appPrefs.developerTools.get()) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
SectionView(stringResource(MR.strings.badges_credential)) {
|
||||
|
||||
@@ -3165,7 +3165,6 @@
|
||||
<string name="badges_support_simplex_title">Support SimpleX</string>
|
||||
<string name="badges_support_simplex_body">Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile.</string>
|
||||
<string name="badges_why_simplex_is_built">Why SimpleX is built.</string>
|
||||
<string name="badges_choose_your_level">Choose your level</string>
|
||||
<string name="badges_redeem_code_button">Redeem badge code</string>
|
||||
<string name="badges_continue">Continue</string>
|
||||
<string name="badges_how_it_works_button">How badges protect your privacy</string>
|
||||
@@ -3180,9 +3179,20 @@
|
||||
<string name="badges_banner_dismiss_message">You can support SimpleX later in Settings.</string>
|
||||
<string name="badges_larger_files_longer">Support SimpleX to send larger files that stay available longer</string>
|
||||
<string name="badges_purchase_successful">Purchase successful</string>
|
||||
<string name="badges_purchase_pending">Purchase pending</string>
|
||||
<string name="badges_purchase_pending_desc">The purchase is awaiting approval. This build does not deliver purchases approved later.</string>
|
||||
<string name="badges_purchase_error">Purchase error</string>
|
||||
<string name="badges_purchase_will_retry">The purchase will be retried, and the badge will arrive.</string>
|
||||
<string name="badges_check_your_order_title">Check your order</string>
|
||||
<string name="badges_order_duration">Duration</string>
|
||||
<string name="badges_order_total">Total</string>
|
||||
<string name="badges_charged_by_play">Charged by Google Play to the account on this device.</string>
|
||||
<string name="badges_issuing_title">Issuing your badge</string>
|
||||
<string name="badges_issuing_body">The store has taken the payment. Keeps trying if you close the app.</string>
|
||||
<string name="badges_waiting_for_approval_title">Waiting for approval</string>
|
||||
<string name="badges_waiting_for_approval_body">Nothing has been charged.</string>
|
||||
<string name="badges_checking_title">Checking your purchase</string>
|
||||
<string name="badges_checking_body">The store has not confirmed a purchase yet.</string>
|
||||
<string name="badges_store_purchase_title">Store purchase</string>
|
||||
<string name="badges_store_purchase_processing">A store purchase is being processed.</string>
|
||||
<string name="badges_buy_in_browser">Buy in browser</string>
|
||||
<string name="badges_redeem_code_title">Redeem code</string>
|
||||
<string name="badges_link_confirm_title">Add badge to your profile?</string>
|
||||
@@ -3211,6 +3221,8 @@
|
||||
<string name="badges_error_unsupported_version">This app version is too old for the badge service. Please update the app.</string>
|
||||
<string name="badges_error_unknown_purchase">The badge service does not recognize this badge.</string>
|
||||
<string name="badges_error_service_internal">The badge service reported an internal error.</string>
|
||||
<string name="badges_error_receipt_invalid">The store did not confirm this purchase.</string>
|
||||
<string name="badges_error_receipt_used">This purchase was already credited to a badge on another device or profile.</string>
|
||||
<string name="badges_error_badge_ended">The code was accepted, but the badge it grants has already ended.</string>
|
||||
<string name="badges_error_unknown">The code could not be redeemed.</string>
|
||||
<string name="badges_code_you_scanned_is_not_badge_code">The code you scanned is not a badge code.</string>
|
||||
|
||||
@@ -356,11 +356,13 @@ undocumentedCommands =
|
||||
"APIChatUnread",
|
||||
"APICheckToken",
|
||||
"APIClearChat",
|
||||
"APICloseBadgeInvoice",
|
||||
"APIConnectContactViaAddress",
|
||||
"APIConnectPreparedContact",
|
||||
"APIConnectPreparedGroup",
|
||||
"APIContactInfo",
|
||||
"APIContactQueueInfo",
|
||||
"APICreateBadgeInvoice",
|
||||
"APICreateChatItems",
|
||||
"APICreateChatTag",
|
||||
"APICreateMemberContact",
|
||||
|
||||
@@ -134,6 +134,7 @@ undocumentedResponses =
|
||||
"CRAppSettings",
|
||||
"CRArchiveExported",
|
||||
"CRArchiveImported",
|
||||
"CRBadgeInvoice",
|
||||
"CRBadgeLedger",
|
||||
"CRBadgeRedeemed",
|
||||
"CRBadgeState",
|
||||
|
||||
@@ -30,6 +30,7 @@ module Simplex.Chat.Badges.Types
|
||||
BadgeAlert (..),
|
||||
BadgeAlertPrice (..),
|
||||
BadgeState (..),
|
||||
BadgeStorePurchase (..),
|
||||
) where
|
||||
|
||||
import Control.Applicative ((<|>))
|
||||
@@ -282,6 +283,14 @@ data BadgeState = BadgeState
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
-- | A store purchase this profile started or presented, neither credited nor closed. Neither
|
||||
-- reference is a secret: Apple's is the transaction id, Google's is a hash of the token.
|
||||
data BadgeStorePurchase = BadgeStorePurchase
|
||||
{ invoiceId :: Maybe Text,
|
||||
transactionRef :: Maybe Text
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
instance TextEncoding BadgePurchaseStatus where
|
||||
textEncode = \case
|
||||
PSAcquiring -> "acquiring"
|
||||
@@ -413,3 +422,5 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "BIF") ''BadgeIssueFailure)
|
||||
$(JQ.deriveJSON defaultJSON ''BadgeIssueError)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''BadgeState)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''BadgeStorePurchase)
|
||||
|
||||
@@ -85,7 +85,7 @@ import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyM
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge)
|
||||
import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry)
|
||||
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..))
|
||||
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..), BadgeStorePurchase (..))
|
||||
import Simplex.Chat.PaymentService (ServicePayment)
|
||||
import Simplex.Messaging.Crypto.BBS (BBSPublicKey)
|
||||
import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
||||
@@ -660,7 +660,9 @@ data ChatCommand
|
||||
| UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file
|
||||
| AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`)
|
||||
| APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service
|
||||
| APIPurchaseBadge {userId :: UserId, payment :: ServicePayment} -- redeem an App Store or Google Play purchase for a badge
|
||||
| APIPurchaseBadge {userId :: UserId, echoedInvoiceId :: Maybe Text, payment :: ServicePayment} -- redeem an App Store or Google Play purchase for a badge
|
||||
| APICreateBadgeInvoice {userId :: UserId} -- the record of a store purchase, created before the store charges; answers the id the store echoes
|
||||
| APICloseBadgeInvoice {userId :: UserId, invoiceId :: Text} -- a store purchase that came to nothing
|
||||
| APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert
|
||||
| APIGetBadgeLedger {userId :: UserId, badgePurchaseId :: Int64} -- the purchase's ledger, oldest first
|
||||
-- episode is last because it is free text: it is the value that makes one occurrence of an
|
||||
@@ -871,7 +873,8 @@ data ChatResponse
|
||||
| CRServiceResponse {user :: User, responseData :: J.Object}
|
||||
| CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId}
|
||||
| CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState}
|
||||
| CRBadgeState {user :: User, badgeState :: Maybe BadgeState}
|
||||
| CRBadgeInvoice {user :: User, invoiceId :: Text}
|
||||
| CRBadgeState {user :: User, badgeState :: Maybe BadgeState, storePurchases :: [BadgeStorePurchase]}
|
||||
| CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]}
|
||||
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
|
||||
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
|
||||
|
||||
@@ -3563,11 +3563,23 @@ processChatCommand cxt nm = \case
|
||||
ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile)
|
||||
AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user
|
||||
APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText
|
||||
APIPurchaseBadge userId payment -> withUserId userId $ \user -> purchaseBadge nm user payment
|
||||
APIPurchaseBadge userId echoedInvoiceId payment -> withUserId userId $ \user -> purchaseBadge nm user echoedInvoiceId payment
|
||||
APICreateBadgeInvoice userId -> withUserId userId $ \user -> do
|
||||
void badgeServiceTarget
|
||||
refuseWhileBadgeHeld user
|
||||
g <- asks random
|
||||
now <- liftIO getCurrentTime
|
||||
invoiceId <- UUID.toText <$> liftIO V4.nextRandom
|
||||
_ <- withStore' $ \db -> createBadgeStoreReceipt db g user (Just invoiceId) Nothing now
|
||||
pure $ CRBadgeInvoice user invoiceId
|
||||
APICloseBadgeInvoice userId invoiceId -> withUserId userId $ \user -> do
|
||||
now <- liftIO getCurrentTime
|
||||
withStore' $ \db -> closeBadgeStoreInvoice db user invoiceId now
|
||||
ok user
|
||||
APIGetBadgeState userId -> withUserId' userId $ \user -> do
|
||||
-- the read also signals the worker, whose results follow as CEvtBadgeChanged
|
||||
lift $ startBadgeWork user
|
||||
CRBadgeState user <$> getUserBadgeState user
|
||||
badgeStateResponse user
|
||||
APIGetBadgeLedger userId badgePurchaseId -> withUserId userId $ \user ->
|
||||
CRBadgeLedger user <$> withStore' (\db -> getBadgeLedger db user badgePurchaseId)
|
||||
APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do
|
||||
@@ -3576,7 +3588,7 @@ processChatCommand cxt nm = \case
|
||||
withStore' $ \db -> setBadgeAlertAcked db user badgePurchaseId alertKind episode snoozeUntil
|
||||
-- after the write, so the pass it signals arms a wake for the snooze rather than raising again
|
||||
lift $ startBadgeWork user
|
||||
CRBadgeState user <$> getUserBadgeState user
|
||||
badgeStateResponse user
|
||||
SetBotCommands commands -> withUser $ \user@User {profile} -> do
|
||||
let LocalProfile {preferences} = profile
|
||||
prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands}
|
||||
@@ -5221,7 +5233,7 @@ presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadg
|
||||
redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse
|
||||
redeemBadgeCode nm user@User {userId} codeText = do
|
||||
code <- maybe (throwRedeemError BREInvalidCode) pure $ parseBadgeCode codeText
|
||||
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure
|
||||
sendTarget <- badgeServiceTarget
|
||||
g <- asks random
|
||||
now <- liftIO getCurrentTime
|
||||
let codeSent = badgeCodeText code
|
||||
@@ -5244,16 +5256,16 @@ redeemBadgeCode nm user@User {userId} codeText = do
|
||||
|
||||
-- | The app presents a purchase until this returns its badge, under whichever profile is active; the
|
||||
-- stash stays with the profile it was first presented under, so every retry is the signer first credited.
|
||||
purchaseBadge :: NetworkRequestMode -> User -> ServicePayment -> CM ChatResponse
|
||||
purchaseBadge nm presentingUser payment = do
|
||||
purchaseBadge :: NetworkRequestMode -> User -> Maybe Text -> ServicePayment -> CM ChatResponse
|
||||
purchaseBadge nm presentingUser echoedInvoiceId payment = do
|
||||
txRef <- maybe (throwRedeemError BREInvalidReceipt) pure $ storeTransactionRef payment
|
||||
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure
|
||||
sendTarget <- badgeServiceTarget
|
||||
g <- asks random
|
||||
now <- liftIO getCurrentTime
|
||||
user@User {userId} <- withStore $ \db -> liftIO (getBadgeStoreReceiptUserId db txRef) >>= maybe (pure presentingUser) (getUser db)
|
||||
user@User {userId} <- withStore $ \db -> liftIO (attachBadgeStoreReceipt db echoedInvoiceId txRef) >>= maybe (pure presentingUser) (getUser db)
|
||||
(present_, purchased) <- withEntityLock "badgePurchase" (CLBadgeUser userId) $ do
|
||||
stash_ <- withStore' $ \db -> getBadgeStoreReceipt db user txRef
|
||||
stash@BadgeStash {masterKey} <- stashBadgeKeys user stash_ $ \db -> createBadgeStoreReceipt db g user txRef now
|
||||
stash@BadgeStash {masterKey} <- stashBadgeKeys user stash_ $ \db -> createBadgeStoreReceipt db g user echoedInvoiceId (Just txRef) now
|
||||
requestStashedBadge nm user sendTarget stash BSCPurchaseBadge {masterKey, payment, upgrade = Nothing} terminalReceiptError
|
||||
-- outside the badge lock: the chat lock must not be taken under it
|
||||
mapM_ presentUserBadgeToContacts present_
|
||||
@@ -5278,9 +5290,15 @@ storeTransactionRef = \case
|
||||
stashBadgeKeys :: User -> Maybe BadgeStash -> (DB.Connection -> IO BadgeStash) -> CM BadgeStash
|
||||
stashBadgeKeys user stash_ createStash = do
|
||||
replaying <- maybe (pure False) (\s -> withStore' $ \db -> isJust <$> getStashBadgePurchase db s) stash_
|
||||
unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive
|
||||
unless replaying $ refuseWhileBadgeHeld user
|
||||
maybe (withStore' createStash) pure stash_
|
||||
|
||||
badgeServiceTarget :: CM (ConnectTarget 'CMContact)
|
||||
badgeServiceTarget = asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure
|
||||
|
||||
refuseWhileBadgeHeld :: User -> CM ()
|
||||
refuseWhileBadgeHeld user = whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive
|
||||
|
||||
-- | A refusal for which stashDead holds drops the stash; any other, and a timeout, keep it.
|
||||
requestStashedBadge :: NetworkRequestMode -> User -> ConnectTarget 'CMContact -> BadgeStash -> BadgeServiceCommand -> (BadgeServiceErrorCode -> Bool) -> CM (Maybe User, ChatResponse)
|
||||
requestStashedBadge nm user sendTarget stash@BadgeStash {purchaseKey, purchasePrivKey} request stashDead = do
|
||||
@@ -5480,6 +5498,12 @@ emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} shownCred now
|
||||
raised <- atomically $ stateTVar emitted (,occurrence)
|
||||
when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert
|
||||
|
||||
-- | Scoped to one profile, so another profile's store purchase, which may be hidden, never shows under it.
|
||||
badgeStateResponse :: User -> CM ChatResponse
|
||||
badgeStateResponse user = do
|
||||
badgeState <- getUserBadgeState user
|
||||
CRBadgeState user badgeState <$> withStore' (`getOpenBadgeStorePurchases` user)
|
||||
|
||||
-- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged.
|
||||
getUserBadgeState :: User -> CM (Maybe BadgeState)
|
||||
getUserBadgeState user = do
|
||||
@@ -6150,7 +6174,9 @@ chatCommandP =
|
||||
"/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)),
|
||||
"/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP),
|
||||
"/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP),
|
||||
"/_badge purchase " *> (APIPurchaseBadge <$> A.decimal <* A.space <*> jsonP),
|
||||
"/_badge purchase " *> (APIPurchaseBadge <$> A.decimal <*> optional (" invoice=" *> (safeDecodeUtf8 <$> A.takeTill (== ' '))) <* A.space <*> jsonP),
|
||||
"/_badge invoice close " *> (APICloseBadgeInvoice <$> A.decimal <* A.space <*> textP),
|
||||
"/_badge invoice " *> (APICreateBadgeInvoice <$> A.decimal),
|
||||
"/_badge state " *> (APIGetBadgeState <$> A.decimal),
|
||||
"/_badge ledger " *> (APIGetBadgeLedger <$> A.decimal <* A.space <*> A.decimal),
|
||||
"/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP),
|
||||
|
||||
@@ -21,6 +21,9 @@ module Simplex.Chat.Store.Badges
|
||||
getBadgeCodeRedemption,
|
||||
createBadgeCodeRedemption,
|
||||
getBadgeStoreReceiptUserId,
|
||||
attachBadgeStoreReceipt,
|
||||
closeBadgeStoreInvoice,
|
||||
getOpenBadgeStorePurchases,
|
||||
getBadgeStoreReceipt,
|
||||
createBadgeStoreReceipt,
|
||||
deleteBadgeStash,
|
||||
@@ -36,6 +39,7 @@ module Simplex.Chat.Store.Badges
|
||||
where
|
||||
|
||||
import Control.Concurrent.STM (TVar, atomically)
|
||||
import Control.Monad (forM, forM_, join)
|
||||
import Crypto.Random (ChaChaDRG)
|
||||
import qualified Data.Aeson as J
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
@@ -47,7 +51,7 @@ import Data.Time.Clock (UTCTime)
|
||||
import Simplex.Chat.Badges
|
||||
import Simplex.Chat.Badges.Ledger
|
||||
import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..))
|
||||
import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..))
|
||||
import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..), BadgeStorePurchase (..))
|
||||
import Simplex.Chat.Store.Shared (insertedRowId)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Agent.Protocol (UserId)
|
||||
@@ -114,6 +118,47 @@ getBadgeStoreReceiptUserId db StoreTransactionRef {provider, transactionRef} =
|
||||
maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT user_id FROM badge_store_receipts WHERE provider = ? AND transaction_ref = ?" (provider, transactionRef)
|
||||
|
||||
-- | The profile a receipt belongs to, attaching it to the record created when Buy was tapped. A
|
||||
-- transaction already attached stays with its record, whose keys the service may have credited.
|
||||
attachBadgeStoreReceipt :: DB.Connection -> Maybe Text -> StoreTransactionRef -> IO (Maybe UserId)
|
||||
attachBadgeStoreReceipt db invoiceId_ txRef@StoreTransactionRef {provider, transactionRef} =
|
||||
getBadgeStoreReceiptUserId db txRef >>= \case
|
||||
Just userId -> pure $ Just userId
|
||||
Nothing -> fmap join . forM invoiceId_ $ \invoiceId -> do
|
||||
userId_ <-
|
||||
maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT user_id FROM badge_store_receipts WHERE invoice_id = ? AND transaction_ref IS NULL" (Only invoiceId)
|
||||
-- a closed record is reopened: new keys for a paid transaction would be refused as receipt_used,
|
||||
-- and the badge the old keys were credited with would be lost
|
||||
forM_ userId_ $ \_ ->
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, closed_at = NULL WHERE invoice_id = ?"
|
||||
(provider, transactionRef, invoiceId)
|
||||
pure userId_
|
||||
|
||||
-- | Only a record no receipt has reached can be closed.
|
||||
closeBadgeStoreInvoice :: DB.Connection -> User -> Text -> UTCTime -> IO ()
|
||||
closeBadgeStoreInvoice db User {userId} invoiceId now =
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE badge_store_receipts SET closed_at = ? WHERE user_id = ? AND invoice_id = ? AND transaction_ref IS NULL"
|
||||
(now, userId, invoiceId)
|
||||
|
||||
getOpenBadgeStorePurchases :: DB.Connection -> User -> IO [BadgeStorePurchase]
|
||||
getOpenBadgeStorePurchases db User {userId} =
|
||||
map (uncurry BadgeStorePurchase)
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT r.invoice_id, r.transaction_ref
|
||||
FROM badge_store_receipts r
|
||||
WHERE r.user_id = ? AND r.closed_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_store_receipt_id = r.badge_store_receipt_id)
|
||||
ORDER BY r.badge_store_receipt_id
|
||||
|]
|
||||
(Only userId)
|
||||
|
||||
getBadgeStoreReceipt :: DB.Connection -> User -> StoreTransactionRef -> IO (Maybe BadgeStash)
|
||||
getBadgeStoreReceipt db User {userId} StoreTransactionRef {provider, transactionRef} =
|
||||
maybeFirstRow (toBadgeStash BSRStoreReceipt) $
|
||||
@@ -126,17 +171,24 @@ getBadgeStoreReceipt db User {userId} StoreTransactionRef {provider, transaction
|
||||
|]
|
||||
(userId, provider, transactionRef)
|
||||
|
||||
createBadgeStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> StoreTransactionRef -> UTCTime -> IO BadgeStash
|
||||
createBadgeStoreReceipt db g User {userId} StoreTransactionRef {provider, transactionRef} now = do
|
||||
-- | An invoice id another record already holds is not repeated: the record is then keyed by its transaction alone.
|
||||
createBadgeStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> Maybe Text -> Maybe StoreTransactionRef -> UTCTime -> IO BadgeStash
|
||||
createBadgeStoreReceipt db g User {userId} invoiceId_ txRef_ now = do
|
||||
(purchaseKey, purchasePrivKey) <- atomically $ C.generateKeyPair g
|
||||
masterKey@(BadgeMasterKey mk) <- generateMasterKey g
|
||||
invoiceId' <- fmap join . forM invoiceId_ $ \invoiceId -> do
|
||||
taken <- DB.query db "SELECT badge_store_receipt_id FROM badge_store_receipts WHERE invoice_id = ?" (Only invoiceId) :: IO [Only Int64]
|
||||
pure $ if null taken then Just invoiceId else Nothing
|
||||
let (provider_, transactionRef_) = case txRef_ of
|
||||
Just StoreTransactionRef {provider, transactionRef} -> (Just provider, Just transactionRef)
|
||||
Nothing -> (Nothing, Nothing)
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO badge_store_receipts (user_id, provider, transaction_ref, purchase_key, purchase_priv_key, master_key, created_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
INSERT INTO badge_store_receipts (user_id, invoice_id, provider, transaction_ref, purchase_key, purchase_priv_key, master_key, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
(userId, provider, transactionRef, purchaseKey, purchasePrivKey, Binary mk, now)
|
||||
((userId, invoiceId', provider_, transactionRef_) :. (purchaseKey, purchasePrivKey, Binary mk, now))
|
||||
storeReceiptId <- insertedRowId db
|
||||
pure BadgeStash {stashRef = BSRStoreReceipt storeReceiptId, purchaseKey, purchasePrivKey, masterKey}
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ m20260925_badge_store_receipts =
|
||||
CREATE TABLE badge_store_receipts(
|
||||
badge_store_receipt_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
transaction_ref TEXT NOT NULL,
|
||||
invoice_id TEXT UNIQUE,
|
||||
provider TEXT,
|
||||
transaction_ref TEXT,
|
||||
purchase_key BYTEA NOT NULL,
|
||||
purchase_priv_key BYTEA NOT NULL,
|
||||
master_key BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
closed_at TIMESTAMPTZ,
|
||||
UNIQUE(provider, transaction_ref)
|
||||
);
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@ m20260925_badge_store_receipts =
|
||||
CREATE TABLE badge_store_receipts(
|
||||
badge_store_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
transaction_ref TEXT NOT NULL,
|
||||
invoice_id TEXT UNIQUE,
|
||||
provider TEXT,
|
||||
transaction_ref TEXT,
|
||||
purchase_key BLOB NOT NULL,
|
||||
purchase_priv_key BLOB NOT NULL,
|
||||
master_key BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
closed_at TEXT,
|
||||
UNIQUE(provider, transaction_ref)
|
||||
) STRICT;
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import Simplex.Chat.Markdown
|
||||
import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus)
|
||||
import Simplex.Chat.Badges.Ledger (creditTypeTag, debitTypeTag)
|
||||
import Simplex.Chat.Badges.Service (StatementEntry (..), StatementEntryType (..))
|
||||
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..))
|
||||
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..), BadgeStorePurchase (..))
|
||||
import Simplex.Chat.Messages hiding (NewChatItem (..))
|
||||
import Simplex.Chat.Messages.CIContent
|
||||
import Simplex.Chat.Operators
|
||||
@@ -193,7 +193,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
|
||||
CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)]
|
||||
-- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be
|
||||
CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"]
|
||||
CRBadgeState u st -> ttyUser u $ viewUserBadgeState st
|
||||
CRBadgeInvoice u invoiceId -> ttyUser u ["badge invoice: " <> plain invoiceId]
|
||||
CRBadgeState u st storePurchases -> ttyUser u $ viewUserBadgeState st <> map viewBadgeStorePurchase storePurchases
|
||||
CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries
|
||||
CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView
|
||||
CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView
|
||||
@@ -1863,6 +1864,10 @@ viewBadgeIssueError BadgeIssueError {failedSince, lastAttemptAt, reason} =
|
||||
viewBadgeAlert :: BadgeAlert -> [StyledString]
|
||||
viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date]
|
||||
|
||||
viewBadgeStorePurchase :: BadgeStorePurchase -> StyledString
|
||||
viewBadgeStorePurchase BadgeStorePurchase {invoiceId, transactionRef} =
|
||||
plain $ "store purchase open: invoice " <> fromMaybe "none" invoiceId <> maybe "" (", transaction " <>) transactionRef
|
||||
|
||||
viewBadgeLedger :: [StatementEntry] -> [StyledString]
|
||||
viewBadgeLedger [] = ["no ledger entries"]
|
||||
viewBadgeLedger entries = map viewEntry entries
|
||||
|
||||
@@ -36,6 +36,7 @@ import Data.Char (toLower)
|
||||
import Data.Either (isLeft, isRight)
|
||||
import Data.Int (Int64)
|
||||
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
|
||||
import Data.List (stripPrefix)
|
||||
import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (isJust, isNothing)
|
||||
import Data.String (fromString)
|
||||
@@ -139,6 +140,15 @@ badgeServiceTests = do
|
||||
it "should answer a receipt presented under a second profile as the profile that bought it" testPurchaseSameReceiptOtherProfile
|
||||
it "should deliver a purchase first presented under another profile to that profile" testPurchaseStrandedUnderOtherProfile
|
||||
it "should deliver a purchase to a hidden profile without naming it" testPurchaseDeliveredToHiddenProfile
|
||||
it "should credit a receipt to the profile that created its invoice, and answer the presenter as delivered" testInvoiceOtherProfile
|
||||
it "should resolve the same receipt to the same record, and replay its credential" testInvoiceSameReceiptTwice
|
||||
it "should credit a receipt naming an unknown invoice to the presenting profile" testInvoiceUnknown
|
||||
it "should reopen a closed record for a late receipt, and credit it" testInvoiceReopened
|
||||
it "should close only a record no receipt has reached" testInvoiceClose
|
||||
it "should refuse an invoice with no service configured or while a badge is held, creating no record" testInvoiceRefusedBeforeCharge
|
||||
it "should refuse a receipt for an invoice while a badge is held, keeping the record for a retry" testInvoiceWhileBadgeHeld
|
||||
it "should not close another profile's invoice" testInvoiceCloseOtherProfile
|
||||
it "should list only the asking profile's open store purchases" testInvoiceStateOtherProfile
|
||||
|
||||
badgeProfile :: Profile
|
||||
badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
|
||||
@@ -1744,3 +1754,152 @@ testPurchaseDeliveredToHiddenProfile ps =
|
||||
(alice </)
|
||||
alice ##> "/user alice password"
|
||||
showActiveUser alice "alice (Alice, * supporter)"
|
||||
|
||||
testInvoiceOtherProfile :: HasCallStack => TestParams -> IO ()
|
||||
testInvoiceOtherProfile ps =
|
||||
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} ->
|
||||
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
|
||||
invoiceId <- createInvoice alice 1
|
||||
alice ##> "/create user alisa"
|
||||
showActiveUser alice "alisa"
|
||||
alice ##> purchaseWithInvoice 2 invoiceId supporterPlay
|
||||
alice <## "badge purchase delivered to another profile"
|
||||
(alice </)
|
||||
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)]
|
||||
alice ##> "/user alice"
|
||||
showActiveUser alice "alice (Alice, * supporter)"
|
||||
|
||||
testInvoiceSameReceiptTwice :: HasCallStack => TestParams -> IO ()
|
||||
testInvoiceSameReceiptTwice ps =
|
||||
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} ->
|
||||
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
|
||||
invoiceId <- createInvoice alice 1
|
||||
alice ##> purchaseWithInvoice 1 invoiceId supporterPlay
|
||||
alice <## "badge redeemed"
|
||||
alice <## "supporter badge - active"
|
||||
alice <##. "expires "
|
||||
alice ##> purchaseWithInvoice 1 invoiceId supporterPlay
|
||||
alice <## "badge already redeemed"
|
||||
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)]
|
||||
rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1
|
||||
|
||||
testInvoiceUnknown :: HasCallStack => TestParams -> IO ()
|
||||
testInvoiceUnknown ps =
|
||||
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} ->
|
||||
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
|
||||
alice ##> "/create user alisa"
|
||||
showActiveUser alice "alisa"
|
||||
-- a reinstall or a second device: the store echoes an invoice this database never created
|
||||
let unknown = "0b6a5e6c-4f3e-4d51-9f55-3a0f7c2f9e11"
|
||||
alice ##> purchaseWithInvoice 2 unknown supporterPlay
|
||||
alice <## "badge redeemed"
|
||||
alice <## "supporter badge - active"
|
||||
alice <##. "expires "
|
||||
storeReceiptRows (chatController alice) `shouldReturn` [(2, Just (T.pack unknown), True, False)]
|
||||
|
||||
testInvoiceReopened :: HasCallStack => TestParams -> IO ()
|
||||
testInvoiceReopened ps =
|
||||
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} ->
|
||||
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
|
||||
invoiceId <- createInvoice alice 1
|
||||
alice ##> ("/_badge invoice close 1 " <> invoiceId)
|
||||
alice <## "ok"
|
||||
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), False, True)]
|
||||
alice ##> purchaseWithInvoice 1 invoiceId supporterPlay
|
||||
alice <## "badge redeemed"
|
||||
alice <## "supporter badge - active"
|
||||
alice <##. "expires "
|
||||
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)]
|
||||
|
||||
testInvoiceClose :: HasCallStack => TestParams -> IO ()
|
||||
testInvoiceClose ps =
|
||||
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsStore = store} ->
|
||||
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
|
||||
invoiceId <- createInvoice alice 1
|
||||
alice ##> "/_badge state 1"
|
||||
alice <## ("store purchase open: invoice " <> invoiceId)
|
||||
alice ##> ("/_badge invoice close 1 " <> invoiceId)
|
||||
alice <## "ok"
|
||||
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), False, True)]
|
||||
-- the late receipt finds the closed record, so a presented record exists and cannot be closed
|
||||
let unsettled = googlePayment "badge_supporter_01" googlePendingToken
|
||||
alice ##> purchaseWithInvoice 1 invoiceId unsettled
|
||||
alice <## "cannot redeem badge code: badge service error: payment_pending"
|
||||
alice ##> ("/_badge invoice close 1 " <> invoiceId)
|
||||
alice <## "ok"
|
||||
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)]
|
||||
settlePending store
|
||||
alice ##> purchaseWithInvoice 1 invoiceId unsettled
|
||||
alice <## "badge redeemed"
|
||||
alice <## "supporter badge - active"
|
||||
alice <##. "expires "
|
||||
|
||||
testInvoiceRefusedBeforeCharge :: HasCallStack => TestParams -> IO ()
|
||||
testInvoiceRefusedBeforeCharge ps = do
|
||||
withNewTestChatCfg ps testCfg {badgeServiceAddress = Nothing} "bob" bobProfile $ \bob -> do
|
||||
bob ##> "/_badge invoice 1"
|
||||
bob <## "cannot redeem badge code: badge service not configured"
|
||||
storeReceiptRows (chatController bob) `shouldReturn` []
|
||||
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} ->
|
||||
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
|
||||
code <- issueCode cc BTSupporter 1
|
||||
redeemFirstBadge alice code
|
||||
alice ##> "/_badge invoice 1"
|
||||
alice <## "cannot redeem badge code: badge already active"
|
||||
storeReceiptRows (chatController alice) `shouldReturn` []
|
||||
|
||||
testInvoiceWhileBadgeHeld :: HasCallStack => TestParams -> IO ()
|
||||
testInvoiceWhileBadgeHeld ps =
|
||||
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} ->
|
||||
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
|
||||
invoiceId <- createInvoice alice 1
|
||||
code <- issueCode cc BTSupporter 1
|
||||
redeemFirstBadge alice code
|
||||
alice ##> purchaseWithInvoice 1 invoiceId supporterPlay
|
||||
alice <## "cannot redeem badge code: badge already active"
|
||||
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)]
|
||||
rowCount cc "sx_badge_service_payments" `shouldReturn` 0
|
||||
|
||||
testInvoiceCloseOtherProfile :: HasCallStack => TestParams -> IO ()
|
||||
testInvoiceCloseOtherProfile ps =
|
||||
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} ->
|
||||
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
|
||||
invoiceId <- createInvoice alice 1
|
||||
alice ##> "/create user alisa"
|
||||
showActiveUser alice "alisa"
|
||||
alice ##> ("/_badge invoice close 2 " <> invoiceId)
|
||||
alice <## "ok"
|
||||
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), False, False)]
|
||||
|
||||
testInvoiceStateOtherProfile :: HasCallStack => TestParams -> IO ()
|
||||
testInvoiceStateOtherProfile ps =
|
||||
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} ->
|
||||
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
|
||||
_ <- createInvoice alice 1
|
||||
alice ##> "/create user alisa"
|
||||
showActiveUser alice "alisa"
|
||||
alice ##> "/_hide user 1 \"password\""
|
||||
alice <## "user alice:"
|
||||
alice <## "messages are hidden (use /tail to view)"
|
||||
alice <## "profile is hidden"
|
||||
invoiceId <- createInvoice alice 2
|
||||
alice ##> "/_badge state 2"
|
||||
alice <## ("store purchase open: invoice " <> invoiceId)
|
||||
(alice </)
|
||||
|
||||
createInvoice :: HasCallStack => TestCC -> Int -> IO String
|
||||
createInvoice cc userId = do
|
||||
cc ##> ("/_badge invoice " <> show userId)
|
||||
line <- getTermLine cc
|
||||
maybe (error $ "expected an invoice, got " <> line) pure $ stripPrefix "badge invoice: " line
|
||||
|
||||
purchaseWithInvoice :: Int -> String -> ServicePayment -> String
|
||||
purchaseWithInvoice userId invoiceId payment = "/_badge purchase " <> show userId <> " invoice=" <> invoiceId <> " " <> paymentArg payment
|
||||
|
||||
-- | Each store purchase record's profile, invoice id, whether a receipt reached it, and whether it is closed.
|
||||
storeReceiptRows :: ChatController -> IO [(Int64, Maybe Text, Bool, Bool)]
|
||||
storeReceiptRows ChatController {chatStore} =
|
||||
withTransaction chatStore $ \db ->
|
||||
DB.query_ db $
|
||||
"SELECT user_id, invoice_id, transaction_ref IS NOT NULL, closed_at IS NOT NULL "
|
||||
<> "FROM badge_store_receipts ORDER BY badge_store_receipt_id"
|
||||
|
||||
Reference in New Issue
Block a user