2026-04-10 16:14:31 -05:00
|
|
|
package solutions.tretter.mindmachine.billing
|
2026-04-10 12:01:02 -05:00
|
|
|
|
|
|
|
|
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
|
2026-04-11 13:01:03 -05:00
|
|
|
import java.text.SimpleDateFormat
|
|
|
|
|
import java.util.Date
|
|
|
|
|
import java.util.Locale
|
2026-04-10 12:01:02 -05:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-04-11 13:01:03 -05:00
|
|
|
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
|
|
|
|
val logs: StateFlow<List<String>> = _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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 12:01:02 -05:00
|
|
|
private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
|
|
|
|
|
.setListener { _, purchases ->
|
|
|
|
|
if (purchases != null) {
|
|
|
|
|
handlePurchases(purchases)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.enablePendingPurchases()
|
|
|
|
|
.build()
|
|
|
|
|
|
|
|
|
|
fun start() {
|
2026-04-11 13:01:03 -05:00
|
|
|
log("BillingManager.start()")
|
2026-04-10 12:01:02 -05:00
|
|
|
if (billingClient.isReady) {
|
2026-04-11 13:01:03 -05:00
|
|
|
log("Billing client already ready")
|
2026-04-10 12:01:02 -05:00
|
|
|
refresh()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
billingClient.startConnection(object : BillingClientStateListener {
|
|
|
|
|
override fun onBillingSetupFinished(result: BillingResult) {
|
2026-04-11 13:01:03 -05:00
|
|
|
log("Billing setup finished: ${result.responseCode} ${result.debugMessage}")
|
2026-04-10 12:01:02 -05:00
|
|
|
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
|
|
|
refresh()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onBillingServiceDisconnected() {
|
2026-04-11 13:01:03 -05:00
|
|
|
log("Billing service disconnected")
|
2026-04-10 12:01:02 -05:00
|
|
|
// No-op, will retry on next call.
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun refresh() {
|
2026-04-11 13:01:03 -05:00
|
|
|
log("refresh() called")
|
2026-04-10 12:01:02 -05:00
|
|
|
queryProductDetails()
|
|
|
|
|
queryActivePurchases()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun launchPurchase(activity: Activity, productId: String) {
|
2026-04-11 13:01:03 -05:00
|
|
|
log("launchPurchase() productId=$productId")
|
|
|
|
|
val details = _productDetails.value[productId]
|
|
|
|
|
if (details == null) {
|
|
|
|
|
log("Product details not found for $productId")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-10 12:01:02 -05:00
|
|
|
|
|
|
|
|
val productDetailsParams = when (details.productType) {
|
|
|
|
|
BillingClient.ProductType.SUBS -> {
|
|
|
|
|
val offerToken = details.subscriptionOfferDetails
|
|
|
|
|
?.firstOrNull()
|
|
|
|
|
?.offerToken
|
2026-04-11 13:01:03 -05:00
|
|
|
if (offerToken == null) {
|
|
|
|
|
log("No subscription offer token for $productId")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-10 12:01:02 -05:00
|
|
|
BillingFlowParams.ProductDetailsParams.newBuilder()
|
|
|
|
|
.setProductDetails(details)
|
|
|
|
|
.setOfferToken(offerToken)
|
|
|
|
|
.build()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
BillingClient.ProductType.INAPP -> {
|
|
|
|
|
BillingFlowParams.ProductDetailsParams.newBuilder()
|
|
|
|
|
.setProductDetails(details)
|
|
|
|
|
.build()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 13:01:03 -05:00
|
|
|
else -> {
|
|
|
|
|
log("Unknown product type for $productId")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-10 12:01:02 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val params = BillingFlowParams.newBuilder()
|
|
|
|
|
.setProductDetailsParamsList(listOf(productDetailsParams))
|
|
|
|
|
.build()
|
|
|
|
|
|
2026-04-11 13:01:03 -05:00
|
|
|
val result = billingClient.launchBillingFlow(activity, params)
|
|
|
|
|
log("launchBillingFlow result: ${result.responseCode} ${result.debugMessage}")
|
2026-04-10 12:01:02 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun queryProductDetails() {
|
2026-04-11 13:01:03 -05:00
|
|
|
log("queryProductDetails()")
|
|
|
|
|
if (!billingClient.isReady) {
|
|
|
|
|
log("Billing client not ready")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-10 12:01:02 -05:00
|
|
|
|
|
|
|
|
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 ->
|
2026-04-11 13:01:03 -05:00
|
|
|
log("queryProductDetails result: ${result.responseCode} ${result.debugMessage}")
|
2026-04-10 12:01:02 -05:00
|
|
|
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
|
|
|
_productDetails.value = detailsList.associateBy { it.productId }
|
2026-04-11 13:01:03 -05:00
|
|
|
log("Product details loaded: ${detailsList.size} items")
|
2026-04-10 12:01:02 -05:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun queryActivePurchases() {
|
2026-04-11 13:01:03 -05:00
|
|
|
log("queryActivePurchases()")
|
|
|
|
|
if (!billingClient.isReady) {
|
|
|
|
|
log("Billing client not ready")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-10 12:01:02 -05:00
|
|
|
|
|
|
|
|
val listener = PurchasesResponseListener { result, purchases ->
|
2026-04-11 13:01:03 -05:00
|
|
|
log("Query purchases result: ${result.responseCode} ${result.debugMessage}")
|
2026-04-10 12:01:02 -05:00
|
|
|
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<Purchase>) {
|
2026-04-11 13:01:03 -05:00
|
|
|
log("handlePurchases() count=${purchases.size}")
|
2026-04-10 12:01:02 -05:00
|
|
|
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 }
|
|
|
|
|
}
|
2026-04-11 13:01:03 -05:00
|
|
|
log("Active entitlement: $active")
|
2026-04-10 12:01:02 -05:00
|
|
|
|
|
|
|
|
if (_hasNoAds.value != active) {
|
2026-04-11 13:01:03 -05:00
|
|
|
log("Entitlement changed: ${_hasNoAds.value} -> $active")
|
2026-04-10 12:01:02 -05:00
|
|
|
_hasNoAds.value = active
|
|
|
|
|
onNoAdsEntitlementChanged(active)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Acknowledge where needed
|
|
|
|
|
purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
|
|
|
|
|
.forEach { purchase ->
|
2026-04-11 13:01:03 -05:00
|
|
|
log("Acknowledging purchase: ${purchase.purchaseToken}")
|
2026-04-10 12:01:02 -05:00
|
|
|
val params = AcknowledgePurchaseParams.newBuilder()
|
|
|
|
|
.setPurchaseToken(purchase.purchaseToken)
|
|
|
|
|
.build()
|
|
|
|
|
billingClient.acknowledgePurchase(params) { /* no-op */ }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|