From 7a1541ac848ca670c74d3a1ab116de7e6574cea0 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Mon, 27 Mar 2023 14:30:52 +0200 Subject: [PATCH] fix issue with subscriptions being reapplied # Conflicts: # version.properties --- .../habitica/helpers/PurchaseHandler.kt | 138 ++++++++++-------- fastlane/changelog.txt | 8 +- 2 files changed, 84 insertions(+), 62 deletions(-) diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/PurchaseHandler.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/PurchaseHandler.kt index bc7918826..ee99b03f6 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/PurchaseHandler.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/PurchaseHandler.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import androidx.core.os.bundleOf +import androidx.lifecycle.asFlow import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener @@ -41,6 +42,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject @@ -51,37 +54,47 @@ import kotlin.time.DurationUnit import kotlin.time.toDuration class PurchaseHandler( - private val context: Context, - private val analyticsManager: AnalyticsManager, - private val apiClient: ApiClient, - private val userViewModel: MainUserViewModel + private val context : Context, + private val analyticsManager : AnalyticsManager, + private val apiClient : ApiClient, + private val userViewModel : MainUserViewModel ) : PurchasesUpdatedListener, PurchasesResponseListener { private val billingClient = BillingClient.newBuilder(context).setListener(this).enablePendingPurchases().build() - override fun onPurchasesUpdated(result: BillingResult, purchases: MutableList?) { + override fun onPurchasesUpdated(result : BillingResult, purchases : MutableList?) { purchases?.let { processPurchases(result, it) } } - override fun onQueryPurchasesResponse(result: BillingResult, purchases: MutableList) { + override fun onQueryPurchasesResponse( + result : BillingResult, + purchases : MutableList + ) { processPurchases(result, purchases) } - private fun processPurchases(result: BillingResult, purchases: List) { + private fun processPurchases(result : BillingResult, purchases : List) { when (result.responseCode) { BillingClient.BillingResponseCode.OK -> { val mostRecentSub = findMostRecentSubscription(purchases) - val plan = userViewModel.user.value?.purchased?.plan - for (purchase in purchases) { - if (plan?.isActive == true && PurchaseTypes.allSubscriptionTypes.contains( - purchase.products.firstOrNull() - ) - ) { - if (((plan.dateTerminated != null) == purchase.isAutoRenewing) || mostRecentSub?.orderId != purchase.orderId) { - return + MainScope().launchCatching { + userViewModel.user.asFlow() + .filterNotNull().take(1).collect { + val plan = it.purchased!!.plan + for (purchase in purchases) { + if (plan?.isActive == true && PurchaseTypes.allSubscriptionTypes.contains( + purchase.products.firstOrNull() + ) + ) { + if (((plan.dateTerminated != null) == purchase.isAutoRenewing) + || mostRecentSub?.orderId != purchase.orderId + || purchase.purchaseToken == plan.customerId) { + continue + } + } + handle(purchase) + } } - } - handle(purchase) } } @@ -111,12 +124,12 @@ class PurchaseHandler( startListening() } - private var billingClientState: BillingClientState = BillingClientState.UNINITIALIZED + private var billingClientState : BillingClientState = BillingClientState.UNINITIALIZED private enum class BillingClientState { UNINITIALIZED, READY, UNAVAILABLE, DISCONNECTED, CONNECTING; - val canMaybePurchase: Boolean + val canMaybePurchase : Boolean get() { return this == UNINITIALIZED || this == READY || this == CONNECTING } @@ -132,7 +145,7 @@ class PurchaseHandler( } billingClientState = BillingClientState.CONNECTING billingClient.startConnection(object : BillingClientStateListener { - override fun onBillingSetupFinished(billingResult: BillingResult) { + override fun onBillingSetupFinished(billingResult : BillingResult) { if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { billingClientState = BillingClientState.READY MainScope().launchCatching { @@ -193,18 +206,18 @@ class PurchaseHandler( suspend fun getAllGiftSubscriptionProducts() = getSKUs(BillingClient.ProductType.INAPP, PurchaseTypes.allSubscriptionNoRenewTypes) - suspend fun getInAppPurchaseSKU(identifier: String) = + suspend fun getInAppPurchaseSKU(identifier : String) = getSKU(BillingClient.ProductType.INAPP, identifier) - private suspend fun getSKUs(type: String, identifiers: List) = + private suspend fun getSKUs(type : String, identifiers : List) = loadInventory(type, identifiers) ?: emptyList() - private suspend fun getSKU(type: String, identifier: String): ProductDetails? { + private suspend fun getSKU(type : String, identifier : String) : ProductDetails? { val inventory = loadInventory(type, listOf(identifier)) return inventory?.firstOrNull() } - private suspend fun loadInventory(type: String, skus: List): List? { + private suspend fun loadInventory(type : String, skus : List) : List? { retryUntil { if (billingClientState == BillingClientState.DISCONNECTED) { startListening() @@ -223,11 +236,11 @@ class PurchaseHandler( } fun purchase( - activity: Activity, - skuDetails: ProductDetails, - recipient: String? = null, - recipientUsername: String? = null, - isSaleGemPurchase: Boolean = false + activity : Activity, + skuDetails : ProductDetails, + recipient : String? = null, + recipientUsername : String? = null, + isSaleGemPurchase : Boolean = false ) { this.isSaleGemPurchase = isSaleGemPurchase recipient?.let { @@ -245,7 +258,7 @@ class PurchaseHandler( billingClient.launchBillingFlow(activity, flowParams) } - private suspend fun consume(purchase: Purchase, retries: Int = 4) { + private suspend fun consume(purchase : Purchase, retries : Int = 4) { retryUntil { billingClientState.canMaybePurchase && billingClient.isReady } val params = ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build() val result = billingClient.consumePurchase(params) @@ -257,10 +270,12 @@ class PurchaseHandler( } } - private fun handle(purchase: Purchase) { - if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) { + private var processedPurchases = mutableSetOf() + private fun handle(purchase : Purchase) { + if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED || processedPurchases.contains(purchase.orderId)) { return } + processedPurchases.add(purchase.orderId) val sku = purchase.products.firstOrNull() when { sku == PurchaseTypes.JubilantGrphatrice -> { @@ -274,7 +289,7 @@ class PurchaseHandler( consume(purchase) } displayGryphatriceConfirmationDialog(purchase, gift?.third) - } catch (throwable: Throwable) { + } catch (throwable : Throwable) { handleError(throwable, purchase) } } @@ -291,7 +306,7 @@ class PurchaseHandler( consume(purchase) } displayConfirmationDialog(purchase, gift?.third) - } catch (throwable: Throwable) { + } catch (throwable : Throwable) { handleError(throwable, purchase) } } @@ -308,7 +323,7 @@ class PurchaseHandler( consume(purchase) } displayConfirmationDialog(purchase, gift?.third) - } catch (throwable: Throwable) { + } catch (throwable : Throwable) { handleError(throwable, purchase) } } @@ -325,7 +340,7 @@ class PurchaseHandler( acknowledgePurchase(purchase) } displayConfirmationDialog(purchase) - } catch (throwable: Throwable) { + } catch (throwable : Throwable) { handleError(throwable, purchase) } } @@ -333,7 +348,7 @@ class PurchaseHandler( } } - private suspend fun acknowledgePurchase(purchase: Purchase, retries: Int = 4) { + private suspend fun acknowledgePurchase(purchase : Purchase, retries : Int = 4) { val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build() val response = billingClient.acknowledgePurchase(params) @@ -343,13 +358,13 @@ class PurchaseHandler( } } - private fun processedPurchase(purchase: Purchase) { + private fun processedPurchase(purchase : Purchase) { MainScope().launch(ExceptionHandler.coroutine()) { userViewModel.userRepository.retrieveUser(false, true) } } - private fun buildValidationRequest(purchase: Purchase): PurchaseValidationRequest { + private fun buildValidationRequest(purchase : Purchase) : PurchaseValidationRequest { val validationRequest = PurchaseValidationRequest() validationRequest.sku = purchase.products.firstOrNull() validationRequest.transaction = Transaction() @@ -367,7 +382,7 @@ class PurchaseHandler( return validationRequest } - private fun handleError(throwable: Throwable, purchase: Purchase) { + private fun handleError(throwable : Throwable, purchase : Purchase) { (throwable as? HttpException)?.let { error -> if (error.code() == 401) { val res = apiClient.getErrorResponse(throwable) @@ -381,27 +396,28 @@ class PurchaseHandler( } } } + processedPurchases.remove(purchase.orderId) FirebaseCrashlytics.getInstance().recordException(throwable) } - suspend fun checkForSubscription(): Purchase? { + suspend fun checkForSubscription() : Purchase? { val result = withContext(Dispatchers.IO) { val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS) .build() billingClient.queryPurchasesAsync(params) } - val fallback: Purchase? = null + val fallback : Purchase? = null if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { return findMostRecentSubscription(result.purchasesList) } return fallback } - private fun findMostRecentSubscription(purchasesList: List): Purchase? { + private fun findMostRecentSubscription(purchasesList : List) : Purchase? { val purchases = purchasesList.filter { it.isAcknowledged }.sortedByDescending { it.purchaseTime } - var fallback: Purchase? = null + var fallback : Purchase? = null // If there is a subscription that is still active, prioritise that. Otherwise return the most recent one. for (purchase in purchases) { if (purchase.isAutoRenewing) { @@ -413,12 +429,15 @@ class PurchaseHandler( return fallback } - suspend fun cancelSubscription(): User? { + private var alreadyTriedCancellation = false + suspend fun cancelSubscription() : User? { + if (alreadyTriedCancellation) return null + alreadyTriedCancellation = true apiClient.cancelSubscription() return userViewModel.userRepository.retrieveUser(false, true) } - private fun durationString(sku: String): String { + private fun durationString(sku : String) : String { return when (sku) { PurchaseTypes.Subscription1MonthNoRenew, PurchaseTypes.Subscription1Month -> "1" PurchaseTypes.Subscription3MonthNoRenew, PurchaseTypes.Subscription3Month -> "3" @@ -430,7 +449,7 @@ class PurchaseHandler( private var isSaleGemPurchase = false - private fun gemAmountString(sku: String): String { + private fun gemAmountString(sku : String) : String { if (isSaleGemPurchase) { isSaleGemPurchase = false return when (sku) { @@ -453,7 +472,7 @@ class PurchaseHandler( private val displayedConfirmations = mutableListOf() - private fun displayConfirmationDialog(purchase: Purchase, giftedTo: String? = null) { + private fun displayConfirmationDialog(purchase : Purchase, giftedTo : String? = null) { if (displayedConfirmations.contains(purchase.orderId)) { return } @@ -509,7 +528,10 @@ class PurchaseHandler( } } - private fun displayGryphatriceConfirmationDialog(purchase: Purchase, giftedTo: String? = null) { + private fun displayGryphatriceConfirmationDialog( + purchase : Purchase, + giftedTo : String? = null + ) { MainScope().launch(ExceptionHandler.coroutine()) { val application = (context as? HabiticaBaseApplication) ?: (context.applicationContext as? HabiticaBaseApplication) ?: return@launch @@ -536,15 +558,15 @@ class PurchaseHandler( companion object { private const val PENDING_GIFTS_KEY = "PENDING_GIFTS_DATED" - private var pendingGifts: MutableMap> = HashMap() - private var preferences: SharedPreferences? = null + private var pendingGifts : MutableMap> = HashMap() + private var preferences : SharedPreferences? = null - fun addGift(sku: String, userID: String, username: String) { + fun addGift(sku : String, userID : String, username : String) { pendingGifts[sku] = Triple(Date(), userID, username) savePendingGifts() } - private fun removeGift(sku: String?): Triple? { + private fun removeGift(sku : String?) : Triple? { val gift = pendingGifts.remove(sku) savePendingGifts() return gift @@ -561,11 +583,11 @@ class PurchaseHandler( } suspend fun retryUntil( - times: Int = Int.MAX_VALUE, - initialDelay: Long = 100, // 0.1 second - maxDelay: Long = 1000, // 1 second - factor: Double = 2.0, - block: suspend () -> Boolean + times : Int = Int.MAX_VALUE, + initialDelay : Long = 100, // 0.1 second + maxDelay : Long = 1000, // 1 second + factor : Double = 2.0, + block : suspend () -> Boolean ) { var currentDelay = initialDelay repeat(times - 1) { diff --git a/fastlane/changelog.txt b/fastlane/changelog.txt index 50cd2bcb0..3829a57c6 100644 --- a/fastlane/changelog.txt +++ b/fastlane/changelog.txt @@ -1,9 +1,9 @@ -New in 4.1.8: --Fixed various for: item names, shared task creation, and duplicate items --You can view, complete, assign, and add tasks to your Group Plan's shared task board! +New in 4.1.9: +-Various subscription status optimizations +-Party invites will show the sender’s username and profile +-You can view, complete, assign, and add tasks to your Group Plan’s shared task board -Tap your name on a task screen to switch to different task boards -More intuitive system notification settings -New Avatar Customization interface -Audio will be controlled by media volume now -Task details are now tinted based on task health -