Files
MindMachine/app/src/main/java/com/mindmachine/mvp/billing/BillingManager.kt
2026-04-15 22:36:13 -05:00

267 lines
9.8 KiB
Kotlin

package solutions.tretter.mindmachine.billing
import android.app.Activity
import android.content.Context
import android.util.Log
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.QueryProductDetailsResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesResponseListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class BillingManager(
context: Context,
private val onNoAdsEntitlementChanged: (Boolean) -> Unit,
) {
private val appContext = context.applicationContext
private val _hasNoAds = MutableStateFlow(false)
val hasNoAds: StateFlow<Boolean> = _hasNoAds.asStateFlow()
private val _productDetails = MutableStateFlow<Map<String, ProductDetails>>(emptyMap())
val productDetails: StateFlow<Map<String, ProductDetails>> = _productDetails.asStateFlow()
private fun log(message: String) {
Log.d("MindMachineBilling", message)
}
private fun logError(message: String) {
Log.e("MindMachineBilling", message)
}
private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
.setListener { _, purchases ->
if (purchases != null) {
handlePurchases(purchases)
}
}
.enablePendingPurchases(
PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.build()
)
.build()
@Volatile
private var isConnecting = false
fun start() {
log("BillingManager.start() - isReady=${billingClient.isReady}, isConnecting=$isConnecting")
if (billingClient.isReady) {
log("Billing client already ready")
refresh()
return
}
if (isConnecting) {
log("Billing client connection already in progress")
return
}
isConnecting = true
log("Calling startConnection()")
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
isConnecting = false
log("Billing setup finished: ${result.responseCode} ${result.debugMessage}")
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
refresh()
} else {
logError("Billing setup failed: ${result.responseCode} ${result.debugMessage}")
}
}
override fun onBillingServiceDisconnected() {
isConnecting = false
log("Billing service disconnected")
}
})
}
fun refresh() {
log("refresh() called")
queryProductDetails()
queryActivePurchases()
}
fun launchPurchase(activity: Activity, productId: String) {
log("launchPurchase() productId=$productId")
val details = _productDetails.value[productId]
if (details == null) {
log("Product details not found for $productId")
return
}
val productDetailsParams = when (details.productType) {
BillingClient.ProductType.SUBS -> {
val offerToken = details.subscriptionOfferDetails
?.firstOrNull()
?.offerToken
if (offerToken == null) {
log("No subscription offer token for $productId")
return
}
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.setOfferToken(offerToken)
.build()
}
BillingClient.ProductType.INAPP -> {
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.build()
}
else -> {
log("Unknown product type for $productId")
return
}
}
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
val result = billingClient.launchBillingFlow(activity, params)
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
log("launchBillingFlow succeeded")
} else {
logError("launchBillingFlow failed: ${result.responseCode} ${result.debugMessage}")
}
}
private fun queryProductDetails() {
log("queryProductDetails()")
if (!billingClient.isReady) {
log("Billing client not ready")
return
}
val featureResult = billingClient.isFeatureSupported(BillingClient.FeatureType.PRODUCT_DETAILS)
log("PRODUCT_DETAILS support: ${featureResult.responseCode} ${featureResult.debugMessage}")
if (featureResult.responseCode != BillingClient.BillingResponseCode.OK) {
logError("PRODUCT_DETAILS feature not supported")
return
}
queryProductDetailsForType(
productType = BillingClient.ProductType.SUBS,
productIds = listOf(BillingProducts.SUB_REMOVE_ADS_MONTHLY)
)
queryProductDetailsForType(
productType = BillingClient.ProductType.INAPP,
productIds = listOf(BillingProducts.INAPP_REMOVE_ADS_FOREVER)
)
}
private fun queryProductDetailsForType(
productType: String,
productIds: List<String>
) {
try {
log("Querying $productType product details for $productIds")
val products = productIds.map { productId ->
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(productType)
.build()
}
val params = QueryProductDetailsParams.newBuilder()
.setProductList(products)
.build()
billingClient.queryProductDetailsAsync(params) { result, queryResult ->
log("queryProductDetailsAsync($productType) result: ${result.responseCode} ${result.debugMessage}")
val detailsList = queryResult.productDetailsList
log("Fetched $productType products: ${detailsList.map { it.productId }}")
if (queryResult.unfetchedProductList.isNotEmpty()) {
logError("Unfetched $productType products: ${queryResult.unfetchedProductList}")
}
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
_productDetails.value = _productDetails.value + detailsList.associateBy { it.productId }
log("Product details cache now has: ${_productDetails.value.keys}")
}
}
} catch (t: Throwable) {
Log.e("MindMachineBilling", "Exception querying $productType product details", t)
}
}
private fun queryActivePurchases() {
log("queryActivePurchases()")
if (!billingClient.isReady) {
log("Billing client not ready")
return
}
val listener = PurchasesResponseListener { result, purchases ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
log("Query purchases returned ${purchases.size} items")
handlePurchases(purchases)
} else {
logError("Query purchases failed: ${result.responseCode} ${result.debugMessage}")
}
}
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build(),
listener
)
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build(),
listener
)
}
private fun handlePurchases(purchases: List<Purchase>) {
log("handlePurchases count=${purchases.size}")
val active = purchases.any { p ->
p.purchaseState == Purchase.PurchaseState.PURCHASED &&
p.products.any { it == BillingProducts.SUB_REMOVE_ADS_MONTHLY || it == BillingProducts.INAPP_REMOVE_ADS_FOREVER }
}
log("Active entitlement: $active")
if (_hasNoAds.value != active) {
log("Entitlement changed: ${_hasNoAds.value} -> $active")
_hasNoAds.value = active
onNoAdsEntitlementChanged(active)
}
// Acknowledge where needed
purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
.forEach { purchase ->
log("Acknowledging purchase: ${purchase.purchaseToken}")
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient.acknowledgePurchase(params) { billingResult ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
log("Purchase acknowledged successfully")
} else {
logError("Failed to acknowledge purchase: ${billingResult.responseCode} ${billingResult.debugMessage}")
}
}
}
}
}