mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-11 16:07:49 +00:00
review: round 1 fixes
ios: stop selling a subscription once this device has seen one, so a second profile is not charged for a subscription Apple folds into the first (R1-12). Continue pins the chosen period on both apps (R1-13). Only a device with a subscription asks its store for products and status when reporting, and the purchases are marked checked before the report (R1-14, R1-5: Kotlin now loads the products, so the price shows after a restart). Restore says "Nothing to restore" after a cancelled purchase (R1-7). Android keeps sweeping one-time purchases when the subscriptions query fails (R1-6). The freshness comment in requestBadgeIssue states the service's one-hour repeat of its last check (R1-1). Run: 003-badge-subscriptions-ui, review round 1
This commit is contained in:
1 parent
1b336bc23d
commit
18617ade60
6 files changed
+33
-14
No files matched your search
@@ -205,6 +205,12 @@ final class BadgeStore: ObservableObject {
|
||||
await loadBadgeStateAsync(userId)
|
||||
do {
|
||||
let outcome = try await storePurchase(product, invoiceId)
|
||||
if product.type == .autoRenewable {
|
||||
switch outcome {
|
||||
case .purchased, .pending: await MainActor.run { subscribedBefore = true }
|
||||
case .cancelled: break
|
||||
}
|
||||
}
|
||||
if case let .purchased(receipt) = outcome, receipt.signatureVerified {
|
||||
try await handOver(receipt, listed: false)
|
||||
}
|
||||
@@ -302,9 +308,9 @@ final class BadgeStore: ObservableObject {
|
||||
for await verification in Transaction.unfinished {
|
||||
await reconcileListed(verification)
|
||||
}
|
||||
await reportSubscriptionStates()
|
||||
await loadCurrentBadgeState()
|
||||
await MainActor.run { reconciledOnce = true }
|
||||
await reportSubscriptionStates()
|
||||
}
|
||||
|
||||
// transactions the store settles outside a purchase call, such as an approved Ask to Buy
|
||||
@@ -339,6 +345,7 @@ final class BadgeStore: ObservableObject {
|
||||
private func reconcile(_ verification: VerificationResult<Transaction>, listed: Bool) async throws {
|
||||
let receipt = storeReceipt(verification)
|
||||
let t = receipt.transaction
|
||||
if t.productType == .autoRenewable { await MainActor.run { subscribedBefore = true } }
|
||||
if t.productType == .autoRenewable && t.revocationDate != nil {
|
||||
// the service refuses a revoked charge as invalid, and that would refuse the whole subscription's record
|
||||
await t.finish()
|
||||
@@ -352,6 +359,7 @@ final class BadgeStore: ObservableObject {
|
||||
// The service asks the store only once the paid period ends, so a cancellation or a failed charge before
|
||||
// then is seen only here. Sent with the latest transaction, which names the subscription as its first one did.
|
||||
func reportSubscriptionStates() async {
|
||||
guard await hasSubscribedBefore() else { return }
|
||||
await load()
|
||||
guard let subscription = await MainActor.run(body: { products.values.first { $0.type == .autoRenewable }?.subscription }) else { return }
|
||||
do {
|
||||
@@ -392,7 +400,7 @@ final class BadgeStore: ObservableObject {
|
||||
}
|
||||
|
||||
// Hands over as the device's own whatever the store lists, so a purchase no record here has is claimed by this profile.
|
||||
// Answers whether a badge is shown or a store purchase is open afterwards.
|
||||
// Answers whether a badge is shown, or a purchase is being added or awaits approval, afterwards.
|
||||
func restorePurchases() async throws -> Bool {
|
||||
try await AppStore.sync()
|
||||
var handOverError: Error? = nil
|
||||
@@ -408,7 +416,7 @@ final class BadgeStore: ObservableObject {
|
||||
return await MainActor.run {
|
||||
let userId = ChatModel.shared.currentUser?.userId
|
||||
let badgeShown = BadgeModel.shared.userId == userId && BadgeModel.shared.badgeState?.shown == true
|
||||
return badgeShown || !openStorePurchases(userId).isEmpty
|
||||
return badgeShown || purchaseState(userId) != nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ struct BadgesHowLongView: View {
|
||||
private func continueButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
chosenPeriod = selectedPeriod
|
||||
continueActive = true
|
||||
} label: {
|
||||
Text("Continue")
|
||||
|
||||
@@ -107,9 +107,15 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
|
||||
// and either awaiting a slow payment
|
||||
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> {
|
||||
val client = connectedBadgeBillingClient()
|
||||
val purchases = queryBadgePurchases(client, BillingClient.ProductType.INAPP) +
|
||||
queryBadgePurchases(client, BillingClient.ProductType.SUBS)
|
||||
return purchases.mapNotNull(::badgePurchaseOutcome)
|
||||
val oneTime = queryBadgePurchases(client, BillingClient.ProductType.INAPP)
|
||||
// a failed subscription query must not cost the one-time purchases their sweep
|
||||
val subscriptions = try {
|
||||
queryBadgePurchases(client, BillingClient.ProductType.SUBS)
|
||||
} catch (e: BadgeStoreError.BillingError) {
|
||||
Log.e(TAG, "unfinishedBadgePurchases: subscriptions ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
return (oneTime + subscriptions).mapNotNull(::badgePurchaseOutcome)
|
||||
}
|
||||
|
||||
private suspend fun queryBadgePurchases(client: BillingClient, productType: String): List<Purchase> {
|
||||
|
||||
+7
-4
@@ -365,11 +365,11 @@ object BadgeStore {
|
||||
Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}")
|
||||
}
|
||||
}
|
||||
reportSubscriptionStates()
|
||||
chatModel.controller.loadBadgeState(chatModel.remoteHostId())
|
||||
} finally {
|
||||
withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true }
|
||||
}
|
||||
reportSubscriptionStates()
|
||||
}
|
||||
|
||||
// Play lists a Google account's purchases on every device signed into it, so these are handed over as listed
|
||||
@@ -410,7 +410,10 @@ object BadgeStore {
|
||||
// the store's purchases are this device's, so they are told to its own core
|
||||
if (chatModel.remoteHostId() != null || useBadgeTestProducts || !platform.androidHasPlatformStore) return
|
||||
try {
|
||||
for (outcome in platform.androidUnfinishedBadgePurchases()) {
|
||||
val purchases = platform.androidUnfinishedBadgePurchases()
|
||||
if (purchases.none { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.autoRenewing != null }) return
|
||||
load()
|
||||
for (outcome in purchases) {
|
||||
val receipt = (outcome as? BadgePurchaseOutcome.Purchased)?.receipt ?: continue
|
||||
val state = when (receipt.autoRenewing) {
|
||||
true -> BadgeSubscriptionState.Renewing
|
||||
@@ -434,7 +437,7 @@ object BadgeStore {
|
||||
}
|
||||
|
||||
// Hands over as the device's own whatever the store lists, so a purchase no record here has is claimed by this profile.
|
||||
// Answers whether a badge is shown or a store purchase is open afterwards.
|
||||
// Answers whether a badge is shown, or a purchase is being added or awaits approval, afterwards.
|
||||
suspend fun restorePurchases(): Boolean {
|
||||
var handOverError: Exception? = null
|
||||
val purchases = if (useBadgeTestProducts) emptyList() else platform.androidUnfinishedBadgePurchases()
|
||||
@@ -454,7 +457,7 @@ object BadgeStore {
|
||||
return withContext(Dispatchers.Main) {
|
||||
val userId = chatModel.currentUser.value?.userId
|
||||
val badgeShown = BadgeModel.isCurrent(rhId, userId) && BadgeModel.badgeState.value?.shown == true
|
||||
badgeShown || openStorePurchases(userId).isNotEmpty()
|
||||
badgeShown || purchaseState(userId) != null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -123,7 +123,7 @@ fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager, unwindToDep
|
||||
Spacer(Modifier.weight(1f).heightIn(min = 8.dp))
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ContinueButton(level, selectedPeriod, modalManager, unwindToDepth)
|
||||
ContinueButton(level, selectedPeriod, modalManager, unwindToDepth) { chosenPeriod = it }
|
||||
BadgeBillingFooter(selectedPeriod)
|
||||
}
|
||||
}
|
||||
@@ -188,12 +188,13 @@ private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? =
|
||||
if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null
|
||||
|
||||
@Composable
|
||||
private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) {
|
||||
private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int, choosePeriod: (BadgePeriod) -> Unit) {
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = MR.strings.badges_continue,
|
||||
onboarding = null,
|
||||
onclick = {
|
||||
choosePeriod(selectedPeriod)
|
||||
modalManager.showModal(cardScreen = true) { BadgesCheckOrderView(level, selectedPeriod, modalManager, unwindToDepth) }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5867,8 +5867,8 @@ requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, badgeType, purchase
|
||||
storedAt <- badgeNow
|
||||
(applied, balance_) <- withStore' $ \db -> do
|
||||
applied <- applyBadgeStatement db g badgePurchaseId badgeType statement verifiedCred_ storedAt
|
||||
-- the service reads the store only for a request whose asserted coverage had ended when it was made;
|
||||
-- to any other it repeats the state it stored, which for an annual plan can be a year old
|
||||
-- past the asserted coverage the state is the store's, except within an hour of an earlier check, when it can be
|
||||
-- the last successful check's (a later fresh answer or device report corrects it); before then it can be a year old
|
||||
when (L.paidThrough lastEntry <= now) $ forM_ subscription $ setBadgeSubscriptionStatus db badgePurchaseId . boundedSubscriptionState
|
||||
(applied,) <$> getBadgeLedgerLastEntry db badgePurchaseId
|
||||
-- the statement is applied either way, so a month can be spent with nothing to show for it
|
||||
|
||||
Reference in new issue
Block a user