package solutions.tretter.mindmachine.billing import android.app.Activity import android.content.Context 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.ProductDetailsResponseListener 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 import java.text.SimpleDateFormat import java.util.Date import java.util.Locale class BillingManager( context: Context, private val onNoAdsEntitlementChanged: (Boolean) -> Unit, ) { private val appContext = context.applicationContext private val _hasNoAds = MutableStateFlow(false) val hasNoAds: StateFlow = _hasNoAds.asStateFlow() private val _productDetails = MutableStateFlow>(emptyMap()) val productDetails: StateFlow> = _productDetails.asStateFlow() private val _logs = MutableStateFlow>(emptyList()) val logs: StateFlow> = _logs.asStateFlow() private fun log(message: String) { val timestamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date()) _logs.value = _logs.value + "[$timestamp] $message" if (_logs.value.size > 50) { _logs.value = _logs.value.takeLast(50) } } private val billingClient: BillingClient = BillingClient.newBuilder(appContext) .setListener { _, purchases -> if (purchases != null) { handlePurchases(purchases) } } .enablePendingPurchases() .build() fun start() { log("BillingManager.start()") if (billingClient.isReady) { log("Billing client already ready") refresh() return } billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(result: BillingResult) { log("Billing setup finished: ${result.responseCode} ${result.debugMessage}") if (result.responseCode == BillingClient.BillingResponseCode.OK) { refresh() } } override fun onBillingServiceDisconnected() { log("Billing service disconnected") // No-op, will retry on next call. } }) } 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) log("launchBillingFlow result: ${result.responseCode} ${result.debugMessage}") } private fun queryProductDetails() { log("queryProductDetails()") if (!billingClient.isReady) { log("Billing client not ready") return } val products = listOf( QueryProductDetailsParams.Product.newBuilder() .setProductId(BillingProducts.SUB_REMOVE_ADS_MONTHLY) .setProductType(BillingClient.ProductType.SUBS) .build(), QueryProductDetailsParams.Product.newBuilder() .setProductId(BillingProducts.INAPP_REMOVE_ADS_FOREVER) .setProductType(BillingClient.ProductType.INAPP) .build(), ) val params = QueryProductDetailsParams.newBuilder() .setProductList(products) .build() billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList -> log("queryProductDetails result: ${result.responseCode} ${result.debugMessage}") if (result.responseCode == BillingClient.BillingResponseCode.OK) { _productDetails.value = detailsList.associateBy { it.productId } log("Product details loaded: ${detailsList.size} items") } }) } private fun queryActivePurchases() { log("queryActivePurchases()") if (!billingClient.isReady) { log("Billing client not ready") return } val listener = PurchasesResponseListener { result, purchases -> log("Query purchases result: ${result.responseCode} ${result.debugMessage}") if (result.responseCode == BillingClient.BillingResponseCode.OK) { handlePurchases(purchases) } } 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) { 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) { /* no-op */ } } } }