<NME> BillingService.kt
<BEF> /*
 * This file is part of Blokada.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 *
 * Copyright © 2022 Blocka AB. All rights reserved.
 *
 * @author Karol Gusak (karol@blocka.net)
 */

package service

import com.android.billingclient.api.*
import com.android.billingclient.api.BillingFlowParams.ProrationMode.DEFERRED
import com.android.billingclient.api.BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import model.*
import utils.Logger
import kotlin.coroutines.resumeWithException

class BillingService: IPaymentService {

    private val context by lazy { Services.context }

    private lateinit var client: BillingClient
    private var connected = false
        @Synchronized set
        @Synchronized get

    private var latestProductList: List<ProductDetails> = emptyList()
        @Synchronized set
        @Synchronized get

    private var ongoingPurchase: Pair<ProductId, CancellableContinuation<PaymentPayload>>? = null
        @Synchronized set
        @Synchronized get

    override suspend fun setup() {
        client = BillingClient.newBuilder(context.requireAppContext())
            .setListener(purchaseListener)
            .enablePendingPurchases()
            .build()
    }

    private suspend fun getConnectedClient(): BillingClient {
        if (connected) return client
        return suspendCancellableCoroutine<BillingClient> { cont ->
            client.startConnection(object : BillingClientStateListener {

                override fun onBillingSetupFinished(billingResult: BillingResult) {
                    when (billingResult.responseCode) {
                        BillingClient.BillingResponseCode.OK -> {
                            connected = true
                            cont.resume(client) {
                                Logger.w("Billing", "Cancelled getConnectedClient()")
                            }
                        }
                        BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> {
                            connected = false
                            cont.resumeWithException(NoPayments())
                        }
                        else -> {
                            connected = false
                // Not sure if this is ever called as a result of startConnection or only later
                override fun onBillingServiceDisconnected() {
                    connected = false
                    cont.resumeWithException(BlokadaException("onBillingServiceDisconnected"))
                }

            })

                // Not sure if this is ever called as a result of startConnection or only later
                override fun onBillingServiceDisconnected() {
                    connected = false
                    if (!cont.isCompleted)
                        cont.resumeWithException(BlokadaException("onBillingServiceDisconnected"))
                }

            })
        }
    }

    override suspend fun refreshProducts(): List<Product> {
        val ids = listOf("cloud_12month", "plus_month", "plus_12month")

        val params = QueryProductDetailsParams.newBuilder()
        params.setProductList(ids.map {
            QueryProductDetailsParams.Product.newBuilder()
            .setProductId(it)
            .setProductType(BillingClient.ProductType.SUBS)
            .build()
        })

        // leverage queryProductDetails Kotlin extension function
        val productDetailsResult = withContext(Dispatchers.IO) {
            getConnectedClient().queryProductDetails(params.build())
        }

        latestProductList = productDetailsResult.productDetailsList ?: emptyList()
        val payments = productDetailsResult.productDetailsList?.mapNotNull {
            val offer = it.subscriptionOfferDetails?.first()
            val phase = offer?.pricingPhases?.pricingPhaseList?.firstOrNull { it.priceAmountMicros > 0 }

            if (offer == null || phase == null) {
                null
            } else {
                Product(
                    id = it.productId,
                    title = it.title,
                    description = it.description,
                    price = getPriceString(phase),
                    pricePerMonth = getPricePerMonthString(phase),
                    periodMonths = getPeriodMonths(phase),
                    type = if(it.productId.startsWith("cloud")) "cloud" else "plus",
                    trial = getTrial(offer)
                )
            }
        }?.sortedBy { it.periodMonths } ?: emptyList()
        if (payments.isEmpty()) throw NoPayments()
        return payments
    }

    private val purchaseListener = PurchasesUpdatedListener { billingResult, purchases ->
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
            ongoingPurchase?.let { c ->
                val (productId, cont) = c
                val purchase = purchases
                    .sortedByDescending { it.purchaseTime }
                    .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
                    .firstOrNull { it.products.any { it == productId } }

                if (purchase == null) {
                    cont.resumeWithException(NoRelevantPurchase())
                } else {
                    cont.resume(PaymentPayload(
                        purchase_token = purchase.purchaseToken,
                        subscription_id = productId,
                        user_initiated = true
                    ), {})
                }
            } ?: run {
                Logger.w("Billing", "There was no ongoing purchase")
            }
        } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
            // Handle an error caused by a user cancelling the purchase flow.
            Logger.v("Billing", "buyProduct: User cancelled purchase")
            ongoingPurchase?.second?.resumeWithException(UserCancelledException())
        } else {
            // Handle any other error codes.
            Logger.w("Billing", "buyProduct: Purchase error: $billingResult")
            val exception = if (billingResult.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) {
                AlreadyPurchasedException()
            } else BlokadaException("Purchase error: $billingResult")

            ongoingPurchase?.second?.resumeWithException(exception)
        }
        ongoingPurchase = null
    }

    override suspend fun getActivePurchase(): ProductId? {
        var result: CancellableContinuation<ProductId?>? = null
        getConnectedClient().queryPurchasesAsync("subs") { billingResult, purchases ->
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                val successfulPurchases = purchases
                    .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
                    .sortedByDescending { it.purchaseTime }

                result?.resume(successfulPurchases.firstOrNull()?.products?.firstOrNull(), {})
            } else {
                result?.resumeWithException(
                    BlokadaException("Failed refreshing purchases, response code not OK")
                )
            }
        }

        return suspendCancellableCoroutine { cont ->
            result = cont
        }
    }

    override suspend fun buyProduct(id: ProductId): PaymentPayload {
        if (runIgnoringException({ restorePurchase() }, otherwise = emptyList()).isNotEmpty())
            throw AlreadyPurchasedException()

        val details = latestProductList.firstOrNull { it.productId == id } ?:
            throw BlokadaException("Unknown product ID")
        val offerToken = details.subscriptionOfferDetails!!.first().offerToken

        val flowParams = BillingFlowParams.newBuilder()
            .setProductDetailsParamsList(listOf(
                BillingFlowParams.ProductDetailsParams.newBuilder()
                .setProductDetails(details)
                .setOfferToken(offerToken)
                .build()
            ))
            .build()
        val activity = context.requireActivity()
        val responseCode = getConnectedClient().launchBillingFlow(activity, flowParams).responseCode

        if (responseCode != BillingClient.BillingResponseCode.OK) {
            throw BlokadaException("buyProduct: error $responseCode")
        }

        return suspendCancellableCoroutine { cont ->
            ongoingPurchase = id to cont
        }
    }

    private var ongoingRestore: CancellableContinuation<List<PaymentPayload>>? = null
        @Synchronized set
        @Synchronized get

    override suspend fun restorePurchase(): List<PaymentPayload> {
        getConnectedClient().queryPurchasesAsync("subs") { billingResult, purchases ->
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                val successfulPurchases = purchases
                    .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
                    .sortedByDescending { it.purchaseTime }

                if (successfulPurchases.isNotEmpty()) {
                    Logger.v("Billing", "restore: Restoring ${successfulPurchases.size} purchases")
                    ongoingRestore?.resume(successfulPurchases.map {
                        PaymentPayload(
                            purchase_token = it.purchaseToken,
                            subscription_id = it.products.first(),
                            user_initiated = false
                        )
                    }, {})
                } else {
                    ongoingRestore?.resumeWithException(
                        BlokadaException("Restoring purchase found no successful purchases")
                    )
                }
            } else {
                ongoingRestore?.resumeWithException(
                    BlokadaException("Restoring purchase error: $billingResult")
                )
            }
            ongoingRestore = null
        }

        return suspendCancellableCoroutine { cont ->
            ongoingRestore = cont
        }
    }

    override suspend fun changeProduct(id: ProductId): PaymentPayload {
        val details = latestProductList.firstOrNull { it.productId == id } ?:
            throw BlokadaException("Unknown product ID")
        val offerToken = details.subscriptionOfferDetails!!.first().offerToken

        // Get existing subscription token
        getConnectedClient().queryPurchasesAsync("subs") { billingResult, purchases ->
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                // Get latest successful assuming it's the current one
                val existingPurchase = purchases
                    .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
                    .sortedByDescending { it.purchaseTime }
                    .firstOrNull()

                if (existingPurchase != null) {
                    Logger.v("Billing", "changeProduct: found subscription to use")
                    Logger.v("Billing", "$existingPurchase")

                    ongoingRestore?.resume(listOf(
                        PaymentPayload(
                            purchase_token = existingPurchase.purchaseToken,
                            subscription_id = existingPurchase.products.first(),
                            user_initiated = false
                        )
                    ), {})
                } else {
                    ongoingRestore?.resumeWithException(
                        BlokadaException("changeProduct: no existing purchase")
                    )
                }
            } else {
                ongoingRestore?.resumeWithException(
                    BlokadaException("changeProduct: error: $billingResult")
                )
            }
            ongoingRestore = null
        }

        // Wait until above async callback completes
        val existingPurchase = suspendCancellableCoroutine<List<PaymentPayload>> { cont ->
            ongoingRestore = cont
        }

        val existingId = existingPurchase.first().subscription_id
        val existingToken = existingPurchase.first().purchase_token

        val prorate = when {
            // Upgrade cases
            existingId == "cloud_12month" -> IMMEDIATE_AND_CHARGE_PRORATED_PRICE
            existingId == "plus_1month" && id == "plus_12month" -> IMMEDIATE_AND_CHARGE_PRORATED_PRICE
            // Downgrade case
            else -> DEFERRED
        }

        val flowParams = BillingFlowParams.newBuilder()
            .setSubscriptionUpdateParams(
                BillingFlowParams.SubscriptionUpdateParams.newBuilder()
                .setOldPurchaseToken(existingToken)
                .setReplaceProrationMode(prorate)
                .build()
            )
            .setProductDetailsParamsList(listOf(
                BillingFlowParams.ProductDetailsParams.newBuilder()
                .setProductDetails(details)
                .setOfferToken(offerToken)
                .build()
            ))
            .build()
        val activity = context.requireActivity()
        val responseCode = getConnectedClient().launchBillingFlow(activity, flowParams).responseCode

        if (responseCode != BillingClient.BillingResponseCode.OK) {
            throw BlokadaException("changeProduct: error $responseCode")
        }

        return suspendCancellableCoroutine { cont ->
            ongoingPurchase = id to cont
        }
    }

    private fun Purchase.isActive(): Boolean {
        return when {
            purchaseState != Purchase.PurchaseState.PURCHASED -> false
            else -> false
        }
    }

    private fun getTrial(it: ProductDetails.SubscriptionOfferDetails): Boolean {
        return it.offerTags.contains("free7")
    }

    private fun getPeriodMonths(it: ProductDetails.PricingPhase): Int {
        return if (it.billingPeriod == "P1Y") 12 else 1
    }

    private fun getPricePerMonthString(it: ProductDetails.PricingPhase): String {
        val periodMonths = getPeriodMonths(it)
        val price = it.priceAmountMicros
        val perMonth = price / periodMonths
        return priceFormat.format(perMonth / 1_000_000f, it.priceCurrencyCode)
    }

    private fun getPriceString(it: ProductDetails.PricingPhase): String {
        return priceFormat.format(it.priceAmountMicros / 1_000_000f, it.priceCurrencyCode)
    }

    private val priceFormat = "%.2f %s"
}
<MSG> android: improve working state ui indication

<DFF> @@ -69,7 +69,8 @@ class BillingService: IPaymentService {
                 // Not sure if this is ever called as a result of startConnection or only later
                 override fun onBillingServiceDisconnected() {
                     connected = false
-                    cont.resumeWithException(BlokadaException("onBillingServiceDisconnected"))
+                    if (!cont.isCompleted)
+                        cont.resumeWithException(BlokadaException("onBillingServiceDisconnected"))
                 }
 
             })

{'commit': 'diff --git a/android5/app/src/main/java/repository/AppRepo.kt b/android5/app/src/main/java/repository/AppRepo.kt\nindex 3300f2a1..d3f30ddb 100644\n--- a/android5/app/src/main/java/repository/AppRepo.kt\n+++ b/android5/app/src/main/java/repository/AppRepo.kt\n@@ -13,6 +13,7 @@\n package repository\n \n import kotlinx.coroutines.GlobalScope\n+import kotlinx.coroutines.delay\n import kotlinx.coroutines.flow.*\n import kotlinx.coroutines.launch\n import model.*\n@@ -227,8 +228,15 @@ class AppRepo {\n         )\n \n         currentlyOngoingHot\n-        .map { it -> it.map { i -> i.component }.toSet() }\n+        .map { it.map { i -> i.component }.toSet() }\n         .map { it.intersect(tasksThatMarkWorkingState).isNotEmpty() }\n+        .map {\n+            if (!it) {\n+                // Always delay the non-working state to smooth out any transitions\n+                delay(2000)\n+            }\n+            it\n+        }\n         .collect {\n             writeWorking.emit(it)\n         }\ndiff --git a/android5/app/src/main/java/service/BillingService.kt b/android5/app/src/main/java/service/BillingService.kt\nindex 49fbbf73..40becc59 100644\n--- a/android5/app/src/main/java/service/BillingService.kt\n+++ b/android5/app/src/main/java/service/BillingService.kt\n@@ -69,7 +69,8 @@ class BillingService: IPaymentService {\n                 // Not sure if this is ever called as a result of startConnection or only later\n                 override fun onBillingServiceDisconnected() {\n                     connected = false\n-                    cont.resumeWithException(BlokadaException("onBillingServiceDisconnected"))\n+                    if (!cont.isCompleted)\n+                        cont.resumeWithException(BlokadaException("onBillingServiceDisconnected"))\n                 }\n \n             })\n', 'commit_subject': 'android: improve working state ui indication', 'repo_name': 'blokadaorg/blokada', 'committer': {'name': 'kar', 'email': 'c22b5f9178342609428d6f51b2c5af4c0bde6a42@kar.gs', 'time_sec': '1651215370', 'tz_offset': '120', 'date': {'seconds': '1651215370'}}, 'commit_tree': 'ea70ffe351bdea4061bdc8f7613dec7532cbac95', 'commit_message': 'android: improve working state ui indication\n', 'language_name': 'C', 'license': 'mpl-2.0', 'hunks': '@@ -69,7 +69,8 @@ class BillingService: IPaymentService {\n                 // Not sure if this is ever called as a result of startConnection or only later\n                 override fun onBillingServiceDisconnected() {\n                     connected = false\n-                    cont.resumeWithException(BlokadaException("onBillingServiceDisconnected"))\n+                    if (!cont.isCompleted)\n+                        cont.resumeWithException(BlokadaException("onBillingServiceDisconnected"))\n                 }\n \n             })\n', 'addition_count': 2, 'deletion_count': 1, 'src_file': 'a/android5/app/src/main/java/service/BillingService.kt', 'tgt_file': 'b/android5/app/src/main/java/service/BillingService.kt', 'file_extension': '.kt', 'before_file': '/*\n * This file is part of Blokada.\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n *\n * Copyright © 2022 Blocka AB. All rights reserved.\n *\n * @author Karol Gusak (karol@blocka.net)\n */\n\npackage service\n\nimport com.android.billingclient.api.*\nimport com.android.billingclient.api.BillingFlowParams.ProrationMode.DEFERRED\nimport com.android.billingclient.api.BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE\nimport kotlinx.coroutines.CancellableContinuation\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.suspendCancellableCoroutine\nimport kotlinx.coroutines.withContext\nimport model.*\nimport utils.Logger\nimport kotlin.coroutines.resumeWithException\n\nclass BillingService: IPaymentService {\n\n    private val context by lazy { Services.context }\n\n    private lateinit var client: BillingClient\n    private var connected = false\n        @Synchronized set\n        @Synchronized get\n\n    private var latestProductList: List<ProductDetails> = emptyList()\n        @Synchronized set\n        @Synchronized get\n\n    private var ongoingPurchase: Pair<ProductId, CancellableContinuation<PaymentPayload>>? = null\n        @Synchronized set\n        @Synchronized get\n\n    override suspend fun setup() {\n        client = BillingClient.newBuilder(context.requireAppContext())\n            .setListener(purchaseListener)\n            .enablePendingPurchases()\n            .build()\n    }\n\n    private suspend fun getConnectedClient(): BillingClient {\n        if (connected) return client\n        return suspendCancellableCoroutine<BillingClient> { cont ->\n            client.startConnection(object : BillingClientStateListener {\n\n                override fun onBillingSetupFinished(billingResult: BillingResult) {\n                    when (billingResult.responseCode) {\n                        BillingClient.BillingResponseCode.OK -> {\n                            connected = true\n                            cont.resume(client) {\n                                Logger.w("Billing", "Cancelled getConnectedClient()")\n                            }\n                        }\n                        BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> {\n                            connected = false\n                            cont.resumeWithException(NoPayments())\n                        }\n                        else -> {\n                            connected = false\n                // Not sure if this is ever called as a result of startConnection or only later\n                override fun onBillingServiceDisconnected() {\n                    connected = false\n                    cont.resumeWithException(BlokadaException("onBillingServiceDisconnected"))\n                }\n\n            })\n\n                // Not sure if this is ever called as a result of startConnection or only later\n                override fun onBillingServiceDisconnected() {\n                    connected = false\n                    if (!cont.isCompleted)\n                        cont.resumeWithException(BlokadaException("onBillingServiceDisconnected"))\n                }\n\n            })\n        }\n    }\n\n    override suspend fun refreshProducts(): List<Product> {\n        val ids = listOf("cloud_12month", "plus_month", "plus_12month")\n\n        val params = QueryProductDetailsParams.newBuilder()\n        params.setProductList(ids.map {\n            QueryProductDetailsParams.Product.newBuilder()\n            .setProductId(it)\n            .setProductType(BillingClient.ProductType.SUBS)\n            .build()\n        })\n\n        // leverage queryProductDetails Kotlin extension function\n        val productDetailsResult = withContext(Dispatchers.IO) {\n            getConnectedClient().queryProductDetails(params.build())\n        }\n\n        latestProductList = productDetailsResult.productDetailsList ?: emptyList()\n        val payments = productDetailsResult.productDetailsList?.mapNotNull {\n            val offer = it.subscriptionOfferDetails?.first()\n            val phase = offer?.pricingPhases?.pricingPhaseList?.firstOrNull { it.priceAmountMicros > 0 }\n\n            if (offer == null || phase == null) {\n                null\n            } else {\n                Product(\n                    id = it.productId,\n                    title = it.title,\n                    description = it.description,\n                    price = getPriceString(phase),\n                    pricePerMonth = getPricePerMonthString(phase),\n                    periodMonths = getPeriodMonths(phase),\n                    type = if(it.productId.startsWith("cloud")) "cloud" else "plus",\n                    trial = getTrial(offer)\n                )\n            }\n        }?.sortedBy { it.periodMonths } ?: emptyList()\n        if (payments.isEmpty()) throw NoPayments()\n        return payments\n    }\n\n    private val purchaseListener = PurchasesUpdatedListener { billingResult, purchases ->\n        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {\n            ongoingPurchase?.let { c ->\n                val (productId, cont) = c\n                val purchase = purchases\n                    .sortedByDescending { it.purchaseTime }\n                    .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }\n                    .firstOrNull { it.products.any { it == productId } }\n\n                if (purchase == null) {\n                    cont.resumeWithException(NoRelevantPurchase())\n                } else {\n                    cont.resume(PaymentPayload(\n                        purchase_token = purchase.purchaseToken,\n                        subscription_id = productId,\n                        user_initiated = true\n                    ), {})\n                }\n            } ?: run {\n                Logger.w("Billing", "There was no ongoing purchase")\n            }\n        } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {\n            // Handle an error caused by a user cancelling the purchase flow.\n            Logger.v("Billing", "buyProduct: User cancelled purchase")\n            ongoingPurchase?.second?.resumeWithException(UserCancelledException())\n        } else {\n            // Handle any other error codes.\n            Logger.w("Billing", "buyProduct: Purchase error: $billingResult")\n            val exception = if (billingResult.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) {\n                AlreadyPurchasedException()\n            } else BlokadaException("Purchase error: $billingResult")\n\n            ongoingPurchase?.second?.resumeWithException(exception)\n        }\n        ongoingPurchase = null\n    }\n\n    override suspend fun getActivePurchase(): ProductId? {\n        var result: CancellableContinuation<ProductId?>? = null\n        getConnectedClient().queryPurchasesAsync("subs") { billingResult, purchases ->\n            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {\n                val successfulPurchases = purchases\n                    .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }\n                    .sortedByDescending { it.purchaseTime }\n\n                result?.resume(successfulPurchases.firstOrNull()?.products?.firstOrNull(), {})\n            } else {\n                result?.resumeWithException(\n                    BlokadaException("Failed refreshing purchases, response code not OK")\n                )\n            }\n        }\n\n        return suspendCancellableCoroutine { cont ->\n            result = cont\n        }\n    }\n\n    override suspend fun buyProduct(id: ProductId): PaymentPayload {\n        if (runIgnoringException({ restorePurchase() }, otherwise = emptyList()).isNotEmpty())\n            throw AlreadyPurchasedException()\n\n        val details = latestProductList.firstOrNull { it.productId == id } ?:\n            throw BlokadaException("Unknown product ID")\n        val offerToken = details.subscriptionOfferDetails!!.first().offerToken\n\n        val flowParams = BillingFlowParams.newBuilder()\n            .setProductDetailsParamsList(listOf(\n                BillingFlowParams.ProductDetailsParams.newBuilder()\n                .setProductDetails(details)\n                .setOfferToken(offerToken)\n                .build()\n            ))\n            .build()\n        val activity = context.requireActivity()\n        val responseCode = getConnectedClient().launchBillingFlow(activity, flowParams).responseCode\n\n        if (responseCode != BillingClient.BillingResponseCode.OK) {\n            throw BlokadaException("buyProduct: error $responseCode")\n        }\n\n        return suspendCancellableCoroutine { cont ->\n            ongoingPurchase = id to cont\n        }\n    }\n\n    private var ongoingRestore: CancellableContinuation<List<PaymentPayload>>? = null\n        @Synchronized set\n        @Synchronized get\n\n    override suspend fun restorePurchase(): List<PaymentPayload> {\n        getConnectedClient().queryPurchasesAsync("subs") { billingResult, purchases ->\n            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {\n                val successfulPurchases = purchases\n                    .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }\n                    .sortedByDescending { it.purchaseTime }\n\n                if (successfulPurchases.isNotEmpty()) {\n                    Logger.v("Billing", "restore: Restoring ${successfulPurchases.size} purchases")\n                    ongoingRestore?.resume(successfulPurchases.map {\n                        PaymentPayload(\n                            purchase_token = it.purchaseToken,\n                            subscription_id = it.products.first(),\n                            user_initiated = false\n                        )\n                    }, {})\n                } else {\n                    ongoingRestore?.resumeWithException(\n                        BlokadaException("Restoring purchase found no successful purchases")\n                    )\n                }\n            } else {\n                ongoingRestore?.resumeWithException(\n                    BlokadaException("Restoring purchase error: $billingResult")\n                )\n            }\n            ongoingRestore = null\n        }\n\n        return suspendCancellableCoroutine { cont ->\n            ongoingRestore = cont\n        }\n    }\n\n    override suspend fun changeProduct(id: ProductId): PaymentPayload {\n        val details = latestProductList.firstOrNull { it.productId == id } ?:\n            throw BlokadaException("Unknown product ID")\n        val offerToken = details.subscriptionOfferDetails!!.first().offerToken\n\n        // Get existing subscription token\n        getConnectedClient().queryPurchasesAsync("subs") { billingResult, purchases ->\n            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {\n                // Get latest successful assuming it\'s the current one\n                val existingPurchase = purchases\n                    .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }\n                    .sortedByDescending { it.purchaseTime }\n                    .firstOrNull()\n\n                if (existingPurchase != null) {\n                    Logger.v("Billing", "changeProduct: found subscription to use")\n                    Logger.v("Billing", "$existingPurchase")\n\n                    ongoingRestore?.resume(listOf(\n                        PaymentPayload(\n                            purchase_token = existingPurchase.purchaseToken,\n                            subscription_id = existingPurchase.products.first(),\n                            user_initiated = false\n                        )\n                    ), {})\n                } else {\n                    ongoingRestore?.resumeWithException(\n                        BlokadaException("changeProduct: no existing purchase")\n                    )\n                }\n            } else {\n                ongoingRestore?.resumeWithException(\n                    BlokadaException("changeProduct: error: $billingResult")\n                )\n            }\n            ongoingRestore = null\n        }\n\n        // Wait until above async callback completes\n        val existingPurchase = suspendCancellableCoroutine<List<PaymentPayload>> { cont ->\n            ongoingRestore = cont\n        }\n\n        val existingId = existingPurchase.first().subscription_id\n        val existingToken = existingPurchase.first().purchase_token\n\n        val prorate = when {\n            // Upgrade cases\n            existingId == "cloud_12month" -> IMMEDIATE_AND_CHARGE_PRORATED_PRICE\n            existingId == "plus_1month" && id == "plus_12month" -> IMMEDIATE_AND_CHARGE_PRORATED_PRICE\n            // Downgrade case\n            else -> DEFERRED\n        }\n\n        val flowParams = BillingFlowParams.newBuilder()\n            .setSubscriptionUpdateParams(\n                BillingFlowParams.SubscriptionUpdateParams.newBuilder()\n                .setOldPurchaseToken(existingToken)\n                .setReplaceProrationMode(prorate)\n                .build()\n            )\n            .setProductDetailsParamsList(listOf(\n                BillingFlowParams.ProductDetailsParams.newBuilder()\n                .setProductDetails(details)\n                .setOfferToken(offerToken)\n                .build()\n            ))\n            .build()\n        val activity = context.requireActivity()\n        val responseCode = getConnectedClient().launchBillingFlow(activity, flowParams).responseCode\n\n        if (responseCode != BillingClient.BillingResponseCode.OK) {\n            throw BlokadaException("changeProduct: error $responseCode")\n        }\n\n        return suspendCancellableCoroutine { cont ->\n            ongoingPurchase = id to cont\n        }\n    }\n\n    private fun Purchase.isActive(): Boolean {\n        return when {\n            purchaseState != Purchase.PurchaseState.PURCHASED -> false\n            else -> false\n        }\n    }\n\n    private fun getTrial(it: ProductDetails.SubscriptionOfferDetails): Boolean {\n        return it.offerTags.contains("free7")\n    }\n\n    private fun getPeriodMonths(it: ProductDetails.PricingPhase): Int {\n        return if (it.billingPeriod == "P1Y") 12 else 1\n    }\n\n    private fun getPricePerMonthString(it: ProductDetails.PricingPhase): String {\n        val periodMonths = getPeriodMonths(it)\n        val price = it.priceAmountMicros\n        val perMonth = price / periodMonths\n        return priceFormat.format(perMonth / 1_000_000f, it.priceCurrencyCode)\n    }\n\n    private fun getPriceString(it: ProductDetails.PricingPhase): String {\n        return priceFormat.format(it.priceAmountMicros / 1_000_000f, it.priceCurrencyCode)\n    }\n\n    private val priceFormat = "%.2f %s"\n}', 'message': 'android: improve working state ui indication\n', 'author_name': 'kar', 'lang': 'kt'}